diff --git a/.credo.exs b/.credo.exs index 8e6be3030..015f2e282 100644 --- a/.credo.exs +++ b/.credo.exs @@ -5,6 +5,9 @@ disabled: [ {Credo.Check.Refactor.CondStatements, false}, {Credo.Check.Refactor.NegatedConditionsWithElse, false} + ], + extra: [ + {Credo.Check.Refactor.Nesting, max_nesting: 3} ] } } diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 8a34fdef8..1401ff7c6 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -1,22 +1,4 @@ [ {"lib/philomena/adverts/restrictions.ex", :call_without_opaque}, - {"lib/philomena/artist_links.ex", :call_without_opaque}, - {"lib/philomena/bans.ex", :call_without_opaque}, - {"lib/philomena/comments.ex", :call_without_opaque}, - {"lib/philomena/commissions.ex", :call_without_opaque}, - {"lib/philomena/conversations.ex", :call_without_opaque}, - {"lib/philomena/duplicate_reports.ex", :call_without_opaque}, - {"lib/philomena/galleries.ex", :call_without_opaque}, - {"lib/philomena/image_faves.ex", :call_without_opaque}, - {"lib/philomena/image_hides.ex", :call_without_opaque}, - {"lib/philomena/image_votes.ex", :call_without_opaque}, - {"lib/philomena/images.ex", :call_without_opaque}, - {"lib/philomena/images/tag_validator.ex", :call_without_opaque}, - {"lib/philomena/interactions.ex", :call_without_opaque}, - {"lib/philomena/poll_votes.ex", :call_without_opaque}, - {"lib/philomena/posts.ex", :call_without_opaque}, - {"lib/philomena/static_pages.ex", :call_without_opaque}, - {"lib/philomena/tags.ex", :call_without_opaque}, - {"lib/philomena/topics.ex", :call_without_opaque}, - {"lib/philomena/users.ex", :call_without_opaque} + {"lib/philomena/images/tag_validator.ex", :call_without_opaque} ] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6a354efb5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# AGENTS.md + +This file provides guidance to coding assistants when working with code in this repository. + +## Development Environment + +Everything runs inside Docker Compose (services: `app`, `postgres`, `opensearch`, `valkey`, `files` (S3 proxy), `mediaproc`, `web`). The recommended setup is the devcontainer, which attaches to the `app` service. `scripts/philomena.sh` is the dev CLI (add `scripts/path` to PATH to get it as `philomena`): + +```bash +philomena up # build + start the dev stack (add --drop-db to reset databases) +philomena down # stop the stack +philomena test # run the full Elixir test/lint suite in the app container +``` + +The app serves at http://localhost:8080 (login: `admin@example.com` / `philomena123`). Vite dev server runs on port 5173. + +Elixir commands (`mix ...`) must run inside the `app` container (or the devcontainer terminal) — config points at compose hostnames like `postgres` and `opensearch`, so they fail on the host. + +## Commands + +### Elixir (run inside the app container) + +- `mix test` — run tests (requires postgres + opensearch up; `mix ecto.create && mix ecto.load` first on a fresh DB) +- `mix test test/philomena/users_test.exs` or `...exs:42` — single file / single test +- `mix format` — format; `mix format --check-formatted` is enforced by CI +- `mix credo` — lint +- `mix sobelow --config` and `mix deps.audit` — security checks (CI runs both) +- `mix dialyzer` — static analysis (CI runs it; slow on first run while PLT builds) +- `philomena test` (from host) replicates the full CI sequence: format check → `mix test` → sobelow → deps.audit → dialyzer. It recompiles everything and runs dialyzer — use it as a final pass, not for iteration; iterate with targeted `mix test` runs instead. + +**Running tests from the host:** the `app` container pins `MIX_ENV=dev`, so a plain `docker compose exec app mix test` hits the dev database and fails with a sandbox error. Override the env: + +```bash +docker compose exec -T -e MIX_ENV=test app mix test [test/path/to/file_test.exs] +``` + +(and once on a fresh stack, `mix ecto.create && mix ecto.load` with the same `-e MIX_ENV=test`). + +Test conventions — file layout, auth-level setup helpers, fixtures, OpenSearch index handling, external-call stubbing — are documented in `test/CONVENTIONS.md`; read it before writing controller or context tests. + +### Context development + +Before creating or refactoring a `Philomena` domain context, its schemas, or a +controller-facing context API, read `CONTEXT_STYLE.md`. It defines the expected +code shape, changeset and query boundaries, transaction composition, result +types, and testing style. + +### Database + +- Schema is managed via SQL structure dump, not migration replay: fresh setup uses `mix ecto.load` (loads `priv/repo/structure.sql`), and the `ecto.migrate`/`ecto.rollback` aliases automatically re-dump the structure file — commit it together with new migrations. +- `mix ecto.setup_dev` — create, load, and seed with development data +- `mix reindex_all` — rebuild all OpenSearch indexes + +### Frontend (in `assets/`) + +- `npm run test` / `npm run test:watch` — vitest with coverage +- `npm run lint` — eslint + stylelint +- `npm run build` — typecheck (tsc) + vite build + +### Rust (in `native/philomena/`) + +- `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` — all enforced by CI + +### Repo-wide formatting + +- `npm run fmt` (repo root) — prettier over everything non-Elixir; CI checks `npx prettier --check .`, plus `typos` and shellcheck for scripts. A pre-commit hook (`.githooks/pre-commit`) runs prettier. + +## Architecture + +Phoenix 1.8 server-rendered MVC — **no LiveView**. Views/templates use `phoenix_view` with Slime templates (`lib/philomena_web/templates/**/*.html.slime`); `phoenix_html` is pinned to 3.x for Slime compatibility. + +Four Elixir app namespaces under `lib/`: + +- **`Philomena`** — domain contexts (images, tags, forums, comments, users, filters, galleries, notifications, ...). Each context is a `.ex` module plus a `/` directory of Ecto schemas and helpers. Background jobs live in `lib/philomena/workers/` and run via Exq (Redis/Valkey-backed); contexts enqueue e.g. `IndexWorker`, `ThumbnailWorker`. +- **`PhilomenaWeb`** — controllers, plugs, views, templates. Routing is aggressively RESTful: instead of custom actions there are many small nested singleton controllers (e.g. `Image.VoteController`, `Topic.SubscriptionController`) with only `create`/`delete`. The public JSON API is `lib/philomena_web/controllers/api/json/` and is documented by `openapi.yaml` at the repo root — keep the two in sync. Authorization uses Canada/Canary (`can?` protocols + plugs). +- **`PhilomenaQuery`** — the search layer. `parse/` is a nimble_parsec-based parser for the user-facing search query language; `search.ex` + `search/` is the OpenSearch client. Each searchable domain implements the `PhilomenaQuery.Search.Index` behaviour (e.g. `Philomena.Images.SearchIndex`) defining the index mapping and document serialization. Data flow: writes go to Postgres, then documents are (re)indexed into OpenSearch via `PhilomenaQuery.Search.reindex`/`IndexWorker`. +- **`PhilomenaMedia`** — media intake pipeline: `analyzers/` (mime/dimension/duration detection), `processors/` (per-format thumbnailing/optimization), intensities for duplicate detection, and `objects.ex` for S3 storage (ex_aws; s3proxy in dev). +- **`PhilomenaProxy`** — outbound HTTP: camo URL signing and `scrapers/` that fetch image metadata from external sites for upload-by-URL. + +**Native code:** `native/philomena/` is a Rustler NIF crate (exposed as `Philomena.Native`) handling Markdown rendering via a forked comrak, plus other hot paths. The same Cargo workspace contains `mediaproc`/`mediaproc_client`/`mediaproc_server` — an RPC service (separate `mediaproc` compose container) that performs actual media processing so the BEAM isn't blocked by ffmpeg/imagemagick work. + +**Frontend:** TypeScript (no framework) in `assets/js`, built with Vite, tested with vitest; CSS uses PostCSS with mixins/vars. diff --git a/CONTEXT_STYLE.md b/CONTEXT_STYLE.md new file mode 100644 index 000000000..97c4dcd8b --- /dev/null +++ b/CONTEXT_STYLE.md @@ -0,0 +1,403 @@ +# Context development style + +This is the implementation guide for `Philomena` domain contexts and the +schemas, query modules, controllers, fixtures, and tests that touch their public +boundary. It turns recurring choices into defaults for future work. + +Read this together with the testing rules in +[`test/CONVENTIONS.md`](test/CONVENTIONS.md). When older code conflicts with +this guide, do not copy the older pattern into new work. Move the code being +changed toward this style without expanding the patch into unrelated cleanup. + +## The governing idea + +A public context operation should read as the domain workflow itself and be +usable from more than a Phoenix controller. Keep its authorization, loading, +changeset construction, persistence pipeline, and result translation close +enough to understand in one pass. Extract domain rules and reusable +composition; do not hide a one-off workflow behind layers of functions and +structs that only rename its steps. + +| Concern | Preferred home | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| HTTP parameter-envelope extraction and rendering | Controller | +| Request orchestration and result contract | Public context function | +| Safe record loading and parent scoping | Small private context loader using `Philomena.Loader` | +| Casting, input validation, and state-transition rules | Schema changeset | +| Non-trivial search input | Embedded `QueryForm` | +| Ecto/OpenSearch query construction | `QueryBuilder` | +| Multi-step database workflow | `Philomena.Multi` pipeline | +| Reusable transaction step owned by another context | A `put_*` function that accepts and returns `Philomena.Multi` | +| Indexing, object storage, jobs, and other post-commit work | `Multi.on_commit/2` callback or an explicit success-path action | +| Independently assembled page data | A typed page/index/form struct, but only when the schema or changeset cannot carry it naturally | + +## Public context boundaries + +- Accept `%Philomena.Attribution.Actor{}` first for request-facing operations. + Follow the exact authorization, loading order, nested-resource scoping, and + normalized error vocabulary in the all-context plan. +- Pass route locators as separate arguments. Controllers should unwrap form + envelopes and pass the domain attribute map, for example + `params["dnp_entry"]`, rather than making a context understand the whole + controller parameter tree. +- Do not recover a route locator or target identity from `attrs`. Load it from + its separate argument, then seed the newly built schema with the trusted + association or foreign key before applying the changeset. The changeset may + still cast an independently editable reference, but a route-selected parent + is not caller-editable input. +- Use `attrs` for mutation attributes and `params` for search or pagination + input. Both are caller inputs, not HTTP-only data structures. Names such as + `creator`, `recipient`, and `closing_user` are preferable to an ambiguous + `user` when the role matters. +- Keep presentation switches out of context APIs. Do not add optional preload, + rendering, or bypass options merely to serve one caller. If any one caller + requires a specific preload, load it for all callers and add it to the result + contract. +- User-supplied values are cast and validated. A structurally impossible call + from trusted application code does not need broad normalization solely to + avoid a function-clause or `Ecto.Changeset.cast/4` error. +- Return the record that was created, updated, or deleted. Do not return a + parent record merely because a controller needs it for a redirect; preload or + attach the parent association to the changed record instead. + +Controllers remain thin consumers of this API. They may destructure a +changeset's data and preloaded associations to render or redirect. Handle the +specific result shapes the controller owns, then pass everything else to the +fallback as `error -> error`; avoid catch-all patterns that pretend every error +has already been normalized. + +## Context APIs are not web APIs + +Controllers are one caller of a context, alongside workers, scheduled tasks, +imports, seeds, tests, and IEx/admin maintenance. A controller's nested, +string-keyed request parameters are therefore an adapter concern, not the +context API's contract. + +- Public create/update functions receive the resource's attribute map directly; + they do not expect `%{"resource" => attrs}` or inspect a controller-specific + top-level key. +- Let an Ecto changeset cast the attributes. This permits a web caller to pass + its string-keyed form map and a non-web caller to pass a consistently + atom-keyed map, without context code branching on `Map.get(attrs, "field")`. + Do not mix atom and string keys in one map: `Ecto.Changeset.cast/4` requires + a consistent key type. +- Do not function-match attribute maps on string keys or manually parse their + fields in the context when a cast, validation, virtual field, or named + changeset helper can express the same rule. Route locators and deliberately + raw search syntax remain separate inputs with their own parsers/loaders. +- Return `%Ecto.Changeset{}` for expected invalid attributes whenever it + contains the data a caller needs: normally `{:error, changeset}` rather than + a bespoke error tuple. The changeset is equally useful to a controller + rendering errors and to a non-web caller inspecting `errors`, `changes`, or + normalized data. +- Keep atom errors for non-validation outcomes such as authorization, a missing + resource, a ban, or a rate limit. Add a tuple or typed form result only when + it carries independent data that cannot naturally live in `changeset.data` or + its associations. +- A trusted non-web workflow may have a narrower, explicitly named service API + with loaded records or a system principal. Do not make the normal + controller-facing API less reusable by adding a controller-only bypass. + +For example, a controller may call +`DnpEntries.create_dnp_entry(actor, params["dnp_entry"])`, while a worker or +test can call the same function with `%{tag_id: tag.id, reason: "..."}`. Both +calls reach the same changeset and receive the same validation contract. + +## Prefer a visible vertical workflow + +Inline private helpers that are used once and only wrap a single operation. +Typical candidates for inlining are: + +- `insert_*`, `update_*`, and `delete_*` helpers that only build a changeset and + call `Repo`; +- `change_*` helpers that only call `Schema.changeset(record, %{})`; +- `persist_*` helpers that hide the only transaction pipeline using them; +- `transact_and_log` or callback helpers that obscure the concrete write and + moderation log; +- page-building helpers used by one public loader; and +- parameter helpers that merely remove a controller's top-level form key. + +Give ordinary schema changesets an `attrs \\ %{}` default so new/edit loaders +can call `Schema.changeset(record)` directly. + +Extract a helper or module when it names a real concept and at least one of the +following is true: + +- it is reused; +- it enforces a non-obvious invariant or state transition; +- it builds a substantial query; +- it composes into transactions owned by multiple contexts; +- it isolates an external side effect; or +- keeping it inline would make the public workflow harder, rather than easier, + to scan. + +Do not measure abstraction quality by line count or brevity. Some duplication +in adjacent public operations is preferable when it keeps each mutation's +authorization, write, audit log, and result contract visible. + +Keep transitional conversions and one-off maintenance work out of the active +domain context. Give it a focused, independently invocable module with the +smallest public surface needed, so the normal context remains about current +workflows and the legacy code has a clear later-deletion boundary. + +## Let changesets own caller input and state rules + +Use changesets as the canonical representation of caller-supplied domain +input, including non-persisted input: + +- Add virtual schema fields for form values such as a tag name or a compiled + search query instead of parsing parallel raw maps in the context. +- Put casts, defaults, inclusion/range checks, transition preconditions, and + field-specific errors in the schema changeset. +- Resolve database-backed references in the context, then pass the loaded + record or the permitted IDs into the changeset. Schemas should not query the + repository. +- For state owned by one participant or role, expose a named changeset such as + `read_changeset/3`, `hidden_changeset/3`, or `transition_changeset/3` rather + than building a conditional update map in the context. +- When later transaction steps depend on whether a changeset changed a + meaningful state, calculate that fact while constructing the changeset and + expose it as a clearly named virtual field (for example, + `became_unapproved?`). This lets the committed record drive counters, + reports, and other coupled work; do not re-infer the transition from a stale + pre-update record or duplicate its predicate in the context. +- Set schema defaults to the real domain default. If transaction composition + depends on the proposed value, expose a narrow helper that reads it from the + changeset with `fetch_field!/2` or `get_assoc/2`. +- Use `Ecto.Changeset.apply_action/2` to validate embedded forms that are not + persisted. +- A rejected state transition is a validation failure, not a successful no-op. + Put an error on the relevant field (such as already closed, unclaimed, or + already approved) and return that changeset. + +Return the rejected `%Ecto.Changeset{}` for expected validation failures. Keep +its `data`, associations, and submitted values intact so the controller can +render the form without reconstructing domain state. + +## Query forms and builders + +Move a non-trivial listing/search filter into a schema-backed query form and a +query builder. + +The `QueryForm` should: + +- use `embedded_schema`; +- cast the caller-facing fields; +- provide canonical defaults; +- validate enums, ranges, arrays, and query syntax; and +- store expensive compiled values in virtual fields when the builder needs + them. + +The `QueryBuilder` should: + +- validate with the `QueryForm` and `apply_action/2`; +- return `{:ok, query, query_form}` (or the equivalent OpenSearch body) on + success and `{:error, changeset}` on failure; +- apply independent filters together instead of accidentally giving one raw + parameter clause precedence over another; +- include deterministic tie-breaker sorts; and +- stop before authorization, execution, and pagination, which belong to the + context. + +Invalid search input must remain explicit. Return or render its rejected +changeset and do not silently broaden it into an unfiltered query. Prefer a +`nil` result page when the view supports it; an established empty-page contract +is acceptable when the template genuinely requires a page struct. + +When there is an HTTP controller, it--not a plug or the query builder--unwraps +the form namespace. On a valid search, build the render changeset from the +applied query form so it retains normalized values for every caller. + +## Transactions and side effects + +Use `Philomena.Multi`, not `Ecto.Multi`, for new context workflows. A single +database write with no coupled work may call `Repo` directly. Use a Multi as +soon as the operation includes another database mutation, an audit record, +counter maintenance, report work, a lock, reusable transaction composition, or +a post-commit effect. + +The public operation should normally show the pipeline: + +```elixir +changeset = Schema.changeset(record, attrs) + +Multi.new() +|> Multi.update(:record, changeset) +|> ModerationLogs.put_log(:moderation_log, actor, fn %{record: record} -> + {type, path, body_for(record)} +end) +|> put_reindex_record(:record) +|> Multi.transact() +|> case do + {:ok, %{record: record}} -> {:ok, record} + {:error, :record, changeset, _changes} -> {:error, changeset} +end +``` + +## Schema-table write ownership + +Every insertion, update, or delete that affects a schema module's table must +be visible as a function in the context that exposes that schema. A caller +should be able to inspect that context's public write surface and find every +operation that can create, change, or remove rows from the table; do not hide a +table write in an unrelated aggregate, query helper, worker, or schema +collaborator. + +- A context may keep its own one-step persistence inline when the operation is + already a complete public workflow. The changeset, authorization, locking, + write, and result contract should remain visible together. +- Cross-context collaboration should normally be a documented `put_*` + function that accepts and returns `Philomena.Multi`. The owning context + constructs the query, changeset, or insert rows and adds named steps, locks, + counters, indexing, and other post-commit work there. The caller composes + that function instead of constructing an `insert`, `insert_all`, + `update_all`, or `delete_all` for the other context's schema. +- A submodule collaborator should call a documented function on its owning + context that performs the complete operation. Do not let a recorder, + maintenance worker, uploader, or verifier update another context's schema + directly just because it lives below the same namespace. +- This applies to bulk inserts, updates, and deletes as well as changeset + writes and row deletes. Reads and query construction may be shared, but + mutation ownership must remain discoverable at the context boundary. +- A `put_*` function owns the post-commit side effects caused by its write. + If the write changes a search document, queues indexing, broadcasts an event, + purges an object, or triggers another external effect, attach that work with + `Multi.on_commit/2` in the owner function. Callers should not have to + remember a second indexing or notification step after composing the write. + +This rule is intentional: locking requirements, lock order, rollback coupling, +and denormalized-counter maintenance cannot be evaluated reliably while writes +to one table are scattered across otherwise unrelated contexts. + +- Put all related PostgreSQL changes in the same pipeline: the primary write, + counters, reports, statistics, subscriptions, and moderation logs should + commit or roll back together. +- Lock mutable rows with `Multi.lock_one/3` when a decision, transition, or + counter depends on their current value. +- Treat locks as a documented protocol, not an incidental query option. Lock + parents before children, scope every child under its locked parent, and use a + stable order for multiple peer rows so opposite-direction operations cannot + deadlock. Read denormalized counters and cached pointers only from rows + protected by that protocol, and update them in the mutation's same Multi. +- Cross-context transaction helpers take a Multi, add clearly named steps, and + return the Multi. Prefix them with `put_*` when they add work to the pipeline. +- When a later step conditionally adds database work based on earlier Multi + changes, use `Multi.merge/2` to compose that work into the same transaction. + Do not replace it with a second transaction or a success callback that needs + rollback semantics. +- Do not add a direct-`Repo` convenience write alongside a composable + transaction helper merely for one trusted caller. The owning workflow should + compose the `put_*` helper into its existing Multi, preserving rollback and + post-commit behavior. Reserve separately named trusted service APIs for + workflows that genuinely cannot compose. +- Use step references rather than passing partly persisted records between + context helpers. +- Register indexing, object storage, job enqueueing, and similar external work + with `Multi.on_commit/2` when it belongs to a composable workflow. Never run + it before the database commit or as an irreversible action inside the + database transaction. +- Treat separate `on_commit` callbacks as unordered. If side effects depend on + one another, express that ordering in one callback or one explicit + success-path action rather than relying on registration order. +- Perform rate-limit recording, firehose broadcasts, and other explicitly + local success actions only after `Multi.transact/1` succeeds. +- Translate the expected primary changeset failure by its exact step name. Do + not erase the failing step with a generic + `{:error, _step, reason, _changes}` branch unless the public contract truly + treats every transaction failure identically. + +## Result types: reuse domain data before adding wrappers + +Before defining a `SomethingForm`, `SomethingPage`, or `SomethingCreated` +struct, ask whether the same contract can be represented by: + +- the schema record itself; +- `%Ecto.Changeset{data: record}`; +- a preloaded association; +- a virtual/calculated field on the returned record; or +- a small tuple of already meaningful values. + +Prefer those existing shapes. For example, a created child can carry its +preloaded parent, and a message can carry a conversation with its calculated +message count. Do not introduce a struct that only renames those two values. + +A dedicated result struct is appropriate when it assembles independent data +that does not naturally belong to one schema, such as a paginated page with +interactions, a search form with current-user state, or a form with an +independent collection of selectable records. Keep such structs typed and +specific to one boundary. + +## Documentation and naming + +- Give every public context function an accurate `@spec` and behavior-focused + `@doc` with realistic success and important error examples. +- Describe authorization differences, parent scoping, atomicity, error + precedence, and externally visible side effects only when they are useful to + a caller. Do not narrate private helper structure or repeat the code. +- Keep moduledocs concise and about domain ownership. Avoid promises that just + restate the architecture. +- Use `load_*` for request-facing reads, `new_*` for form preparation, + `create_*`/`update_*`/`delete_*` for writes, and `put_*` for transaction + composition. +- Use comments for a reason, invariant, or unresolved design decision. Delete + comments that only label an obvious CRUD call. + +## Tests and fixtures + +Follow `test/CONVENTIONS.md`, including its separate rules for characterization +work. For implementation and refactor tests: + +- Exercise the public context API and assert its exact result shape, persisted + state, associations needed by callers, and important side effects. +- Test rollback coupling for multi-step writes and prove post-commit work does + not run on a rejected primary changeset where practical. +- Add an `async: false` concurrency test whenever a workflow maintains a + counter, cached pointer, uniqueness-dependent choice, or state transition + that can race. Start competing calls together through the SQL sandbox, then + assert the persisted invariant from base records rather than merely asserting + that each call returned an expected tuple. Include opposite-order operations + when a workflow locks multiple peer rows. +- Pin desired domain behavior with a regression test when correcting an + accidental or unsafe behavior; do not preserve an accident merely because an + old test described it. +- Do not add a public production function solely for fixtures. Use the real + public creation path where the fixture convention calls for it. Where direct + persistence is an established exception, keep it in the fixture module and + build it through the schema changeset. +- Assert that validation failures preserve the loaded record and associations + the controller needs, rather than asserting a custom wrapper exists. +- Update context, controller, and controller/context tests together when a + result contract changes. + +## Review checklist + +Before considering a context change complete, check: + +- Can each public operation be understood without jumping through one-use CRUD + or transaction helpers? +- Can a controller and a non-web caller use the same operation with the direct + attribute map appropriate to their boundary? +- Are route-selected identities passed and loaded separately rather than taken + from caller attributes? +- Does the changeset own casting, validation, and transition rules? +- Does the controller pass domain attrs rather than its whole parameter tree? +- Are IDs loaded safely and nested children constrained by their parents before + authorization? +- Are related database changes and audit records in one `Philomena.Multi`? +- When current state, counters, or cache pointers can race, are the required + rows locked in a stable order and the invariant covered by a concurrency test? +- Are external side effects guaranteed to happen only after commit? +- Does an expected validation failure return the actual changeset without + losing loaded data? +- Is every new result struct carrying information that a schema, association, + changeset, calculated field, or tuple cannot carry naturally? +- Does invalid search input remain visible instead of becoming an unfiltered + query? +- Do the specs, docs, controller patterns, and tests match the final result + contract exactly? + +Useful current exemplars include the query form/builder pairs under +`Galleries`, `Users`, `Commissions`, `Conversations`, and `DnpEntries`; the +transaction pipelines in `Comments`; the upload callbacks in `Adverts` and +`Badges`; the association-backed item results in `Commissions`; and the +transactional DNP transition in `DnpEntries`. diff --git a/assets/eslint.config.js b/assets/eslint.config.js index e040ad58c..e43dc2b3f 100644 --- a/assets/eslint.config.js +++ b/assets/eslint.config.js @@ -24,7 +24,9 @@ export default tsEslint.config( 'block-scoped-var': 2, camelcase: [ 2, - { allow: ['camo_url', 'spoiler_image_uri', 'image_ids', 'image_id', 'user_id', 'interaction_type'] }, + { + allow: ['camo_url', 'spoiler_image_uri', 'image_ids', 'image_id', 'user_id', 'interaction_type', 'tag_list'], + }, ], 'class-methods-use-this': 0, complexity: 0, diff --git a/assets/js/__tests__/quick-tag.spec.ts b/assets/js/__tests__/quick-tag.spec.ts index b0ee8e8f0..b73a8a37a 100644 --- a/assets/js/__tests__/quick-tag.spec.ts +++ b/assets/js/__tests__/quick-tag.spec.ts @@ -151,7 +151,7 @@ describe('Batch tagging', () => { }); it('should return to normal state on successful submission', () => { - fetchMock.mockResponse('{"failed":[]}'); + fetchMock.mockResponse('{"failed":0}'); submitButton.click(); expect(fetch).toHaveBeenCalledOnce(); @@ -163,7 +163,7 @@ describe('Batch tagging', () => { }); it('should show error on failed submission', () => { - fetchMock.mockResponse('{"failed":[0,1]}'); + fetchMock.mockResponse('{"failed":2}'); submitButton.click(); const spy = vi.spyOn(window, 'alert').mockImplementation(() => {}); diff --git a/assets/js/quick-tag.ts b/assets/js/quick-tag.ts index a83e4d244..0f55e49e0 100644 --- a/assets/js/quick-tag.ts +++ b/assets/js/quick-tag.ts @@ -67,13 +67,13 @@ function submit(event: Event) { setTagButton(`Wait... (${currentTags()})`); fetchJson('PUT', '/admin/batch/tags', { - tags: currentTags(), + tag_list: currentTags(), image_ids: currentQueue(), }) .then(handleError) .then(r => r.json()) - .then(data => { - if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); + .then((data: { failed: number }) => { + if (data.failed > 0) window.alert(`Failed to add tags to ${data.failed} images.`); reset(); }); diff --git a/config/aggregation.json b/config/aggregation.json deleted file mode 100644 index c9366c106..000000000 --- a/config/aggregation.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "comments": { - "aggs": { - "deleted": { - "filter": { - "term": { - "hidden_from_users": true - } - } - }, - "last_24h": { - "filter": { - "range": { - "created_at": { - "gt": "now-24h" - } - } - } - } - }, - "track_total_hits": true - }, - "images": { - "aggs": { - "deleted": { - "filter": { - "term": { - "hidden_from_users": true - } - } - }, - "non_deleted": { - "aggs": { - "all_time": { - "date_histogram": { - "field": "created_at", - "calendar_interval": "day" - } - }, - "avg_comments": { - "avg": { - "field": "comment_count" - } - }, - "faves_gt_0": { - "filter": { - "range": { - "faves": { - "gt": 0 - } - } - } - }, - "last_24h": { - "filter": { - "range": { - "created_at": { - "gt": "now-24h" - } - } - } - }, - "score_gt_0": { - "filter": { - "range": { - "score": { - "gt": 0 - } - } - } - }, - "score_lt_0": { - "filter": { - "range": { - "score": { - "lt": 0 - } - } - } - } - }, - "filter": { - "term": { - "hidden_from_users": false - } - } - } - } - } -} diff --git a/config/config.exs b/config/config.exs index dbe405049..006c1fc41 100644 --- a/config/config.exs +++ b/config/config.exs @@ -29,11 +29,6 @@ config :exq, scheduler_enable: true, start_on_application: false -config :canary, - repo: Philomena.Repo, - unauthorized_handler: {PhilomenaWeb.NotAuthorizedPlug, :call}, - not_found_handler: {PhilomenaWeb.NotFoundPlug, :call} - # Configures the endpoint config :philomena, PhilomenaWeb.Endpoint, adapter: Bandit.PhoenixAdapter, diff --git a/config/runtime.exs b/config/runtime.exs index 461f28723..d84d979dc 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -46,7 +46,6 @@ app_dir = System.get_env("APP_DIR", File.cwd!()) json_config = %{ - aggregation: "aggregation.json", avatar: "avatar.json", footer: "footer.json", quick_tag_table: "quick_tag_table.json", diff --git a/config/test.exs b/config/test.exs index 298e8d6f3..60509a63b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -21,6 +21,12 @@ config :philomena, pwned_passwords: false, captcha: false +# Keep test enqueues in memory. The application still exercises the same +# enqueue calls, but the test suite cannot fill the shared development Valkey +# instance with jobs that reference the test database. +config :exq, + queue_adapter: Exq.Adapters.Queue.Mock + # Namespace OpenSearch indexes so test runs cannot touch dev data on the # shared cluster. Search-backed tests recreate their index in setup; see # test/CONVENTIONS.md. @@ -54,3 +60,6 @@ config :philomena, PhilomenaWeb.Endpoint, # Print only warnings and errors during test config :logger, level: :warning + +# Initialize plugs at runtime for faster test recompilation +config :phoenix, :plug_init_mode, :runtime diff --git a/lib/philomena/activities.ex b/lib/philomena/activities.ex new file mode 100644 index 000000000..37fb26aeb --- /dev/null +++ b/lib/philomena/activities.ex @@ -0,0 +1,162 @@ +defmodule Philomena.Activities do + @moduledoc """ + The site homepage: the recent, top-scoring, watched, featured, comment, + stream, and topic strips it assembles for a viewer. + """ + + import Ecto.Query, only: [preload: 2] + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Activities.FrontPage + alias Philomena.Attribution.Actor + alias Philomena.Channels + alias Philomena.Comments + alias Philomena.Comments.Comment + alias Philomena.Filters.Filter + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Search, as: ImageSearch + alias Philomena.Images.Search.Scope + alias Philomena.Interactions + alias Philomena.Topics + alias PhilomenaQuery.Search + + @strip_size 6 + @image_preloads [:sources, tags: :aliases] + @comment_preloads [:user, image: @image_preloads] + + defp multi_search(images, top_scoring, comments, nil) do + Search.msearch_records( + images: {images, preload(Image, ^@image_preloads)}, + top_scoring: {top_scoring, preload(Image, ^@image_preloads)}, + comments: {comments, preload(Comment, ^@comment_preloads)} + ) + |> Map.put(:watched, nil) + end + + defp multi_search(images, top_scoring, comments, watched) do + Search.msearch_records( + images: {images, preload(Image, ^@image_preloads)}, + top_scoring: {top_scoring, preload(Image, ^@image_preloads)}, + comments: {comments, preload(Comment, ^@comment_preloads)}, + watched: {watched, preload(Image, ^@image_preloads)} + ) + end + + defp watched_definition(%Actor{user: nil}, _scope), do: {:ok, nil} + + defp watched_definition(%Actor{} = actor, scope) do + with {:ok, {definition, _tags}} <- + ImageSearch.search_string(actor, scope, "my:watched", + pagination: %{scope.pagination | page_number: 1} + ) do + {:ok, definition} + end + end + + defp search_definitions(%Actor{} = actor, %Scope{} = scope, %Filter{} = filter) do + {images_definition, _tags} = + ImageSearch.default_query(actor, scope, pagination: %{scope.pagination | page_number: 1}) + + {top_scoring_definition, _tags} = + ImageSearch.query( + actor, + scope, + %{range: %{first_seen_at: %{gt: "now-3d"}}}, + sorts: &%{query: &1, sorts: [%{wilson_score: :desc}, %{first_seen_at: :desc}]}, + pagination: %{page_number: :rand.uniform(6), page_size: 4} + ) + + comments_definition = + Comments.comment_search_definition( + actor, + filter, + %{range: %{created_at: %{gt: "now-1w"}}}, + pagination: %{page_number: 1, page_size: 6}, + show_hidden: false + ) + + case watched_definition(actor, scope) do + {:ok, watched_definition} -> + {:ok, + {images_definition, top_scoring_definition, comments_definition, watched_definition}} + + _error -> + {:ok, {images_definition, top_scoring_definition, comments_definition, nil}} + end + end + + defp load_search_sections({images, top_scoring, comments, watched}) do + multi_search(images, top_scoring, comments, watched) + end + + defp load_featured_image(actor, scope) do + include_hidden? = scope.hidden == true + + case Images.show_featured_image(actor, include_hidden?) do + {:ok, image} -> image + {:error, :not_found} -> nil + end + end + + defp assemble_front_page(actor, scope, definitions, show_nsfw_channels?) do + sections = load_search_sections(definitions) + featured_image = load_featured_image(actor, scope) + topics = Topics.list_front_page_topics(actor, @strip_size) + streams = Channels.list_front_page_channels(actor, show_nsfw_channels?, @strip_size) + + interactions = + Interactions.user_interactions(actor, [ + sections.images, + sections.top_scoring, + sections.watched, + featured_image + ]) + + %FrontPage{ + images: sections.images, + top_scoring: sections.top_scoring, + comments: sections.comments, + watched: sections.watched, + featured_image: featured_image, + streams: streams, + topics: topics, + interactions: interactions + } + end + + @doc """ + Assembles the homepage for `actor` using the image-search state in `scope`. + + `scope` retains the compiled filter, pagination, and display parameters for + for the images strip, top scoring strip, and optional watched strip. `filter` + supplies hidden tags for the recent comments strip, and `show_nsfw_channels?` + controls the channel strip. + + All search queries execute as one multi-search. Anonymous actors receive no + watched strip. + + ## Examples + + iex> show_activity(anonymous_actor, scope, filter, false) + {:ok, %FrontPage{watched: nil}} + + iex> show_activity(actor, scope, filter, true) + {:ok, %FrontPage{watched: %Scrivener.Page{}}} + + """ + @spec show_activity(Actor.t(), Scope.t(), Filter.t(), boolean()) :: + {:ok, FrontPage.t()} | {:error, :unauthorized} + def show_activity( + %Actor{} = actor, + %Scope{} = scope, + %Filter{} = filter, + show_nsfw_channels? + ) + when is_boolean(show_nsfw_channels?) do + with :ok <- authorize(actor, :show, FrontPage), + {:ok, definitions} <- search_definitions(actor, scope, filter) do + {:ok, assemble_front_page(actor, scope, definitions, show_nsfw_channels?)} + end + end +end diff --git a/lib/philomena/activities/front_page.ex b/lib/philomena/activities/front_page.ex new file mode 100644 index 000000000..e9b8c0844 --- /dev/null +++ b/lib/philomena/activities/front_page.ex @@ -0,0 +1,43 @@ +defmodule Philomena.Activities.FrontPage do + @moduledoc """ + The assembled homepage: the recent-image listing, the top-scoring strip, the + recent-comment strip, the viewer's watched images (`nil` for anonymous + visitors), the current featured image, the live-stream and forum-topic + strips, and the viewer's interactions across the image collections. + """ + + alias Philomena.Channels.Channel + alias Philomena.Comments.Comment + alias Philomena.Images.Image + alias Philomena.Topics.Topic + + @enforce_keys [ + :images, + :top_scoring, + :comments, + :watched, + :featured_image, + :streams, + :topics, + :interactions + ] + defstruct images: nil, + top_scoring: nil, + comments: nil, + watched: nil, + featured_image: nil, + streams: [], + topics: [], + interactions: [] + + @type t :: %__MODULE__{ + images: Scrivener.Page.t(Image.t()), + top_scoring: Scrivener.Page.t(Image.t()), + comments: Scrivener.Page.t(Comment.t()), + watched: Scrivener.Page.t(Image.t()) | nil, + featured_image: Image.t() | nil, + streams: [Channel.t()], + topics: [Topic.t()], + interactions: list() + } +end diff --git a/lib/philomena/adverts.ex b/lib/philomena/adverts.ex index a6e4c31fa..459bf3c4f 100644 --- a/lib/philomena/adverts.ex +++ b/lib/philomena/adverts.ex @@ -1,15 +1,54 @@ defmodule Philomena.Adverts do @moduledoc """ - The Adverts context. + Advert selection, click/impression tracking, and administration. """ import Ecto.Query, warn: false + + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Adverts.{Advert, Restrictions, Server, Uploader} + alias Philomena.Attribution.Actor + alias Philomena.Authorization + alias Philomena.Images.Image + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.Multi alias Philomena.Repo - alias Philomena.Adverts.Advert - alias Philomena.Adverts.Restrictions - alias Philomena.Adverts.Server - alias Philomena.Adverts.Uploader + defp live_adverts_query(restrictions \\ nil) do + now = DateTime.utc_now() + + query = + Advert + |> where(live: true) + |> where([a], a.start_date < ^now and a.finish_date > ^now) + + if restrictions do + where(query, [a], a.restrictions in ^restrictions) + else + query + end + end + + defp random_live_for_tags(tags) do + tags + |> Restrictions.tags() + |> live_adverts_query() + |> order_by(asc: fragment("random()")) + |> limit(1) + |> Repo.one() + end + + defp load_advert(actor, action, id) do + Loader.fetch_and_authorize(Advert, actor, action, id) + end + + defp increment_counter({id, count}, field) do + Advert + |> where(id: ^id) + |> Repo.update_all(inc: [{field, count}]) + end @doc """ Gets an advert that is currently live. @@ -23,6 +62,7 @@ defmodule Philomena.Adverts do %Advert{} """ + @spec random_live() :: Advert.t() | nil def random_live do random_live_for_tags([]) end @@ -42,6 +82,7 @@ defmodule Philomena.Adverts do %Advert{} """ + @spec random_live(Image.t()) :: Advert.t() | nil def random_live(image) do image |> Repo.preload(:tags) @@ -50,21 +91,6 @@ defmodule Philomena.Adverts do |> random_live_for_tags() end - defp random_live_for_tags(tags) do - now = DateTime.utc_now() - restrictions = Restrictions.tags(tags) - - query = - from a in Advert, - where: a.live == true, - where: a.restrictions in ^restrictions, - where: a.start_date < ^now and a.finish_date > ^now, - order_by: [asc: fragment("random()")], - limit: 1 - - Repo.one(query) - end - @doc """ Asynchronously records a new impression. @@ -74,141 +100,285 @@ defmodule Philomena.Adverts do :ok """ + @spec record_impression(Advert.t()) :: :ok def record_impression(%Advert{id: id}) do Server.record_impression(id) end @doc """ - Asynchronously records a new click. + Loads the currently live advert named by `id` and asynchronously records a + click. Malformed, absent, disabled, not-yet-started, and expired adverts are + not found. ## Example - iex> record_click(%Advert{}) - :ok + iex> record_click(advert.id) + {:ok, %Advert{}} """ - def record_click(%Advert{id: id}) do - Server.record_click(id) + @spec record_click(Loader.integer_id()) :: {:ok, Advert.t()} | {:error, :not_found} + def record_click(id) do + with {:ok, advert} <- Loader.fetch(live_adverts_query(), id), + :ok <- Server.record_click(advert.id) do + {:ok, advert} + end end @doc """ - Gets a single advert. + Returns paginated adverts for the admin listing, on behalf of `actor`, + newest finish date first. - Raises `Ecto.NoResultsError` if the Advert does not exist. + ## Examples + + iex> list_adverts(admin, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_adverts(user, pagination) + {:error, :unauthorized} + + """ + @spec list_adverts(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Advert.t())} | {:error, :unauthorized} + def list_adverts(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :index, Advert) do + adverts = + Advert + |> order_by(desc: :finish_date) + |> Repo.paginate(pagination) + + {:ok, adverts} + end + end + + @doc """ + Builds the changeset for a new advert, on behalf of `actor`. + + Returns an `%Ecto.Changeset{}` for tracking advert changes. ## Examples - iex> get_advert!(123) - %Advert{} + iex> new_advert(admin) + {:ok, %Ecto.Changeset{}} - iex> get_advert!(456) - ** (Ecto.NoResultsError) + iex> new_advert(user) + {:error, :unauthorized} """ - def get_advert!(id), do: Repo.get!(Advert, id) + @spec new_advert(Actor.t()) :: {:ok, Ecto.Changeset.t()} | Authorization.write_error() + def new_advert(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Advert) do + {:ok, Advert.changeset(%Advert{})} + end + end @doc """ - Creates an advert. + Creates an advert with an image, on behalf of `actor`. + + On success a moderation log attributing the creation to `actor` is written. ## Examples - iex> create_advert(%{field: value}) + iex> create_advert(admin, advert_params, upload) {:ok, %Advert{}} - iex> create_advert(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> create_advert(user, advert_params, upload) + {:error, :unauthorized} """ - def create_advert(attrs \\ %{}) do - %Advert{} - |> Advert.changeset(attrs) - |> Uploader.analyze_upload(attrs) - |> Repo.insert() - |> case do - {:ok, advert} -> - Uploader.persist_upload(advert) - Uploader.unpersist_old_upload(advert) - - {:ok, advert} - - error -> - error + @spec create_advert(Actor.t(), map(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, Advert.t()} | Authorization.write_error() | {:error, Ecto.Changeset.t()} + def create_advert(%Actor{} = actor, attrs, upload) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Advert) do + advert_changeset = + %Advert{} + |> Advert.changeset(attrs) + |> Uploader.analyze_upload(upload) + + Multi.new() + |> Multi.insert(:advert, advert_changeset) + |> Uploader.put_persist_upload_and_unpersist_old(:advert) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{advert: advert} -> + {"Admin.Advert:create", "/admin/adverts", "Created advert #{advert.id}"} + end) + |> Multi.transact() + |> case do + {:ok, %{advert: %Advert{} = advert}} -> + {:ok, advert} + + {:error, :advert, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Updates an Advert without updating its image. + Loads the advert named by the `id` for editing, on behalf of + `actor`, pairing it with a change-tracking changeset. ## Examples - iex> update_advert(advert, %{field: new_value}) - {:ok, %Advert{}} + iex> edit_advert(admin, advert_id) + {:ok, {%Advert{}, %Ecto.Changeset{}}} - iex> update_advert(advert, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_advert(admin, invalid_id) + {:error, :not_found} + + iex> edit_advert(user, advert_id) + {:error, :unauthorized} """ - def update_advert(%Advert{} = advert, attrs) do - advert - |> Advert.changeset(attrs) - |> Repo.update() + @spec edit_advert(Actor.t(), Loader.integer_id()) :: + {:ok, {Advert.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_advert(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, advert} <- load_advert(actor, :edit, id) do + {:ok, {advert, Advert.changeset(advert)}} + end end @doc """ - Updates the image for an Advert. + Updates the advert named by the `id` without touching its image, + on behalf of `actor`. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> update_advert_image(advert, %{image: new_value}) + iex> update_advert(admin, advert_id, advert_params) {:ok, %Advert{}} - iex> update_advert_image(advert, %{image: bad_value}) + iex> update_advert(admin, advert_id, invalid_params) {:error, %Ecto.Changeset{}} + iex> update_advert(admin, invalid_id, advert_params) + {:error, :not_found} + + iex> update_advert(user, advert_id, advert_params) + {:error, :unauthorized} + """ - def update_advert_image(%Advert{} = advert, attrs) do - advert - |> Advert.changeset(attrs) - |> Uploader.analyze_upload(attrs) - |> Repo.update() - |> case do - {:ok, advert} -> - Uploader.persist_upload(advert) - Uploader.unpersist_old_upload(advert) - - {:ok, advert} - - error -> - error + @spec update_advert(Actor.t(), Loader.integer_id(), map()) :: + {:ok, Advert.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_advert(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, advert} <- load_advert(actor, :update, id) do + advert_changeset = Advert.changeset(advert, attrs) + + Multi.new() + |> Multi.update(:advert, advert_changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{advert: advert} -> + {"Admin.Advert:update", "/admin/adverts", "Updated advert #{advert.id}"} + end) + |> Multi.transact() + |> case do + {:ok, %{advert: %Advert{} = advert}} -> + {:ok, advert} + + {:error, :advert, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Deletes an Advert. + Deletes the advert named by the `id`, on behalf of `actor`. + + On success a moderation log attributing the deletion to `actor` is written. ## Examples - iex> delete_advert(advert) + iex> delete_advert(admin, advert_id) {:ok, %Advert{}} - iex> delete_advert(advert) - {:error, %Ecto.Changeset{}} + iex> delete_advert(admin, invalid_id) + {:error, :not_found} + + iex> delete_advert(user, advert_id) + {:error, :unauthorized} """ - def delete_advert(%Advert{} = advert) do - Repo.delete(advert) + @spec delete_advert(Actor.t(), Loader.integer_id()) :: + {:ok, Advert.t()} | {:error, Authorization.write_error_reason() | :not_found} + def delete_advert(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, advert} <- load_advert(actor, :delete, id) do + Multi.new() + |> Multi.delete(:advert, Advert.remove_image_changeset(advert)) + |> Uploader.put_unpersist_old_upload(:advert) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{advert: advert} -> + {"Admin.Advert:delete", "/admin/adverts", "Deleted advert #{advert.id}"} + end) + |> Multi.transact() + |> case do + {:ok, %{advert: %Advert{} = advert}} -> + {:ok, advert} + + {:error, :advert, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking advert changes. + Updates the image of the advert named by the `id`, on behalf of + `actor`, running the image upload pipeline. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> change_advert(advert) - %Ecto.Changeset{source: %Advert{}} + iex> update_advert_image(admin, advert_id, upload) + {:ok, %Advert{}} + + iex> update_advert_image(admin, advert_id, nil) + {:error, %Ecto.Changeset{}} + iex> update_advert_image(admin, invalid_id, upload) + {:error, :not_found} + + iex> update_advert_image(user, advert_id, upload) + {:error, :unauthorized} + + """ + @spec update_advert_image(Actor.t(), Loader.integer_id(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, Advert.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_advert_image(%Actor{} = actor, id, upload) do + with :ok <- verify_write_access(actor), + {:ok, advert} <- load_advert(actor, :update_image, id) do + advert_changeset = + advert + |> Advert.changeset() + |> Uploader.analyze_upload(upload) + + Multi.new() + |> Multi.update(:advert, advert_changeset) + |> Uploader.put_persist_upload_and_unpersist_old(:advert) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{advert: advert} -> + {"Admin.Advert.Image:update", "/admin/adverts", "Updated image for advert #{advert.id}"} + end) + |> Multi.transact() + |> case do + {:ok, %{advert: %Advert{} = advert}} -> + {:ok, advert} + + {:error, :advert, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc """ + Records batched advert impressions and clicks. """ - def change_advert(%Advert{} = advert) do - Advert.changeset(advert, %{}) + @spec record_counters(%{impressions: map(), clicks: map()}) :: :ok + def record_counters(%{impressions: impressions, clicks: clicks}) do + Enum.each(impressions, &increment_counter(&1, :impressions)) + Enum.each(clicks, &increment_counter(&1, :clicks)) + :ok end end diff --git a/lib/philomena/adverts/advert.ex b/lib/philomena/adverts/advert.ex index fcf1f5fcb..fb7c10f21 100644 --- a/lib/philomena/adverts/advert.ex +++ b/lib/philomena/adverts/advert.ex @@ -2,6 +2,8 @@ defmodule Philomena.Adverts.Advert do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + schema "adverts" do field :image, :string field :link, :string @@ -26,7 +28,7 @@ defmodule Philomena.Adverts.Advert do end @doc false - def changeset(advert, attrs) do + def changeset(advert, attrs \\ %{}) do advert |> cast(attrs, [:title, :link, :start_date, :finish_date, :live, :restrictions, :notes]) |> validate_required([:title, :link, :start_date, :finish_date]) @@ -51,4 +53,11 @@ defmodule Philomena.Adverts.Advert do |> validate_inclusion(:image_height, 79..91) |> validate_inclusion(:image_size, 0..1_048_576) end + + @doc false + def remove_image_changeset(advert) do + advert + |> change(removed_image: advert.image) + |> change(image: nil) + end end diff --git a/lib/philomena/adverts/recorder.ex b/lib/philomena/adverts/recorder.ex deleted file mode 100644 index 15bc37936..000000000 --- a/lib/philomena/adverts/recorder.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule Philomena.Adverts.Recorder do - alias Philomena.Adverts.Advert - alias Philomena.Repo - import Ecto.Query - - def run(%{impressions: impressions, clicks: clicks}) do - now = DateTime.utc_now(:second) - - # Create insert statements for Ecto - impressions = Enum.map(impressions, &impressions_insert_all(&1, now)) - clicks = Enum.map(clicks, &clicks_insert_all(&1, now)) - - # Merge into table - impressions_update = update(Advert, inc: [impressions: fragment("EXCLUDED.impressions")]) - clicks_update = update(Advert, inc: [clicks: fragment("EXCLUDED.clicks")]) - - Repo.insert_all(Advert, impressions, on_conflict: impressions_update, conflict_target: [:id]) - Repo.insert_all(Advert, clicks, on_conflict: clicks_update, conflict_target: [:id]) - - :ok - end - - defp impressions_insert_all({advert_id, impressions}, now) do - %{ - id: advert_id, - impressions: impressions, - created_at: now, - updated_at: now - } - end - - defp clicks_insert_all({advert_id, clicks}, now) do - %{ - id: advert_id, - clicks: clicks, - created_at: now, - updated_at: now - } - end -end diff --git a/lib/philomena/adverts/server.ex b/lib/philomena/adverts/server.ex index be759693c..0b5b38971 100644 --- a/lib/philomena/adverts/server.ex +++ b/lib/philomena/adverts/server.ex @@ -1,17 +1,14 @@ defmodule Philomena.Adverts.Server do @moduledoc """ - Advert impression and click aggregator. - - Updating the impression count for adverts and clicks on every pageload is unnecessary - and slows down requests. This module collects the adverts and clicks and submits a batch - of updates to the database after every 10 seconds asynchronously, reducing the amount of - work to be done. + Batches advert click and impression updates and submits them to the database + every 10 seconds. """ use GenServer - alias Philomena.Adverts.Recorder + alias Philomena.Adverts - @type advert_id :: integer() + @timeout 0 + @flush_interval to_timeout(second: 10) @doc """ Starts the GenServer. @@ -31,7 +28,7 @@ defmodule Philomena.Adverts.Server do :ok """ - @spec record_impression(advert_id()) :: :ok + @spec record_impression(integer()) :: :ok def record_impression(advert_id) do GenServer.cast(__MODULE__, {:impressions, advert_id}) end @@ -45,16 +42,11 @@ defmodule Philomena.Adverts.Server do :ok """ - @spec record_click(advert_id()) :: :ok + @spec record_click(integer()) :: :ok def record_click(advert_id) do GenServer.cast(__MODULE__, {:clicks, advert_id}) end - # Used to force the GenServer to immediately sleep when no - # messages are available. - @timeout 0 - @sleep :timer.seconds(10) - @impl true @doc false def init(_) do @@ -75,13 +67,10 @@ defmodule Philomena.Adverts.Server do @doc false def handle_info(:timeout, state) do # Process all updates from state now - Recorder.run(state) - - # Sleep for the specified delay - :timer.sleep(@sleep) + Adverts.record_counters(state) - # Return to GenServer event loop - {:noreply, initial_state(), @timeout} + # Return to GenServer event loop and wait for the next flush interval. + {:noreply, initial_state(), @flush_interval} end defp increment_counter(map, advert_id) do diff --git a/lib/philomena/adverts/uploader.ex b/lib/philomena/adverts/uploader.ex index d575ebe45..f28d25180 100644 --- a/lib/philomena/adverts/uploader.ex +++ b/lib/philomena/adverts/uploader.ex @@ -3,19 +3,25 @@ defmodule Philomena.Adverts.Uploader do Upload and processing callback logic for Advert images. """ + alias Philomena.Multi alias Philomena.Adverts.Advert alias PhilomenaMedia.Uploader - def analyze_upload(advert, params) do - Uploader.analyze_upload(advert, "image", params["image"], &Advert.image_changeset/2) + def analyze_upload(advert, upload) do + Uploader.analyze_upload(advert, "image", upload, &Advert.image_changeset/2) end - def persist_upload(advert) do - Uploader.persist_upload(advert, advert_file_root(), "image") + def put_persist_upload_and_unpersist_old(multi, step) do + Multi.on_commit(multi, fn %{^step => advert} -> + Uploader.persist_upload(advert, advert_file_root(), "image") + Uploader.unpersist_old_upload(advert, advert_file_root(), "image") + end) end - def unpersist_old_upload(advert) do - Uploader.unpersist_old_upload(advert, advert_file_root(), "image") + def put_unpersist_old_upload(multi, step) do + Multi.on_commit(multi, fn %{^step => advert} -> + Uploader.unpersist_old_upload(advert, advert_file_root(), "image") + end) end defp advert_file_root do diff --git a/lib/philomena/application.ex b/lib/philomena/application.ex index c92401286..6182e25f8 100644 --- a/lib/philomena/application.ex +++ b/lib/philomena/application.ex @@ -37,12 +37,15 @@ defmodule Philomena.Application do ] ]}, - # Advert update batching + # Updating certain high-traffic counters on every action is unnecessary + # and creates a large volume of dead database rows. These server modules + # collect updates asynchronously and submit periodic batches of updates + # to the database, reducing churn. Philomena.Adverts.Server, + Philomena.UserFingerprints.Server, + Philomena.UserIps.Server, # Start the endpoint when the application starts - PhilomenaWeb.UserFingerprintUpdater, - PhilomenaWeb.UserIpUpdater, PhilomenaWeb.Endpoint ] diff --git a/lib/philomena/artist_links.ex b/lib/philomena/artist_links.ex index 28f468a92..d800244d2 100644 --- a/lib/philomena/artist_links.ex +++ b/lib/philomena/artist_links.ex @@ -1,172 +1,511 @@ defmodule Philomena.ArtistLinks do @moduledoc """ - The ArtistLinks context. + Artist link submission and staff verification workflows. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Repo - alias Philomena.ArtistLinks.ArtistLink - alias Philomena.ArtistLinks.AutomaticVerifier - alias Philomena.ArtistLinks.BadgeAwarder + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + + alias Philomena.ArtistLinks.{ + ArtistLink, + AutomaticVerifier, + QueryBuilder, + QueryForm + } + + alias Philomena.Attribution.Actor + alias Philomena.Authorization + alias Philomena.Badges + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.Multi + alias Philomena.Repo alias Philomena.Tags + alias Philomena.Users.User + + @artist_link_preloads [:user, :tag, :contacted_by_user] + + defp load_authorized_profile(%Actor{} = actor, action, slug) do + User + |> where(slug: ^slug) + |> where([u], is_nil(u.deleted_at)) + |> Loader.one_and_authorize(actor, action) + end + + defp load_scoped_artist_link(%Actor{} = actor, action, slug, id) do + with {:ok, user} <- load_authorized_profile(actor, :show, slug) do + ArtistLink + |> where(user_id: ^user.id) + |> preload(^@artist_link_preloads) + |> Loader.fetch_and_authorize(actor, action, id) + end + end + + defp load_artist_link(%Actor{} = actor, action, id) do + Loader.fetch_and_authorize(ArtistLink, actor, action, id, @artist_link_preloads) + end @doc """ Updates all artist links pending verification, by transitioning to link verified state or resetting next update time. + + This function is designed for automatic link verification as a background task, + and is not intended for use from request-facing code. """ - def automatic_verify! do + @spec run_automatic_verification!() :: :ok + def run_automatic_verification! do Enum.each(AutomaticVerifier.generate_updates(), &Repo.update!/1) end @doc """ - Gets a single artist link. - - Raises `Ecto.NoResultsError` if the Artist link does not exist. + Lists the artist links belonging to the user named by the profile `slug`, on + behalf of `actor`. ## Examples - iex> get_artist_link!(123) - %ArtistLink{} + iex> list_artist_links(user_actor, user.slug) + {:ok, {%User{}, [%ArtistLink{}, ...]}} + + iex> list_artist_links(anonymous_actor, user.slug) + {:error, :unauthorized} - iex> get_artist_link!(456) - ** (Ecto.NoResultsError) + iex> list_artist_links(user_actor, invalid_slug) + {:error, :not_found} """ - def get_artist_link!(id), do: Repo.get!(ArtistLink, id) + @spec list_artist_links(Actor.t(), String.t()) :: + {:ok, {User.t(), [ArtistLink.t()]}} | {:error, :unauthorized | :not_found} + def list_artist_links(%Actor{} = actor, slug) do + with {:ok, user} <- load_authorized_profile(actor, :create_links, slug) do + links = + ArtistLink + |> where(user_id: ^user.id) + |> Repo.all() + + {:ok, {user, links}} + end + end @doc """ - Creates an artist link. + Returns paginated artist links for the admin listing, on behalf of + `actor`, newest first, with the moderation associations preloaded. + + The query form filters by artist-link states and `%term%` matches on the + profile user name or link URI. By default, it lists only links awaiting + moderation (`unverified`/`link_verified`/`contacted`). ## Examples - iex> create_artist_link(%{field: value}) - {:ok, %ArtistLink{}} + iex> list_admin_artist_links(admin, params, pagination) + {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} - iex> create_artist_link(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> list_admin_artist_links(user, params, pagination) + {:error, :unauthorized} """ - def create_artist_link(user, attrs \\ %{}) do - tag = Tags.get_tag_or_alias_by_name(attrs["tag_name"]) + @spec list_admin_artist_links(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(ArtistLink.t()), Ecto.Changeset.t()} | {:error, :unauthorized} + def list_admin_artist_links(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, ArtistLink) do + {artist_links, changeset} = + case QueryBuilder.build_query(params) do + {:ok, query, query_form} -> + page = + query + |> preload([ + :tag, + :verified_by_user, + :contacted_by_user, + user: [:linked_tags, awards: :badge] + ]) + |> Repo.paginate(pagination) + + {page, QueryForm.changeset(query_form)} + + {:error, changeset} -> + {Repo.paginate(where(ArtistLink, false), pagination), changeset} + end + + {:ok, artist_links, changeset} + end + end + + @doc """ + Loads the profile user named by `slug` for creating a new artist link, on + behalf of `actor`. + + ## Examples + + iex> new_artist_link(user_actor, user.slug) + {:ok, {%User{}, %Ecto.Changeset{}}} + + iex> new_artist_link(admin_actor, other_user.slug) + {:ok, {%User{}, %Ecto.Changeset{}}} + + iex> new_artist_link(banned_actor, banned_user.slug) + {:error, :ban} - %ArtistLink{} - |> ArtistLink.creation_changeset(attrs, user, tag) - |> Repo.insert() + iex> new_artist_link(admin_actor, invalid_slug) + {:error, :not_found} + + iex> new_artist_link(user_actor, other_user.slug) + {:error, :unauthorized} + + """ + @spec new_artist_link(Actor.t(), String.t()) :: + {:ok, {User.t(), Ecto.Changeset.t()}} + | {:error, :ban | :unauthorized | :not_found} + def new_artist_link(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_authorized_profile(actor, :create_links, slug) do + {:ok, {user, ArtistLink.changeset(%ArtistLink{})}} + end end @doc """ - Updates an artist link. + Submits a new artist link for the user named by the profile `slug`, on behalf + of `actor`, from `attrs`. ## Examples - iex> update_artist_link(artist_link, %{field: new_value}) - {:ok, %ArtistLink{}} + iex> create_artist_link(user_actor, user.slug, artist_link_params) + {:ok, {%User{}, %ArtistLink{}}} + + iex> create_artist_link(admin_actor, other_user.slug, artist_link_params) + {:ok, {%User{}, %ArtistLink{}}} + + iex> create_artist_link(user_actor, user.slug, invalid_params) + {:error, {%User{}, %Ecto.Changeset{}}} + + iex> create_artist_link(banned_actor, banned_user.slug, artist_link_params) + {:error, :ban} + + iex> create_artist_link(user_actor, other_user.slug, artist_link_params) + {:error, :unauthorized} - iex> update_artist_link(artist_link, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> create_artist_link(admin_actor, invalid_slug, artist_link_params) + {:error, :not_found} """ - def update_artist_link(%ArtistLink{} = artist_link, attrs) do - tag = Tags.get_tag_or_alias_by_name(attrs["tag_name"]) + @spec create_artist_link(Actor.t(), String.t(), map()) :: + {:ok, {User.t(), ArtistLink.t()}} + | {:error, {User.t(), Ecto.Changeset.t()}} + | {:error, :ban | :unauthorized | :not_found} + def create_artist_link(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_authorized_profile(actor, :create_links, slug), + {:ok, artist_link} <- + %ArtistLink{} + |> ArtistLink.tag_name_changeset(attrs) + |> Ecto.Changeset.apply_action(:create) do + tag_names = List.wrap(artist_link.tag_name) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, tag_names, []}]) + |> Multi.insert(:artist_link, fn %{canonical_tags: %{tag: tags}} -> + ArtistLink.creation_changeset(%ArtistLink{}, attrs, user, List.first(tags)) + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{artist_link: %ArtistLink{} = artist_link}} -> + {:ok, {user, artist_link}} + + {:error, :artist_link, %Ecto.Changeset{} = changeset, _changes} -> + {:error, {user, changeset}} + end + end + end + + @doc """ + Loads the artist link named by `id` under the profile `slug`, on behalf of + `actor`. + + ## Examples + + iex> show_artist_link(user, user.slug, artist_link_id) + {:ok, {%User{}, %ArtistLink{}}} + + iex> show_artist_link(admin, other_user.slug, artist_link_id) + {:ok, {%User{}, %ArtistLink{}}} + + iex> show_artist_link(user, other_user.slug, artist_link_id) + {:error, :unauthorized} - artist_link - |> ArtistLink.edit_changeset(attrs, tag) - |> Repo.update() + iex> show_artist_link(user, invalid_slug, invalid_id) + {:error, :not_found} + + """ + @spec show_artist_link(Actor.t(), String.t(), Loader.integer_id()) :: + {:ok, {User.t(), ArtistLink.t()}} | {:error, :unauthorized | :not_found} + def show_artist_link(%Actor{} = actor, slug, id) do + with {:ok, artist_link} <- load_scoped_artist_link(actor, :show, slug, id) do + {:ok, {artist_link.user, artist_link}} + end end @doc """ - Transitions an artist link to the verified state. + Loads the artist link named by `id` under the profile `slug` for editing, on + behalf of `actor`. ## Examples - iex> verify_artist_link(artist_link, verifying_user) - {:ok, %ArtistLink{}} + iex> edit_artist_link(user, user.slug, artist_link_id) + {:ok, {%ArtistLink{}, %Ecto.Changeset{}}} + + iex> edit_artist_link(admin, other_user.slug, artist_link_id) + {:ok, {%ArtistLink{}, %Ecto.Changeset{}}} - iex> verify_artist_link(artist_link, verifying_user) - :error + iex> edit_artist_link(user, other_user.slug, artist_link_id) + {:error, :unauthorized} + + iex> edit_artist_link(user, invalid_slug, invalid_id) + {:error, :not_found} """ - def verify_artist_link(%ArtistLink{} = artist_link, verifying_user) do - artist_link_changeset = ArtistLink.verify_changeset(artist_link, verifying_user) - - Multi.new() - |> Multi.update(:artist_link, artist_link_changeset) - |> Multi.run(:add_award, BadgeAwarder.award_callback(artist_link, verifying_user)) - |> Repo.transaction() - |> case do - {:ok, %{artist_link: artist_link}} -> - {:ok, artist_link} - - {:error, _operation, _value, _changes} -> - :error + @spec edit_artist_link(Actor.t(), String.t(), Loader.integer_id()) :: + {:ok, {ArtistLink.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_artist_link(%Actor{} = actor, slug, id) do + with :ok <- verify_write_access(actor), + {:ok, artist_link} <- load_scoped_artist_link(actor, :edit, slug, id) do + {:ok, {artist_link, ArtistLink.changeset(artist_link)}} end end @doc """ - Transitions an artist link to the rejected state. + Updates the artist link named by `id` under the profile `slug`, on behalf of + `actor`, from `attrs`. ## Examples - iex> reject_artist_link(artist_link) - {:ok, %ArtistLink{}} + iex> update_artist_link(admin, user.slug, artist_link_id, artist_link_params) + {:ok, {%User{}, %ArtistLink{}}} + + iex> update_artist_link(admin, user.slug, artist_link_id, invalid_params) + {:error, {%ArtistLink{}, %Ecto.Changeset{}}} + + iex> update_artist_link(admin, user.slug, invalid_id, invalid_params) + {:error, :not_found} - iex> reject_artist_link(artist_link) - {:error, %Ecto.Changeset{}} + iex> update_artist_link(user, user.slug, artist_link_id, artist_link_params) + {:error, :unauthorized} """ - def reject_artist_link(%ArtistLink{} = artist_link) do - artist_link - |> ArtistLink.reject_changeset() - |> Repo.update() + @spec update_artist_link(Actor.t(), String.t(), Loader.integer_id(), map()) :: + {:ok, {User.t(), ArtistLink.t()}} + | {:error, {ArtistLink.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def update_artist_link(%Actor{} = actor, slug, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, artist_link} <- load_scoped_artist_link(actor, :update, slug, id), + {:ok, artist_link} <- + artist_link + |> ArtistLink.tag_name_changeset(attrs) + |> Ecto.Changeset.apply_action(:update) do + tag_names = List.wrap(artist_link.tag_name) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, tag_names, []}]) + |> Multi.update(:artist_link, fn %{canonical_tags: %{tag: tags}} -> + ArtistLink.edit_changeset(artist_link, attrs, List.first(tags)) + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{artist_link: %ArtistLink{} = artist_link}} -> + {:ok, {artist_link.user, artist_link}} + + {:error, :artist_link, %Ecto.Changeset{} = changeset, _changes} -> + {:error, {artist_link, changeset}} + end + end end @doc """ - Transitions an artist link to the contacted state. + Verifies the artist link named by `id`, on behalf of `actor`, transitioning it + to the verified state and awarding the artist badge to its owner. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> contact_artist_link(artist_link) + iex> create_artist_link_verification(admin, artist_link_id) {:ok, %ArtistLink{}} - iex> contact_artist_link(artist_link) - {:error, %Ecto.Changeset{}} + iex> create_artist_link_verification(admin, invalid_id) + {:error, :not_found} + + iex> create_artist_link_verification(user, artist_link_id) + {:error, :unauthorized} """ - def contact_artist_link(%ArtistLink{} = artist_link, user) do - artist_link - |> ArtistLink.contact_changeset(user) - |> Repo.update() + @spec create_artist_link_verification(Actor.t(), Loader.integer_id()) :: + {:ok, ArtistLink.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def create_artist_link_verification(%Actor{user: user} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, artist_link} <- load_artist_link(actor, :verify, id) do + verify_changeset = ArtistLink.verify_changeset(artist_link, user) + + Multi.new() + |> Multi.update(:artist_link, verify_changeset) + |> Badges.put_award_artist_badge(artist_link.user, user) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{artist_link: artist_link} -> + { + "Admin.ArtistLink.Verification:create", + Paths.artist_link_path(artist_link.user, artist_link), + "Verified artist link #{artist_link.uri} created by #{artist_link.user.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{artist_link: %ArtistLink{} = artist_link}} -> + {:ok, artist_link} + + {:error, :artist_link, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Deletes an artist link. + Rejects the artist link named by `id`, on behalf of `actor`, transitioning it + to the rejected state. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> delete_artist_link(artist_link) + iex> create_artist_link_reject(admin, artist_link_id) {:ok, %ArtistLink{}} - iex> delete_artist_link(artist_link) - {:error, %Ecto.Changeset{}} + iex> create_artist_link_reject(admin, invalid_id) + {:error, :not_found} + + iex> create_artist_link_reject(user, artist_link_id) + {:error, :unauthorized} """ - def delete_artist_link(%ArtistLink{} = artist_link) do - Repo.delete(artist_link) + @spec create_artist_link_reject(Actor.t(), Loader.integer_id()) :: + {:ok, ArtistLink.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def create_artist_link_reject(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, artist_link} <- load_artist_link(actor, :reject, id) do + reject_changeset = ArtistLink.reject_changeset(artist_link) + + Multi.new() + |> Multi.update(:artist_link, reject_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{artist_link: artist_link} -> + { + "Admin.ArtistLink.Reject:create", + Paths.artist_link_path(artist_link.user, artist_link), + "Rejected artist link #{artist_link.uri} created by #{artist_link.user.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{artist_link: %ArtistLink{} = artist_link}} -> + {:ok, artist_link} + + {:error, :artist_link, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking artist link changes. + Marks the artist link named by `id` as contacted, on behalf of `actor`, + transitioning it to the contacted state. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> change_artist_link(artist_link) - %Ecto.Changeset{source: %ArtistLink{}} + iex> create_artist_link_contact(admin, artist_link_id) + {:ok, %ArtistLink{}} + + iex> create_artist_link_contact(admin, invalid_id) + {:error, :not_found} + + iex> create_artist_link_contact(user, artist_link_id) + {:error, :unauthorized} + + """ + @spec create_artist_link_contact(Actor.t(), Loader.integer_id()) :: + {:ok, ArtistLink.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def create_artist_link_contact(%Actor{user: user} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, artist_link} <- load_artist_link(actor, :contact, id) do + contact_changeset = ArtistLink.contact_changeset(artist_link, user) + + Multi.new() + |> Multi.update(:artist_link, contact_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{artist_link: artist_link} -> + { + "Admin.ArtistLink.Contact:create", + Paths.artist_link_path(artist_link.user, artist_link), + "Contacted artist #{artist_link.user.name} at #{artist_link.uri}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{artist_link: %ArtistLink{} = artist_link}} -> + {:ok, artist_link} + + {:error, :artist_link, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc """ + Repoints artist links from one tag to another inside `multi`. + Links that would duplicate an existing non-rejected target link are removed + first. Tag aliasing composes this operation instead of writing the + `artist_links` table directly. """ - def change_artist_link(%ArtistLink{} = artist_link) do - ArtistLink.changeset(artist_link, %{}) + @spec put_alias_tag(Multi.t(), integer(), integer()) :: Multi.t() + def put_alias_tag(%Multi{} = multi, source_tag_id, target_tag_id) do + conflicts = + from source in ArtistLink, + join: target in ArtistLink, + on: + target.tag_id == ^target_tag_id and + target.uri == source.uri and + target.user_id == source.user_id, + where: + source.tag_id == ^source_tag_id and + source.aasm_state != "rejected" and + target.aasm_state != "rejected", + select: source.id + + multi + |> Multi.delete_all( + :delete_conflicting_artist_links, + from(link in ArtistLink, where: link.id in subquery(conflicts)) + ) + |> Multi.update_all( + :update_artist_links, + where(ArtistLink, tag_id: ^source_tag_id), + set: [tag_id: target_tag_id] + ) end @doc """ @@ -182,8 +521,9 @@ defmodule Philomena.ArtistLinks do 0 """ + @spec count_artist_links(User.t() | nil) :: non_neg_integer() | nil def count_artist_links(user) do - if Canada.Can.can?(user, :index, %ArtistLink{}) do + if authorize(user, :index, ArtistLink) == :ok do ArtistLink |> where([ul], ul.aasm_state in ^["unverified", "link_verified"]) |> Repo.aggregate(:count) diff --git a/lib/philomena/artist_links/artist_link.ex b/lib/philomena/artist_links/artist_link.ex index a3bcce8d4..ca89009c1 100644 --- a/lib/philomena/artist_links/artist_link.ex +++ b/lib/philomena/artist_links/artist_link.ex @@ -5,6 +5,8 @@ defmodule Philomena.ArtistLinks.ArtistLink do alias Philomena.Users.User alias Philomena.Tags.Tag + @type t :: %__MODULE__{} + schema "artist_links" do belongs_to :user, User belongs_to :verified_by_user, User @@ -18,16 +20,25 @@ defmodule Philomena.ArtistLinks.ArtistLink do field :next_check_at, :utc_datetime field :contacted_at, :utc_datetime + field :tag_name, :string, virtual: true + timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(artist_link, attrs) do + def changeset(artist_link, attrs \\ %{}) do artist_link |> cast(attrs, []) |> validate_required([]) end + @doc false + def tag_name_changeset(artist_link, attrs) do + artist_link + |> cast(attrs, [:tag_name]) + |> update_change(:tag_name, &Tag.clean_tag_name/1) + end + def edit_changeset(artist_link, attrs, nil) do artist_link |> cast(attrs, [:uri, :public]) @@ -103,4 +114,12 @@ defmodule Philomena.ArtistLinks.ArtistLink do change(changeset, next_check_at: time) end + + def states do + ~w(unverified link_verified contacted verified rejected) + end + + def pending_states do + ~w(unverified link_verified contacted) + end end diff --git a/lib/philomena/artist_links/automatic_verifier.ex b/lib/philomena/artist_links/automatic_verifier.ex index 2ec3c66c2..b5cf9fa98 100644 --- a/lib/philomena/artist_links/automatic_verifier.ex +++ b/lib/philomena/artist_links/automatic_verifier.ex @@ -22,8 +22,7 @@ defmodule Philomena.ArtistLinks.AutomaticVerifier do Returns a list of changesets with updated links. """ def generate_updates do - # Automatically retry in an hour if we don't manage to - # successfully verify any given link + # Automatically retry in an hour if unsuccessful now = DateTime.utc_now(:second) recheck_time = DateTime.add(now, 3600, :second) diff --git a/lib/philomena/artist_links/badge_awarder.ex b/lib/philomena/artist_links/badge_awarder.ex deleted file mode 100644 index 275a5aeed..000000000 --- a/lib/philomena/artist_links/badge_awarder.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Philomena.ArtistLinks.BadgeAwarder do - @moduledoc """ - Handles awarding a badge to the user of an associated artist link. - """ - - alias Philomena.Badges - - @badge_title "Artist" - - @doc """ - Awards a badge to an artist with a verified link. - - If the badge with the title `"Artist"` does not exist, no award will be created. - If the user already has an award with that badge title, no award will be created. - - Returns `{:ok, award}`, `{:ok, nil}`, or `{:error, changeset}`. The return value is - suitable for use as the return value to an `Ecto.Multi.run/3` callback. - """ - def award_badge(artist_link, verifying_user) do - with badge when not is_nil(badge) <- Badges.get_badge_by_title(@badge_title), - award when is_nil(award) <- Badges.get_badge_award_for(badge, artist_link.user) do - Badges.create_badge_award(verifying_user, artist_link.user, %{badge_id: badge.id}) - else - _ -> - {:ok, nil} - end - end - - @doc """ - Get a callback for issuing a badge award from within an `m:Ecto.Multi`. - """ - def award_callback(artist_link, verifying_user) do - fn _repo, _changes -> - award_badge(artist_link, verifying_user) - end - end -end diff --git a/lib/philomena/artist_links/query_builder.ex b/lib/philomena/artist_links/query_builder.ex new file mode 100644 index 000000000..6f4b0c12b --- /dev/null +++ b/lib/philomena/artist_links/query_builder.ex @@ -0,0 +1,61 @@ +defmodule Philomena.ArtistLinks.QueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.ArtistLinks.ArtistLink + alias Philomena.ArtistLinks.QueryForm + + @doc """ + Builds an artist link query based on the given parameters. + + ## Parameters + + * `states` - Filter by artist-link states; an empty list includes every state + * `text` - Search profile user names and link URIs + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: {:ok, Ecto.Query.t(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + ArtistLink + |> maybe_filter_states(query_form) + |> maybe_filter_text(query_form) + |> order_by([artist_link], desc: artist_link.created_at, desc: artist_link.id) + + {:ok, query, query_form} + end + end + + defp maybe_filter_states(query, %QueryForm{states: []}), do: query + + defp maybe_filter_states(query, %QueryForm{states: states}) do + where(query, [artist_link], artist_link.aasm_state in ^states) + end + + defp maybe_filter_text(query, %QueryForm{text: text}) do + if text do + pattern = "%#{unsanitized_like(text)}%" + + query + |> join(:inner, [artist_link], user in assoc(artist_link, :user)) + |> where( + [artist_link, user], + ilike(user.name, ^pattern) or ilike(artist_link.uri, ^pattern) + ) + else + query + end + end + + defp unsanitized_like(query_string) do + query_string + end +end diff --git a/lib/philomena/artist_links/query_form.ex b/lib/philomena/artist_links/query_form.ex new file mode 100644 index 000000000..cf79e3cc7 --- /dev/null +++ b/lib/philomena/artist_links/query_form.ex @@ -0,0 +1,23 @@ +defmodule Philomena.ArtistLinks.QueryForm do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + alias Philomena.ArtistLinks.ArtistLink + + @type t :: %__MODULE__{} + + embedded_schema do + field :states, {:array, :string}, default: ArtistLink.pending_states() + field :text, :string + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + query_form + |> cast(attrs, [:states, :text]) + |> validate_subset(:states, ArtistLink.states()) + end +end diff --git a/lib/philomena/attribution/actor.ex b/lib/philomena/attribution/actor.ex index 92440cc02..a2267b796 100644 --- a/lib/philomena/attribution/actor.ex +++ b/lib/philomena/attribution/actor.ex @@ -3,27 +3,40 @@ defmodule Philomena.Attribution.Actor do The typed actor/attribution passed to context functions. Context functions that act on behalf of someone take the actor first. - Where that actor is really an *attribution* (a user together with the - request's IP and browser fingerprint - e.g. image uploads, - tag changes, posts), this struct formalizes the shape that - `PhilomenaWeb.UserAttributionPlug` builds so context signatures can be typed. - - It carries the same three values as the existing `t:Philomena.Users.principal/0` - keyword list (`lib/philomena/users.ex`), which many contexts still consume via - Access (`attribution[:user]`). + + Where that actor is really an *attribution* (a user together with the IP and + fingerprint attributing an action - e.g. image uploads, tag changes, posts), + this struct holds all relevant data for authorization and persistence. """ alias Philomena.Users.User @enforce_keys [:ip] - defstruct user: nil, ip: nil, fingerprint: nil + defstruct user: nil, ip: nil, fingerprint: nil, ban: nil - # `%User{}` is used rather than `User.t()` to match the existing - # `t:Philomena.Users.principal/0` type: the `User` schema does not define a - # `t/0` type, so referencing it here would fail `--warnings-as-errors`. @type t :: %__MODULE__{ - user: %User{} | nil, + user: User.t() | nil, + ip: EctoNetwork.INET.t(), + fingerprint: String.t() | nil, + ban: map() | nil + } + + @doc """ + Converts an `Actor` to a map of changes suitable for passing as the second argument + to `Ecto.Changeset.change/2`. + + ## Examples + + iex> to_changes(actor) + %{fingerprint: "abcdef", ip: %Postgrex.INET{}, user: %User{}} + + """ + @spec to_changes(t()) :: %{ + fingerprint: String.t() | nil, ip: EctoNetwork.INET.t(), - fingerprint: String.t() | nil + user: User.t() | nil } + def to_changes(%__MODULE__{fingerprint: fingerprint, ip: ip, user: user}) do + %{fingerprint: fingerprint, ip: ip, user: user} + end end diff --git a/lib/philomena/attribution/anonymous_name.ex b/lib/philomena/attribution/anonymous_name.ex new file mode 100644 index 000000000..1913f83a2 --- /dev/null +++ b/lib/philomena/attribution/anonymous_name.ex @@ -0,0 +1,41 @@ +defmodule Philomena.Attribution.AnonymousName do + @moduledoc """ + Generates the stable pseudonym used for anonymous attribution. + + The hash is scoped by the attributed object's parent identifier and the best + available user, fingerprint, or IP identifier. + """ + + alias Philomena.Attribution + + @spec anonymous?(struct()) :: boolean() + def anonymous?(object) do + not is_nil(Attribution.impl_for(object)) and Attribution.anonymous?(object) + end + + @spec anonymous_user?(struct()) :: boolean() + def anonymous_user?(object), do: is_nil(object.user) or anonymous?(object) + + @spec name(struct()) :: String.t() + def name(object) do + if anonymous_user?(object), do: generate(object), else: object.user.name + end + + @spec generate(struct(), boolean()) :: String.t() + def generate(object, reveal_anonymous? \\ false) do + salt = Application.get_env(:philomena, :anonymous_name_salt) |> to_string() + object_id = Attribution.object_identifier(object) + user_id = Attribution.best_user_identifier(object) + + {:ok, <>} = + :pbkdf2.pbkdf2(:sha256, object_id <> user_id, salt, 100, 2) + + hash = key |> Integer.to_string(16) |> String.pad_leading(4, "0") + + if object.user && reveal_anonymous? do + "#{object.user.name} (##{hash}, hidden)" + else + "Background Pony ##{hash}" + end + end +end diff --git a/lib/philomena/authorization.ex b/lib/philomena/authorization.ex index c4d48187e..31e02a46b 100644 --- a/lib/philomena/authorization.ex +++ b/lib/philomena/authorization.ex @@ -11,13 +11,34 @@ defmodule Philomena.Authorization do The actor is permitted to be `nil` (an anonymous visitor). """ + alias Philomena.Attribution.Actor + alias Philomena.Users.User + + @typedoc "Type of acceptable actor inputs." + @type actor :: Actor.t() | User.t() | nil + + @typedoc "The normalized reason returned by a failed ability check." + @type error_reason :: :unauthorized + + @typedoc "The normalized failure returned by an ability check." + @type error :: {:error, error_reason()} + + @typedoc "Reasons returned by the global write prerequisite." + @type write_error_reason :: :ban | error_reason() + + @typedoc "Failures returned by the global write prerequisite." + @type write_error :: {:error, write_error_reason()} + @doc """ Authorizes `actor` to perform `action` on `subject`. Returns `:ok` when Canada permits the action, otherwise `{:error, :unauthorized}`. - `actor` may be `nil` for an anonymous visitor. + `actor` may be `nil` for an anonymous visitor. It may also be a + `Philomena.Attribution.Actor`: permissions are decided by its `user` alone - + the IP and fingerprint attribute the action but grant nothing - so contexts + that take an attribution can pass it here unchanged. ## Examples @@ -27,10 +48,46 @@ defmodule Philomena.Authorization do iex> authorize(nil, :hide, image) {:error, :unauthorized} + iex> authorize(%Actor{user: moderator, ip: ip}, :revert, TagChange) + :ok + """ - @spec authorize(actor :: any(), action :: atom(), subject :: any()) :: - :ok | {:error, :unauthorized} + @spec authorize(actor :: actor(), action :: atom(), subject :: any()) :: + :ok | error() + def authorize(%Actor{user: user}, action, subject), do: authorize(user, action, subject) + def authorize(actor, action, subject) do if Canada.Can.can?(actor, action, subject), do: :ok, else: {:error, :unauthorized} end + + @doc """ + Verifies that `actor` may perform a write. + + Decides, in order: + + * `{:error, :ban}` when the actor carries an active ban; + * `{:error, :unauthorized}` when the actor has no fingerprint; + * `:ok` otherwise. + + The fingerprint requirement applies regardless of whether a user is signed in. + + ## Deliberate exceptions + + These personal preference actions intentionally permit banned users: + + - Switching the current filter + - Clearing recent filters + - Changing the active spoiler type + - Clearing notifications + - Updating user settings + - Watching/unwatching tags + - Creating/deleting subscriptions + + The actions still perform their own authentication and resource + authorization checks. + """ + @spec verify_write_access(actor :: Actor.t()) :: :ok | write_error() + def verify_write_access(%Actor{ban: ban}) when not is_nil(ban), do: {:error, :ban} + def verify_write_access(%Actor{fingerprint: nil}), do: {:error, :unauthorized} + def verify_write_access(%Actor{}), do: :ok end diff --git a/lib/philomena/autocomplete.ex b/lib/philomena/autocomplete.ex index e496ffc49..ac577ef8c 100644 --- a/lib/philomena/autocomplete.ex +++ b/lib/philomena/autocomplete.ex @@ -1,54 +1,75 @@ defmodule Philomena.Autocomplete do @moduledoc """ - Pregenerated autocomplete files. + Public access to the pregenerated autocomplete binary stored in PostgreSQL. - These are used to eliminate the latency of looking up search results on the server. - A script can parse the binary and generate results directly as the user types, without - incurring any roundtrip penalty. + Browsers download the opaque binary and search it locally, avoiding a server + round trip for each suggestion. Reading is deliberately unauthenticated. + Generation is an operational service used by the release task. """ import Ecto.Query, warn: false + + alias Philomena.Autocomplete.{Autocomplete, Generator} + alias Philomena.Loader alias Philomena.Repo - alias Philomena.Autocomplete.Autocomplete - alias Philomena.Autocomplete.Generator + defp latest_query do + Autocomplete + |> order_by(desc: :created_at) + |> limit(1) + end + + defp replace_autocomplete!(content) do + Repo.transact(fn -> + Repo.delete_all(Autocomplete) + + autocomplete = + %Autocomplete{} + |> Autocomplete.changeset(%{content: content}) + |> Repo.insert!() + + {:ok, autocomplete} + end) + end @doc """ - Gets the current local autocompletion binary. + Loads the current compiled autocomplete artifact. - Returns nil if the binary is not currently generated. + Before the first successful generation, it returns `{:error, :not_found}`. ## Examples - iex> get_autocomplete() - nil + iex> show_compiled_autocomplete() + {:error, :not_found} - iex> get_autocomplete() - %Autocomplete{} + iex> show_compiled_autocomplete() + {:ok, %Autocomplete{}} """ - def get_autocomplete do - Autocomplete - |> order_by(desc: :created_at) - |> limit(1) - |> Repo.one() + @spec show_compiled_autocomplete() :: {:ok, Autocomplete.t()} | {:error, :not_found} + def show_compiled_autocomplete do + latest_query() + |> Loader.one() end @doc """ - Creates a new local autocompletion binary, replacing any which currently exist. - """ - def generate_autocomplete! do - ac_file = Generator.generate() + Generates and atomically replaces the compiled autocomplete artifact. + + Binary generation runs before the replacement transaction. The transaction + deletes every previous row and inserts exactly one new row, so readers see + either the old artifact or the complete replacement. Raises when generation + or persistence violates an invariant. - # Insert the autocomplete binary - new_ac = + ## Examples + + iex> generate_autocomplete!() %Autocomplete{} - |> Autocomplete.changeset(%{content: ac_file}) - |> Repo.insert!() - # Remove anything older - Autocomplete - |> where([ac], ac.created_at < ^new_ac.created_at) - |> Repo.delete_all() + """ + @spec generate_autocomplete!() :: Autocomplete.t() + def generate_autocomplete! do + content = Generator.generate() + {:ok, autocomplete} = replace_autocomplete!(content) + autocomplete end end diff --git a/lib/philomena/badges.ex b/lib/philomena/badges.ex index 3cd0cd550..aaf8f233c 100644 --- a/lib/philomena/badges.ex +++ b/lib/philomena/badges.ex @@ -1,275 +1,545 @@ defmodule Philomena.Badges do @moduledoc """ - The Badges context. + Administration for badges and associated awards attached to user profiles. + + Performs artist badge awarding for verified artist links. """ import Ecto.Query, warn: false - alias Philomena.Repo - - alias Philomena.Badges.Badge - alias Philomena.Badges.Uploader - - @doc """ - Returns the list of badges. - ## Examples + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] - iex> list_badges() - [%Badge{}, ...] + alias Philomena.Multi + alias Philomena.Attribution.Actor + alias Philomena.Authorization + alias Philomena.Badges.{Award, Badge, Uploader} + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.Repo + alias Philomena.Users.User - """ - def list_badges do - Repo.all(Badge) + defp load_badge(actor, action, id) do + Loader.fetch_and_authorize(Badge, actor, action, id) end @doc """ - Gets a single badge. - - Raises `Ecto.NoResultsError` if the Badge does not exist. + Returns the paginated badges for the admin listing, on behalf of `actor`, + ordered by title. ## Examples - iex> get_badge!(123) - %Badge{} + iex> list_badges(admin, pagination) + {:ok, %Scrivener.Page{}} - iex> get_badge!(456) - ** (Ecto.NoResultsError) + iex> list_badges(user, pagination) + {:error, :unauthorized} """ - def get_badge!(id), do: Repo.get!(Badge, id) + @spec list_badges(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t()} | {:error, :unauthorized} + def list_badges(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :index, Badge) do + {:ok, + Badge + |> order_by(asc: :title) + |> Repo.paginate(pagination)} + end + end @doc """ - Gets a single badge by its title. - - Returns nil if the Badge does not exist. + Builds the changeset for creating a badge, on behalf of `actor`. ## Examples - iex> get_badge_by_title("Artist") - %Badge{} + iex> new_badge(admin) + {:ok, %Ecto.Changeset{}} - iex> get_badge_by_title("Nonexistent") - nil + iex> new_badge(user) + {:error, :unauthorized} """ - def get_badge_by_title(title), do: Repo.get_by(Badge, title: title) + @spec new_badge(Actor.t()) :: {:ok, Ecto.Changeset.t()} | Authorization.write_error() + def new_badge(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Badge) do + {:ok, Badge.changeset(%Badge{})} + end + end @doc """ - Creates a badge. + Creates a badge on behalf of `actor`, running the SVG upload pipeline. + + On success a moderation log attributing the creation to `actor` is written. ## Examples - iex> create_badge(%{field: value}) + iex> create_badge(admin, badge_params, upload) {:ok, %Badge{}} - iex> create_badge(%{field: bad_value}) + iex> create_badge(admin, invalid_params, upload) {:error, %Ecto.Changeset{}} + iex> create_badge(user, badge_params, upload) + {:error, :unauthorized} + """ - def create_badge(attrs \\ %{}) do - %Badge{} - |> Badge.changeset(attrs) - |> Uploader.analyze_upload(attrs) - |> Repo.insert() - |> case do - {:ok, badge} -> - Uploader.persist_upload(badge) - Uploader.unpersist_old_upload(badge) - - {:ok, badge} - - error -> - error + @spec create_badge(Actor.t(), map(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, Badge.t()} + | {:error, Authorization.write_error_reason() | Ecto.Changeset.t()} + def create_badge(%Actor{} = actor, attrs, upload) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Badge) do + badge_changeset = + %Badge{} + |> Badge.changeset(attrs) + |> Uploader.analyze_upload(upload) + + Multi.new() + |> Multi.insert(:badge, badge_changeset) + |> Uploader.put_persist_upload_and_unpersist_old(:badge) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{badge: badge} -> + {"Admin.Badge:create", "/admin/badges", "Created badge '#{badge.title}'"} + end + ) + |> Multi.transact() + |> case do + {:ok, %{badge: %Badge{} = badge}} -> + {:ok, badge} + + {:error, :badge, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Updates a badge without updating its image. + Loads the badge named by `id` for editing, on behalf of `actor`, pairing it + with a changeset for editing it. ## Examples - iex> update_badge(badge, %{field: new_value}) - {:ok, %Badge{}} + iex> edit_badge(admin, badge_id) + {:ok, {%Badge{}, %Ecto.Changeset{}}} - iex> update_badge(badge, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_badge(admin, invalid_id) + {:error, :not_found} + + iex> edit_badge(user, badge_id) + {:error, :unauthorized} """ - def update_badge(%Badge{} = badge, attrs) do - badge - |> Badge.changeset(attrs) - |> Repo.update() + @spec edit_badge(Actor.t(), Loader.integer_id()) :: + {:ok, {Badge.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_badge(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, badge} <- load_badge(actor, :edit, id) do + {:ok, {badge, Badge.changeset(badge)}} + end end @doc """ - Updates the image for a badge. + Updates the badge named by `id` without touching its image, on behalf of + `actor`. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> update_badge_image(badge, %{image: new_value}) + iex> update_badge(admin, badge_id, badge_params) {:ok, %Badge{}} - iex> update_badge_image(badge, %{image: bad_value}) + iex> update_badge(admin, badge_id, invalid_params) {:error, %Ecto.Changeset{}} + iex> update_badge(admin, invalid_id, badge_params) + {:error, :not_found} + + iex> update_badge(user, badge_id, badge_params) + {:error, :unauthorized} + """ - def update_badge_image(%Badge{} = badge, attrs) do - badge - |> Badge.changeset(attrs) - |> Uploader.analyze_upload(attrs) - |> Repo.update() - |> case do - {:ok, badge} -> - Uploader.persist_upload(badge) - Uploader.unpersist_old_upload(badge) - - {:ok, badge} - - error -> - error + @spec update_badge(Actor.t(), Loader.integer_id(), map()) :: + {:ok, Badge.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_badge(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, badge} <- load_badge(actor, :update, id) do + badge_changeset = Badge.changeset(badge, attrs) + + Multi.new() + |> Multi.update(:badge, badge_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{badge: badge} -> + {"Admin.Badge:update", "/admin/badges", "Updated badge '#{badge.title}'"} + end + ) + |> Multi.transact() + |> case do + {:ok, %{badge: %Badge{} = badge}} -> + {:ok, badge} + + {:error, :badge, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Deletes a Badge. + Updates the image of the badge named by `id`, on behalf of `actor`, running + the SVG upload pipeline. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> delete_badge(badge) + iex> update_badge_image(admin, badge_id, upload) {:ok, %Badge{}} - iex> delete_badge(badge) + iex> update_badge_image(admin, badge_id, nil) {:error, %Ecto.Changeset{}} + iex> update_badge_image(admin, invalid_id, upload) + {:error, :not_found} + + iex> update_badge_image(user, badge_id, upload) + {:error, :unauthorized} + """ - def delete_badge(%Badge{} = badge) do - Repo.delete(badge) + @spec update_badge_image(Actor.t(), Loader.integer_id(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, Badge.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_badge_image(%Actor{} = actor, id, upload) do + with :ok <- verify_write_access(actor), + {:ok, badge} <- load_badge(actor, :update_image, id) do + badge_changeset = + badge + |> Badge.changeset() + |> Uploader.analyze_upload(upload) + + Multi.new() + |> Multi.update(:badge, badge_changeset) + |> Uploader.put_persist_upload_and_unpersist_old(:badge) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{badge: badge} -> + {"Admin.Badge.Image:update", "/admin/badges", "Updated image of badge '#{badge.title}'"} + end + ) + |> Multi.transact() + |> case do + {:ok, %{badge: %Badge{} = badge}} -> + {:ok, badge} + + {:error, :badge, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking badge changes. + Loads the badge named by `id` together with the users who hold it, on behalf + of `actor`, paginated and ordered by name. ## Examples - iex> change_badge(badge) - %Ecto.Changeset{source: %Badge{}} + iex> list_badge_users(admin, badge_id, pagination) + {:ok, {%Badge{}, %Scrivener.Page{}}} - """ - def change_badge(%Badge{} = badge) do - Badge.changeset(badge, %{}) - end + iex> list_badge_users(admin, invalid_id, pagination) + {:error, :not_found} - alias Philomena.Badges.Award + iex> list_badge_users(user, badge_id, pagination) + {:error, :unauthorized} - @doc """ - Returns the list of badge_awards. + """ + @spec list_badge_users(Actor.t(), Loader.integer_id(), Repo.pagination_params()) :: + {:ok, {Badge.t(), Scrivener.Page.t()}} | {:error, :unauthorized | :not_found} + def list_badge_users(%Actor{} = actor, id, pagination) do + with {:ok, badge} <- load_badge(actor, :show_users, id) do + users = + User + |> join(:inner, [u], _ in assoc(u, :awards)) + |> where([_u, a], a.badge_id == ^badge.id) + |> order_by([u, _a], asc: u.name) + |> Repo.paginate(pagination) + + {:ok, {badge, users}} + end + end - ## Examples + defp awardable_badges do + Badge + |> where(disable_award: false) + |> order_by(asc: :title) + |> Repo.all() + end - iex> list_badge_awards() - [%Award{}, ...] + defp load_authorized_profile(%Actor{} = actor, action, slug) do + User + |> where(slug: ^slug) + |> where([u], is_nil(u.deleted_at)) + |> Loader.one_and_authorize(actor, action) + end - """ - def list_badge_awards do - Repo.all(Award) + defp load_scoped_award(%Actor{} = actor, action, slug, id) do + with {:ok, user} <- load_authorized_profile(actor, :show, slug) do + Award + |> where(user_id: ^user.id) + |> preload([:user, :badge]) + |> Loader.fetch_and_authorize(actor, action, id) + end end @doc """ - Gets a single badge_award. - - Raises `Ecto.NoResultsError` if the Badge award does not exist. + Loads the user named by the profile `slug` for creating an award, on behalf + of `actor`. ## Examples - iex> get_badge_award!(123) - %Award{} + iex> new_award(admin, user.slug) + {:ok, {%User{}, %Ecto.Changeset{}, [%Badge{}, ...]}} + + iex> new_award(admin, invalid_slug) + {:error, :not_found} - iex> get_badge_award!(456) - ** (Ecto.NoResultsError) + iex> new_award(user, user.slug) + {:error, :unauthorized} """ - def get_badge_award!(id), do: Repo.get!(Award, id) + @spec new_award(Actor.t(), String.t()) :: + {:ok, {User.t(), Ecto.Changeset.t(), [Badge.t()]}} + | {:error, Authorization.write_error_reason() | :not_found} + def new_award(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_authorized_profile(actor, :show, slug), + :ok <- authorize(actor, :new, Award) do + {:ok, {user, Award.changeset(%Award{}), awardable_badges()}} + end + end @doc """ - Gets a the badge_award with the given badge type belonging to the user. + Awards a badge to the user named by the profile `slug`, on behalf of `actor`, + from `attrs`. - Raises nil if the Badge award does not exist. + On success a moderation log attributing the award to `actor` is written. + Duplicate grants are allowed and create separate award records. ## Examples - iex> get_badge_award_for(badge, user) - %Award{} + iex> create_award(admin, user.slug, award_params) + {:ok, {%User{}, %Award{}}} + + iex> create_award(admin, user.slug, invalid_params) + {:error, {%User{}, %Ecto.Changeset{}, [%Badge{}, ...]}} + + iex> create_award(admin, invalid_slug, award_params) + {:error, :not_found} - iex> get_badge_award_for(badge, user) - nil + iex> create_award(user, user.slug, award_params) + {:error, :unauthorized} """ - def get_badge_award_for(badge, user) do - Repo.get_by(Award, badge_id: badge.id, user_id: user.id) + @spec create_award(Actor.t(), String.t(), map()) :: + {:ok, {User.t(), Award.t()}} + | {:error, {User.t(), Ecto.Changeset.t(), [Badge.t()]}} + | {:error, Authorization.write_error_reason() | :not_found} + def create_award(%Actor{user: creator} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_authorized_profile(actor, :show, slug), + :ok <- authorize(actor, :create, Award) do + award_changeset = + %Award{awarded_by_id: creator.id, user_id: user.id} + |> Award.changeset(attrs) + + Multi.new() + |> Multi.insert(:award, award_changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{award: award} -> + award = Repo.preload(award, :badge) + + { + "Profile.Award:create", + Paths.profile_path(user), + "Awarded badge '#{award.badge.title}' to #{user.name}" + } + end) + |> Multi.transact() + |> case do + {:ok, %{award: %Award{} = award}} -> + {:ok, {user, award}} + + {:error, :award, %Ecto.Changeset{} = changeset, _changes} -> + {:error, {user, changeset, awardable_badges()}} + end + end end @doc """ - Creates a badge_award. + Loads the award named by `id` under the profile `slug` for editing, on behalf + of `actor`. ## Examples - iex> create_badge_award(%{field: value}) - {:ok, %Award{}} + iex> edit_award(admin, user.slug, award_id) + {:ok, {%User{}, %Award{}, %Ecto.Changeset{}, [%Badge{}, ...]}} - iex> create_badge_award(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_award(admin, invalid_slug, award_id) + {:error, :not_found} + + iex> edit_award(admin, user.slug, invalid_id) + {:error, :not_found} + + iex> edit_award(user, user.slug, award_id) + {:error, :unauthorized} """ - def create_badge_award(creator, user, attrs \\ %{}) do - %Award{awarded_by_id: creator.id, user_id: user.id} - |> Award.changeset(attrs) - |> Repo.insert() + @spec edit_award(Actor.t(), String.t(), Loader.integer_id()) :: + {:ok, {User.t(), Award.t(), Ecto.Changeset.t(), [Badge.t()]}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_award(%Actor{} = actor, slug, id) do + with :ok <- verify_write_access(actor), + {:ok, award} <- load_scoped_award(actor, :edit, slug, id) do + {:ok, {award.user, award, Award.changeset(award), awardable_badges()}} + end end @doc """ - Updates a badge_award. + Updates the award named by `id` under the profile `slug`, on behalf of + `actor`, from `attrs`. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> update_badge_award(badge_award, %{field: new_value}) - {:ok, %Award{}} + iex> update_award(admin, user.slug, award_id, award_params) + {:ok, {%User{}, %Award{}}} - iex> update_badge_award(badge_award, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> update_award(admin, user.slug, award_id, invalid_params) + {:error, {%User{}, %Award{}, %Ecto.Changeset{}, [%Badge{}, ...]}} + + iex> update_award(admin, invalid_slug, award_id, award_params) + {:error, :not_found} + + iex> update_award(admin, user.slug, invalid_id, award_params) + {:error, :not_found} + + iex> update_award(user, user.slug, award_id, award_params) + {:error, :unauthorized} """ - def update_badge_award(%Award{} = badge_award, attrs) do - badge_award - |> Award.changeset(attrs) - |> Repo.update() + @spec update_award(Actor.t(), String.t(), Loader.integer_id(), map()) :: + {:ok, {User.t(), Award.t()}} + | {:error, {User.t(), Award.t(), Ecto.Changeset.t(), [Badge.t()]}} + | {:error, Authorization.write_error_reason() | :not_found} + def update_award(%Actor{} = actor, slug, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, award} <- load_scoped_award(actor, :update, slug, id) do + award_changeset = Award.changeset(award, attrs) + + Multi.new() + |> Multi.update(:award, award_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{award: award} -> + { + "Profile.Award:update", + Paths.profile_path(award.user), + "Updated award of badge '#{award.badge.title}' on #{award.user.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{award: %Award{} = award}} -> + {:ok, {award.user, award}} + + {:error, :award, %Ecto.Changeset{} = changeset, _changes} -> + {:error, {award.user, award, changeset, awardable_badges()}} + end + end end @doc """ - Deletes a Award. + Revokes the award named by `id` under the profile `slug`, on behalf of + `actor`. + + On success a moderation log attributing the removal to `actor` is written. ## Examples - iex> delete_badge_award(badge_award) - {:ok, %Award{}} + iex> delete_award(admin, user.slug, award_id) + {:ok, {%User{}, %Award{}}} - iex> delete_badge_award(badge_award) - {:error, %Ecto.Changeset{}} + iex> delete_award(admin, invalid_slug, award_id) + {:error, :not_found} + + iex> delete_award(admin, user.slug, invalid_id) + {:error, :not_found} + + iex> delete_award(user, user.slug, award_id) + {:error, :unauthorized} """ - def delete_badge_award(%Award{} = badge_award) do - Repo.delete(badge_award) + @spec delete_award(Actor.t(), String.t(), Loader.integer_id()) :: + {:ok, {User.t(), Award.t()}} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def delete_award(%Actor{} = actor, slug, id) do + with :ok <- verify_write_access(actor), + {:ok, award} <- load_scoped_award(actor, :delete, slug, id) do + Multi.new() + |> Multi.delete(:award, award) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{award: award} -> + { + "Profile.Award:delete", + Paths.profile_path(award.user), + "Removed badge '#{award.badge.title}' from #{award.user.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{award: %Award{} = award}} -> + {:ok, {award.user, award}} + + {:error, :award, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking badge_award changes. - - ## Examples - - iex> change_badge_award(badge_award) - %Ecto.Changeset{source: %Award{}} + Adds the automatic "Artist" badge award for a verified artist link to `multi`. + The underlying award operation returns `{:ok, award}`, `{:ok, nil}`, or + `{:error, changeset}` as the result of the `Multi.run/3` callback. + Existing awards and a missing Artist badge are intentional no-ops. """ - def change_badge_award(%Award{} = badge_award) do - Award.changeset(badge_award, %{}) + @spec put_award_artist_badge( + multi :: Multi.t(), + target_user :: User.t(), + verifying_user :: User.t() + ) :: Multi.t() + def put_award_artist_badge(%Multi{} = multi, %User{} = target_user, %User{} = verifying_user) do + Multi.run(multi, :award, fn repo, _changes -> + with %Badge{} = badge <- repo.get_by(Badge, title: "Artist"), + nil <- repo.get_by(Award, badge_id: badge.id, user_id: target_user.id) do + %Award{awarded_by_id: verifying_user.id, user_id: target_user.id} + |> Award.changeset(%{badge_id: badge.id}) + |> repo.insert() + else + _ -> {:ok, nil} + end + end) end end diff --git a/lib/philomena/badges/award.ex b/lib/philomena/badges/award.ex index e0237abb4..26021c127 100644 --- a/lib/philomena/badges/award.ex +++ b/lib/philomena/badges/award.ex @@ -5,6 +5,8 @@ defmodule Philomena.Badges.Award do alias Philomena.Badges.Badge alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "badge_awards" do belongs_to :user, User belongs_to :awarded_by, User @@ -19,7 +21,7 @@ defmodule Philomena.Badges.Award do end @doc false - def changeset(badge_award, attrs) do + def changeset(badge_award, attrs \\ %{}) do badge_award |> cast(attrs, [:badge_id, :label, :reason, :badge_name]) |> put_awarded_on() diff --git a/lib/philomena/badges/badge.ex b/lib/philomena/badges/badge.ex index 7bd31c489..2169c258d 100644 --- a/lib/philomena/badges/badge.ex +++ b/lib/philomena/badges/badge.ex @@ -2,6 +2,8 @@ defmodule Philomena.Badges.Badge do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + schema "badges" do field :title, :string field :description, :string, default: "" @@ -17,7 +19,7 @@ defmodule Philomena.Badges.Badge do end @doc false - def changeset(badge, attrs) do + def changeset(badge, attrs \\ %{}) do badge |> cast(attrs, [:title, :description, :disable_award, :priority]) |> validate_required([:title]) diff --git a/lib/philomena/badges/uploader.ex b/lib/philomena/badges/uploader.ex index 6410bae9d..2128c213f 100644 --- a/lib/philomena/badges/uploader.ex +++ b/lib/philomena/badges/uploader.ex @@ -3,19 +3,25 @@ defmodule Philomena.Badges.Uploader do Upload and processing callback logic for Badge images. """ + alias Philomena.Multi alias Philomena.Badges.Badge alias PhilomenaMedia.Uploader - def analyze_upload(badge, params) do - Uploader.analyze_upload(badge, "image", params["image"], &Badge.image_changeset/2) + def analyze_upload(badge, upload) do + Uploader.analyze_upload(badge, "image", upload, &Badge.image_changeset/2) end - def persist_upload(badge) do - Uploader.persist_upload(badge, badge_file_root(), "image") + def put_persist_upload_and_unpersist_old(multi, step) do + Multi.on_commit(multi, fn %{^step => badge} -> + Uploader.persist_upload(badge, badge_file_root(), "image") + Uploader.unpersist_old_upload(badge, badge_file_root(), "image") + end) end - def unpersist_old_upload(badge) do - Uploader.unpersist_old_upload(badge, badge_file_root(), "image") + def put_unpersist_old_upload(multi, step) do + Multi.on_commit(multi, fn %{^step => badge} -> + Uploader.unpersist_old_upload(badge, badge_file_root(), "image") + end) end defp badge_file_root do diff --git a/lib/philomena/bans.ex b/lib/philomena/bans.ex index e8b8ec67d..3244a966f 100644 --- a/lib/philomena/bans.ex +++ b/lib/philomena/bans.ex @@ -1,338 +1,809 @@ defmodule Philomena.Bans do @moduledoc """ - The Bans context. + Ban enforcement and actor-scoped administration for user, subnet, and + fingerprint bans. + + Handles automatic creation of subnet bans for an input user ban. + + This prevents trivial ban evasion with the creation of a new account from the same address. + The user must work around or wait out the subnet ban first. """ import Ecto.Query, warn: false - alias Ecto.Multi + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.Repo + alias Philomena.Loader + alias Philomena.Attribution.Actor + alias Philomena.Authorization alias Philomena.Bans.Finder alias Philomena.Bans.Fingerprint - alias Philomena.Bans.SubnetCreator + alias Philomena.Bans.FingerprintQueryBuilder + alias Philomena.Bans.FingerprintQueryForm alias Philomena.Bans.Subnet + alias Philomena.Bans.SubnetQueryBuilder + alias Philomena.Bans.SubnetQueryForm alias Philomena.Bans.User + alias Philomena.Bans.UserQueryBuilder + alias Philomena.Bans.UserQueryForm + alias Philomena.ModerationLogs + alias Philomena.Multi + alias Philomena.UserIps alias Philomena.Users + # For every ban type: authorize on schema module first, then on instance. + defp load_ban(actor, schema, id, action, preloads \\ []) do + with {:ok, ban} <- Loader.fetch(schema, id, preloads), + :ok <- authorize(actor, action, schema), + :ok <- authorize(actor, action, ban) do + {:ok, ban} + end + end + @doc """ - Returns the list of fingerprint bans. + Returns the fingerprint bans matching `fingerprint`, newest first. + """ + @spec fingerprint_bans_for(String.t()) :: [Fingerprint.t()] + def fingerprint_bans_for(fingerprint) do + Fingerprint + |> where(fingerprint: ^fingerprint) + |> order_by(desc: :created_at) + |> Repo.all() + end + + @doc """ + Returns paginated fingerprint bans for the admin listing, on behalf of + `actor`. + + Filters by the `"bq"` full-text search or the exact `"fingerprint"` branch + when either is present in params. Results are ordered newest first. ## Examples - iex> list_fingerprint_bans() - [%Fingerprint{}, ...] + iex> list_fingerprint_bans(admin, params, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_fingerprint_bans(user, params, pagination) + {:error, :unauthorized} """ - def list_fingerprint_bans do - Repo.all(Fingerprint) + @spec list_fingerprint_bans(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Fingerprint.t()), Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_fingerprint_bans(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Fingerprint), + {:ok, query, form} <- FingerprintQueryBuilder.build_query(params) do + fingerprint_bans = + query + |> preload(:banning_user) + |> Repo.paginate(pagination) + + {:ok, fingerprint_bans, FingerprintQueryForm.changeset(form)} + end end @doc """ - Gets a single fingerprint ban. - - Raises `Ecto.NoResultsError` if the fingerprint ban does not exist. + Builds a changeset for a new fingerprint ban on behalf of `actor`, prefilling + the fingerprint from the `fingerprint` argument (which may be `nil`). ## Examples - iex> get_fingerprint!(123) - %Fingerprint{} + iex> new_fingerprint_ban(admin, fingerprint) + {:ok, %Ecto.Changeset{}} - iex> get_fingerprint!(456) - ** (Ecto.NoResultsError) + iex> new_fingerprint_ban(user, fingerprint) + {:error, :unauthorized} """ - def get_fingerprint!(id), do: Repo.get!(Fingerprint, id) + @spec new_fingerprint_ban(Actor.t(), String.t() | nil) :: + {:ok, Ecto.Changeset.t()} | Authorization.write_error() + def new_fingerprint_ban(%Actor{} = actor, fingerprint) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Fingerprint) do + {:ok, Fingerprint.changeset(%Fingerprint{fingerprint: fingerprint})} + end + end @doc """ - Creates a fingerprint ban. + Creates a fingerprint ban on behalf of `actor`. + + On success a moderation log attributing the creation to `actor` is written. ## Examples - iex> create_fingerprint(%{field: value}) + iex> create_fingerprint_ban(admin, ban_params) {:ok, %Fingerprint{}} - iex> create_fingerprint(%{field: bad_value}) + iex> create_fingerprint_ban(admin, invalid_params) {:error, %Ecto.Changeset{}} + iex> create_fingerprint_ban(user, ban_params) + {:error, :unauthorized} + """ - def create_fingerprint(creator, attrs \\ %{}) do - %Fingerprint{banning_user_id: creator.id} - |> Fingerprint.changeset(attrs) - |> Repo.insert() + @spec create_fingerprint_ban(Actor.t(), map()) :: + {:ok, Fingerprint.t()} + | Authorization.write_error() + | {:error, Ecto.Changeset.t()} + def create_fingerprint_ban(%Actor{user: creator} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Fingerprint) do + fingerprint_changeset = + %Fingerprint{banning_user_id: creator.id} + |> Fingerprint.changeset(attrs) + + Multi.new() + |> Multi.insert(:fingerprint, fingerprint_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{fingerprint: fingerprint} -> + { + "Admin.FingerprintBan:create", + "/admin/fingerprint_bans", + "Created a fingerprint ban #{fingerprint.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{fingerprint: %Fingerprint{} = fingerprint}} -> + {:ok, fingerprint} + + {:error, :fingerprint, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Updates a fingerprint ban. + Loads the fingerprint ban named by `id` for editing, on behalf of `actor`, + pairing it with a changeset for editing it. ## Examples - iex> update_fingerprint(fingerprint, %{field: new_value}) - {:ok, %Fingerprint{}} + iex> edit_fingerprint_ban(admin, fingerprint_ban_id) + {:ok, {%Fingerprint{}, %Ecto.Changeset{}}} - iex> update_fingerprint(fingerprint, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_fingerprint_ban(admin, invalid_id) + {:error, :not_found} + + iex> edit_fingerprint_ban(user, fingerprint_ban_id) + {:error, :unauthorized} """ - def update_fingerprint(%Fingerprint{} = fingerprint, attrs) do - fingerprint - |> Fingerprint.changeset(attrs) - |> Repo.update() + @spec edit_fingerprint_ban(Actor.t(), Loader.integer_id()) :: + {:ok, {Fingerprint.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_fingerprint_ban(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, fingerprint_ban} <- load_ban(actor, Fingerprint, id, :edit) do + {:ok, {fingerprint_ban, Fingerprint.changeset(fingerprint_ban)}} + end end @doc """ - Deletes a fingerprint ban. + Updates the fingerprint ban named by `id`, on behalf of `actor`. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> delete_fingerprint(fingerprint) + iex> update_fingerprint_ban(admin, fingerprint_ban_id, fingerprint_ban_params) {:ok, %Fingerprint{}} - iex> delete_fingerprint(fingerprint) + iex> update_fingerprint_ban(admin, fingerprint_ban_id, invalid_params) {:error, %Ecto.Changeset{}} + iex> update_fingerprint_ban(admin, invalid_id, fingerprint_ban_params) + {:error, :not_found} + + iex> update_fingerprint_ban(user, fingerprint_ban_id, fingerprint_ban_params) + {:error, :unauthorized} + """ - def delete_fingerprint(%Fingerprint{} = fingerprint) do - Repo.delete(fingerprint) + @spec update_fingerprint_ban(Actor.t(), Loader.integer_id(), map()) :: + {:ok, Fingerprint.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_fingerprint_ban(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, fingerprint_ban} <- load_ban(actor, Fingerprint, id, :update) do + fingerprint_changeset = Fingerprint.changeset(fingerprint_ban, attrs) + + Multi.new() + |> Multi.update(:fingerprint, fingerprint_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{fingerprint: fingerprint} -> + { + "Admin.FingerprintBan:update", + "/admin/fingerprint_bans", + "Updated a fingerprint ban #{fingerprint.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{fingerprint: %Fingerprint{} = fingerprint}} -> + {:ok, fingerprint} + + {:error, :fingerprint, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking fingerprint ban changes. + Deletes the fingerprint ban named by `id`, on behalf of `actor`. + + On success a moderation log attributing the removal to `actor` is written. ## Examples - iex> change_fingerprint(fingerprint) - %Ecto.Changeset{source: %Fingerprint{}} + iex> delete_fingerprint_ban(admin, fingerprint_ban_id) + {:ok, %Fingerprint{}} + + iex> delete_fingerprint_ban(admin, invalid_id) + {:error, :not_found} + iex> delete_fingerprint_ban(user, fingerprint_ban_id) + {:error, :unauthorized} + + """ + @spec delete_fingerprint_ban(Actor.t(), Loader.integer_id()) :: + {:ok, Fingerprint.t()} + | {:error, Authorization.write_error_reason() | :not_found} + def delete_fingerprint_ban(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, fingerprint_ban} <- load_ban(actor, Fingerprint, id, :delete) do + Multi.new() + |> Multi.delete(:fingerprint, fingerprint_ban) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{fingerprint: fingerprint} -> + { + "Admin.FingerprintBan:delete", + "/admin/fingerprint_bans", + "Deleted a fingerprint ban #{fingerprint.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{fingerprint: %Fingerprint{} = fingerprint}} -> + {:ok, fingerprint} + + {:error, :fingerprint, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc """ + Returns the subnet bans whose specification contains `ip`, newest first. """ - def change_fingerprint(%Fingerprint{} = fingerprint) do - Fingerprint.changeset(fingerprint, %{}) + @spec subnet_bans_for_ip(Postgrex.INET.t()) :: [Subnet.t()] + def subnet_bans_for_ip(ip) do + Subnet + |> where([s], fragment("? >>= ?", s.specification, ^ip)) + |> order_by(desc: :created_at) + |> Repo.all() end @doc """ - Returns the list of subnet bans. + Returns paginated subnet bans for the admin listing, on behalf of + `actor`. + + Filters by the `"bq"` full-text search or the `"ip"` branch + when either is present in params. Results are ordered newest first. ## Examples - iex> list_subnet_bans() - [%Subnet{}, ...] + iex> list_subnet_bans(admin, params, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_subnet_bans(admin, %{"ip" => "512.512.512.512"}, pagination) + {:error, {:invalid_ip, "512.512.512.512}} + + iex> list_subnet_bans(user, params, pagination) + {:error, :unauthorized} """ - def list_subnet_bans do - Repo.all(Subnet) + @spec list_subnet_bans(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Subnet.t()), Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_subnet_bans(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Subnet), + {:ok, query, form} <- SubnetQueryBuilder.build_query(params) do + subnet_bans = + query + |> preload(:banning_user) + |> Repo.paginate(pagination) + + {:ok, subnet_bans, SubnetQueryForm.changeset(form)} + end end @doc """ - Gets a single subnet ban. - - Raises `Ecto.NoResultsError` if the subnet ban does not exist. + Prepares a new subnet ban on behalf of `actor`, prefilling the specification + from the `specification` argument (which may be `nil`). ## Examples - iex> get_subnet!(123) - %Subnet{} + iex> new_subnet_ban(admin, ip_or_cidr) + {:ok, %Ecto.Changeset{}} + + iex> new_subnet_ban(admin, "512.512.512.512") + {:error, %Ecto.Changeset{}} - iex> get_subnet!(456) - ** (Ecto.NoResultsError) + iex> new_subnet_ban(user, ip_or_cidr) + {:error, :unauthorized} """ - def get_subnet!(id), do: Repo.get!(Subnet, id) + @spec new_subnet_ban(Actor.t(), String.t() | nil) :: + {:ok, Ecto.Changeset.t()} + | {:error, Authorization.write_error_reason()} + def new_subnet_ban(%Actor{} = actor, specification) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Subnet) do + {:ok, Subnet.changeset(%Subnet{}, %{specification: specification})} + end + end @doc """ - Creates a subnet ban. + Creates a subnet ban on behalf of `actor`. + + On success a moderation log attributing the creation to `actor` is written. ## Examples - iex> create_subnet(%{field: value}) + iex> create_subnet_ban(admin, ban_params) {:ok, %Subnet{}} - iex> create_subnet(%{field: bad_value}) + iex> create_subnet_ban(admin, invalid_params) {:error, %Ecto.Changeset{}} + iex> create_subnet_ban(user, ban_params) + {:error, :unauthorized} + """ - def create_subnet(creator, attrs \\ %{}) do - %Subnet{banning_user_id: creator.id} - |> Subnet.changeset(attrs) - |> Repo.insert() + @spec create_subnet_ban(Actor.t(), map()) :: + {:ok, Subnet.t()} + | Authorization.write_error() + | {:error, Ecto.Changeset.t()} + def create_subnet_ban(%Actor{user: creator} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Subnet) do + subnet_changeset = + %Subnet{banning_user_id: creator.id} + |> Subnet.changeset(attrs) + + Multi.new() + |> Multi.insert(:subnet, subnet_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{subnet: subnet} -> + { + "Admin.SubnetBan:create", + "/admin/subnet_bans", + "Created a subnet ban #{subnet.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{subnet: %Subnet{} = subnet}} -> + {:ok, subnet} + + {:error, :subnet, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Updates a subnet ban. + Loads the subnet ban named by `id` for editing, on behalf of `actor`, + pairing it with a changeset for editing it. ## Examples - iex> update_subnet(subnet, %{field: new_value}) - {:ok, %Subnet{}} + iex> edit_subnet_ban(admin, subnet_ban_id) + {:ok, {%Subnet{}, %Ecto.Changeset{}}} - iex> update_subnet(subnet, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_subnet_ban(admin, invalid_id) + {:error, :not_found} + + iex> edit_subnet_ban(user, subnet_ban_id) + {:error, :unauthorized} """ - def update_subnet(%Subnet{} = subnet, attrs) do - subnet - |> Subnet.changeset(attrs) - |> Repo.update() + @spec edit_subnet_ban(Actor.t(), Loader.integer_id()) :: + {:ok, {Subnet.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_subnet_ban(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, subnet_ban} <- load_ban(actor, Subnet, id, :edit) do + {:ok, {subnet_ban, Subnet.changeset(subnet_ban)}} + end end @doc """ - Deletes a subnet ban. + Updates the subnet ban named by `id`, on behalf of `actor`. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> delete_subnet(subnet) + iex> update_subnet_ban(admin, subnet_ban_id, subnet_ban_params) {:ok, %Subnet{}} - iex> delete_subnet(subnet) + iex> update_subnet_ban(admin, subnet_ban_id, invalid_params) {:error, %Ecto.Changeset{}} + iex> update_subnet_ban(admin, invalid_id, subnet_ban_params) + {:error, :not_found} + + iex> update_subnet_ban(user, subnet_ban_id, subnet_ban_params) + {:error, :unauthorized} + """ - def delete_subnet(%Subnet{} = subnet) do - Repo.delete(subnet) + @spec update_subnet_ban(Actor.t(), Loader.integer_id(), map()) :: + {:ok, Subnet.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_subnet_ban(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, subnet_ban} <- load_ban(actor, Subnet, id, :update) do + subnet_changeset = Subnet.changeset(subnet_ban, attrs) + + Multi.new() + |> Multi.update(:subnet, subnet_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{subnet: subnet} -> + { + "Admin.SubnetBan:update", + "/admin/subnet_bans", + "Updated a subnet ban #{subnet.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{subnet: %Subnet{} = subnet}} -> + {:ok, subnet} + + {:error, :subnet, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking subnet ban changes. + Deletes the subnet ban named by `id`, on behalf of `actor`. + + On success a moderation log attributing the removal to `actor` is written. ## Examples - iex> change_subnet(subnet) - %Ecto.Changeset{source: %Subnet{}} + iex> delete_subnet_ban(admin, subnet_ban_id) + {:ok, %Subnet{}} + + iex> delete_subnet_ban(admin, invalid_id) + {:error, :not_found} + + iex> delete_subnet_ban(user, subnet_ban_id) + {:error, :unauthorized} """ - def change_subnet(%Subnet{} = subnet) do - Subnet.changeset(subnet, %{}) + @spec delete_subnet_ban(Actor.t(), Loader.integer_id()) :: + {:ok, Subnet.t()} + | {:error, Authorization.write_error_reason() | :not_found} + def delete_subnet_ban(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, subnet_ban} <- load_ban(actor, Subnet, id, :delete) do + Multi.new() + |> Multi.delete(:subnet, subnet_ban) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{subnet: subnet} -> + { + "Admin.SubnetBan:delete", + "/admin/subnet_bans", + "Deleted a subnet ban #{subnet.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{subnet: %Subnet{} = subnet}} -> + {:ok, subnet} + + {:error, :subnet, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + defp create_user_multi(%Users.User{} = creator, %Users.User{} = target, attrs) do + user_changeset = + %User{banning_user_id: creator.id, user_id: target.id} + |> User.changeset(attrs) + + Multi.new() + |> Multi.insert(:user, user_changeset) + |> Multi.run(:subnet, fn repo, _changes -> + # Create a subnet ban for the given user's last known IP address as part + # of the user-ban creation transaction. No ban is created if none is known. + case UserIps.latest_ip_for_user(target.id) do + nil -> + {:ok, nil} + + ip -> + %Subnet{banning_user_id: creator.id} + |> Subnet.paired_ban_changeset(%{specification: ip}) + |> Subnet.changeset(attrs) + |> repo.insert() + end + end) + |> Multi.on_commit(fn _changes -> + Users.reindex_user(%Users.User{id: target.id}) + end) end @doc """ - Returns the list of user bans. + Returns paginated user bans for the admin listing, on behalf of + `actor`. + + Filters by the `"bq"` full-text search or the exact `"user_id"` branch + when either is present in params. Results are ordered newest first. ## Examples - iex> list_user_bans() - [%User{}, ...] + iex> list_user_bans(admin, params, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_user_bans(user, params, pagination) + {:error, :unauthorized} """ - def list_user_bans do - Repo.all(User) + @spec list_user_bans(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(User.t()), Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_user_bans(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, User), + {:ok, query, form} <- UserQueryBuilder.build_query(params) do + user_bans = + query + |> preload([:user, :banning_user]) + |> Repo.paginate(pagination) + + {:ok, user_bans, UserQueryForm.changeset(form)} + end end @doc """ - Gets a single user ban. - - Raises `Ecto.NoResultsError` if the user ban does not exist. + Builds a changeset for a new user ban on behalf of `actor`, prefilling + the user from the `user_id` argument and optionally applying `attrs` for form + redisplay. The target is loaded safely inside the authorized context boundary. ## Examples - iex> get_user!(123) - %User{} + iex> new_user_ban(admin, user_id) + {:ok, {%Users.User{}, %Ecto.Changeset{}}} - iex> get_user!(456) - ** (Ecto.NoResultsError) + iex> new_user_ban(admin, invalid_user_id) + {:error, :not_found} + + iex> new_user_ban(user, user_id) + {:error, :unauthorized} """ - def get_user!(id), do: Repo.get!(User, id) + @spec new_user_ban(Actor.t(), Loader.integer_id(), map()) :: + {:ok, {Users.User.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def new_user_ban(%Actor{} = actor, user_id, attrs \\ %{}) do + with :ok <- verify_write_access(actor), + {:ok, target} <- Loader.fetch(Users.User, user_id), + :ok <- authorize(actor, :new, User) do + {:ok, {target, %User{user_id: target.id} |> User.changeset(attrs)}} + end + end @doc """ - Creates a user ban. + Creates a user ban on behalf of `actor`. + + On success a moderation log attributing the creation to `actor` is written. ## Examples - iex> create_user(%{field: value}) + iex> create_user_ban(admin, user_id, ban_params) {:ok, %User{}} - iex> create_user(%{field: bad_value}) + iex> create_user_ban(admin, user_id, invalid_params) {:error, %Ecto.Changeset{}} - """ - def create_user(creator, attrs \\ %{}) do - changeset = - %User{banning_user_id: creator.id} - |> User.changeset(attrs) - - Multi.new() - |> Multi.insert(:user_ban, changeset) - |> Multi.run(:subnet_ban, fn _repo, %{user_ban: %{user_id: user_id}} -> - SubnetCreator.create_for_user(creator, user_id, attrs) - end) - |> Repo.transaction() - |> case do - {:ok, %{user_ban: user_ban}} -> - Users.reindex_user(%Users.User{id: user_ban.user_id}) - - {:ok, user_ban} + iex> create_user_ban(user, user_id, ban_params) + {:error, :unauthorized} - {:error, :user_ban, changeset, _changes} -> - {:error, changeset} + """ + @spec create_user_ban(Actor.t(), Loader.integer_id(), map()) :: + {:ok, User.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def create_user_ban(%Actor{user: creator} = actor, user_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, target} <- Loader.fetch(Users.User, user_id), + :ok <- authorize(actor, :create, User) do + creator + |> create_user_multi(target, attrs) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{user: user} -> + { + "Admin.UserBan:create", + "/admin/user_bans", + "Created a user ban #{user.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Updates a user ban. + Loads the user ban named by `id` for editing, on behalf of `actor`, + pairing it with a changeset for editing it. ## Examples - iex> update_user(user, %{field: new_value}) - {:ok, %User{}} + iex> edit_user_ban(admin, user_ban_id) + {:ok, {%User{}, %Ecto.Changeset{}}} - iex> update_user(user, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_user_ban(admin, invalid_id) + {:error, :not_found} + + iex> edit_user_ban(user, user_ban_id) + {:error, :unauthorized} """ - def update_user(%User{} = user, attrs) do - user - |> User.changeset(attrs) - |> Repo.update() - |> case do - {:ok, user} -> - Users.reindex_user(%Users.User{id: user.user_id}) - - {:ok, user} - - error -> - error + @spec edit_user_ban(Actor.t(), Loader.integer_id()) :: + {:ok, {User.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_user_ban(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, user_ban} <- load_ban(actor, User, id, :edit, [:user]) do + {:ok, {user_ban, User.changeset(user_ban)}} end end @doc """ - Deletes a user ban. + Updates the user ban named by `id`, on behalf of `actor`. + + On success a moderation log attributing the update to `actor` is written. ## Examples - iex> delete_user(user) + iex> update_user_ban(admin, user_ban_id, user_ban_params) {:ok, %User{}} - iex> delete_user(user) + iex> update_user_ban(admin, user_ban_id, invalid_params) {:error, %Ecto.Changeset{}} - """ - def delete_user(%User{} = user) do - Repo.delete(user) - |> case do - {:ok, user} -> - Users.reindex_user(%Users.User{id: user.user_id}) + iex> update_user_ban(admin, invalid_id, user_ban_params) + {:error, :not_found} - {:ok, user} + iex> update_user_ban(user, user_ban_id, user_ban_params) + {:error, :unauthorized} - error -> - error + """ + @spec update_user_ban(Actor.t(), Loader.integer_id(), map()) :: + {:ok, User.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_user_ban(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user_ban} <- load_ban(actor, User, id, :update, [:user]) do + user_changeset = User.changeset(user_ban, attrs) + + Multi.new() + |> Multi.update(:user, user_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{user: user} -> + { + "Admin.UserBan:update", + "/admin/user_bans", + "Updated a user ban #{user.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking user ban changes. + Deletes the user ban named by `id`, on behalf of `actor`. + + On success a moderation log attributing the removal to `actor` is written. ## Examples - iex> change_user(user) - %Ecto.Changeset{source: %User{}} + iex> delete_user_ban(admin, user_ban_id) + {:ok, %User{}} + + iex> delete_user_ban(admin, invalid_id) + {:error, :not_found} + + iex> delete_user_ban(user, user_ban_id) + {:error, :unauthorized} """ - def change_user(%User{} = user) do - User.changeset(user, %{}) + @spec delete_user_ban(Actor.t(), Loader.integer_id()) :: + {:ok, User.t()} + | {:error, Authorization.write_error_reason() | :not_found} + def delete_user_ban(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, user_ban} <- load_ban(actor, User, id, :delete) do + Multi.new() + |> Multi.delete(:user, user_ban) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{user: user} -> + { + "Admin.UserBan:delete", + "/admin/user_bans", + "Deleted a user ban #{user.generated_ban_id}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns the first ban, if any, that matches the specified request attributes. + Returns the effective ban, if any, matching the request identity. + + This request-time lookup is deliberately unauthenticated. Signed-in requests + consider only their user ban. Anonymous requests prefer a matching subnet ban + over a fingerprint ban when both apply. Within one ban kind, the newest ban + wins. """ + @spec find( + Users.User.t() | nil, + Postgrex.INET.t() | :inet.ip_address() | nil, + String.t() | nil + ) :: map() | nil def find(user, ip, fingerprint) do Finder.find(user, ip, fingerprint) end diff --git a/lib/philomena/bans/finder.ex b/lib/philomena/bans/finder.ex index 39ad494d5..4582ccc31 100644 --- a/lib/philomena/bans/finder.ex +++ b/lib/philomena/bans/finder.ex @@ -1,6 +1,6 @@ defmodule Philomena.Bans.Finder do @moduledoc """ - Helper to find a bans associated with a set of request attributes. + Finds the effective ban associated with a set of request attributes. """ import Ecto.Query, warn: false @@ -14,41 +14,68 @@ defmodule Philomena.Bans.Finder do @subnet "Subnet" @user "User" + # User bans have the highest priority, followed by subnet bans, then + # by fingerprint bans. + # + # Note that signed-in users will never receive subnet or fingerprint + # bans; they can only receive user bans. So the priority enumerated + # here for user bans is effectively a placeholder. + @user_ban_priority 0 + @subnet_ban_priority 1 + @fingerprint_ban_priority 2 + @doc """ Returns the first ban, if any, that matches the specified request attributes. """ + @spec find( + Philomena.Users.User.t() | nil, + Postgrex.INET.t() | :inet.ip_address() | nil, + String.t() | nil + ) :: + map() | nil def find(user, ip, fingerprint) do - bans = + queries = generate_valid_queries([ {ip, &subnet_query/2}, {fingerprint, &fingerprint_query/2}, {user, &user_query/2} ]) - |> union_all_queries() - |> Repo.all() + + bans = + case queries do + [] -> + [] + + queries -> + queries + |> Enum.reduce(&union_all(&2, ^&1)) + |> Repo.all() + end # Don't return a fingerprint or subnet ban if the user is currently signed in. if is_nil(user) do - Enum.at(bans, 0) + effective_ban(bans) else user_ban(bans) end end - defp query_base(schema, name, now) do + defp query_base(schema, name, priority, now) do from b in schema, where: b.enabled and b.valid_until > ^now, select: %{ reason: b.reason, valid_until: b.valid_until, generated_ban_id: b.generated_ban_id, - type: type(^name, :string) + type: type(^name, :string), + priority: type(^priority, :integer), + sort_at: b.created_at } end defp fingerprint_query(fingerprint, now) do Fingerprint - |> query_base(@fingerprint, now) + |> query_base(@fingerprint, @fingerprint_ban_priority, now) |> where([f], f.fingerprint == ^fingerprint) end @@ -56,13 +83,13 @@ defmodule Philomena.Bans.Finder do {:ok, inet} = EctoNetwork.INET.cast(ip) Subnet - |> query_base(@subnet, now) + |> query_base(@subnet, @subnet_ban_priority, now) |> where(fragment("specification >>= ?", ^inet)) end defp user_query(user, now) do User - |> query_base(@user, now) + |> query_base(@user, @user_ban_priority, now) |> where([u], u.user_id == ^user.id) end @@ -75,13 +102,17 @@ defmodule Philomena.Bans.Finder do end) end - defp union_all_queries([query | rest]) do - Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end) - end - defp user_ban(bans) do bans |> Enum.filter(&(&1.type == @user)) - |> Enum.at(0) + |> effective_ban() + end + + defp effective_ban([]), do: nil + + defp effective_ban(bans) do + bans + |> Enum.min_by(fn ban -> {ban.priority, -DateTime.to_unix(ban.sort_at)} end) + |> Map.drop([:priority, :sort_at]) end end diff --git a/lib/philomena/bans/fingerprint.ex b/lib/philomena/bans/fingerprint.ex index c903032f6..71194e23b 100644 --- a/lib/philomena/bans/fingerprint.ex +++ b/lib/philomena/bans/fingerprint.ex @@ -5,6 +5,8 @@ defmodule Philomena.Bans.Fingerprint do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "fingerprint_bans" do belongs_to :banning_user, User @@ -19,7 +21,7 @@ defmodule Philomena.Bans.Fingerprint do end @doc false - def changeset(fingerprint_ban, attrs) do + def changeset(fingerprint_ban, attrs \\ %{}) do fingerprint_ban |> cast(attrs, [:reason, :note, :enabled, :fingerprint, :valid_until]) |> put_ban_id("F") diff --git a/lib/philomena/bans/fingerprint_query_builder.ex b/lib/philomena/bans/fingerprint_query_builder.ex new file mode 100644 index 000000000..cef5d4b2e --- /dev/null +++ b/lib/philomena/bans/fingerprint_query_builder.ex @@ -0,0 +1,64 @@ +defmodule Philomena.Bans.FingerprintQueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.Bans.Fingerprint + alias Philomena.Bans.FingerprintQueryForm + + @doc """ + Builds a fingerprint ban query based on the given parameters. + + ## Parameters + + * `bq` - Search fingerprints, ban IDs, reasons, and notes + * `fingerprint` - Filter by an exact fingerprint + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: + {:ok, Ecto.Query.t(), FingerprintQueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %FingerprintQueryForm{} + |> FingerprintQueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + Fingerprint + |> maybe_filter_bq(query_form) + |> maybe_filter_fingerprint(query_form) + |> order_by([fb], desc: fb.created_at, desc: fb.id) + + {:ok, query, query_form} + end + end + + defp maybe_filter_bq(query, %FingerprintQueryForm{bq: bq}) do + if bq do + where( + query, + [fb], + ilike(fb.fingerprint, ^unsanitized_like("%#{bq}%")) or + fb.generated_ban_id == ^bq or + fragment("to_tsvector(?) @@ plainto_tsquery(?)", fb.reason, ^bq) or + fragment("to_tsvector(?) @@ plainto_tsquery(?)", fb.note, ^bq) + ) + else + query + end + end + + defp maybe_filter_fingerprint(query, %FingerprintQueryForm{fingerprint: fingerprint}) do + if fingerprint do + where(query, fingerprint: ^fingerprint) + else + query + end + end + + defp unsanitized_like(query_string) do + query_string + end +end diff --git a/lib/philomena/bans/fingerprint_query_form.ex b/lib/philomena/bans/fingerprint_query_form.ex new file mode 100644 index 000000000..1c21c4671 --- /dev/null +++ b/lib/philomena/bans/fingerprint_query_form.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Bans.FingerprintQueryForm do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :bq, :string + field :fingerprint, :string + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + cast(query_form, attrs, [:bq, :fingerprint]) + end +end diff --git a/lib/philomena/bans/subnet.ex b/lib/philomena/bans/subnet.ex index bc62a277f..7dc8491a8 100644 --- a/lib/philomena/bans/subnet.ex +++ b/lib/philomena/bans/subnet.ex @@ -5,6 +5,8 @@ defmodule Philomena.Bans.Subnet do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "subnet_bans" do belongs_to :banning_user, User @@ -19,7 +21,7 @@ defmodule Philomena.Bans.Subnet do end @doc false - def changeset(subnet_ban, attrs) do + def changeset(subnet_ban, attrs \\ %{}) do subnet_ban |> cast(attrs, [:reason, :note, :enabled, :specification, :valid_until]) |> put_ban_id("S") @@ -28,7 +30,14 @@ defmodule Philomena.Bans.Subnet do |> mask_specification() end + @doc false + def paired_ban_changeset(subnet_ban, attrs \\ %{}) do + cast(subnet_ban, attrs, [:specification]) + end + defp mask_specification(changeset) do + # IPv6 privacy addresses rotate their lower 64 bits. A subnet ban + # therefore covers the stable /64 prefix. IPv4 addresses remain unchanged. specification = changeset |> get_field(:specification) diff --git a/lib/philomena/bans/subnet_creator.ex b/lib/philomena/bans/subnet_creator.ex deleted file mode 100644 index 3f54a3c54..000000000 --- a/lib/philomena/bans/subnet_creator.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Philomena.Bans.SubnetCreator do - @moduledoc """ - Handles automatic creation of subnet bans for an input user ban. - - This prevents trivial ban evasion with the creation of a new account from the same address. - The user must work around or wait out the subnet ban first. - """ - - alias Philomena.UserIps - alias Philomena.Bans - - @doc """ - Creates a subnet ban for the given user's last known IP address. - - Returns `{:ok, ban}`, `{:ok, nil}`, or `{:error, changeset}`. The return value is - suitable for use as the return value to an `Ecto.Multi.run/3` callback. - """ - def create_for_user(creator, user_id, attrs) do - ip = UserIps.get_ip_for_user(user_id) - - if ip do - Bans.create_subnet(creator, Map.put(attrs, "specification", UserIps.masked_ip(ip))) - else - {:ok, nil} - end - end -end diff --git a/lib/philomena/bans/subnet_query_builder.ex b/lib/philomena/bans/subnet_query_builder.ex new file mode 100644 index 000000000..56613a05c --- /dev/null +++ b/lib/philomena/bans/subnet_query_builder.ex @@ -0,0 +1,59 @@ +defmodule Philomena.Bans.SubnetQueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.Bans.Subnet + alias Philomena.Bans.SubnetQueryForm + + @doc """ + Builds a subnet ban query based on the given parameters. + + ## Parameters + + * `bq` - Search ban IDs, reasons, and notes + * `ip` - Filter by subnet bans containing an IP address or CIDR range + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: + {:ok, Ecto.Query.t(), SubnetQueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %SubnetQueryForm{} + |> SubnetQueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + Subnet + |> maybe_filter_bq(query_form) + |> maybe_filter_ip(query_form) + |> order_by([sb], desc: sb.created_at, desc: sb.id) + + {:ok, query, query_form} + end + end + + defp maybe_filter_bq(query, %SubnetQueryForm{bq: bq}) do + if bq do + where( + query, + [sb], + sb.generated_ban_id == ^bq or + fragment("to_tsvector(?) @@ plainto_tsquery(?)", sb.reason, ^bq) or + fragment("to_tsvector(?) @@ plainto_tsquery(?)", sb.note, ^bq) + ) + else + query + end + end + + defp maybe_filter_ip(query, %SubnetQueryForm{ip: ip}) do + if ip do + where(query, [sb], fragment("? >>= ?", sb.specification, ^ip)) + else + query + end + end +end diff --git a/lib/philomena/bans/subnet_query_form.ex b/lib/philomena/bans/subnet_query_form.ex new file mode 100644 index 000000000..55577dcdd --- /dev/null +++ b/lib/philomena/bans/subnet_query_form.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Bans.SubnetQueryForm do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :bq, :string + field :ip, EctoNetwork.INET + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + cast(query_form, attrs, [:bq, :ip]) + end +end diff --git a/lib/philomena/bans/user.ex b/lib/philomena/bans/user.ex index 0d128762b..8a32278be 100644 --- a/lib/philomena/bans/user.ex +++ b/lib/philomena/bans/user.ex @@ -5,6 +5,8 @@ defmodule Philomena.Bans.User do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "user_bans" do belongs_to :user, User belongs_to :banning_user, User @@ -20,11 +22,11 @@ defmodule Philomena.Bans.User do end @doc false - def changeset(user_ban, attrs) do + def changeset(user_ban, attrs \\ %{}) do user_ban |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :user_id, :valid_until]) |> put_ban_id("U") - |> validate_required([:reason, :enabled, :user_id, :valid_until]) + |> validate_required([:reason, :enabled, :valid_until]) |> check_constraint(:valid_until, name: :user_ban_duration_must_be_valid) end end diff --git a/lib/philomena/bans/user_query_builder.ex b/lib/philomena/bans/user_query_builder.ex new file mode 100644 index 000000000..2dd37ef42 --- /dev/null +++ b/lib/philomena/bans/user_query_builder.ex @@ -0,0 +1,63 @@ +defmodule Philomena.Bans.UserQueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.Bans.User + alias Philomena.Bans.UserQueryForm + + @doc """ + Builds a user ban query based on the given parameters. + + ## Parameters + + * `bq` - Search banned-user names, ban IDs, reasons, and notes + * `user_id` - Filter by an exact banned-user ID + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: + {:ok, Ecto.Query.t(), UserQueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %UserQueryForm{} + |> UserQueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + User + |> maybe_filter_bq(query_form) + |> maybe_filter_user(query_form) + |> order_by([ub], desc: ub.created_at, desc: ub.id) + + {:ok, query, query_form} + end + end + + defp maybe_filter_bq(query, %UserQueryForm{bq: bq}) do + if bq do + like_bq = "%#{bq}%" + + query + |> join(:inner, [ub], _ in assoc(ub, :user)) + |> where( + [ub, u], + ilike(u.name, ^like_bq) or + ub.generated_ban_id == ^bq or + fragment("to_tsvector(?) @@ plainto_tsquery(?)", ub.reason, ^bq) or + fragment("to_tsvector(?) @@ plainto_tsquery(?)", ub.note, ^bq) + ) + else + query + end + end + + defp maybe_filter_user(query, %UserQueryForm{user_id: user_id}) do + if user_id do + where(query, user_id: ^user_id) + else + query + end + end +end diff --git a/lib/philomena/bans/user_query_form.ex b/lib/philomena/bans/user_query_form.ex new file mode 100644 index 000000000..ac0aee22a --- /dev/null +++ b/lib/philomena/bans/user_query_form.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Bans.UserQueryForm do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :bq, :string + field :user_id, :integer + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + cast(query_form, attrs, [:bq, :user_id]) + end +end diff --git a/lib/philomena/channels.ex b/lib/philomena/channels.ex index efa6d47c8..0a4b4e134 100644 --- a/lib/philomena/channels.ex +++ b/lib/philomena/channels.ex @@ -1,157 +1,457 @@ defmodule Philomena.Channels do @moduledoc """ - The Channels context. + Livestream discovery, staff-managed channel configuration, and per-user + subscription/read state. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.Attribution.Actor alias Philomena.Channels.AutomaticUpdater alias Philomena.Channels.Channel + alias Philomena.Channels.QueryBuilder + alias Philomena.Channels.QueryForm + alias Philomena.Loader + alias Philomena.Multi alias Philomena.Notifications + alias Philomena.Repo alias Philomena.Tags use Philomena.Subscriptions, - on_delete: :clear_channel_notification, id_name: :channel_id + defp change_channel(%Channel{} = channel, attrs, canonical_tags) do + changeset = Channel.changeset(channel, attrs) + + if is_nil(channel.artist_tag) do + Channel.artist_tag_changeset(changeset, nil, nil) + else + Channel.artist_tag_changeset(changeset, channel.artist_tag, List.first(canonical_tags)) + end + end + + defp clear_notification_for(%Channel{} = channel, user) do + Notifications.clear_channel_live(channel, user) + :ok + end + + defp load_channel(actor, id, action, preloads \\ []) do + Loader.fetch_and_authorize(Channel, actor, action, id, preloads) + end + + defp maybe_show_nsfw(query, true), do: query + defp maybe_show_nsfw(query, _falsy), do: where(query, nsfw: false) + + defp channels_query(query, show_nsfw?) do + query + |> maybe_show_nsfw(show_nsfw?) + |> where([c], not is_nil(c.last_fetched_at)) + |> order_by(desc: :is_live, asc: :title) + |> preload([:associated_artist_tag]) + end + @doc """ - Updates all the tracked channels for which an update scheme is known. + Updates all tracked channels for which an automatic fetch scheme is known. + + Raises when the updater cannot maintain its fetch invariant. + + ## Examples + + iex> update_tracked_channels!() + :ok + """ + @spec update_tracked_channels!() :: :ok def update_tracked_channels! do AutomaticUpdater.update_tracked_channels!() end @doc """ - Gets a single channel. + Counts channels which are currently live. + + This aggregate includes live channels even when they have not yet been + stamped by the automatic fetcher. + + ## Examples + + iex> count_live_channels() + 2 + + """ + @spec count_live_channels() :: non_neg_integer() + def count_live_channels do + Channel + |> where(is_live: true) + |> Repo.aggregate(:count) + end + + @doc """ + Loads the livestream listing for the home page. - Raises `Ecto.NoResultsError` if the Channel does not exist. + Only channels the fetcher has stamped (`last_fetched_at` set) are listed, + ordered live-first and then by title. `show_nsfw?` includes NSFW channels. ## Examples - iex> get_channel!(123) - %Channel{} + iex> list_front_page_channels(actor, false, 6) + [%Channel{}, ...] - iex> get_channel!(456) - ** (Ecto.NoResultsError) + """ + @spec list_front_page_channels(Actor.t(), boolean(), pos_integer()) :: + [Channel.t()] + def list_front_page_channels(%Actor{} = _actor, show_nsfw?, strip_size) do + Channel + |> channels_query(show_nsfw?) + |> limit(^strip_size) + |> Repo.all() + end + + @doc """ + Loads the livestream listing and the acting user's subscription state. + + Only channels the fetcher has stamped (`last_fetched_at` set) are listed, + ordered live-first and then by title. `show_nsfw?` includes NSFW channels; + a non-empty `"cq"` matches title, short name, or artist tag name. Subscription + state is scoped to the actor's user and is empty for an anonymous actor. + + ## Examples + + iex> list_channels(actor, false, %{"cq" => "pony"}, pagination) + {:ok, %Scrivener.Page{}, %{12 => true}, %Ecto.Changeset{}} """ - def get_channel!(id), do: Repo.get!(Channel, id) + @spec list_channels(Actor.t(), boolean(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(), %{optional(integer()) => true}, Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + def list_channels(%Actor{} = actor, show_nsfw?, params, pagination) do + with {:ok, query, query_form} <- QueryBuilder.build_query(params) do + channels = + query + |> channels_query(show_nsfw?) + |> Repo.paginate(pagination) + + {:ok, channels, subscriptions(channels, actor.user), QueryForm.changeset(query_form)} + end + end @doc """ - Creates a channel. + Loads the channel named by `id` for a public visit and clears the acting + user's live notification when signed in. + + The named `:visit` ability permits anonymous browsing. Malformed and missing + IDs are always `{:error, :not_found}`. ## Examples - iex> create_channel(%{field: value}) + iex> show_channel(user, "1") {:ok, %Channel{}} - iex> create_channel(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_channel(actor, "999999999") + {:error, :not_found} + + """ + @spec show_channel(Actor.t(), Loader.integer_id()) :: + {:ok, Channel.t()} | {:error, :not_found | :unauthorized} + def show_channel(%Actor{} = actor, id) do + with {:ok, channel} <- load_channel(actor, id, :visit) do + clear_notification_for(channel, actor.user) + {:ok, channel} + end + end + + @doc """ + Clears the acting user's live notification for the channel named by the + `id`, returning the channel. + + This authenticated read-state operation authorizes `:mark_read`. It is + specifically exempt from `verify_write_access/1`. + + ## Examples + + iex> create_channel_read(user, "1") + {:ok, %Channel{}} + + iex> create_channel_read(user, "999999999") + {:error, :not_found} """ - def create_channel(attrs \\ %{}) do - %Channel{} - |> update_artist_tag(attrs) - |> Channel.changeset(attrs) - |> Repo.insert() + @spec create_channel_read(Actor.t(), Loader.integer_id()) :: + {:ok, Channel.t()} | {:error, :not_found | :unauthorized} + def create_channel_read(%Actor{} = actor, id) do + with {:ok, channel} <- load_channel(actor, id, :mark_read) do + clear_notification_for(channel, actor.user) + {:ok, channel} + end end @doc """ - Updates a channel. + Builds the changeset for a new channel, on behalf of `actor`. ## Examples - iex> update_channel(channel, %{field: new_value}) + iex> new_channel(moderator) + {:ok, %Ecto.Changeset{}} + + iex> new_channel(user) + {:error, :unauthorized} + + """ + @spec new_channel(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_channel(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Channel) do + {:ok, Channel.changeset(%Channel{})} + end + end + + @doc """ + Creates a channel on behalf of `actor`. + + An optional artist tag can be specified in the `"artist_tag"` attribute. + + ## Examples + + iex> create_channel(moderator, %{"type" => "PicartoChannel", "short_name" => "x"}) {:ok, %Channel{}} - iex> update_channel(channel, %{field: bad_value}) + iex> create_channel(moderator, invalid_params) {:error, %Ecto.Changeset{}} + iex> create_channel(user, channel_params) + {:error, :unauthorized} + """ - def update_channel(%Channel{} = channel, attrs) do - channel - |> update_artist_tag(attrs) - |> Channel.changeset(attrs) - |> Repo.update() + @spec create_channel(Actor.t(), map()) :: + {:ok, Channel.t()} | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def create_channel(%Actor{} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Channel), + {:ok, channel} <- + %Channel{} + |> Channel.artist_tag_name_changeset(attrs) + |> Ecto.Changeset.apply_action(:create) do + tag_names = List.wrap(channel.artist_tag) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:artist_tag, tag_names, []}]) + |> Multi.insert(:channel, fn %{canonical_tags: %{artist_tag: tags}} -> + change_channel(channel, attrs, tags) + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{channel: %Channel{} = channel}} -> + {:ok, channel} + + {:error, :channel, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Adds the artist tag from the `"artist_tag"` tag name attribute. + Loads the channel named by the `id` for editing, on behalf of + `actor`, pairing it with a change-tracking changeset. ## Examples - iex> update_artist_tag(%Channel{}, %{"artist_tag" => "artist:nighty"}) - %Ecto.Changeset{} + iex> edit_channel(moderator, "1") + {:ok, {%Channel{}, %Ecto.Changeset{}}} + + iex> edit_channel(moderator, "999999999") + {:error, :not_found} + + iex> edit_channel(user, "1") + {:error, :unauthorized} """ - def update_artist_tag(%Channel{} = channel, attrs) do - tag = - attrs - |> Map.get("artist_tag", "") - |> Tags.get_tag_by_name() + @spec edit_channel(Actor.t(), Loader.integer_id()) :: + {:ok, {Channel.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_channel(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, channel} <- load_channel(actor, id, :edit) do + {:ok, {channel, Channel.changeset(channel)}} + end + end + + @doc """ + Updates the channel named by the `id`, on behalf of `actor`. + + On success, only `:type` and `:short_name` are applied. + Fetcher-managed fields are ignored. + + ## Examples + + iex> update_channel(moderator, "1", %{"short_name" => "renamed"}) + {:ok, %Channel{}} + + iex> update_channel(moderator, "1", invalid_params) + {:error, %Ecto.Changeset{}} - Channel.artist_tag_changeset(channel, tag) + iex> update_channel(moderator, "999999999", channel_params) + {:error, :not_found} + + iex> update_channel(user, "1", channel_params) + {:error, :unauthorized} + + """ + @spec update_channel(Actor.t(), Loader.integer_id(), map()) :: + {:ok, Channel.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_channel(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, channel} <- load_channel(actor, id, :update, [:associated_artist_tag]), + {:ok, channel} <- + channel + |> Channel.artist_tag_name_changeset(attrs) + |> Ecto.Changeset.apply_action(:update) do + tag_names = List.wrap(channel.artist_tag) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:artist_tag, tag_names, []}]) + |> Multi.update(:channel, fn %{canonical_tags: %{artist_tag: tags}} -> + change_channel(channel, attrs, tags) + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{channel: %Channel{} = channel}} -> + {:ok, channel} + + {:error, :channel, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Updates a channel's state when it goes live. + Updates channel state from the automatic updater. + + This function is not request-facing and performs no authorization. ## Examples - iex> update_channel_state(channel, %{field: new_value}) + iex> update_fetch_state(channel, %{field: new_value}) {:ok, %Channel{}} - iex> update_channel_state(channel, %{field: bad_value}) + iex> update_fetch_state(channel, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ - def update_channel_state(%Channel{} = channel, attrs) do + @spec update_fetch_state(Channel.t(), map()) :: + {:ok, Channel.t()} | {:error, Ecto.Changeset.t()} + def update_fetch_state(%Channel{} = channel, attrs) do channel |> Channel.update_changeset(attrs) |> Repo.update() end @doc """ - Deletes a Channel. + Deletes the channel named by the `id`, on behalf of `actor`. ## Examples - iex> delete_channel(channel) + iex> delete_channel(moderator, "1") {:ok, %Channel{}} - iex> delete_channel(channel) - {:error, %Ecto.Changeset{}} - """ - def delete_channel(%Channel{} = channel) do - Repo.delete(channel) + @spec delete_channel(Actor.t(), Loader.integer_id()) :: + {:ok, Channel.t()} | {:error, :ban | :not_found | :unauthorized} + def delete_channel(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, channel} <- load_channel(actor, id, :delete) do + Repo.delete(channel) + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking channel changes. + Subscribes `actor` to the channel named by the `id`. + + Repeated subscription is an idempotent success. Unexpected persistence + failures are returned as changeset errors. + + Subscription management is deliberately exempt from + `verify_write_access/1`; channel visibility and subscription authorization + still apply. ## Examples - iex> change_channel(channel) - %Ecto.Changeset{source: %Channel{}} + iex> create_channel_subscription(user, "1") + {:ok, %Channel{}} + + iex> create_channel_subscription(anonymous_actor, "1") + {:error, :unauthorized} + + iex> create_channel_subscription(user, "999999999") + {:error, :not_found} """ - def change_channel(%Channel{} = channel) do - Channel.changeset(channel, %{}) + @spec create_channel_subscription(Actor.t(), Loader.integer_id()) :: + {:ok, Channel.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def create_channel_subscription(%Actor{} = actor, id) do + with {:ok, channel} <- load_channel(actor, id, :subscribe), + {:ok, _subscription} <- create_subscription(channel, actor.user) do + {:ok, channel} + end end @doc """ - Removes all channel notifications for a given channel and user. + Unsubscribes `actor` from the channel named by the `id`. + + Repeated unsubscription is an idempotent success and also clears any live + notification for the channel. Subscription management is deliberately + exempt from `verify_write_access/1`; channel visibility and subscription + authorization still apply. ## Examples - iex> clear_channel_notification(channel, user) - :ok + iex> delete_channel_subscription(user, "1") + {:ok, %Channel{}} + + iex> delete_channel_subscription(anonymous_actor, "1") + {:error, :unauthorized} + + iex> delete_channel_subscription(user, "999999999") + {:error, :not_found} """ - def clear_channel_notification(%Channel{} = channel, user) do - Notifications.clear_channel_live_notification(channel, user) - :ok + @spec delete_channel_subscription(Actor.t(), Loader.integer_id()) :: + {:ok, Channel.t()} | {:error, :not_found | :unauthorized} + def delete_channel_subscription(%Actor{} = actor, id) do + with {:ok, channel} <- load_channel(actor, id, :unsubscribe), + {:ok, _subscription} <- delete_subscription(channel, actor.user) do + clear_notification_for(channel, actor.user) + {:ok, channel} + end + end + + @doc """ + Repoints artist associations from an aliased tag to its target inside `multi`. + """ + @spec put_replace_artist_tag(Multi.t(), Multi.name(), integer(), integer()) :: Multi.t() + def put_replace_artist_tag(%Multi{} = multi, step, source_tag_id, target_tag_id) do + query = + Channel + |> where(associated_artist_tag_id: ^source_tag_id) + |> update(set: [associated_artist_tag_id: ^target_tag_id]) + + Multi.update_all(multi, step, query, []) + end + + @doc """ + Marks tracked channels for a provider offline when they are absent from the + provider's current live name set. + """ + @spec mark_provider_channels_offline(String.t(), [String.t()], DateTime.t()) :: + {non_neg_integer(), nil} + def mark_provider_channels_offline(provider_name, channel_names, now) do + query = + from channel in Channel, + where: channel.type == ^provider_name and channel.short_name not in ^channel_names, + update: [set: [is_live: false, updated_at: ^now]] + + Repo.update_all(query, []) end end diff --git a/lib/philomena/channels/automatic_updater.ex b/lib/philomena/channels/automatic_updater.ex index efb9a6baf..ba3c90697 100644 --- a/lib/philomena/channels/automatic_updater.ex +++ b/lib/philomena/channels/automatic_updater.ex @@ -7,16 +7,17 @@ defmodule Philomena.Channels.AutomaticUpdater do """ import Ecto.Query, warn: false - alias Philomena.Repo alias Philomena.Channels alias Philomena.Channels.Channel alias Philomena.Channels.PicartoChannel alias Philomena.Channels.PiczelChannel + alias Philomena.Repo @doc """ Updates all the tracked channels for which an update scheme is known. """ + @spec update_tracked_channels!() :: :ok def update_tracked_channels! do now = DateTime.utc_now(:second) Enum.each(providers(), &update_provider(&1, now)) @@ -32,9 +33,7 @@ defmodule Philomena.Channels.AutomaticUpdater do defp update_provider({provider_name, live_channels}, now) do channel_names = Map.keys(live_channels) - provider_name - |> update_offline_query(channel_names, now) - |> Repo.update_all([]) + Channels.mark_provider_channels_offline(provider_name, channel_names, now) provider_name |> online_query(channel_names) @@ -42,12 +41,6 @@ defmodule Philomena.Channels.AutomaticUpdater do |> Enum.each(&update_online_channel(&1, live_channels, now)) end - defp update_offline_query(provider_name, channel_names, now) do - from c in Channel, - where: c.type == ^provider_name and c.short_name not in ^channel_names, - update: [set: [is_live: false, updated_at: ^now]] - end - defp online_query(provider_name, channel_names) do from c in Channel, where: c.type == ^provider_name and c.short_name in ^channel_names @@ -59,6 +52,6 @@ defmodule Philomena.Channels.AutomaticUpdater do |> Map.get(channel.short_name, %{}) |> Map.merge(%{last_fetched_at: now}) - Channels.update_channel_state(channel, attrs) + Channels.update_fetch_state(channel, attrs) end end diff --git a/lib/philomena/channels/channel.ex b/lib/philomena/channels/channel.ex index 62df2a073..04afb4e92 100644 --- a/lib/philomena/channels/channel.ex +++ b/lib/philomena/channels/channel.ex @@ -4,10 +4,12 @@ defmodule Philomena.Channels.Channel do alias Philomena.Tags.Tag + @type t :: %__MODULE__{} + schema "channels" do belongs_to :associated_artist_tag, Tag - # fixme: rails STI + # Provider modules are selected from this legacy Rails STI discriminator. field :type, :string field :short_name, :string @@ -18,11 +20,13 @@ defmodule Philomena.Channels.Channel do field :last_fetched_at, :utc_datetime field :thumbnail_url, :string, default: "" + field :artist_tag, :string, virtual: true + timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(channel, attrs) do + def changeset(channel, attrs \\ %{}) do channel |> cast(attrs, [:type, :short_name]) |> validate_required([:type, :short_name]) @@ -42,9 +46,18 @@ defmodule Philomena.Channels.Channel do end @doc false - def artist_tag_changeset(channel, tag) do - tag_id = Map.get(tag || %{}, :id) + def artist_tag_name_changeset(channel, attrs) do + channel + |> cast(attrs, [:artist_tag]) + |> update_change(:artist_tag, &Tag.clean_tag_name/1) + end - change(channel, associated_artist_tag_id: tag_id) + @doc false + def artist_tag_changeset(changeset, name, tag) do + if not is_nil(name) and is_nil(tag) do + add_error(changeset, :artist_tag, "is invalid") + else + put_change(changeset, :associated_artist_tag, tag) + end end end diff --git a/lib/philomena/channels/query_builder.ex b/lib/philomena/channels/query_builder.ex new file mode 100644 index 000000000..5b9b92708 --- /dev/null +++ b/lib/philomena/channels/query_builder.ex @@ -0,0 +1,51 @@ +defmodule Philomena.Channels.QueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.Channels.Channel + alias Philomena.Channels.QueryForm + + @doc """ + Builds a channel query based on the given parameters. + + ## Parameters + + * `cq` - Search channel titles and short names by prefix, or artist tag + names by substring + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: {:ok, Ecto.Query.t(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = Channel |> maybe_search(query_form) + + {:ok, query, query_form} + end + end + + defp maybe_search(query, %QueryForm{cq: cq}) do + if cq do + title_query = "#{like_sanitize(cq)}%" + tag_query = "%#{like_sanitize(cq)}%" + + from channel in query, + left_join: tag in assoc(channel, :associated_artist_tag), + where: + ilike(channel.title, ^title_query) or ilike(channel.short_name, ^title_query) or + ilike(tag.name, ^tag_query) + else + query + end + end + + defp like_sanitize(input) do + String.replace(input, ["\\", "%", "_"], &<<"\\", &1>>) + end +end diff --git a/lib/philomena/channels/query_form.ex b/lib/philomena/channels/query_form.ex new file mode 100644 index 000000000..5f0c90a87 --- /dev/null +++ b/lib/philomena/channels/query_form.ex @@ -0,0 +1,18 @@ +defmodule Philomena.Channels.QueryForm do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :cq, :string + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + cast(query_form, attrs, [:cq]) + end +end diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index d72a3e31e..a7e3fe27b 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -1,146 +1,347 @@ defmodule Philomena.Comments do @moduledoc """ - The Comments context. + Image comment reads, writes, moderation, search, and indexing. + + Comment mutations lock their parent image before locking or changing the + comment. This serializes them with image hides and merges, which can change + the visibility or ownership of the image's comments. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Repo - alias PhilomenaQuery.Search - alias Philomena.UserStatistics - alias Philomena.Users.User - alias Philomena.Comments.Comment + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Multi + alias Philomena.Attribution.Actor alias Philomena.Comments - alias Philomena.IndexWorker - alias Philomena.Images.Image + alias Philomena.Comments.{Comment, CommentHistory, Query, Visibility} + alias Philomena.Filters.Filter alias Philomena.Images - alias Philomena.Tags.Tag + alias Philomena.Images.Image + alias Philomena.IndexWorker + alias Philomena.IntegerId + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths alias Philomena.Notifications - alias Philomena.Versions + alias Philomena.RateLimiter + alias Philomena.Repo alias Philomena.Reports + alias Philomena.Tags.Tag + alias Philomena.UserStatistics + alias Philomena.Users.User + alias Philomena.Versions + alias PhilomenaQuery.Batch + alias PhilomenaQuery.Search + + @comment_create_window 15 + @image_preloads [:sources, tags: :aliases] + @preloads [:deleted_by, image: @image_preloads, user: [awards: :badge]] + + defp load_image_comment(%Actor{} = actor, %Image{} = image, comment_id, action, preloads) do + Comment + |> where(image_id: ^image.id) + |> Loader.fetch_and_authorize(actor, action, comment_id, preloads) + end + + defp notify_comment(_repo, %{locked_image: image, comment: comment}) do + Notifications.broadcast_image_comment(comment.user, image, comment) + end + + defp broadcast_comment(event, %Comment{} = comment) do + PhilomenaWeb.Endpoint.broadcast!( + "firehose", + event, + PhilomenaWeb.Api.Json.CommentView.render("show.json", %{comment: comment}) + ) + + comment + end + + defp load_direction(%User{settings: %{comments_newest_first: false}}), do: :asc + defp load_direction(_user), do: :desc + + defp filter_direction(query, %Comment{} = comment, %User{ + settings: %{comments_newest_first: false} + }) do + where( + query, + [candidate], + candidate.created_at < ^comment.created_at or + (candidate.created_at == ^comment.created_at and candidate.id < ^comment.id) + ) + end + + defp filter_direction(query, %Comment{} = comment, _user) do + where( + query, + [candidate], + candidate.created_at > ^comment.created_at or + (candidate.created_at == ^comment.created_at and candidate.id > ^comment.id) + ) + end + + defp put_reindex_comment(%Multi{} = multi, step \\ :comment) do + Multi.on_commit(multi, fn %{^step => comment} -> reindex_comment(comment) end) + end + + defp put_approval_report(%Multi{} = multi) do + Multi.merge(multi, fn %{comment: comment} -> + if comment.became_unapproved? do + Multi.new() + |> UserStatistics.put_increment(comment.user_id, :comments_count, -1) + |> Reports.put_create_system_report( + "Approval", + "Comment contains external links", + :comment_id, + comment.id + ) + else + Multi.new() + end + end) + end + + defp put_lock_image(%Multi{} = multi, actor, image_id, action) do + image_query = where(Image, id: ^image_id) + + multi + |> Multi.lock_one(:locked_image, image_query) + |> Multi.run(:authorize, fn _repo, %{locked_image: image} -> + with :ok <- authorize(actor, action, image) do + {:ok, nil} + end + end) + end + + defp map_lock_errors(result) do + case result do + {:error, _step, :unauthorized, _changes} -> + {:error, :unauthorized} + + {:error, _step, :not_found, _changes} -> + {:error, :not_found} + end + end @doc """ - Gets a single comment. + Builds the blank comment changeset used while assembling an image page. - Raises `Ecto.NoResultsError` if the Comment does not exist. + This is a cross-context form builder. Authorization of the containing + image page remains with `Philomena.Images`. ## Examples - iex> get_comment!(123) - %Comment{} - - iex> get_comment!(456) - ** (Ecto.NoResultsError) + iex> new_comment_changeset() + %Ecto.Changeset{} """ - def get_comment!(id), do: Repo.get!(Comment, id) + @spec new_comment_changeset() :: Ecto.Changeset.t() + def new_comment_changeset, do: Comment.changeset(%Comment{}) @doc """ - Creates a comment. + Loads a globally addressed comment visible to `actor`. + + Destroyed comments and missing IDs are not-found. The parent image is authorized + alongside the comment, so either forbidden resource returns unauthorized. ## Examples - iex> create_comment(%{field: value}) + iex> show_comment(actor, "1") {:ok, %Comment{}} - iex> create_comment(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_comment(actor, "not-a-number") + {:error, :not_found} """ - def create_comment(image, attribution, params \\ %{}) do - comment = - Ecto.build_assoc(image, :comments) - |> Comment.creation_changeset(params, attribution) + @spec show_comment(Actor.t(), IntegerId.integer_id()) :: + {:ok, Comment.t()} | {:error, :unauthorized | :not_found} + def show_comment(%Actor{} = actor, id) do + with {:ok, comment} <- Loader.fetch_and_authorize(Comment, actor, :show, id, @preloads), + :ok <- authorize(actor, :show, comment.image) do + {:ok, comment} + end + end - image_query = - Image - |> where(id: ^image.id) + @doc """ + Searches comments visible to `actor`, applying `filter`, `query_string`, and + `pagination`, newest first. - image_lock_query = - lock(image_query, "FOR UPDATE") + Hidden images, hidden or destroyed comments, and approval states are filtered + independently through the actor's abilities. A signed-in author may see their + own unapproved comments. - Multi.new() - |> Multi.one(:image, image_lock_query) - |> Multi.insert(:comment, comment) - |> Multi.update_all(:update_image, image_query, inc: [comments_count: 1]) - |> Multi.run(:notification, ¬ify_comment/2) - |> Images.maybe_subscribe_on(:image, attribution[:user], :watch_on_reply) - |> Repo.transaction() - end + ## Examples + + iex> query_comments(actor, filter, "created_at.gte:1 week ago", pagination) + {:ok, %Scrivener.Page{}} - defp notify_comment(_repo, %{image: image, comment: comment}) do - Notifications.create_image_comment_notification(comment.user, image, comment) + iex> query_comments(actor, filter, "created_at.gte:not-a-date", pagination) + {:error, "Cannot parse date."} + + """ + @spec query_comments( + Actor.t(), + Filter.t(), + String.t() | nil, + Search.pagination_params() + ) :: + {:ok, Scrivener.Page.t(Comment.t())} | {:error, String.t()} + def query_comments(%Actor{} = actor, %Filter{} = filter, query_string, pagination) do + case Query.compile(query_string, actor: actor) do + {:ok, query} -> + results = + actor + |> comment_search_definition(filter, query, pagination: pagination) + |> Search.search_records(preload(Comment, ^@preloads)) + + {:ok, results} + + {:error, msg} -> + {:error, msg} + end end @doc """ - Updates a comment. + Builds an unexecuted comment search definition for `actor`. + + `show_hidden: false` forces public visibility even for privileged actors. ## Examples - iex> update_comment(comment, %{field: new_value}) - {:ok, %Comment{}} + iex> comment_search_definition(actor, filter, %{term: %{author_id: 1}}) + %{module: Comment, ...} - iex> update_comment(comment, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + @spec comment_search_definition(Actor.t(), Filter.t(), map() | [map()], keyword()) :: + Search.search_definition() + def comment_search_definition(%Actor{} = actor, %Filter{} = filter, body, opts \\ []) do + pagination = Keyword.get(opts, :pagination, %{}) + allow_privileged? = Keyword.get(opts, :show_hidden, true) + + Search.search_definition( + Comment, + %{ + query: %{ + bool: %{ + must: body, + must_not: Visibility.search_exclusions(actor, filter, allow_privileged?) + } + }, + sort: %{created_at: :desc} + }, + pagination + ) + end + + @doc """ + Returns a database-paginated page of comments visible beneath `image`. + + Visibility, approval, and destroyed-content filters run before pagination. + Results use the actor's newest/oldest-first setting. + + ## Examples + + iex> list_image_comments(actor, image, page: 1, page_size: 25) + %Scrivener.Page{} """ - def update_comment(%Comment{} = comment, editor, attrs) do - now = DateTime.utc_now(:second) - comment_changes = Comment.changeset(comment, attrs, now) - - Multi.new() - |> Multi.update(:comment, comment_changes) - |> Multi.run(:version, fn repo, %{comment: updated} -> - Versions.record_edit(repo, comment, updated, editor) - end) - |> Repo.transaction() + @spec list_image_comments(Actor.t(), Image.t(), Repo.pagination_params()) :: + Scrivener.Page.t(Comment.t()) + def list_image_comments(%Actor{} = actor, %Image{} = image, pagination) do + direction = load_direction(actor.user) + + Comment + |> where(image_id: ^image.id) + |> Visibility.visible_comments(actor) + |> order_by([{^direction, :created_at}, {^direction, :id}]) + |> preload(^@preloads) + |> Repo.paginate(pagination) end @doc """ - Deletes a Comment. + Locates the comment page containing `comment_id` through its parent image, + on behalf of `actor`. + + Missing, malformed, mismatched, or collection-invisible comments are + not-found. A loaded comment forbidden to the actor is unauthorized. + Returns the loaded image for the caller to reuse. ## Examples - iex> delete_comment(comment) - {:ok, %Comment{}} + iex> list_comment_page(actor, image_id, comment.id, page_size: 25) + {:ok, {%Image{}, 3}} - iex> delete_comment(comment) - {:error, %Ecto.Changeset{}} + """ + @spec list_comment_page( + actor :: Actor.t(), + image_id :: IntegerId.integer_id(), + comment_id :: IntegerId.integer_id(), + pagination :: Repo.pagination_params() + ) :: + {:ok, {Image.t(), pos_integer()}} | {:error, :unauthorized | :not_found} + def list_comment_page(%Actor{} = actor, image_id, comment_id, pagination) do + with {:ok, image} <- load_image(actor, image_id, :index), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :show, []) do + offset = + Comment + |> where(image_id: ^image.id) + |> Visibility.visible_comments(actor) + |> filter_direction(comment, actor.user) + |> Repo.aggregate(:count) + + {:ok, {image, div(offset, pagination[:page_size]) + 1}} + end + end + + @doc """ + Returns the final visible comment page beneath `image` for `actor`. + + ## Examples + + iex> last_comment_page(actor, image, page_size: 25) + 4 """ - def delete_comment(%Comment{} = comment) do - Repo.delete(comment) + @spec last_comment_page(Actor.t(), Image.t(), Repo.pagination_params()) :: pos_integer() + def last_comment_page(%Actor{} = actor, %Image{} = image, pagination) do + count = + Comment + |> where(image_id: ^image.id) + |> Visibility.visible_comments(actor) + |> Repo.aggregate(:count) + + max(Integer.ceil_div(count, pagination[:page_size]), 1) end @doc """ - Hides a comment and handles associated reports. + Loads and authorizes an image for comment actions. - ## Parameters - - comment: The comment to hide - - attrs: Attributes for the hide operation - - user: The user performing the hide action + `action` must be one of `:index`, `:show`, or `:create_comment`. + + Duplicate images are resolved to their target. Missing IDs are + always not-found. ## Examples - iex> hide_comment(comment, %{staff_note: "Rule violation"}, user) - {:ok, %Comment{}} + iex> load_image(actor, "1", :index) + {:ok, %Image{}} - """ - def hide_comment(%Comment{} = comment, attrs, user) do - report_query = Reports.close_report_query(user, comment_id: comment.id) - comment = Comment.hide_changeset(comment, attrs, user) + iex> load_image(actor, "not-a-number", :show) + {:error, :not_found} - Multi.new() - |> Multi.update(:comment, comment) - |> Multi.update_all(:reports, report_query, []) - |> Repo.transaction() - |> case do - {:ok, %{comment: comment, reports: {_count, reports}}} -> - Reports.reindex_reports(reports) - reindex_comment(comment) + """ + @spec load_image(Actor.t(), IntegerId.integer_id(), atom()) :: + {:ok, Image.t()} | {:error, :unauthorized | :not_found} + def load_image(%Actor{} = actor, image_id, action) + when action in [:index, :show, :create_comment] do + case Loader.fetch_and_authorize(Image, actor, action, image_id, @image_preloads) do + {:ok, %Image{duplicate_id: nil} = image} -> + {:ok, image} - {:ok, comment} + {:ok, %Image{duplicate_id: duplicate_id}} -> + Loader.fetch_and_authorize(Image, actor, action, duplicate_id, @image_preloads) error -> error @@ -148,169 +349,472 @@ defmodule Philomena.Comments do end @doc """ - Unhides a previously hidden comment. + Creates a comment through its parent image, on behalf of `actor`. + + Write access, image commenting permission, the Images-owned forced-filter + prerequisite, and the 15-second creation limit are checked before insertion. + The transaction updates the image count, notification, and subscription state. + Indexing, statistics/reporting, rate tracking, and the firehose broadcast run + after commit. The image is returned for the caller to reuse. ## Examples - iex> unhide_comment(comment) - {:ok, %Comment{}} + iex> create_comment(actor, image_id, %{"body" => "Hi"}) + {:ok, {%Image{}, %Comment{}}} + + iex> create_comment(actor, image_id, %{"body" => ""}) + {:error, {%Image{}, %Ecto.Changeset{}}} + + iex> create_comment(banned_actor, image_id, %{"body" => "Hi"}) + {:error, :ban} """ - def unhide_comment(%Comment{} = comment) do - comment - |> Comment.unhide_changeset() - |> Repo.update() - |> reindex_after_update() + @spec create_comment(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, Comment.t()} + | {:error, {Image.t(), Ecto.Changeset.t()}} + | {:error, :ban | :unauthorized | :forced_filter | :rate_limited} + def create_comment(%Actor{user: creator} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :create_comment), + :ok <- Images.verify_forced_filter_access(actor, image) do + comment_changeset = + image + |> Ecto.build_assoc(:comments) + |> Comment.creation_changeset(attrs, actor) + + Multi.new() + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :comment_create, @comment_create_window) end, + fn -> RateLimiter.rollback_action(actor, :comment_create) end + ) + |> put_lock_image(actor, image.id, :create_comment) + |> Multi.insert(:comment, comment_changeset) + |> Images.put_image_counter_delta(:update_image, image.id, :comments_count, 1) + |> Multi.run(:notification, ¬ify_comment/2) + |> Images.maybe_subscribe_on(:locked_image, creator, :watch_on_reply) + |> Images.put_reindex_image(:locked_image) + |> UserStatistics.put_increment(creator, :comments_count) + |> put_approval_report() + |> put_reindex_comment() + |> Multi.transact() + |> case do + {:ok, %{comment: %Comment{} = comment}} -> + broadcast_comment("comment:create", comment) + {:ok, comment} + + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} + + {:error, :comment, %Ecto.Changeset{} = changeset, _changes} -> + {:error, {image, changeset}} + + error -> + map_lock_errors(error) + end + end end @doc """ - Marks a comment as destroyed and removes its text (hard deletion). + Loads a visible comment through its parent image. - ## Examples + The parent image and scoped comment are independently authorized for `:show`. + The loaded image is returned for the caller to reuse. - iex> destroy_comment(comment) - {:ok, %Comment{}} + ## Examples - """ - def destroy_comment(%Comment{} = comment) do - comment = comment |> Repo.preload(:user) - - Multi.new() - |> Multi.update(:comment, Comment.destroy_changeset(comment)) - |> Multi.update_all( - :image, - Image |> where(id: ^comment.image_id), - inc: [comments_count: -1] - ) - |> Repo.transaction() - |> case do - {:ok, %{comment: comment}} -> - UserStatistics.inc_stat(comment.user_id, :comments_count, -1) - reindex_comment(comment) + iex> show_comment(actor, "1", "2") + {:ok, {%Image{}, %Comment{}}} - {:ok, comment} + iex> show_comment(actor, "1", "not-a-number") + {:error, :not_found} - error -> - error + """ + @spec show_comment( + actor :: Actor.t(), + image_id :: IntegerId.integer_id(), + comment_id :: IntegerId.integer_id() + ) :: + {:ok, {Image.t(), Comment.t()}} | {:error, :unauthorized | :not_found} + def show_comment(%Actor{} = actor, image_id, comment_id) do + with {:ok, image} <- load_image(actor, image_id, :show), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :show, @preloads) do + {:ok, {image, comment}} end end - defp reindex_after_update(result) do - case result do - {:ok, comment} -> - reindex_comment(comment) + @doc """ + Loads an editable comment and changeset through its parent image. - {:ok, comment} + Write access is checked before image authorization, forced-filter enforcement, + and comment authorization. - error -> - error + ## Examples + + iex> edit_comment(actor, "1", "2") + {:ok, %Ecto.Changeset{data: %Comment{}}} + + iex> edit_comment(banned_actor, "1", "2") + {:error, :ban} + + """ + @spec edit_comment( + actor :: Actor.t(), + image_id :: IntegerId.integer_id(), + comment_id :: IntegerId.integer_id() + ) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized | :not_found | :forced_filter} + def edit_comment(%Actor{} = actor, image_id, comment_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :create_comment), + :ok <- Images.verify_forced_filter_access(actor, image), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :edit, @preloads) do + {:ok, Comment.changeset(comment)} end end @doc """ - Approves a comment, closes associated reports, and increments the user comments - posted count. + Updates a parent-scoped comment on behalf of `actor`. - ## Parameters - - comment: The comment to approve - - user: The user performing the approval + Write access is checked before image authorization, forced-filter enforcement, + and comment authorization. A successful transaction records the prior version + Reporting, indexing, and the firehose broadcast run after commit. Validation + returns the changeset preserving the loaded comment and image. On success, + the image is returned for the caller to reuse. ## Examples - iex> approve_comment(comment, user) - {:ok, %Comment{}} + iex> update_comment(actor, image, "1", %{"body" => "Edited"}) + {:ok, {%Image{}, %Comment{}}} - """ - def approve_comment(%Comment{} = comment, user) do - report_query = Reports.close_report_query(user, comment_id: comment.id) - comment = Comment.approve_changeset(comment) - - Multi.new() - |> Multi.update(:comment, comment) - |> Multi.update_all(:reports, report_query, []) - |> Repo.transaction() - |> case do - {:ok, %{comment: comment, reports: {_count, reports}}} -> - UserStatistics.inc_stat(comment.user_id, :comments_count) - Reports.reindex_reports(reports) - reindex_comment(comment) - - {:ok, comment} + iex> update_comment(actor, image, "1", %{"body" => ""}) + {:error, %Ecto.Changeset{data: %Comment{}}} - error -> - error + """ + @spec update_comment( + actor :: Actor.t(), + image_id :: IntegerId.integer_id(), + comment_id :: IntegerId.integer_id(), + attrs :: map() | nil + ) :: + {:ok, {Image.t(), Comment.t()}} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found | :forced_filter} + def update_comment(%Actor{} = actor, image_id, comment_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :create_comment), + :ok <- Images.verify_forced_filter_access(actor, image), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :update, @preloads) do + now = DateTime.utc_now(:second) + comment_changeset = Comment.changeset(comment, attrs, now) + + comment_query = + Comment + |> where(id: ^comment.id) + |> preload(:user) + + Multi.new() + |> put_lock_image(actor, image.id, :create_comment) + |> Multi.lock_one(:original_comment, comment_query) + |> Multi.update(:comment, comment_changeset) + |> Versions.record_edit(:version, :original_comment, :comment, actor) + |> put_approval_report() + |> put_reindex_comment() + |> Multi.transact() + |> case do + {:ok, %{comment: %Comment{} = comment}} -> + broadcast_comment("comment:update", comment) + + {:ok, {image, comment}} + + {:error, :comment, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end end end @doc """ - Creates a system report for non-approved comments containing external images. - Returns false for already approved comments. + Loads a visible comment's edit history through its parent image. - ## Returns - - `false`: If the comment is already approved - - `{:ok, %Report{}}`: If a system report was created + Parent and child IDs are parsed and scoped before authorization. The returned + `CommentHistory` carries the latest 25 versions with authors and diffs. ## Examples - iex> report_non_approved(approved_comment) - false + iex> list_comment_history(actor, "1", "2") + {:ok, %CommentHistory{}} - iex> report_non_approved(unapproved_comment) - {:ok, %Report{}} + iex> list_comment_history(actor, "1", "not-a-number") + {:error, :not_found} """ - def report_non_approved(%Comment{approved: true}), do: false + @spec list_comment_history(Actor.t(), IntegerId.integer_id(), IntegerId.integer_id()) :: + {:ok, CommentHistory.t()} | {:error, :unauthorized | :not_found} + def list_comment_history(%Actor{} = actor, image_id, comment_id) do + with {:ok, image} <- load_image(actor, image_id, :show), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :show, @preloads) do + {:ok, + %CommentHistory{ + image: image, + comment: comment, + versions: Versions.for_comment(comment) + }} + end + end - def report_non_approved(comment) do - Reports.create_system_report( - "Approval", - "Comment contains external links", - comment_id: comment.id - ) + @doc """ + Loads a comment as a report target through its parent image. + + Both resources are authorized for `:show`. Malformed, missing, and mismatched + IDs are not-found. Reports owns the write prerequisite and form changeset. + + ## Examples + + iex> load_report_target(actor, "1", "2") + {:ok, %Comment{}} + + """ + @spec load_report_target(Actor.t(), IntegerId.integer_id(), IntegerId.integer_id()) :: + {:ok, Comment.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, image_id, comment_id) do + with {:ok, image} <- load_image(actor, image_id, :show) do + load_image_comment(actor, image, comment_id, :show, @preloads) + end end @doc """ - Migrates comments from one image to another when handling duplicate images. - Returns the duplicate image parameter unchanged, for use in a pipeline. + Hides a comment scoped through its parent image. - ## Parameters - - image: The source image whose comments will be moved - - duplicate_of_image: The target image that will receive the comments + The comment update, report closure, and moderation log commit atomically. ## Examples - iex> migrate_comments(source_image, target_image) - %Image{} + iex> create_comment_hide(moderator, "1", "2", %{"deletion_reason" => "Spam"}) + {:ok, %Comment{}} """ - def migrate_comments(image, duplicate_of_image) do - {count, nil} = - Comment - |> where(image_id: ^image.id) - |> Repo.update_all(set: [image_id: duplicate_of_image.id]) + @spec create_comment_hide(Actor.t(), IntegerId.integer_id(), IntegerId.integer_id(), map()) :: + {:ok, Comment.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_comment_hide(%Actor{user: user} = actor, image_id, comment_id, params) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :show), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :hide, @preloads) do + changeset = Comment.hide_changeset(comment, params, user) + reason = Ecto.Changeset.get_field(changeset, :deletion_reason) + + Multi.new() + |> put_lock_image(actor, image.id, :show) + |> Multi.update(:comment, changeset) + |> Reports.put_close_reports(:reports, user, comment_id: comment.id) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Image.Comment.Hide:create", + Paths.image_comment_path(comment.image_id, comment.id), + "Deleted comment on image #{comment.image_id} (#{reason})" + ) + |> put_reindex_comment() + |> Multi.transact() + |> case do + {:ok, %{comment: %Comment{} = comment}} -> + {:ok, comment} + + {:error, :comment, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end + end + + @doc """ + Restores a comment through its parent image. + + ## Examples + + iex> delete_comment_hide(moderator, "1", "2") + {:ok, %Comment{}} - Image - |> where(id: ^duplicate_of_image.id) - |> Repo.update_all(inc: [comments_count: count]) + """ + @spec delete_comment_hide(Actor.t(), IntegerId.integer_id(), IntegerId.integer_id()) :: + {:ok, Comment.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def delete_comment_hide(%Actor{} = actor, image_id, comment_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :show), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :hide, @preloads) do + changeset = Comment.unhide_changeset(comment) + + Multi.new() + |> put_lock_image(actor, image.id, :show) + |> Multi.update(:comment, changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Image.Comment.Hide:delete", + Paths.image_comment_path(comment.image_id, comment.id), + "Restored comment on image #{comment.image_id}" + ) + |> put_reindex_comment() + |> Multi.transact() + |> case do + {:ok, %{comment: %Comment{} = comment}} -> + {:ok, comment} + + {:error, :comment, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end + end + + @doc """ + Destroys a comment's content through its parent image. + + Authorization uses the distinct `:delete` action. Content removal, image + counters, and the moderation log commit together. - reindex_comments_on_image(duplicate_of_image) + ## Examples + + iex> create_comment_delete(moderator, "1", "2") + {:ok, %Comment{}} + + """ + @spec create_comment_delete(Actor.t(), IntegerId.integer_id(), IntegerId.integer_id()) :: + {:ok, Comment.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_comment_delete(%Actor{} = actor, image_id, comment_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :show), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :delete, @preloads) do + comment_query = from(c in Comment, where: c.id == ^comment.id) + + Multi.new() + |> put_lock_image(actor, image.id, :show) + |> Multi.lock_one(:locked_comment, comment_query) + |> Multi.update(:comment, fn %{locked_comment: comment} -> + Comment.destroy_changeset(comment) + end) + |> Images.put_image_counter_delta(:update_image, image.id, :comments_count, -1) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Image.Comment.Delete:create", + Paths.image_comment_path(comment.image_id, comment.id), + "Destroyed comment on image #{comment.image_id}" + ) + |> UserStatistics.put_increment( + fn %{comment: comment} -> + if comment.approved, do: comment.user_id + end, + :comments_count, + -1 + ) + |> Images.put_reindex_image(:locked_image) + |> put_reindex_comment() + |> Multi.transact() + |> case do + {:ok, %{comment: %Comment{} = comment}} -> + {:ok, comment} + + {:error, :comment, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking comment changes. + Approves a comment through its parent image. + + Approval, report closure, author statistics, and the moderation log commit + together. ## Examples - iex> change_comment(comment) - %Ecto.Changeset{source: %Comment{}} + iex> create_comment_approve(moderator, "1", "2") + {:ok, %Comment{}} + + """ + @spec create_comment_approve(Actor.t(), IntegerId.integer_id(), IntegerId.integer_id()) :: + {:ok, Comment.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_comment_approve(%Actor{user: user} = actor, image_id, comment_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image(actor, image_id, :show), + {:ok, comment} <- load_image_comment(actor, image, comment_id, :approve, @preloads) do + comment_query = from(c in Comment, where: c.id == ^comment.id) + + Multi.new() + |> put_lock_image(actor, image.id, :show) + |> Multi.lock_one(:locked_comment, comment_query) + |> Multi.update(:comment, fn %{locked_comment: comment} -> + Comment.approve_changeset(comment) + end) + |> Reports.put_close_reports(:reports, user, comment_id: comment.id) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Image.Comment.Approve:create", + Paths.image_comment_path(comment.image_id, comment.id), + "Approved comment on image #{comment.image_id}" + ) + |> UserStatistics.put_increment(comment.user_id, :comments_count) + |> put_reindex_comment() + |> Multi.transact() + |> case do + {:ok, %{comment: %Comment{} = comment}} -> + {:ok, comment} + + {:error, :comment, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end + end + + @doc """ + Moves comments from one image to another inside `multi`. + + Image merge workflows compose this operation and then adjust the target + image's denormalized count through `Images.put_image_counter_delta`. The + returned `:migrated_comments` step contains the number of moved rows. + """ + @spec put_migrate_image_comments(Multi.t(), Image.t(), Image.t()) :: Multi.t() + def put_migrate_image_comments(%Multi{} = multi, %Image{} = source, %Image{} = target) do + query = where(Comment, image_id: ^source.id) |> update(set: [image_id: ^target.id]) + Multi.update_all(multi, :migrated_comments, query, []) + end + @doc """ + Replaces attribution data on a user's comments in batches. """ - def change_comment(%Comment{} = comment) do - Comment.changeset(comment, %{}) + @spec wipe_user_attribution!(integer(), term(), String.t()) :: :ok + def wipe_user_attribution!(user_id, ip, fingerprint) do + Comment + |> where(user_id: ^user_id) + |> Batch.query_batches() + |> Enum.each(&Repo.update_all(&1, set: [ip: ip, fingerprint: fingerprint])) + + :ok end @doc """ - Updates comment search indices when a user's name changes. + Updates indexed comment author names after a user rename. ## Examples @@ -318,15 +822,14 @@ defmodule Philomena.Comments do :ok """ + @spec user_name_reindex(String.t(), String.t()) :: term() def user_name_reindex(old_name, new_name) do data = Comments.SearchIndex.user_name_update_by_query(old_name, new_name) - Search.update_by_query(Comment, data.query, data.set_replacements, data.replacements) end @doc """ - Queues a single comment for search index updates. - Returns the comment struct unchanged, for use in a pipeline. + Queues one comment for search indexing and returns it unchanged. ## Examples @@ -334,15 +837,14 @@ defmodule Philomena.Comments do %Comment{} """ + @spec reindex_comment(Comment.t()) :: Comment.t() def reindex_comment(%Comment{} = comment) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "id", [comment.id]]) - comment end @doc """ - Queues all comments associated with an image for search index updates. - Returns the image struct unchanged, for use in a pipeline. + Queues every comment on `image` for indexing and returns the image unchanged. ## Examples @@ -350,15 +852,14 @@ defmodule Philomena.Comments do %Image{} """ - def reindex_comments_on_image(image) do + @spec reindex_comments_on_image(Image.t()) :: Image.t() + def reindex_comments_on_image(%Image{} = image) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "image_id", [image.id]]) - image end @doc """ - Queues all comments associated with a list of image IDs for search index updates. - Returns the list unchanged, for use in a pipeline. + Queues comments on the given image IDs for reindexing and returns the list unchanged. ## Examples @@ -366,57 +867,48 @@ defmodule Philomena.Comments do [1, 2, 3] """ - def reindex_comments_on_images(image_ids) do + @spec reindex_comments_on_images([integer()]) :: [integer()] + def reindex_comments_on_images(image_ids) when is_list(image_ids) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Comments", "image_id", image_ids]) - image_ids end @doc """ - Provides preload queries for comment indexing operations. + Returns the association queries required to serialize comment search records. ## Examples iex> indexing_preloads() - [user: user_query, image: image_query] + [user: user_query, image: image_query, deleted_by: user_query] """ + @spec indexing_preloads() :: list() def indexing_preloads do - user_query = select(User, [u], map(u, [:id, :name])) - tag_query = select(Tag, [t], map(t, [:id, :name])) + user_query = select(User, [user], map(user, [:id, :name])) + tag_query = select(Tag, [tag], map(tag, [:id, :name])) image_query = Image - |> select([i], struct(i, [:approved, :hidden_from_users, :id])) + |> select([image], struct(image, [:approved, :hidden_from_users, :id])) |> preload(tags: ^tag_query) - [ - user: user_query, - image: image_query, - deleted_by: user_query - ] + [user: user_query, image: image_query, deleted_by: user_query] end @doc """ - Performs a search reindex operation on comments matching the given criteria. - - ## Parameters - - column: The database column to filter on (e.g., :id, :image_id) - - condition: A list of values to match against the column + Reindexes comments selected by a trusted worker column and values. ## Examples iex> perform_reindex(:id, [1, 2, 3]) :ok - iex> perform_reindex(:image_id, [123]) - :ok - """ - def perform_reindex(column, condition) do + @spec perform_reindex(atom(), [term()]) :: term() + def perform_reindex(column, condition) when is_atom(column) and is_list(condition) do Comment |> preload(^indexing_preloads()) - |> where([c], field(c, ^column) in ^condition) + |> where([comment], field(comment, ^column) in ^condition) |> Search.reindex(Comment) end end diff --git a/lib/philomena/comments/comment.ex b/lib/philomena/comments/comment.ex index e93efd85b..a4d436f3f 100644 --- a/lib/philomena/comments/comment.ex +++ b/lib/philomena/comments/comment.ex @@ -2,14 +2,19 @@ defmodule Philomena.Comments.Comment do use Ecto.Schema import Ecto.Changeset + alias Philomena.Attribution.Actor alias Philomena.Images.Image alias Philomena.Users.User + alias Philomena.Reports.Report alias Philomena.Schema.Approval + @type t :: %__MODULE__{} + schema "comments" do belongs_to :user, User belongs_to :image, Image belongs_to :deleted_by, User + has_many :reports, Report field :body, :string field :ip, EctoNetwork.INET @@ -20,22 +25,23 @@ defmodule Philomena.Comments.Comment do field :edited_at, :utc_datetime field :deletion_reason, :string, default: "" field :destroyed_content, :boolean, default: false - field :approved, :boolean + field :approved, :boolean, default: true + field :became_unapproved?, :boolean, virtual: true, default: false timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def creation_changeset(comment, attrs, attribution) do + def creation_changeset(comment, attrs, %Actor{} = actor) do comment |> cast(attrs, [:body, :anonymous]) |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) - |> change(attribution) - |> Approval.maybe_put_approval(attribution[:user], :external_links) + |> change(Actor.to_changes(actor)) + |> Approval.maybe_put_approval(actor.user, :external_links) end - def changeset(comment, attrs, edited_at \\ nil) do + def changeset(comment, attrs \\ %{}, edited_at \\ nil) do comment |> cast(attrs, [:body, :edit_reason]) |> put_change(:edited_at, edited_at) @@ -55,6 +61,7 @@ defmodule Philomena.Comments.Comment do def unhide_changeset(comment) do change(comment) + |> validate_undestroyed() |> put_change(:hidden_from_users, false) |> put_change(:deleted_by_id, nil) |> put_change(:deletion_reason, "") @@ -62,12 +69,33 @@ defmodule Philomena.Comments.Comment do def destroy_changeset(comment) do change(comment) + |> validate_hidden() + |> validate_undestroyed() |> put_change(:destroyed_content, true) |> put_change(:body, "") end + @doc false def approve_changeset(comment) do - change(comment) - |> put_change(:approved, true) + comment + |> change() + |> validate_undestroyed() + |> Approval.approve_changeset() + end + + defp validate_hidden(changeset) do + if not get_field(changeset, :hidden_from_users) do + add_error(changeset, :destroyed_content, "cannot be set while comment is visible") + else + changeset + end + end + + defp validate_undestroyed(changeset) do + if get_field(changeset, :destroyed_content) do + add_error(changeset, :destroyed_content, "has already been destroyed") + else + changeset + end end end diff --git a/lib/philomena/comments/comment_history.ex b/lib/philomena/comments/comment_history.ex new file mode 100644 index 000000000..2952bd658 --- /dev/null +++ b/lib/philomena/comments/comment_history.ex @@ -0,0 +1,17 @@ +defmodule Philomena.Comments.CommentHistory do + @moduledoc """ + An image's comment and the versions displayed on its history page. + """ + + alias Philomena.Comments.{Comment, CommentVersion} + alias Philomena.Images.Image + + @enforce_keys [:image, :comment, :versions] + defstruct [:image, :comment, versions: []] + + @type t :: %__MODULE__{ + image: Image.t(), + comment: Comment.t(), + versions: [CommentVersion.t()] + } +end diff --git a/lib/philomena/comments/comment_version.ex b/lib/philomena/comments/comment_version.ex index bccb91a9f..efa9af280 100644 --- a/lib/philomena/comments/comment_version.ex +++ b/lib/philomena/comments/comment_version.ex @@ -4,6 +4,8 @@ defmodule Philomena.Comments.CommentVersion do alias Philomena.Comments.Comment alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "comment_versions" do belongs_to :comment, Comment belongs_to :user, User diff --git a/lib/philomena/comments/query.ex b/lib/philomena/comments/query.ex index c8f4f23bc..185fd08cf 100644 --- a/lib/philomena/comments/query.ex +++ b/lib/philomena/comments/query.ex @@ -1,4 +1,12 @@ defmodule Philomena.Comments.Query do + @moduledoc """ + Compiles the user-facing comment search language. + """ + + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Attribution.Actor + alias Philomena.Comments.Comment alias PhilomenaQuery.Parse.Parser alias Philomena.Tags.Tag @@ -51,21 +59,38 @@ defmodule Philomena.Comments.Query do |> Parser.parse(query_string, context) end - def compile(query_string, opts \\ []) do - user = Keyword.get(opts, :user) + defp fields_for(nil), do: anonymous_fields() - case user do - nil -> - parse(anonymous_fields(), %{user: nil}, query_string) + defp fields_for(%Actor{} = actor) do + case authorize(actor, :search_sensitive, Comment) do + :ok -> moderator_fields() + {:error, :unauthorized} -> user_fields() + end + end - %{role: role} when role in ~W(user assistant) -> - parse(user_fields(), %{user: user}, query_string) + @doc """ + Compiles `query_string` using the fields available to `opts[:actor]`. - %{role: role} when role in ~W(moderator admin) -> - parse(moderator_fields(), %{user: user}, query_string) + Anonymous callers receive public fields, signed-in callers receive the `my` + transform, and actors authorized for sensitive comment search receive + moderation metadata fields as well. - _ -> - raise ArgumentError, "Unknown user role." - end + ## Examples + + iex> compile("body:hello", actor: actor) + {:ok, %{match: %{body: %{query: "hello", analyzer: "fulltext_analyzer"}}}} + + iex> compile("ip:192.0.2.1", actor: moderator_actor) + {:ok, %{term: %{ip: "192.0.2.1"}}} + + """ + @spec compile(String.t() | nil, keyword()) :: {:ok, map()} | {:error, String.t()} + def compile(query_string, opts \\ []) do + actor = Keyword.get(opts, :actor) + user = if actor, do: actor.user + + actor + |> fields_for() + |> parse(%{user: user}, query_string) end end diff --git a/lib/philomena/comments/visibility.ex b/lib/philomena/comments/visibility.ex new file mode 100644 index 000000000..fbe20fc15 --- /dev/null +++ b/lib/philomena/comments/visibility.ex @@ -0,0 +1,132 @@ +defmodule Philomena.Comments.Visibility do + @moduledoc """ + Database query scopes for comment collection reads. + """ + + import Ecto.Query, warn: false + import Philomena.Authorization + + alias Philomena.Attribution.Actor + alias Philomena.Comments.Comment + alias Philomena.Filters.Filter + alias Philomena.Images.Image + alias Philomena.Users.User + + defp authorized?(%Actor{} = actor, action, subject), + do: authorize(actor, action, subject) == :ok + + defp visibility_policy(%Actor{} = actor, allow_privileged?) do + %{ + show_hidden_comments?: + allow_privileged? and + authorized?(actor, :show, %Comment{hidden_from_users: true}), + show_hidden_images?: + allow_privileged? and authorized?(actor, :show, %Image{hidden_from_users: true}), + show_destroyed_comments?: allow_privileged? and authorized?(actor, :delete, %Comment{}), + show_unapproved_comments?: allow_privileged? and authorized?(actor, :approve, %Comment{}), + show_unapproved_images?: allow_privileged? and authorized?(actor, :approve, %Image{}) + } + end + + defp filter_hidden_comments(query, true), do: query + + defp filter_hidden_comments(query, false), + do: where(query, [comment], not comment.hidden_from_users) + + defp filter_destroyed_comments(query, true), do: query + + defp filter_destroyed_comments(query, false), + do: where(query, [comment], not comment.destroyed_content) + + defp filter_non_approved(query, _user, true), do: query + + defp filter_non_approved(query, %User{id: user_id}, false), + do: where(query, [comment], comment.approved or comment.user_id == ^user_id) + + defp filter_non_approved(query, _user, false), + do: where(query, [comment], comment.approved) + + defp exclude_hidden_comments(filters, true), do: filters + + defp exclude_hidden_comments(filters, false), + do: [%{term: %{hidden_from_users: true}} | filters] + + defp exclude_hidden_images(filters, true), do: filters + + defp exclude_hidden_images(filters, false), + do: [%{term: %{"image.hidden_from_users" => true}} | filters] + + defp exclude_destroyed_comments(filters, true), do: filters + + defp exclude_destroyed_comments(filters, false), + do: [%{term: %{destroyed_content: true}} | filters] + + defp exclude_unapproved_comments(filters, _user, true), do: filters + + defp exclude_unapproved_comments(filters, %User{id: user_id}, false) do + [ + %{ + bool: %{ + must: [%{term: %{approved: false}}], + must_not: [%{term: %{user_id: user_id}}] + } + } + | filters + ] + end + + defp exclude_unapproved_comments(filters, _user, false), + do: [%{term: %{approved: false}} | filters] + + defp exclude_unapproved_images(filters, true), do: filters + + defp exclude_unapproved_images(filters, false), + do: [%{term: %{"image.approved" => false}} | filters] + + @doc """ + Restricts a comment query to comments visible to `actor`. + + ## Examples + + iex> visible_forums(Forum, actor) + #Ecto.Query<...> + + """ + @spec visible_comments(Ecto.Queryable.t(), Actor.t()) :: Ecto.Query.t() + def visible_comments(queryable, %Actor{} = actor) do + policy = visibility_policy(actor, true) + + queryable + |> filter_hidden_comments(policy.show_hidden_comments?) + |> filter_destroyed_comments(policy.show_destroyed_comments?) + |> filter_non_approved(actor.user, policy.show_unapproved_comments?) + end + + @doc """ + Generates an OpenSearch boolean `must_not` clause to select comments + visible to `actor`. + + Moderators, administrators, and image moderator assistants can see hidden + comments. `allow_privileged?` determines whether any may be returned. + + ## Examples + + iex> search_exclusions(admin, filter, true) + [%{terms: %{"image.tag_ids" => []}}] + + iex> search_exclusions(user, filter, true) + [%{term: %{hidden_from_users: true}}, ...] + + """ + @spec search_exclusions(Actor.t(), Filter.t(), boolean()) :: list() + def search_exclusions(%Actor{user: user} = actor, %Filter{} = filter, allow_privileged?) do + policy = visibility_policy(actor, allow_privileged?) + + [%{terms: %{"image.tag_ids" => filter.hidden_tag_ids}}] + |> exclude_hidden_comments(policy.show_hidden_comments?) + |> exclude_hidden_images(policy.show_hidden_images?) + |> exclude_destroyed_comments(policy.show_destroyed_comments?) + |> exclude_unapproved_comments(user, policy.show_unapproved_comments?) + |> exclude_unapproved_images(policy.show_unapproved_images?) + end +end diff --git a/lib/philomena/commissions.ex b/lib/philomena/commissions.ex index 4b4fcba20..a12d080e8 100644 --- a/lib/philomena/commissions.ex +++ b/lib/philomena/commissions.ex @@ -1,250 +1,452 @@ defmodule Philomena.Commissions do @moduledoc """ - The Commissions context. + Commission directory, profile listings, and listing item management. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.Multi + alias Philomena.Attribution.Actor alias Philomena.Commissions.Commission + alias Philomena.Commissions.Directory alias Philomena.Commissions.Item alias Philomena.Commissions.QueryBuilder - alias Philomena.Commissions.SearchQuery + alias Philomena.Commissions.QueryForm + alias Philomena.IntegerId + alias Philomena.Loader + alias Philomena.Repo alias Philomena.Reports + alias Philomena.Users + alias Philomena.Users.User + + @profile_preloads [:commission, :verified_links] + @commission_preloads [ + sheet_image: [:sources, tags: :aliases], + user: [awards: :badge], + items: [example_image: [:sources, tags: :aliases]] + ] + + defp load_profile(actor, slug, _action) do + Users.load_profile(actor, slug, @profile_preloads) + end + + defp load_profile_commission(%Actor{} = actor, %User{id: user_id}, action) do + Commission + |> where(user_id: ^user_id) + |> preload(^@commission_preloads) + |> Loader.one_and_authorize(actor, action) + end + + defp load_commission_item(%Commission{} = commission, id) do + Item + |> where(commission_id: ^commission.id) + |> preload(commission: :user) + |> Loader.fetch(id) + end + + defp new_commission(%Actor{} = actor, %User{} = user, action) do + commission = + user + |> Ecto.build_assoc(:commission) + |> Map.put(:user, user) + + with :ok <- authorize(actor, action, commission) do + cond do + not is_nil(user.commission) -> + {:error, :unauthorized} + + Enum.empty?(user.verified_links) -> + {:error, :no_verified_links} + + true -> + {:ok, commission} + end + end + end + + defp new_item(%Commission{} = commission) do + commission + |> Ecto.build_assoc(:items) + |> Map.put(:commission, commission) + end @doc """ - Gets a single commission. + Loads the public commission directory for `actor`. - Raises `Ecto.NoResultsError` if the Commission does not exist. + The `:index` commission ability is checked before searching. Results include + only open listings with items whose active owner has recent activity. + Invalid search parameters return an empty page and the rejected search + changeset. If present, the viewing user is returned with commission preloaded. ## Examples - iex> get_commission!(123) - %Commission{} - - iex> get_commission!(456) - ** (Ecto.NoResultsError) + iex> list_commissions(actor, params, page: 1, page_size: 25) + {:ok, %Directory{}} """ - def get_commission!(id), do: Repo.get!(Commission, id) + @spec list_commissions(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Directory.t()} | {:error, :unauthorized} + def list_commissions(%Actor{user: user} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Commission) do + {commissions, changeset} = + params + |> QueryBuilder.search_commissions() + |> case do + {:ok, query, query_form} -> + {Repo.paginate(query, pagination), QueryForm.changeset(query_form)} + + {:error, changeset} -> + {nil, changeset} + end + + {:ok, + %Directory{ + commissions: commissions, + changeset: changeset, + current_user: Repo.preload(user, @profile_preloads) + }} + end + end @doc """ - Creates a commission. + Loads the active profile named by `slug` and its visible commission listing. + + Missing or deactivated profiles and profiles without a listing are not found. + Items are returned by ascending base price with ID as a deterministic tie + breaker. ## Examples - iex> create_commission(%{field: value}) + iex> show_commission(actor, "artist") {:ok, %Commission{}} - iex> create_commission(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ - def create_commission(user, attrs \\ %{}) do - Ecto.build_assoc(user, :commission) - |> Commission.changeset(attrs) - |> Repo.insert() + @spec show_commission(Actor.t(), String.t()) :: + {:ok, Commission.t()} | {:error, :unauthorized | :not_found} + def show_commission(%Actor{} = actor, slug) do + with {:ok, user} <- load_profile(actor, slug, :show) do + load_profile_commission(actor, user, :show) + end end @doc """ - Updates a commission. + Loads a visible commission listing as a report target. + + This shares the active profile and `:show` gates used by the listing page. ## Examples - iex> update_commission(commission, %{field: new_value}) + iex> load_report_target(actor, "artist") {:ok, %Commission{}} - iex> update_commission(commission, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + @spec load_report_target(Actor.t(), String.t()) :: + {:ok, Commission.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, slug) do + show_commission(actor, slug) + end + + @doc """ + Builds a new commission form for the active profile named by `slug`. + + Write access is checked before loading. The owner, moderators, and admins may + manage a commission on the profile's behalf. The profile must have a verified + artist link and no existing commission. + + ## Examples + + iex> new_commission(actor, "artist") + {:ok, %Ecto.Changeset{}} """ - def update_commission(%Commission{} = commission, attrs) do - commission - |> Commission.changeset(attrs) - |> Repo.update() + @spec new_commission(Actor.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found | :no_verified_links} + def new_commission(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- new_commission(actor, user, :new) do + {:ok, Commission.changeset(commission)} + end end @doc """ - Deletes a Commission. + Creates the sole commission listing for the active profile named by `slug`. + + Authorization and verified link rules match `new_commission/2`. The database + uniquely enforces one commission per profile. Validation failures return a + `m:Ecto.Changeset` retaining the loaded profile and attempted changes. ## Examples - iex> delete_commission(commission) + iex> create_commission(actor, "artist", attrs) {:ok, %Commission{}} - iex> delete_commission(commission) + iex> create_commission(actor, "artist", invalid_attrs) {:error, %Ecto.Changeset{}} """ - def delete_commission(%Commission{} = commission, closing_user) do - Multi.new() - |> Multi.update_all( - :reports, - Reports.close_report_query(closing_user, commission_id: commission.id), - [] - ) - |> Multi.delete(:commission, commission) - |> Repo.transaction() - |> case do - {:ok, %{commission: commission, reports: {_count, reports}}} -> - Reports.reindex_reports(reports) - - {:ok, commission} - - error -> - error + @spec create_commission(Actor.t(), String.t(), map()) :: + {:ok, Commission.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found | :no_verified_links} + def create_commission(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- new_commission(actor, user, :create), + {:ok, commission} <- + commission + |> Commission.changeset(attrs) + |> Repo.insert() do + {:ok, Repo.preload(commission, @commission_preloads)} end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking commission changes. + Loads the existing commission form for the active profile named by `slug`. + + Write access, owner/staff authorization, and verified link requirements match + the update operation. ## Examples - iex> change_commission(commission) - %Ecto.Changeset{source: %Commission{}} + iex> edit_commission(actor, "artist") + {:ok, %Ecto.Changeset{}} """ - def change_commission(%Commission{} = commission) do - Commission.changeset(commission, %{}) + @spec edit_commission(Actor.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def edit_commission(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :edit) do + {:ok, Commission.changeset(commission)} + end end @doc """ - Searches commissions based on the given parameters. + Updates the existing commission for the active profile named by `slug`. - ## Parameters + Validation failures return a `m:Ecto.Changeset`. Successful updates preserve + item ordering and count. + + ## Examples - * params - Map of optional search parameters: - * item_type - Filter by item type - * category - Filter by category - * keywords - Search in information and will_create fields - * price_min - Minimum base price - * price_max - Maximum base price + iex> update_commission(actor, "artist", attrs) + {:ok, %Commission{}} - Returns `{:ok, query}` with a queryable that can be used with Repo.paginate/2, - or `{:error, changeset}` if the provided parameters are invalid. """ - def execute_search_query(params \\ %{}) do - QueryBuilder.search_commissions(params) + @spec update_commission(Actor.t(), String.t(), map()) :: + {:ok, Commission.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def update_commission(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :update) do + commission + |> Commission.changeset(attrs) + |> Repo.update() + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking search query changes. + Deletes the commission for the active profile named by `slug`. + + The commission, its items, and its report-target foreign keys are delete atomically. + Open reports are closed by the acting user and reindexed only after commit. ## Examples - iex> change_search_query(search_query) - %Ecto.Changeset{source: %SearchQuery{}} + iex> delete_commission(actor, "artist") + {:ok, %Commission{}} """ - def change_search_query(%SearchQuery{} = search_query) do - SearchQuery.changeset(search_query, %{}) + @spec delete_commission(Actor.t(), String.t()) :: + {:ok, Commission.t()} + | {:error, :ban | :unauthorized | :not_found} + def delete_commission(%Actor{user: closing_user} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :delete) do + Multi.new() + |> Reports.put_close_reports(:reports, closing_user, commission_id: commission.id) + |> Multi.delete(:commission, commission) + |> Multi.transact() + |> case do + {:ok, %{commission: %Commission{} = commission}} -> + {:ok, commission} + + {:error, :commission, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Gets a single item. + Builds a new item changeset for the commission belonging to the active profile + named by `slug`. - Raises `Ecto.NoResultsError` if the Item does not exist. + Creating commission items requires permission to edit the commission. Write access + is checked before the profile and commission are loaded. ## Examples - iex> get_item!(123) - %Item{} - - iex> get_item!(456) - ** (Ecto.NoResultsError) + iex> new_item(actor, "artist") + {:ok, %Ecto.Changeset{}} """ - def get_item!(id), do: Repo.get!(Item, id) + @spec new_item(Actor.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized | :not_found} + def new_item(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :new_item) do + {:ok, + commission + |> new_item() + |> Item.changeset()} + end + end @doc """ - Creates a item. + Creates an item under the commission belonging to the active profile named by + `slug` and increments the listing's item count. ## Examples - iex> create_item(%{field: value}) + iex> create_item(actor, "artist", attrs) {:ok, %Item{}} - iex> create_item(%{field: bad_value}) + iex> create_item(actor, "artist", invalid_attrs) {:error, %Ecto.Changeset{}} """ - def create_item(commission, attrs \\ %{}) do - changeset = - Ecto.build_assoc(commission, :items) - |> Item.changeset(attrs) - - update = - Commission - |> where(id: ^commission.id) - |> update(inc: [commission_items_count: 1]) - - Multi.new() - |> Multi.insert(:item, changeset) - |> Multi.update_all(:commission, update, []) - |> Repo.transaction() - |> case do - {:error, :item, changeset, _} -> - {:error, changeset} - - result -> - result + @spec create_item(Actor.t(), String.t(), map()) :: + {:ok, Item.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_item(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :create_item) do + changeset = + commission + |> new_item() + |> Item.changeset(attrs) + + counter_query = + Commission + |> where(id: ^commission.id) + |> update(inc: [commission_items_count: 1]) + + Multi.new() + |> Multi.insert(:item, changeset) + |> Multi.update_all(:commission, counter_query, []) + |> Multi.transact() + |> case do + {:ok, %{item: %Item{} = item}} -> + {:ok, item} + + {:error, :item, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Updates a item. + Loads the item named by `id` for editing under the commission belonging to + the active profile named by `slug`. + + The item lookup is constrained by the commission before authorization. + Malformed, absent, and wrong-commission IDs are all not found. ## Examples - iex> update_item(item, %{field: new_value}) - {:ok, %Item{}} + iex> edit_item(actor, "artist", "12") + {:ok, %Ecto.Changeset{}} - iex> update_item(item, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_item(actor, "artist", "bad") + {:error, :not_found} """ - def update_item(%Item{} = item, attrs) do - item - |> Item.changeset(attrs) - |> Repo.update() + @spec edit_item(Actor.t(), String.t(), IntegerId.integer_id()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized | :not_found} + def edit_item(%Actor{} = actor, slug, id) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :edit_item), + {:ok, item} <- load_commission_item(commission, id) do + {:ok, Item.changeset(item)} + end end @doc """ - Deletes a Item. + Updates the item named by `id` under the commission belonging to the active + profile named by `slug`. + + The item lookup is constrained by the commission before authorization. + Malformed, absent, and wrong-commission IDs are all not found. Validation + failures return an `m:Ecto.Changeset`. ## Examples - iex> delete_item(item) + iex> update_item(actor, "artist", "12", attrs) {:ok, %Item{}} - iex> delete_item(item) - {:error, %Ecto.Changeset{}} - """ - def delete_item(%Item{} = item) do - update = - Commission - |> where(id: ^item.commission_id) - |> update(inc: [commission_items_count: -1]) - - Multi.new() - |> Multi.delete(:item, item) - |> Multi.update_all(:commission, update, []) - |> Repo.transaction() + @spec update_item(Actor.t(), String.t(), IntegerId.integer_id(), map()) :: + {:ok, Item.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def update_item(%Actor{} = actor, slug, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :update_item), + {:ok, item} <- load_commission_item(commission, id) do + item + |> Item.changeset(attrs) + |> Repo.update() + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking item changes. + Deletes the item named by `id` under the commission belonging to the active + profile named by `slug` and decrements the listing's item count. + + Malformed, absent, and wrong-commission IDs are all not found. ## Examples - iex> change_item(item) - %Ecto.Changeset{source: %Item{}} + iex> delete_item(actor, "artist", "12") + {:ok, %Item{}} """ - def change_item(%Item{} = item) do - Item.changeset(item, %{}) + @spec delete_item(Actor.t(), String.t(), IntegerId.integer_id()) :: + {:ok, Item.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_item(%Actor{} = actor, slug, id) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_profile(actor, slug, :show), + {:ok, commission} <- load_profile_commission(actor, user, :delete_item), + {:ok, item} <- load_commission_item(commission, id) do + counter_query = + Commission + |> where(id: ^item.commission_id) + |> update(inc: [commission_items_count: -1]) + + Multi.new() + |> Multi.delete(:item, item) + |> Multi.update_all(:commission, counter_query, []) + |> Multi.transact() + |> case do + {:ok, %{item: %Item{} = item}} -> + {:ok, item} + + {:error, :item, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end end diff --git a/lib/philomena/commissions/commission.ex b/lib/philomena/commissions/commission.ex index c4108cee6..00cd73afb 100644 --- a/lib/philomena/commissions/commission.ex +++ b/lib/philomena/commissions/commission.ex @@ -5,11 +5,15 @@ defmodule Philomena.Commissions.Commission do alias Philomena.Commissions.Item alias Philomena.Images.Image alias Philomena.Users.User + alias Philomena.Reports.Report + + @type t :: %__MODULE__{} schema "commissions" do belongs_to :user, User belongs_to :sheet_image, Image - has_many :items, Item + has_many :items, Item, preload_order: [asc: :base_price, asc: :id] + has_many :reports, Report field :open, :boolean field :categories, {:array, :string}, default: [] @@ -23,7 +27,7 @@ defmodule Philomena.Commissions.Commission do end @doc false - def changeset(commission, attrs) do + def changeset(commission, attrs \\ %{}) do commission |> cast(attrs, [ :information, @@ -41,6 +45,7 @@ defmodule Philomena.Commissions.Commission do |> validate_length(:will_create, max: 1000, count: :bytes) |> validate_length(:will_not_create, max: 1000, count: :bytes) |> validate_subset(:categories, Keyword.values(categories())) + |> unique_constraint(:user_id, name: :index_commissions_on_user_id) end defp drop_blank_categories(changeset) do diff --git a/lib/philomena/commissions/directory.ex b/lib/philomena/commissions/directory.ex new file mode 100644 index 000000000..9d98983d4 --- /dev/null +++ b/lib/philomena/commissions/directory.ex @@ -0,0 +1,18 @@ +defmodule Philomena.Commissions.Directory do + @moduledoc """ + Commission directory page: the paginated listings, search form + changeset, and current viewer with their commission preloaded when signed in. + """ + + alias Philomena.Commissions.Commission + alias Philomena.Users.User + + @enforce_keys [:commissions, :changeset, :current_user] + defstruct [:commissions, :changeset, :current_user] + + @type t :: %__MODULE__{ + commissions: Scrivener.Page.t(Commission.t()) | nil, + changeset: Ecto.Changeset.t(), + current_user: User.t() | nil + } +end diff --git a/lib/philomena/commissions/item.ex b/lib/philomena/commissions/item.ex index 34f1a1530..a8cad4fb5 100644 --- a/lib/philomena/commissions/item.ex +++ b/lib/philomena/commissions/item.ex @@ -5,6 +5,8 @@ defmodule Philomena.Commissions.Item do alias Philomena.Commissions.Commission alias Philomena.Images.Image + @type t :: %__MODULE__{} + schema "commission_items" do belongs_to :commission, Commission belongs_to :example_image, Image @@ -18,7 +20,7 @@ defmodule Philomena.Commissions.Item do end @doc false - def changeset(item, attrs) do + def changeset(item, attrs \\ %{}) do item |> cast(attrs, [:item_type, :description, :base_price, :add_ons, :example_image_id]) |> validate_required([:commission_id, :base_price, :item_type, :description]) diff --git a/lib/philomena/commissions/query_builder.ex b/lib/philomena/commissions/query_builder.ex index 3633e2d58..c9e1f2373 100644 --- a/lib/philomena/commissions/query_builder.ex +++ b/lib/philomena/commissions/query_builder.ex @@ -3,8 +3,9 @@ defmodule Philomena.Commissions.QueryBuilder do alias Philomena.Commissions.Commission alias Philomena.Commissions.Item - alias Philomena.Commissions.SearchQuery + alias Philomena.Commissions.QueryForm alias Philomena.UserIps.UserIp + alias Philomena.Users.User import Ecto.Query @doc """ @@ -19,24 +20,23 @@ defmodule Philomena.Commissions.QueryBuilder do * price_min - Minimum base price * price_max - Maximum base price - Returns `{:ok, query}` with a queryable that can be used with Repo.paginate/2, - or `{:error, changeset}` if the provided parameters are invalid. + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. """ def search_commissions(params \\ %{}) do - %SearchQuery{} - |> SearchQuery.changeset(params) - |> Ecto.Changeset.apply_action(:create) - |> case do - {:ok, sq} -> - {:ok, - commission_search_query() - |> maybe_filter_price(sq) - |> maybe_filter_item_type(sq) - |> maybe_filter_categories(sq) - |> maybe_filter_keywords(sq)} + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + commission_search_query() + |> maybe_filter_price(query_form) + |> maybe_filter_item_type(query_form) + |> maybe_filter_categories(query_form) + |> maybe_filter_keywords(query_form) - {:error, changeset} -> - {:error, changeset} + {:ok, query, query_form} end end @@ -47,6 +47,10 @@ defmodule Philomena.Commissions.QueryBuilder do as: :commission, where: c.open == true, where: c.commission_items_count > 0, + inner_join: u in User, + as: :user, + on: u.id == c.user_id, + where: is_nil(u.deleted_at), inner_join: ci in Item, as: :commission_item, on: ci.commission_id == c.id @@ -66,7 +70,7 @@ defmodule Philomena.Commissions.QueryBuilder do preload: [user: [awards: :badge], items: [example_image: [:sources, tags: :aliases]]] end - defp maybe_filter_price(query, sq = %SearchQuery{}) do + defp maybe_filter_price(query, %QueryForm{} = sq) do if not is_nil(sq.price_min) and not is_nil(sq.price_max) do from [commission_item: ci] in query, where: ci.base_price >= ^sq.price_min and ci.base_price <= ^sq.price_max @@ -75,7 +79,7 @@ defmodule Philomena.Commissions.QueryBuilder do end end - def maybe_filter_item_type(query, sq = %SearchQuery{}) do + defp maybe_filter_item_type(query, %QueryForm{} = sq) do if sq.item_type do from [commission_item: ci] in query, where: ci.item_type == ^sq.item_type @@ -84,7 +88,7 @@ defmodule Philomena.Commissions.QueryBuilder do end end - defp maybe_filter_categories(query, sq = %SearchQuery{}) do + defp maybe_filter_categories(query, %QueryForm{} = sq) do if sq.category do from [commission: c] in query, where: fragment("? @> ?", c.categories, ^sq.category) @@ -93,9 +97,9 @@ defmodule Philomena.Commissions.QueryBuilder do end end - defp maybe_filter_keywords(query, sq = %SearchQuery{}) do + defp maybe_filter_keywords(query, %QueryForm{} = sq) do if sq.keywords do - keywords = like_sanitize(sq.keywords) + keywords = "%#{like_sanitize(sq.keywords)}%" from [commission: c] in query, where: ilike(c.information, ^keywords) or ilike(c.will_create, ^keywords) @@ -105,6 +109,6 @@ defmodule Philomena.Commissions.QueryBuilder do end defp like_sanitize(input) do - "%" <> String.replace(input, ["\\", "%", "_"], &<<"\\", &1>>) <> "%" + String.replace(input, ["\\", "%", "_"], &<<"\\", &1>>) end end diff --git a/lib/philomena/commissions/search_query.ex b/lib/philomena/commissions/query_form.ex similarity index 80% rename from lib/philomena/commissions/search_query.ex rename to lib/philomena/commissions/query_form.ex index f203c1cfc..785c4bf03 100644 --- a/lib/philomena/commissions/search_query.ex +++ b/lib/philomena/commissions/query_form.ex @@ -1,4 +1,4 @@ -defmodule Philomena.Commissions.SearchQuery do +defmodule Philomena.Commissions.QueryForm do @moduledoc false use Ecto.Schema @@ -13,7 +13,7 @@ defmodule Philomena.Commissions.SearchQuery do end @doc false - def changeset(query, params) do + def changeset(query, params \\ %{}) do cast(query, params, [:item_type, :category, :keywords, :price_min, :price_max]) end end diff --git a/lib/philomena/conversations.ex b/lib/philomena/conversations.ex index 2c0a79288..4bb5ff5df 100644 --- a/lib/philomena/conversations.ex +++ b/lib/philomena/conversations.ex @@ -1,359 +1,405 @@ defmodule Philomena.Conversations do @moduledoc """ - The Conversations context. + Conversation listing, creation, reading, replies, and message approval. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Multi + alias Philomena.Attribution.Actor alias Philomena.Conversations.Conversation + alias Philomena.Conversations.ConversationIndex + alias Philomena.Conversations.ConversationPage + alias Philomena.Conversations.QueryBuilder + alias Philomena.Conversations.QueryForm alias Philomena.Conversations.Message + alias Philomena.IntegerId + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.RateLimiter + alias Philomena.Repo alias Philomena.Reports + alias Philomena.Schema.Approval alias Philomena.Users - @doc """ - Returns the number of unread conversations for the given user. - - Conversations hidden by the given user are not counted. + @conversation_create_window 60 - ## Examples + @doc """ + Returns whether the actor may send image embeds without approval review. + """ + @spec trusted_sender?(Actor.t()) :: boolean() + def trusted_sender?(%Actor{user: user}), do: Approval.trusted?(user) - iex> count_unread_conversations(user1) - 0 + defp load_conversation(%Actor{} = actor, slug, action, preloads \\ []) do + Conversation + |> where(slug: ^slug) + |> preload(^preloads) + |> Loader.one_and_authorize(actor, action) + end - iex> count_unread_conversations(user2) - 7 + defp load_conversation_message( + %Actor{} = actor, + %Conversation{} = conversation, + message_id, + action + ) do + Message + |> where(conversation_id: ^conversation.id) + |> preload(:conversation) + |> Loader.fetch_and_authorize(actor, action, message_id) + end - """ - def count_unread_conversations(user) do - Conversation - |> where( - [c], - ((c.to_id == ^user.id and c.to_read == false) or - (c.from_id == ^user.id and c.from_read == false)) and - not ((c.to_id == ^user.id and c.to_hidden == true) or - (c.from_id == ^user.id and c.from_hidden == true)) - ) - |> Repo.aggregate(:count) + defp put_approval_report(%Multi{} = multi, message_callback) + when is_function(message_callback, 1) do + Multi.merge(multi, fn %{conversation: conversation} = changes -> + message = message_callback.(changes) + + if message.became_unapproved? do + Reports.put_create_system_report( + Multi.new(), + "Approval", + "PM contains externally-embedded images", + :conversation_id, + conversation.id + ) + else + Multi.new() + end + end) end @doc """ - Returns a `m:Scrivener.Page` of conversations between the partner and the user. + Loads the signed-in actor's paginated conversation index. + + The optional `"with"` filter is parsed as a user ID. + Invalid filters return an blank page and a rejected changeset. ## Examples - iex> list_conversations_with("123", %User{}, page_size: 10) - %Scrivener.Page{} + iex> list_conversations(actor, %{}, page: 1, page_size: 25) + {:ok, %ConversationIndex{}} """ - def list_conversations_with(partner_id, user, pagination) do - query = - from c in Conversation, - where: - (c.from_id == ^partner_id and c.to_id == ^user.id) or - (c.to_id == ^partner_id and c.from_id == ^user.id) - - list_conversations(query, user, pagination) + @spec list_conversations(Actor.t(), term(), Repo.pagination_params()) :: + {:ok, ConversationIndex.t()} | {:error, :unauthorized} + def list_conversations(%Actor{user: user} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Conversation) do + {conversations, changeset} = + params + |> QueryBuilder.search_conversations(user) + |> case do + {:ok, query, query_form} -> + {Repo.paginate(query, pagination), QueryForm.changeset(query_form)} + + {:error, changeset} -> + {nil, changeset} + end + + {:ok, %ConversationIndex{conversations: conversations, changeset: changeset}} + end end @doc """ - Returns a `m:Scrivener.Page` of conversations sent by or received from the user. + Returns the authenticated actor's unread, non-hidden conversation count. ## Examples - iex> list_conversations_with("123", %User{}, page_size: 10) - %Scrivener.Page{} + iex> unread_conversation_count(actor) + {:ok, 3} """ - def list_conversations(queryable \\ Conversation, user, pagination) do - query = - from c in queryable, - as: :conversations, - where: - (c.from_id == ^user.id and not c.from_hidden) or - (c.to_id == ^user.id and not c.to_hidden), - inner_lateral_join: - cnt in subquery( - from m in Message, - where: m.conversation_id == parent_as(:conversations).id, - select: %{count: count()} - ), - on: true, - order_by: [desc: :last_message_at], - preload: [:to, :from], - select: %{c | message_count: cnt.count} - - Repo.paginate(query, pagination) + @spec unread_conversation_count(Actor.t()) :: + {:ok, non_neg_integer()} | {:error, :unauthorized} + def unread_conversation_count(%Actor{user: user} = actor) do + with :ok <- authorize(actor, :index, Conversation) do + count = + Conversation + |> where( + [conversation], + ((conversation.to_id == ^user.id and not conversation.to_read) or + (conversation.from_id == ^user.id and not conversation.from_read)) and + not ((conversation.to_id == ^user.id and conversation.to_hidden) or + (conversation.from_id == ^user.id and conversation.from_hidden)) + ) + |> Repo.aggregate(:count) + + {:ok, count} + end end @doc """ - Creates a conversation. + Loads one visible conversation page for `actor`. - ## Examples + Missing slugs are not found for every actor. Participants and authorized + staff may view the page. A participant's own read flag is set idempotently; + staff viewing a conversation do not mutate either participant's state. - iex> create_conversation(from, to, %{field: value}) - {:ok, %Conversation{}} + ## Examples - iex> create_conversation(from, to, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_conversation(actor, "slug", page_size: 25) + {:ok, %ConversationPage{}} """ - def create_conversation(from, attrs \\ %{}) do - to = Users.get_user_by_name(attrs["recipient"]) - - %Conversation{} - |> Conversation.creation_changeset(from, to, attrs) - |> Repo.insert() - |> case do - {:ok, conversation} -> - report_non_approved_message(hd(conversation.messages)) - {:ok, conversation} - - error -> - error + @spec show_conversation(Actor.t(), String.t(), Repo.pagination_params()) :: + {:ok, ConversationPage.t()} | {:error, :unauthorized | :not_found} + def show_conversation(%Actor{user: user} = actor, slug, pagination) do + with {:ok, conversation} <- load_conversation(actor, slug, :show, [:to, :from]) do + {:ok, _conversation} = + conversation + |> Conversation.read_changeset(user, true) + |> Repo.update() + + direction = if user.settings.messages_newest_first, do: :desc, else: :asc + + messages = + Message + |> where(conversation_id: ^conversation.id) + |> order_by([{^direction, :created_at}, {^direction, :id}]) + |> preload(:from) + |> Repo.paginate(pagination) + + {:ok, + %ConversationPage{ + conversation: conversation, + messages: messages, + changeset: Message.changeset(%Message{}) + }} end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking conversation changes. - - ## Examples - - iex> change_conversation(conversation) - %Ecto.Changeset{source: %Conversation{}} + Loads a visible conversation as a report target. + This shares the canonical load-before-authorize slug contract used by the + conversation page. """ - def change_conversation(%Conversation{} = conversation) do - Conversation.changeset(conversation, %{}) + @spec load_report_target(Actor.t(), String.t()) :: + {:ok, Conversation.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, slug) do + load_conversation(actor, slug, :show, [:from, :to]) end @doc """ - Marks a conversation as read or unread from the perspective of the given user. + Builds a new-conversation form for `actor`. - ## Examples + New and create apply the same write-access and class-ability prerequisites. + Non-string recipient input is normalized to an empty recipient. - iex> mark_conversation_read(conversation, user, true) - {:ok, %Conversation{}} - - iex> mark_conversation_read(conversation, user, false) - {:ok, %Conversation{}} + ## Examples - iex> mark_conversation_read(conversation, %User{}, true) - {:error, %Ecto.Changeset{}} + iex> new_conversation(actor, "Recipient") + {:ok, %Ecto.Changeset{}} """ - def mark_conversation_read(%Conversation{} = conversation, user, read \\ true) do - changes = - %{} - |> put_conditional(:to_read, read, conversation.to_id == user.id) - |> put_conditional(:from_read, read, conversation.from_id == user.id) - - conversation - |> Conversation.read_changeset(changes) - |> Repo.update() + @spec new_conversation(Actor.t(), term()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_conversation(%Actor{} = actor, params) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Conversation) do + conversation = %Conversation{messages: [%Message{}]} + + {:ok, Conversation.changeset(conversation, params)} + end end @doc """ - Marks a conversation as hidden or visible from the perspective of the given user. + Creates a conversation and its single initial message for `actor`. - Hidden conversations are not shown in the list of conversations for the user, and - are not counted when retrieving the number of unread conversations. + Active recipients resolve through Users. Missing or deactivated recipients produce + a changeset validation error. Successful writes are rate-counted after commit. An + unapproved first message creates a system report. ## Examples - iex> mark_conversation_hidden(conversation, user, true) - {:ok, %Conversation{}} - - iex> mark_conversation_hidden(conversation, user, false) + iex> create_conversation(actor, attrs) {:ok, %Conversation{}} - iex> mark_conversation_hidden(conversation, %User{}, true) + iex> create_conversation(actor, invalid_attrs) {:error, %Ecto.Changeset{}} """ - def mark_conversation_hidden(%Conversation{} = conversation, user, hidden \\ true) do - changes = - %{} - |> put_conditional(:to_hidden, hidden, conversation.to_id == user.id) - |> put_conditional(:from_hidden, hidden, conversation.from_id == user.id) - - conversation - |> Conversation.hidden_changeset(changes) - |> Repo.update() - end - - defp put_conditional(map, key, value, condition) do - if condition do - Map.put(map, key, value) - else - map + @spec create_conversation(Actor.t(), term()) :: + {:ok, Conversation.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :rate_limited} + def create_conversation(%Actor{user: user} = actor, params) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Conversation), + {:ok, recipient_name} <- Conversation.recipient_name(params) do + recipient = + case Users.load_active_user_by_name(actor, recipient_name) do + {:ok, user} -> user + _ -> nil + end + + conversation_changeset = + Conversation.creation_changeset(%Conversation{}, user, recipient, params) + + Multi.new() + |> Multi.reserve_action( + fn -> + RateLimiter.record_action(actor, :conversation_create, @conversation_create_window) + end, + fn -> RateLimiter.rollback_action(actor, :conversation_create) end + ) + |> Multi.insert(:conversation, conversation_changeset) + |> put_approval_report(fn %{conversation: %{messages: [message]}} -> message end) + |> Multi.transact() + |> case do + {:ok, %{conversation: %Conversation{} = conversation}} -> + {:ok, conversation} + + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} + + {:error, :conversation, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Returns the number of messages in the given conversation. - - ## Example - - iex> count_messages(%Conversation{}) - 3 - - """ - def count_messages(conversation) do - Message - |> where(conversation_id: ^conversation.id) - |> Repo.aggregate(:count) - end - - @doc """ - Returns a `m:Scrivener.Page` of 2-tuples of messages and rendered output - within a conversation. - - Messages are ordered by user message preference (`messages_newest_first`). - - When coerced to a list and rendered as Markdown, the result may look like: - - [ - {%Message{body: "hello *world*"}, "hello world"} - ] - - ## Example - - iex> list_messages(%Conversation{}, %User{}, & &1.body, page_size: 10) - %Scrivener.Page{} + Marks `actor`'s participant side of the conversation read or unread. + The operation is idempotent. Authorized staff may view the conversation but, + because they are not a participant, do not change either participant flag. + Missing slugs are always not found. """ - def list_messages(conversation, user, collection_renderer, pagination) do - direction = - if user.settings.messages_newest_first do - :desc - else - :asc - end - - query = - from m in Message, - where: m.conversation_id == ^conversation.id, - order_by: [{^direction, :created_at}], - preload: :from - - messages = Repo.paginate(query, pagination) - rendered = collection_renderer.(messages) - - put_in(messages.entries, Enum.zip(messages.entries, rendered)) + @spec update_conversation_read(Actor.t(), String.t(), boolean()) :: + {:ok, Conversation.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def update_conversation_read(%Actor{user: user} = actor, slug, read \\ true) do + with {:ok, conversation} <- load_conversation(actor, slug, :show) do + conversation + |> Conversation.read_changeset(user, read) + |> Repo.update() + end end @doc """ - Creates a message within a conversation. - - ## Examples - - iex> create_message(%Conversation{}, %User{}, %{field: value}) - {:ok, %Message{}} - - iex> create_message(%Conversation{}, %User{}, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + Marks `actor`'s participant side of the conversation hidden or restored. + The operation is idempotent. Authorized staff may view the conversation, but + do not change either participant flag. Missing slugs are always not found. """ - def create_message(conversation, user, attrs \\ %{}) do - message_changeset = + @spec update_conversation_hide(Actor.t(), String.t(), boolean()) :: + {:ok, Conversation.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def update_conversation_hide(%Actor{user: user} = actor, slug, hidden \\ true) do + with {:ok, conversation} <- load_conversation(actor, slug, :show) do conversation - |> Ecto.build_assoc(:messages) - |> Message.creation_changeset(attrs, user) - - conversation_changeset = - Conversation.new_message_changeset(conversation) - - Multi.new() - |> Multi.insert(:message, message_changeset) - |> Multi.update(:conversation, conversation_changeset) - |> Repo.transaction() - |> case do - {:ok, %{message: message}} -> - report_non_approved_message(message) - {:ok, message} - - _error -> - {:error, message_changeset} + |> Conversation.hidden_changeset(user, hidden) + |> Repo.update() end end @doc """ - Approves a previously-posted message which was not approved at post time. + Posts a reply to a visible conversation. + + Write access is checked before loading. Participant and staff reply policy is + represented by the `:reply` ability. Validation failures return a rejected + changeset. Success returns the message and total count needed for the redirect + page. ## Examples - iex> approve_message(%Message{}, %User{}) + iex> create_message(actor, "slug", %{"body" => "hello"}) {:ok, %Message{}} - iex> approve_message(%Message{}, %User{}) + iex> create_message(actor, "slug", %{"body" => ""}) {:error, %Ecto.Changeset{}} """ - def approve_message(message, approving_user) do - message_changeset = Message.approve_changeset(message) - - conversation_update_query = - from c in Conversation, - where: c.id == ^message.conversation_id, - update: [set: [from_read: false, to_read: false]] - - reports_query = - Reports.close_report_query(approving_user, conversation_id: message.conversation_id) - - Multi.new() - |> Multi.update(:message, message_changeset) - |> Multi.update_all(:conversation, conversation_update_query, []) - |> Multi.update_all(:reports, reports_query, []) - |> Repo.transaction() - |> case do - {:ok, %{reports: {_count, reports}, message: message}} -> - Reports.reindex_reports(reports) - - {:ok, message} - - _error -> - {:error, message_changeset} + @spec create_message(Actor.t(), String.t(), term()) :: + {:ok, Message.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def create_message(%Actor{user: user} = actor, slug, params) do + with :ok <- verify_write_access(actor), + {:ok, conversation} <- load_conversation(actor, slug, :reply) do + message_count_query = + from message in Message, + where: message.conversation_id == ^conversation.id, + select: count(message.id) + + message_changeset = + conversation + |> Ecto.build_assoc(:messages) + |> Message.creation_changeset(params, user) + + conversation_changeset = Conversation.new_message_changeset(conversation) + + Multi.new() + |> Multi.insert(:message, message_changeset) + |> Multi.update(:conversation, conversation_changeset) + |> Multi.one(:message_count, message_count_query) + |> put_approval_report(fn %{message: message} -> message end) + |> Multi.transact() + |> case do + {:ok, + %{ + conversation: %Conversation{} = conversation, + message: %Message{} = message, + message_count: message_count + }} -> + conversation = %{conversation | message_count: message_count} + message = %{message | conversation: conversation} + + {:ok, message} + + {:error, :message, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Generates a system report for an unapproved message. + Approves one message scoped to the conversation named by `conversation_slug`. - This is called by `create_conversation/2` and `create_message/3`, so it normally does not - need to be called explicitly. + The route conversation loads before the nested message query. Malformed, + absent, and wrong-conversation message IDs are therefore all not found. + Approval, participant unread flags, report closure, and the moderation log + commit atomically. Affected reports are reindexed after commit. ## Examples - iex> report_non_approved_message(%Message{approved: false}) - {:ok, %Report{}} - - iex> report_non_approved_message(%Message{approved: true}) - {:ok, nil} + iex> create_message_approve(actor, "conversation-slug", "1") + {:ok, %Message{}} """ - def report_non_approved_message(message) do - if message.approved do - {:ok, nil} - else - Reports.create_system_report( - "Approval", - "PM contains externally-embedded images", + @spec create_message_approve(Actor.t(), String.t(), IntegerId.integer_id()) :: + {:ok, Message.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_message_approve(%Actor{} = actor, conversation_slug, message_id) do + with {:ok, conversation} <- load_conversation(actor, conversation_slug, :show), + {:ok, message} <- load_conversation_message(actor, conversation, message_id, :approve) do + conversation_update_query = + from conversation in Conversation, + where: conversation.id == ^message.conversation_id, + update: [set: [from_read: false, to_read: false]] + + Multi.new() + |> Multi.update(:message, Message.approve_changeset(message)) + |> Multi.update_all(:conversation, conversation_update_query, []) + |> Reports.put_close_reports( + :reports, + actor.user, conversation_id: message.conversation_id ) - end - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking message changes. - - ## Examples - - iex> change_message(message) - %Ecto.Changeset{source: %Message{}} + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Conversation.Message.Approve:create", + "/", + "Approved private message in conversation ##{message.conversation_id}" + ) + |> Multi.transact() + |> case do + {:ok, %{message: %Message{} = message}} -> + {:ok, message} - """ - def change_message(%Message{} = message) do - Message.changeset(message, %{}) + {:error, :message, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end end diff --git a/lib/philomena/conversations/conversation.ex b/lib/philomena/conversations/conversation.ex index b0122eb2f..d2e4bce69 100644 --- a/lib/philomena/conversations/conversation.ex +++ b/lib/philomena/conversations/conversation.ex @@ -3,14 +3,18 @@ defmodule Philomena.Conversations.Conversation do import Ecto.Changeset alias Philomena.Users.User + alias Philomena.Reports.Report alias Philomena.Conversations.Message @derive {Phoenix.Param, key: :slug} + @type t :: %__MODULE__{} + schema "conversations" do belongs_to :from, User belongs_to :to, User has_many :messages, Message + has_many :reports, Report field :title, :string field :to_read, :boolean, default: false @@ -27,26 +31,25 @@ defmodule Philomena.Conversations.Conversation do end @doc false - def changeset(conversation, attrs) do - conversation - |> cast(attrs, []) - |> validate_required([]) + def changeset(conversation, attrs \\ %{}) do + cast(conversation, attrs, [:recipient]) end @doc false - def read_changeset(conversation, attrs) do - cast(conversation, attrs, [:from_read, :to_read]) - end - - @doc false - def hidden_changeset(conversation, attrs) do - cast(conversation, attrs, [:from_hidden, :to_hidden]) + def recipient_name(attrs) do + with {:ok, conversation} <- + %__MODULE__{} + |> cast(attrs, [:recipient]) + |> validate_required([:recipient]) + |> apply_action(:create) do + {:ok, conversation.recipient} + end end @doc false def creation_changeset(conversation, from, to, attrs) do conversation - |> cast(attrs, [:title]) + |> cast(attrs, [:title, :recipient]) |> put_assoc(:from, from) |> put_assoc(:to, to) |> put_change(:slug, Ecto.UUID.generate()) @@ -57,6 +60,22 @@ defmodule Philomena.Conversations.Conversation do |> validate_required([:title, :from, :to]) end + @doc false + def read_changeset(conversation, user, desired_state) do + conversation + |> change() + |> put_conditional(conversation.from_id == user.id, :from_read, desired_state) + |> put_conditional(conversation.to_id == user.id, :to_read, desired_state) + end + + @doc false + def hidden_changeset(conversation, user, desired_state) do + conversation + |> change() + |> put_conditional(conversation.from_id == user.id, :from_hidden, desired_state) + |> put_conditional(conversation.to_id == user.id, :to_hidden, desired_state) + end + @doc false def new_message_changeset(conversation) do conversation @@ -68,4 +87,7 @@ defmodule Philomena.Conversations.Conversation do defp set_last_message(changeset) do change(changeset, last_message_at: DateTime.utc_now(:second)) end + + defp put_conditional(changeset, true, key, value), do: put_change(changeset, key, value) + defp put_conditional(changeset, false, _key, _value), do: changeset end diff --git a/lib/philomena/conversations/conversation_index.ex b/lib/philomena/conversations/conversation_index.ex new file mode 100644 index 000000000..e7dce2f26 --- /dev/null +++ b/lib/philomena/conversations/conversation_index.ex @@ -0,0 +1,16 @@ +defmodule Philomena.Conversations.ConversationIndex do + @moduledoc """ + A conversation index result containing paginated conversations and the + partner filter changeset. + """ + + alias Philomena.Conversations.Conversation + + @enforce_keys [:conversations, :changeset] + defstruct [:conversations, :changeset] + + @type t :: %__MODULE__{ + conversations: Scrivener.Page.t(Conversation.t()), + changeset: Ecto.Changeset.t() + } +end diff --git a/lib/philomena/conversations/conversation_page.ex b/lib/philomena/conversations/conversation_page.ex new file mode 100644 index 000000000..f70157c5d --- /dev/null +++ b/lib/philomena/conversations/conversation_page.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Conversations.ConversationPage do + @moduledoc """ + Assembled data for the conversation page: the conversation, a + `m:Scrivener.Page` of its messages, and a changeset for a reply. + + Message bodies are carried unrendered. + """ + + alias Philomena.Conversations.Conversation + + @enforce_keys [:conversation, :messages, :changeset] + defstruct [:conversation, :messages, :changeset] + + @type t :: %__MODULE__{ + conversation: Conversation.t(), + messages: Scrivener.Page.t(), + changeset: Ecto.Changeset.t() + } +end diff --git a/lib/philomena/conversations/message.ex b/lib/philomena/conversations/message.ex index b01ae9ff1..4bf74d556 100644 --- a/lib/philomena/conversations/message.ex +++ b/lib/philomena/conversations/message.ex @@ -6,18 +6,21 @@ defmodule Philomena.Conversations.Message do alias Philomena.Users.User alias Philomena.Schema.Approval + @type t :: %__MODULE__{} + schema "messages" do belongs_to :conversation, Conversation belongs_to :from, User field :body, :string - field :approved, :boolean, default: false + field :approved, :boolean, default: true + field :became_unapproved?, :boolean, virtual: true, default: false timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(message, attrs) do + def changeset(message, attrs \\ %{}) do message |> cast(attrs, []) |> validate_required([]) @@ -35,6 +38,8 @@ defmodule Philomena.Conversations.Message do @doc false def approve_changeset(message) do - change(message, approved: true) + message + |> change() + |> Approval.approve_changeset() end end diff --git a/lib/philomena/conversations/query_builder.ex b/lib/philomena/conversations/query_builder.ex new file mode 100644 index 000000000..2b2448cd8 --- /dev/null +++ b/lib/philomena/conversations/query_builder.ex @@ -0,0 +1,81 @@ +defmodule Philomena.Conversations.QueryBuilder do + @moduledoc false + + alias Philomena.Conversations.Conversation + alias Philomena.Conversations.Message + alias Philomena.Conversations.QueryForm + alias Philomena.Users.User + import Ecto.Query + + @doc """ + Searches conversations based on the given parameters. + + ## Parameters + + * params - Map of optional search parameters: + * with - Filter by partner ID + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + def search_conversations(params \\ %{}, %User{} = user) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + user + |> conversation_index_query() + |> maybe_filter_partner(user, query_form) + |> assign_message_count() + |> apply_sort() + |> apply_preloads() + + {:ok, query, query_form} + end + end + + defp conversation_index_query(%User{id: user_id}) do + Conversation + |> where( + [conversation], + (conversation.from_id == ^user_id and not conversation.from_hidden) or + (conversation.to_id == ^user_id and not conversation.to_hidden) + ) + end + + defp maybe_filter_partner(query, %User{id: user_id}, %QueryForm{with: partner_id}) do + if partner_id do + where( + query, + [conversation], + (conversation.from_id == ^partner_id and conversation.to_id == ^user_id) or + (conversation.to_id == ^partner_id and conversation.from_id == ^user_id) + ) + else + query + end + end + + defp assign_message_count(query) do + from conversation in query, + as: :conversation, + inner_lateral_join: + count in subquery( + from message in Message, + where: message.conversation_id == parent_as(:conversation).id, + select: %{value: count()} + ), + on: true, + select: %{conversation | message_count: count.value} + end + + defp apply_sort(query) do + order_by(query, desc: :last_message_at, desc: :id) + end + + defp apply_preloads(query) do + preload(query, [:to, :from]) + end +end diff --git a/lib/philomena/conversations/query_form.ex b/lib/philomena/conversations/query_form.ex new file mode 100644 index 000000000..3e3333344 --- /dev/null +++ b/lib/philomena/conversations/query_form.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Conversations.QueryForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @int_max 2_147_483_647 + + embedded_schema do + field :with, :integer + end + + @doc false + def changeset(query, attrs \\ %{}) do + query + |> cast(attrs, [:with]) + |> validate_number(:with, greater_than: 0, less_than_or_equal_to: @int_max) + end +end diff --git a/lib/philomena/dnp_entries.ex b/lib/philomena/dnp_entries.ex index 96b9bd5a7..f1a1b7813 100644 --- a/lib/philomena/dnp_entries.ex +++ b/lib/philomena/dnp_entries.ex @@ -1,149 +1,433 @@ defmodule Philomena.DnpEntries do @moduledoc """ - The DnpEntries context. + Do-Not-Post listings, forms, and staff transitions. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Attribution.Actor + alias Philomena.DnpEntries.{DnpEntry, DnpEntryForm, DnpEntryPage, DnpListing} + alias Philomena.DnpEntries.{QueryBuilder, QueryForm} + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.ModNotes + alias Philomena.Multi alias Philomena.Repo + alias Philomena.Tags + alias Philomena.Tags.Tag + alias Philomena.Users.User + + defp dnp_entry_form(%DnpEntry{} = dnp_entry, selectable_tags, changeset \\ nil) do + %DnpEntryForm{ + dnp_entry: dnp_entry, + changeset: changeset || DnpEntry.changeset(dnp_entry), + selectable_tags: selectable_tags + } + end + + defp linked_tags(%User{} = user) do + user + |> Repo.preload(:linked_tags) + |> Map.fetch!(:linked_tags) + end + + defp linked_tags(_user), do: [] + + defp get_tag_from_params(params) do + with {:ok, tag_id} <- DnpEntry.fetch_tag_id(params), + {:ok, tag} <- Loader.fetch(Tag, tag_id) do + tag + else + _ -> nil + end + end + + defp may_select_any_tag?(actor) do + authorize(actor, :select_any_tag, DnpEntry) == :ok + end + + defp nonempty_tags([]), do: {:error, :unauthorized} + defp nonempty_tags(tags), do: {:ok, tags} + + defp selectable_tags(%Actor{user: user} = actor, default_tag) do + if not is_nil(default_tag) and may_select_any_tag?(actor) do + {:ok, [default_tag]} + else + user + |> linked_tags() + |> nonempty_tags() + end + end + + defp selected_tag_names(params, selectable_tags) do + with {:ok, tag_id} <- DnpEntry.fetch_tag_id(params), + %Tag{name: name} <- Enum.find(selectable_tags, &(&1.id == tag_id)) do + [name] + else + _ -> [] + end + end - alias Philomena.DnpEntries.DnpEntry + defp load_authorized_dnp_entry(actor, id, action) do + Loader.fetch_and_authorize(DnpEntry, actor, action, id, [:tag]) + end @doc """ - Returns the list of dnp_entries. + Assembles the public or current-user DNP listing. + + A signed-in actor using the `"mine"` parameter receives their own entries + ordered by creation time and a visible status column. Every other request + receives only listed entries ordered by tag name. The viewer's linked tags + accompany both views. ## Examples - iex> list_dnp_entries() - [%DnpEntry{}, ...] + iex> list_dnp_entries(actor, %{"mine" => "1"}, pagination) + %DnpListing{status_column: true} """ - def list_dnp_entries do - Repo.all(DnpEntry) + @spec list_dnp_entries(Actor.t(), map(), Repo.pagination_params()) :: DnpListing.t() + def list_dnp_entries(actor, params, pagination) + + def list_dnp_entries(%Actor{user: %User{} = user}, %{"mine" => _mine}, pagination) do + entries = + DnpEntry + |> where(requesting_user_id: ^user.id) + |> preload(:tag) + |> order_by(asc: :created_at) + |> Repo.paginate(pagination) + + %DnpListing{dnp_entries: entries, linked_tags: linked_tags(user), status_column: true} + end + + def list_dnp_entries(%Actor{} = actor, _params, pagination) do + entries = + DnpEntry + |> where(aasm_state: "listed") + |> join(:inner, [d], t in Tag, on: d.tag_id == t.id) + |> preload(:tag) + |> order_by([_d, t], asc: t.name_in_namespace) + |> Repo.paginate(pagination) + + %DnpListing{dnp_entries: entries, linked_tags: linked_tags(actor.user), status_column: false} end @doc """ - Gets a single dnp_entry. + Loads the newest admin DNP entries authorized for `actor`. - Raises `Ecto.NoResultsError` if the Dnp entry does not exist. + A list-valued `"states"` filter takes precedence over the text `"eq"` + filter. Without either, active requests are listed. ## Examples - iex> get_dnp_entry!(123) - %DnpEntry{} + iex> list_admin_dnp_entries(moderator, %{}, pagination) + {:ok, %Scrivener.Page{}} - iex> get_dnp_entry!(456) - ** (Ecto.NoResultsError) + iex> list_admin_dnp_entries(user, %{}, pagination) + {:error, :unauthorized} """ - def get_dnp_entry!(id), do: Repo.get!(DnpEntry, id) + @spec list_admin_dnp_entries(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(DnpEntry.t()), Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_admin_dnp_entries(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, DnpEntry) do + {entries, changeset} = + case QueryBuilder.search_dnp_entries(params) do + {:ok, query, query_form} -> + {Repo.paginate(query, pagination), QueryForm.changeset(query_form)} + + {:error, changeset} -> + {Repo.paginate(where(DnpEntry, false), pagination), changeset} + end + + {:ok, entries, changeset} + end + end @doc """ - Creates a dnp_entry. + Loads an authorized DNP page and any moderation notes visible to `actor`. + + IDs are parsed and loaded before instance authorization, so malformed and + missing IDs are consistently not-found. ## Examples - iex> create_dnp_entry(%{field: value}) - {:ok, %DnpEntry{}} + iex> show_dnp_entry(actor, "1", renderer) + {:ok, %DnpEntryPage{}} - iex> create_dnp_entry(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_dnp_entry(actor, "not-an-id", renderer) + {:error, :not_found} """ - def create_dnp_entry(user, tags, attrs \\ %{}) do - tag = Enum.find(tags, &(to_string(&1.id) == attrs["tag_id"])) + @spec show_dnp_entry(Actor.t(), Loader.integer_id(), (list() -> list())) :: + {:ok, DnpEntryPage.t()} | {:error, :not_found | :unauthorized} + def show_dnp_entry(%Actor{} = actor, id, collection_renderer) do + with {:ok, dnp_entry} <- load_authorized_dnp_entry(actor, id, :show) do + mod_notes = + case ModNotes.list_for_target( + actor, + {:dnp_entry, dnp_entry.id}, + collection_renderer + ) do + {:ok, notes} -> notes + {:error, _reason} -> nil + end + + {:ok, %DnpEntryPage{dnp_entry: dnp_entry, mod_notes: mod_notes}} + end + end - %DnpEntry{} - |> DnpEntry.creation_changeset(attrs, tag, user) - |> Repo.insert() + @doc """ + Builds a new DNP request form on behalf of `actor`. + + Write access and the `:new` ability are checked before tag selection. Normal + users may select from verified linked tags; staff may request one arbitrary + tag by ID. Malformed or missing privileged tag IDs are not-found. + + ## Examples + + iex> new_dnp_entry(actor, %{}) + {:ok, %DnpEntryForm{}} + + iex> new_dnp_entry(banned_actor, %{}) + {:error, :ban} + + """ + @spec new_dnp_entry(Actor.t(), map()) :: + {:ok, DnpEntryForm.t()} | {:error, :ban | :unauthorized | :not_found} + def new_dnp_entry(%Actor{} = actor, params) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, DnpEntry), + default_tag = get_tag_from_params(params), + {:ok, tags} <- selectable_tags(actor, default_tag) do + {:ok, dnp_entry_form(%DnpEntry{}, tags)} + end end @doc """ - Updates a dnp_entry. + Creates a DNP request using the same access and tag-selection policy as the + new form. Validation failures return a `DnpEntryForm`. ## Examples - iex> update_dnp_entry(dnp_entry, %{field: new_value}) + iex> create_dnp_entry(actor, attrs) {:ok, %DnpEntry{}} - iex> update_dnp_entry(dnp_entry, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> create_dnp_entry(actor, invalid_attrs) + {:error, %DnpEntryForm{}} """ - def update_dnp_entry(%DnpEntry{} = dnp_entry, tags, attrs) do - tag = Enum.find(tags, &(to_string(&1.id) == attrs["tag_id"])) - - dnp_entry - |> DnpEntry.update_changeset(attrs, tag) - |> Repo.update() + @spec create_dnp_entry(Actor.t(), map()) :: + {:ok, DnpEntry.t()} + | {:error, DnpEntryForm.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_dnp_entry(%Actor{user: user} = actor, params) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, DnpEntry), + default_tag = get_tag_from_params(params), + {:ok, selectable_tags} <- selectable_tags(actor, default_tag) do + selectable_tag_ids = Enum.map(selectable_tags, & &1.id) + tag_names = selected_tag_names(params, selectable_tags) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, tag_names, []}]) + |> Multi.insert(:dnp_entry, fn + %{canonical_tags: %{tag: [tag]}} -> + DnpEntry.creation_changeset(%DnpEntry{}, params, user, tag) + + %{canonical_tags: %{tag: []}} -> + DnpEntry.creation_changeset(%DnpEntry{}, params, user, selectable_tag_ids) + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{dnp_entry: %DnpEntry{} = dnp_entry}} -> + {:ok, dnp_entry} + + {:error, :dnp_entry, %Ecto.Changeset{} = changeset, _changes} -> + {:error, dnp_entry_form(changeset.data, selectable_tags, changeset)} + end + end end @doc """ - Transitions a DNP entry to a new state. + Loads an existing DNP entry edit form. + + The entry is loaded before instance authorization. Its current tag is the + default moderator selection, so a bare edit URL is sufficient. An explicit + privileged tag ID, if provided, is loaded safely. ## Examples - iex> transition_dnp_entry(dnp_entry, user, "acknowledged") - {:ok, %DnpEntry{}} + iex> edit_dnp_entry(moderator, "1") + {:ok, %DnpEntryForm{}} - iex> transition_dnp_entry(dnp_entry, user, "invalid_state") - {:error, %Ecto.Changeset{}} + iex> edit_dnp_entry(user, "1") + {:error, :unauthorized} """ - def transition_dnp_entry(%DnpEntry{} = dnp_entry, user, new_state) do - dnp_entry - |> DnpEntry.transition_changeset(user, new_state) - |> Repo.update() + @spec edit_dnp_entry(Actor.t(), Loader.integer_id()) :: + {:ok, DnpEntryForm.t()} | {:error, :ban | :unauthorized | :not_found} + def edit_dnp_entry(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, dnp_entry} <- load_authorized_dnp_entry(actor, id, :edit), + {:ok, tags} <- selectable_tags(actor, dnp_entry.tag) do + {:ok, dnp_entry_form(dnp_entry, tags)} + end end @doc """ - Deletes a DnpEntry. + Updates an existing DNP entry using the same access and tag-selection policy + as the edit form. Validation failures return a `DnpEntryForm`. ## Examples - iex> delete_dnp_entry(dnp_entry) + iex> update_dnp_entry(moderator, "1", attrs) {:ok, %DnpEntry{}} - iex> delete_dnp_entry(dnp_entry) - {:error, %Ecto.Changeset{}} + iex> update_dnp_entry(moderator, "1", invalid_attrs) + {:error, %DnpEntryForm{}} """ - def delete_dnp_entry(%DnpEntry{} = dnp_entry) do - Repo.delete(dnp_entry) + @spec update_dnp_entry(Actor.t(), Loader.integer_id(), map()) :: + {:ok, DnpEntry.t()} + | {:error, DnpEntryForm.t()} + | {:error, :ban | :unauthorized | :not_found} + def update_dnp_entry(%Actor{} = actor, id, params) do + with :ok <- verify_write_access(actor), + {:ok, dnp_entry} <- load_authorized_dnp_entry(actor, id, :update), + {:ok, selectable_tags} <- selectable_tags(actor, dnp_entry.tag) do + selectable_tag_ids = Enum.map(selectable_tags, & &1.id) + tag_names = selected_tag_names(params, selectable_tags) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, tag_names, []}]) + |> Multi.update(:dnp_entry, fn + %{canonical_tags: %{tag: [tag]}} -> + DnpEntry.update_changeset(dnp_entry, params, tag) + + %{canonical_tags: %{tag: []}} -> + DnpEntry.update_changeset(dnp_entry, params, selectable_tag_ids) + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{dnp_entry: %DnpEntry{} = dnp_entry}} -> + {:ok, dnp_entry} + + {:error, :dnp_entry, %Ecto.Changeset{} = changeset, _changes} -> + {:error, dnp_entry_form(dnp_entry, selectable_tags, changeset)} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking dnp_entry changes. + Transitions one DNP entry on behalf of staff. + + The entry is locked, loaded, and authorized with the distinct `:transition` + ability. The state update and moderation log commit atomically. ## Examples - iex> change_dnp_entry(dnp_entry) - %Ecto.Changeset{source: %DnpEntry{}} + iex> create_dnp_entry_transition(moderator, "1", "listed") + {:ok, %DnpEntry{aasm_state: "listed"}} + + iex> create_dnp_entry_transition(user, "1", "listed") + {:error, :unauthorized} """ - def change_dnp_entry(%DnpEntry{} = dnp_entry) do - DnpEntry.changeset(dnp_entry, %{}) + @spec create_dnp_entry_transition(Actor.t(), Loader.integer_id(), String.t() | nil) :: + {:ok, DnpEntry.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def create_dnp_entry_transition(%Actor{user: user} = actor, id, new_state) do + with :ok <- verify_write_access(actor), + {:ok, dnp_entry} <- load_authorized_dnp_entry(actor, id, :transition) do + dnp_entry_changeset = DnpEntry.transition_changeset(dnp_entry, user, new_state) + + Multi.new() + |> Multi.update(:dnp_entry, dnp_entry_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{dnp_entry: dnp_entry} -> + { + "Admin.DnpEntry.Transition:create", + Paths.dnp_entry_path(dnp_entry), + "#{String.capitalize(dnp_entry.aasm_state)} DNP entry #{dnp_entry.id} on #{dnp_entry.tag.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{dnp_entry: %DnpEntry{} = dnp_entry}} -> + {:ok, dnp_entry} + + {:error, :dnp_entry, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc """ + Repoints DNP entries from one tag to another inside `multi`. + + Tag aliasing uses this boundary so the DnpEntries context owns its table + update and the operation remains coupled to the alias transaction. + """ + @spec put_replace_tag(Multi.t(), Multi.name(), integer(), integer()) :: Multi.t() + def put_replace_tag(%Multi{} = multi, step, source_tag_id, target_tag_id) do + query = + DnpEntry + |> where(tag_id: ^source_tag_id) + |> update(set: [tag_id: ^target_tag_id]) + + Multi.update_all(multi, step, query, []) end @doc """ - Returns the count of active DNP entries in requested, claimed, - or acknowledged state, if the user has permission to view them. + Loads the DNP entries needed to validate an image's canonical tags inside + the caller's transaction. + + The canonical tag step must contain an `:added_tags` list. The resulting + preloaded tags are stored under `dnp_step` for the image changeset to use. + """ + @spec put_dnp_tags(Multi.t(), Multi.name(), Multi.name()) :: Multi.t() + def put_dnp_tags(%Multi{} = multi, dnp_step, canonical_tags_step) do + Multi.all(multi, dnp_step, fn %{^canonical_tags_step => %{added_tags: tags}} -> + tag_names = Enum.map(tags, & &1.name) + + Tag + |> from(as: :tag) + |> where([t], t.name in ^tag_names) + |> where(exists(where(DnpEntry, [d], d.tag_id == parent_as(:tag).id))) + |> preload(dnp_entries: [tag: :verified_links]) + end) + end + + @doc """ + Returns the count of active DNP requests when `actor` may view the admin DNP + index, otherwise `nil`. ## Examples - iex> count_dnp_entries(admin) - 42 + iex> count_dnp_entries(moderator) + 3 iex> count_dnp_entries(user) nil """ - def count_dnp_entries(user) do - if Canada.Can.can?(user, :index, DnpEntry) do - DnpEntry - |> where([dnp], dnp.aasm_state in ["requested", "claimed", "acknowledged"]) - |> Repo.aggregate(:count, :id) - else - nil + @spec count_dnp_entries(Actor.t()) :: non_neg_integer() | nil + def count_dnp_entries(%Actor{} = actor) do + case authorize(actor, :index, DnpEntry) do + :ok -> + DnpEntry + |> where([dnp], dnp.aasm_state in ["requested", "claimed", "acknowledged"]) + |> Repo.aggregate(:count) + + {:error, :unauthorized} -> + nil end end end diff --git a/lib/philomena/dnp_entries/dnp_entry.ex b/lib/philomena/dnp_entries/dnp_entry.ex index ca9b91a32..02bf76304 100644 --- a/lib/philomena/dnp_entries/dnp_entry.ex +++ b/lib/philomena/dnp_entries/dnp_entry.ex @@ -5,6 +5,8 @@ defmodule Philomena.DnpEntries.DnpEntry do alias Philomena.Tags.Tag alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "dnp_entries" do belongs_to :requesting_user, User belongs_to :modifying_user, User @@ -22,38 +24,87 @@ defmodule Philomena.DnpEntries.DnpEntry do end @doc false - def changeset(dnp_entry, attrs) do + def changeset(dnp_entry, attrs \\ %{}) do dnp_entry |> cast(attrs, []) |> validate_required([]) end - def update_changeset(dnp_entry, attrs, tag) do + @doc false + def update_changeset(dnp_entry, attrs, selectable_tag_ids) when is_list(selectable_tag_ids) do dnp_entry - |> cast(attrs, [:conditions, :reason, :hide_reason, :instructions, :feedback, :dnp_type]) - |> put_tag(tag) + |> cast(attrs, [ + :conditions, + :reason, + :hide_reason, + :instructions, + :feedback, + :dnp_type, + :tag_id + ]) |> validate_required([:reason, :dnp_type]) |> validate_inclusion(:dnp_type, types()) + |> validate_required(:tag_id, message: "must be one of your linked tags") + |> validate_inclusion(:tag_id, selectable_tag_ids, message: "must be one of your linked tags") |> validate_conditions() |> foreign_key_constraint(:tag_id, name: "fk_rails_473a736b4a") end - defp put_tag(changeset, nil), - do: add_error(changeset, :tag_id, "must be one of your linked tags") + @doc false + def update_changeset(dnp_entry, attrs, %Tag{} = tag) do + dnp_entry + |> cast(attrs, [ + :conditions, + :reason, + :hide_reason, + :instructions, + :feedback, + :dnp_type + ]) + |> put_change(:tag_id, tag.id) + |> validate_required([:reason, :dnp_type]) + |> validate_inclusion(:dnp_type, types()) + |> validate_required(:tag_id, message: "must be one of your linked tags") + |> validate_inclusion(:tag_id, [tag.id], message: "must be one of your linked tags") + |> validate_conditions() + |> foreign_key_constraint(:tag_id, name: "fk_rails_473a736b4a") + end - defp put_tag(changeset, tag), - do: put_change(changeset, :tag_id, tag.id) + @doc false + def creation_changeset(dnp_entry, attrs, %User{} = user, selectable_tag_ids) + when is_list(selectable_tag_ids) do + dnp_entry + |> change(requesting_user_id: user.id) + |> update_changeset(attrs, selectable_tag_ids) + end - def creation_changeset(dnp_entry, attrs, tag, user) do + @doc false + def creation_changeset(dnp_entry, attrs, %User{} = user, %Tag{} = tag) do dnp_entry |> change(requesting_user_id: user.id) |> update_changeset(attrs, tag) end + @doc false + def fetch_tag_id(attrs) do + %__MODULE__{} + |> cast(attrs, [:tag_id]) + |> validate_required(:tag_id) + |> apply_action(:create) + |> case do + {:ok, %{tag_id: tag_id}} -> + {:ok, tag_id} + + _ -> + {:error, :not_found} + end + end + def transition_changeset(dnp_entry, user, new_state) do dnp_entry |> change(modifying_user_id: user.id) |> change(aasm_state: new_state) + |> validate_required([:aasm_state]) |> validate_inclusion(:aasm_state, states()) end @@ -96,4 +147,13 @@ defmodule Philomena.DnpEntries.DnpEntry do "closed" ] end + + def active_states do + [ + "requested", + "claimed", + "rescinded", + "acknowledged" + ] + end end diff --git a/lib/philomena/dnp_entries/dnp_entry_form.ex b/lib/philomena/dnp_entries/dnp_entry_form.ex new file mode 100644 index 000000000..324a346c5 --- /dev/null +++ b/lib/philomena/dnp_entries/dnp_entry_form.ex @@ -0,0 +1,20 @@ +defmodule Philomena.DnpEntries.DnpEntryForm do + @moduledoc """ + A new or existing DNP entry, its changeset, and the tags available to the + acting user. + + The same shape is returned for initial form renders and validation failures. + """ + + alias Philomena.DnpEntries.DnpEntry + alias Philomena.Tags.Tag + + @enforce_keys [:dnp_entry, :changeset, :selectable_tags] + defstruct [:dnp_entry, :changeset, :selectable_tags] + + @type t :: %__MODULE__{ + dnp_entry: DnpEntry.t(), + changeset: Ecto.Changeset.t(), + selectable_tags: [Tag.t()] + } +end diff --git a/lib/philomena/dnp_entries/dnp_entry_page.ex b/lib/philomena/dnp_entries/dnp_entry_page.ex new file mode 100644 index 000000000..6b3f4add3 --- /dev/null +++ b/lib/philomena/dnp_entries/dnp_entry_page.ex @@ -0,0 +1,14 @@ +defmodule Philomena.DnpEntries.DnpEntryPage do + @moduledoc """ + An authorized DNP entry and its optional rendered moderation notes. + + `mod_notes` is `nil` when the viewer may not read moderation notes. + """ + + alias Philomena.DnpEntries.DnpEntry + + @enforce_keys [:dnp_entry, :mod_notes] + defstruct [:dnp_entry, :mod_notes] + + @type t :: %__MODULE__{dnp_entry: DnpEntry.t(), mod_notes: list() | nil} +end diff --git a/lib/philomena/dnp_entries/dnp_listing.ex b/lib/philomena/dnp_entries/dnp_listing.ex new file mode 100644 index 000000000..b71c360a8 --- /dev/null +++ b/lib/philomena/dnp_entries/dnp_listing.ex @@ -0,0 +1,17 @@ +defmodule Philomena.DnpEntries.DnpListing do + @moduledoc """ + The assembled Do-Not-Post listing: a paginated set of DNP entries, the + viewer's linked tags, and whether the state column is included. + """ + + alias Philomena.Tags.Tag + + @enforce_keys [:dnp_entries, :linked_tags, :status_column] + defstruct [:dnp_entries, :linked_tags, :status_column] + + @type t :: %__MODULE__{ + dnp_entries: Scrivener.Page.t(), + linked_tags: [Tag.t()], + status_column: boolean() + } +end diff --git a/lib/philomena/dnp_entries/query_builder.ex b/lib/philomena/dnp_entries/query_builder.ex new file mode 100644 index 000000000..4023fb369 --- /dev/null +++ b/lib/philomena/dnp_entries/query_builder.ex @@ -0,0 +1,67 @@ +defmodule Philomena.DnpEntries.QueryBuilder do + @moduledoc false + + alias Philomena.DnpEntries.DnpEntry + alias Philomena.DnpEntries.QueryForm + import Ecto.Query + + @doc """ + Searches DNP entries based on the given parameters. + + ## Parameters + + * params - Map of optional search parameters: + * states - Filter by entry states + * text - Search requesting users, tags, reasons, conditions, and instructions + + When neither filter is present, only active DNP entries are returned. + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec search_dnp_entries(map()) :: + {:ok, Ecto.Query.t(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def search_dnp_entries(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + DnpEntry + |> maybe_filter_states(query_form) + |> maybe_filter_text(query_form) + |> preload([:tag, :requesting_user, :modifying_user]) + |> order_by(desc: :updated_at) + + {:ok, query, query_form} + end + end + + defp maybe_filter_states(query, %QueryForm{states: []}), do: query + + defp maybe_filter_states(query, %QueryForm{states: states}) do + where(query, [d], d.aasm_state in ^states) + end + + defp maybe_filter_text(query, %QueryForm{text: text}) do + if text do + pattern = "%#{unsanitized_like(text)}%" + + query + |> join(:inner, [d], _ in assoc(d, :tag)) + |> join(:inner, [d, _t], _ in assoc(d, :requesting_user)) + |> where( + [d, t, u], + ilike(u.name, ^pattern) or ilike(t.name, ^pattern) or ilike(d.reason, ^pattern) or + ilike(d.conditions, ^pattern) or ilike(d.instructions, ^pattern) + ) + else + query + end + end + + defp unsanitized_like(query_string) do + query_string + end +end diff --git a/lib/philomena/dnp_entries/query_form.ex b/lib/philomena/dnp_entries/query_form.ex new file mode 100644 index 000000000..da7e144af --- /dev/null +++ b/lib/philomena/dnp_entries/query_form.ex @@ -0,0 +1,22 @@ +defmodule Philomena.DnpEntries.QueryForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.DnpEntries.DnpEntry + + @type t :: %__MODULE__{} + + embedded_schema do + field :states, {:array, :string}, default: DnpEntry.active_states() + field :text, :string + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + query_form + |> cast(attrs, [:states, :text]) + |> validate_subset(:states, DnpEntry.states()) + end +end diff --git a/lib/philomena/donations.ex b/lib/philomena/donations.ex index e9a040b02..134c1c9a7 100644 --- a/lib/philomena/donations.ex +++ b/lib/philomena/donations.ex @@ -1,104 +1,110 @@ defmodule Philomena.Donations do @moduledoc """ - The Donations context. + Authorized administration of donation records. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.Attribution.Actor + alias Philomena.Authorization alias Philomena.Donations.Donation + alias Philomena.Loader + alias Philomena.Repo + alias Philomena.Users.User @doc """ - Returns the list of donations. + Returns the paginated donation listing for the admin index, on behalf of + `actor`, newest first, with each donation's user preloaded. ## Examples - iex> list_donations() - [%Donation{}, ...] + iex> list_donations(admin, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_donations(user, pagination) + {:error, :unauthorized} """ - def list_donations do - Repo.all(Donation) + @spec list_donations(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t()} | {:error, :unauthorized} + def list_donations(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :index, Donation) do + donations = + Donation + |> order_by(desc: :created_at, asc: :user_id) + |> preload(:user) + |> Repo.paginate(pagination) + + {:ok, donations} + end end @doc """ - Gets a single donation. + Loads the user named by `slug` with their donations and a new-donation + changeset. - Raises `Ecto.NoResultsError` if the Donation does not exist. + This form loader verifies write access, authorizes the routed `:show` action + against donations, then loads and authorizes the target user's + donation history. ## Examples - iex> get_donation!(123) - %Donation{} - - iex> get_donation!(456) - ** (Ecto.NoResultsError) - - """ - def get_donation!(id), do: Repo.get!(Donation, id) - - @doc """ - Creates a donation. - - ## Examples + iex> show_user_donations(admin, user.slug) + {:ok, {%User{}, %Ecto.Changeset{}}} - iex> create_donation(%{field: value}) - {:ok, %Donation{}} + iex> show_user_donations(admin, invalid_slug) + {:error, :not_found} - iex> create_donation(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_user_donations(user, user.slug) + {:error, :unauthorized} """ - def create_donation(attrs \\ %{}) do - %Donation{} - |> Donation.changeset(attrs) - |> Repo.insert() + @spec show_user_donations(Actor.t(), String.t()) :: + {:ok, {User.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def show_user_donations(%Actor{} = actor, slug) do + user_query = + User + |> where(slug: ^slug) + |> preload(donations: :user) + + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :show, Donation), + {:ok, user} <- Loader.one(user_query), + :ok <- authorize(actor, :show_donations, user) do + {:ok, {user, Donation.changeset(%Donation{})}} + end end @doc """ - Updates a donation. - - ## Examples + Creates a donation on behalf of `actor` from `attrs`. - iex> update_donation(donation, %{field: new_value}) - {:ok, %Donation{}} - - iex> update_donation(donation, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_donation(%Donation{} = donation, attrs) do - donation - |> Donation.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Donation. + Verifies write access and authorizes `:create` before inserting. Database and + validation failures are returned as changesets. ## Examples - iex> delete_donation(donation) + iex> create_donation(admin, donation_params) {:ok, %Donation{}} - iex> delete_donation(donation) + iex> create_donation(admin, invalid_params) {:error, %Ecto.Changeset{}} - """ - def delete_donation(%Donation{} = donation) do - Repo.delete(donation) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking donation changes. - - ## Examples - - iex> change_donation(donation) - %Ecto.Changeset{source: %Donation{}} + iex> create_donation(user, donation_params) + {:error, :unauthorized} """ - def change_donation(%Donation{} = donation) do - Donation.changeset(donation, %{}) + @spec create_donation(Actor.t(), map()) :: + {:ok, Donation.t()} + | Authorization.write_error() + | {:error, Ecto.Changeset.t()} + def create_donation(%Actor{} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Donation) do + %Donation{} + |> Donation.changeset(attrs) + |> Repo.insert() + end end end diff --git a/lib/philomena/donations/donation.ex b/lib/philomena/donations/donation.ex index 067377fe5..39c1cc98d 100644 --- a/lib/philomena/donations/donation.ex +++ b/lib/philomena/donations/donation.ex @@ -4,6 +4,8 @@ defmodule Philomena.Donations.Donation do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "donations" do belongs_to :user, User @@ -18,7 +20,7 @@ defmodule Philomena.Donations.Donation do end @doc false - def changeset(donation, attrs) do + def changeset(donation, attrs \\ %{}) do donation |> cast(attrs, [:email, :amount, :note, :user_id]) |> validate_required([]) diff --git a/lib/philomena/duplicate_reports.ex b/lib/philomena/duplicate_reports.ex index a920e87ad..b2964ff94 100644 --- a/lib/philomena/duplicate_reports.ex +++ b/lib/philomena/duplicate_reports.ex @@ -1,361 +1,694 @@ defmodule Philomena.DuplicateReports do @moduledoc """ - The DuplicateReports context. + Duplicate-report submission, staff review, perceptual matching, and reverse + image search. """ - import Philomena.DuplicateReports.Power import Ecto.Query, warn: false + import Philomena.DuplicateReports.Power - alias Ecto.Multi - alias Philomena.Repo + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + import Philomena.DuplicateReports.TransactionWorkflow + + alias Philomena.Attribution.Actor alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.DuplicateReports.QueryBuilder + alias Philomena.DuplicateReports.QueryForm alias Philomena.DuplicateReports.SearchQuery + alias Philomena.DuplicateReports.SearchResult alias Philomena.DuplicateReports.Uploader alias Philomena.ImageIntensities.ImageIntensity - alias Philomena.Images.Image alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.Multi + alias Philomena.Repo - @doc """ - Generates automated duplicate reports for an image based on perceptual matching. - - Takes a source image and generates duplicate reports for similar images based on - intensity and aspect ratio comparison. - - ## Examples - - iex> generate_reports(source_image) - [{:ok, %DuplicateReport{}}, ...] - - """ - def generate_reports(source) do - source = Repo.preload(source, :intensity) - - {source.intensity, source.image_aspect_ratio} - |> find_duplicates(dist: 0.2) - |> where([i, _it], i.id != ^source.id) - |> Repo.all() - |> Enum.map(fn target -> - create_duplicate_report(source, target, %{}, %{ - "reason" => "Automated Perceptual dedupe match" - }) - end) + @report_preloads [ + :user, + :modifier, + image: [:user, :sources, tags: :aliases], + duplicate_of_image: [:user, :sources, tags: :aliases] + ] + + defp load_report(%Actor{} = actor, action, report_id) do + with {:ok, report} <- + Loader.fetch_and_authorize(DuplicateReport, actor, action, report_id, @report_preloads), + :ok <- authorize(actor, :show, report.image), + :ok <- authorize(actor, :show, report.duplicate_of_image) do + {:ok, report} + end end - @doc """ - Query for potential duplicate images based on intensity values and aspect ratio. - - Takes a tuple of {intensities, aspect_ratio} and optional options to control the search: - - `:aspect_dist` - Maximum aspect ratio difference (default: 0.05) - - `:limit` - Maximum number of results (default: 10) - - `:dist` - Maximum intensity difference per channel (default: 0.25) - - ## Examples - - iex> find_duplicates({%{nw: 0.5, ne: 0.5, sw: 0.5, se: 0.5}, 1.0}) - #Ecto.Query<...> - - iex> find_duplicates({intensities, ratio}, dist: 0.3, limit: 20) - #Ecto.Query<...> + defp visible_images_query(%Actor{} = actor) do + if authorize(actor, :show, %Image{hidden_from_users: true}) == :ok do + Image + else + where(Image, hidden_from_users: false) + end + end - """ - def find_duplicates({intensities, aspect_ratio}, opts \\ []) do + defp duplicate_query(image_query \\ Image, {intensities, aspect_ratio}, opts) do aspect_dist = Keyword.get(opts, :aspect_dist, 0.05) limit = Keyword.get(opts, :limit, 10) - dist = Keyword.get(opts, :dist, 0.25) - - # for each color channel - dist = dist * 3 - - from i in Image, - inner_join: it in ImageIntensity, - on: it.image_id == i.id, - where: it.nw >= ^(intensities.nw - dist) and it.nw <= ^(intensities.nw + dist), - where: it.ne >= ^(intensities.ne - dist) and it.ne <= ^(intensities.ne + dist), - where: it.sw >= ^(intensities.sw - dist) and it.sw <= ^(intensities.sw + dist), - where: it.se >= ^(intensities.se - dist) and it.se <= ^(intensities.se + dist), + dist = Keyword.get(opts, :dist, 0.25) * 3 + + from image in image_query, + inner_join: intensity in ImageIntensity, + on: intensity.image_id == image.id, + where: + intensity.nw >= ^(intensities.nw - dist) and + intensity.nw <= ^(intensities.nw + dist), where: - i.image_aspect_ratio >= ^(aspect_ratio - aspect_dist) and - i.image_aspect_ratio <= ^(aspect_ratio + aspect_dist), + intensity.ne >= ^(intensities.ne - dist) and + intensity.ne <= ^(intensities.ne + dist), + where: + intensity.sw >= ^(intensities.sw - dist) and + intensity.sw <= ^(intensities.sw + dist), + where: + intensity.se >= ^(intensities.se - dist) and + intensity.se <= ^(intensities.se + dist), + where: + image.image_aspect_ratio >= ^(aspect_ratio - aspect_dist) and + image.image_aspect_ratio <= ^(aspect_ratio + aspect_dist), order_by: [ asc: - power(it.nw - ^intensities.nw, 2) + - power(it.ne - ^intensities.ne, 2) + - power(it.sw - ^intensities.sw, 2) + - power(it.se - ^intensities.se, 2) + - power(i.image_aspect_ratio - ^aspect_ratio, 2) + power(intensity.nw - ^intensities.nw, 2) + + power(intensity.ne - ^intensities.ne, 2) + + power(intensity.sw - ^intensities.sw, 2) + + power(intensity.se - ^intensities.se, 2) + + power(image.image_aspect_ratio - ^aspect_ratio, 2), + asc: image.id ], limit: ^limit end + defp put_reject_open_reports(%Multi{} = multi) do + Multi.update_all( + multi, + :reject_open_reports, + fn %{locked_source_image: %{id: source_id}, locked_target_image: %{id: target_id}} -> + from report in DuplicateReport, + where: + (report.image_id == ^source_id and report.duplicate_of_image_id == ^target_id) or + (report.duplicate_of_image_id == ^source_id and report.image_id == ^target_id), + where: report.state in ~w(open claimed) + end, + set: [state: "rejected"] + ) + end + @doc """ - Executes the reverse image search query from parameters. + Loads the staff duplicate-report index described by `params`. + + Unlike regular reports, which are private to staff and submitting users, + duplicate reports are publicly-accessible information. Access to the index is + therefore permitted to any user. + + Blank or omitted states select open and claimed reports. Invalid state + selections return an empty page with their rejected query changeset. ## Examples - iex> execute_search_query(%{"image" => ..., "distance" => "0.25"}) - {:ok, [%Image{...}, ....]} + iex> list_duplicate_reports(moderator, %{"states" => ["rejected"]}, pagination) + {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} - iex> execute_search_query(%{"image" => ..., "distance" => "asdf"}) - {:error, %Ecto.Changeset{}} + iex> list_duplicate_reports(user, %{}, pagination) + {:error, :unauthorized} """ - def execute_search_query(attrs \\ %{}) do - %SearchQuery{} - |> SearchQuery.changeset(attrs) - |> Uploader.analyze_upload(attrs) - |> Ecto.Changeset.apply_action(:create) - |> case do - {:ok, search_query} -> - intensities = generate_intensities(search_query) - aspect = search_query.image_aspect_ratio - limit = search_query.limit - dist = search_query.distance - - images = - {intensities, aspect} - |> find_duplicates(dist: dist, aspect_dist: dist, limit: limit) - |> preload([:user, :intensity, [:sources, tags: :aliases]]) - |> Repo.paginate(page_size: 50) - - {:ok, images} - - error -> - error + @spec list_duplicate_reports(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(DuplicateReport.t()), Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_duplicate_reports(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :search, DuplicateReport) do + case QueryBuilder.build_query(params) do + {:ok, query, query_form} -> + query = preload(query, ^@report_preloads) + + {:ok, Repo.paginate(query, pagination), QueryForm.changeset(query_form)} + + {:error, changeset} -> + {:ok, Repo.paginate(where(DuplicateReport, false), pagination), changeset} + end end end - defp generate_intensities(search_query) do - analysis = SearchQuery.to_analysis(search_query) - file = search_query.uploaded_image - - PhilomenaMedia.Processors.intensities(analysis, file) - end - @doc """ - Returns an `%Ecto.Changeset{}` for tracking search query changes. + Loads one duplicate report for display. + + The report is authorized with `:show`, then both associated images must also + be visible to the actor. Missing and malformed IDs are always not-found. ## Examples - iex> change_search_query(search_query) - %Ecto.Changeset{source: %SearchQuery{}} + iex> show_duplicate_report(actor, "42") + {:ok, %DuplicateReport{}} + + iex> show_duplicate_report(actor, "not-an-id") + {:error, :not_found} """ - def change_search_query(%SearchQuery{} = search_query) do - SearchQuery.changeset(search_query) + @spec show_duplicate_report(Actor.t(), Loader.integer_id()) :: + {:ok, DuplicateReport.t()} | {:error, :not_found | :unauthorized} + def show_duplicate_report(%Actor{} = actor, report_id) do + load_report(actor, :show, report_id) end @doc """ - Gets a single duplicate_report. + Prepares the duplicate-report form for one visible image. - Raises `Ecto.NoResultsError` if the Duplicate report does not exist. + The form uses the same write-access and `:create` prerequisites as submission. + A list of all existing reports is provided for the actor to review before + submitting a new report. ## Examples - iex> get_duplicate_report!(123) - %DuplicateReport{} + iex> new_duplicate_report(actor, "42") + {:ok, {%Image{}, [%DuplicateReport{}], %Ecto.Changeset{}}} - iex> get_duplicate_report!(456) - ** (Ecto.NoResultsError) + iex> new_duplicate_report(banned_actor, "42") + {:error, :ban} """ - def get_duplicate_report!(id), do: Repo.get!(DuplicateReport, id) + @spec new_duplicate_report(Actor.t(), Loader.integer_id()) :: + {:ok, {Image.t(), [DuplicateReport.t()], Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def new_duplicate_report(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, DuplicateReport), + {:ok, image} <- Images.load_report_target(actor, image_id) do + changeset = + %DuplicateReport{image_id: image.id, image: image} + |> DuplicateReport.creation_changeset(%{}, actor.user) + + reports = + DuplicateReport + |> where( + [report], + report.image_id == ^image.id or report.duplicate_of_image_id == ^image.id + ) + |> order_by(desc: :created_at, desc: :id) + |> preload(^@report_preloads) + |> Repo.all() + + {:ok, {image, reports, changeset}} + end + end @doc """ - Creates a duplicate_report. + Creates a duplicate report between two actor-visible images. + + Write access and the class `:create` ability are checked before either image + is loaded. The locators are parsed independently; malformed, missing, or + forbidden images return the shared loader errors. Validation failures return + the report changeset with both images attached. ## Examples - iex> create_duplicate_report(%{field: value}) + iex> create_duplicate_report(actor, "1", "2", %{"reason" => "same image"}) {:ok, %DuplicateReport{}} - iex> create_duplicate_report(%{field: bad_value}) + iex> create_duplicate_report(actor, "1", "1", %{}) {:error, %Ecto.Changeset{}} """ - def create_duplicate_report(source, target, attribution, attrs \\ %{}) do - %DuplicateReport{image_id: source.id, duplicate_of_image_id: target.id} - |> DuplicateReport.creation_changeset(attrs, attribution) - |> Repo.insert() + @spec create_duplicate_report( + Actor.t(), + Loader.integer_id(), + Loader.integer_id(), + map() + ) :: + {:ok, DuplicateReport.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def create_duplicate_report(%Actor{user: user} = actor, source_id, target_id, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, DuplicateReport), + {:ok, source} <- Images.load_report_target(actor, source_id), + {:ok, target} <- Images.load_report_target(actor, target_id) do + changeset = + %DuplicateReport{ + image_id: source.id, + image: source, + duplicate_of_image_id: target.id, + duplicate_of_image: target + } + |> DuplicateReport.creation_changeset(attrs, user) + + Multi.new() + |> put_lock_image_pair(actor, source.id, target.id, :show) + |> Multi.insert(:duplicate_report, changeset) + |> Multi.transact() + |> case do + {:ok, %{duplicate_report: %DuplicateReport{} = report}} -> + {:ok, report} + + {:error, :duplicate_report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Accepts a duplicate report and merges the duplicate image into the target image. + Generates automated duplicate reports for one media-pipeline image. - Takes an optional Ecto.Multi, the duplicate report to accept, and the user accepting the report. - Handles rejecting any other duplicate reports between the same images and merges the images. + Up to ten visible perceptual matches are considered. Each insert is + independent and the per-target insert results are returned to the worker. ## Examples - iex> accept_duplicate_report(nil, duplicate_report, user) - {:ok, %{duplicate_report: %DuplicateReport{}, ...}} - - iex> accept_duplicate_report(existing_multi, duplicate_report, user) - %Ecto.Multi{} + iex> generate_reports(source_image) + [{:ok, %DuplicateReport{}}, ...] """ - def accept_duplicate_report(multi \\ nil, %DuplicateReport{} = duplicate_report, user) do - duplicate_report = Repo.preload(duplicate_report, [:image, :duplicate_of_image]) + @spec generate_reports(Image.t()) :: + [{:ok, DuplicateReport.t()} | {:error, Ecto.Changeset.t()}] + def generate_reports(%Image{} = source) do + source = Repo.preload(source, :intensity) - other_duplicate_reports = - DuplicateReport - |> where( - [dr], - (dr.image_id == ^duplicate_report.image_id and - dr.duplicate_of_image_id == ^duplicate_report.duplicate_of_image_id) or - (dr.image_id == ^duplicate_report.duplicate_of_image_id and - dr.duplicate_of_image_id == ^duplicate_report.image_id) - ) - |> where([dr], dr.id != ^duplicate_report.id) - |> update(set: [state: "rejected"]) + {source.intensity, source.image_aspect_ratio} + |> duplicate_query(dist: 0.2) + |> where([image], image.id != ^source.id) + |> Repo.all() + |> Enum.map(fn target -> + changeset = + %DuplicateReport{ + image_id: source.id, + image: source, + duplicate_of_image_id: target.id, + duplicate_of_image: target + } + |> DuplicateReport.creation_changeset(%{reason: "Automated Perceptual dedupe match"}) - changeset = DuplicateReport.accept_changeset(duplicate_report, user) + Multi.new() + |> put_lock_image_pair_without_authorization(source.id, target.id) + |> Multi.insert(:duplicate_report, changeset) + |> Multi.transact() + |> case do + {:ok, %{duplicate_report: %DuplicateReport{} = report}} -> + {:ok, report} - multi = multi || Multi.new() + {:error, :duplicate_report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} - multi - |> Multi.update(:duplicate_report, changeset) - |> Multi.update_all(:other_reports, other_duplicate_reports, []) - |> Images.merge_image(duplicate_report.image, duplicate_report.duplicate_of_image, user) + error -> + map_lock_errors(error) + end + end) end @doc """ - Accepts a duplicate report in reverse, making the target image the duplicate instead. - - Creates a new duplicate report with reversed image relationship if one doesn't exist, - rejects the original report, and accepts the reversed report. + Prepares an empty reverse-image-search result for the actor. ## Examples - iex> accept_reverse_duplicate_report(duplicate_report, user) - {:ok, %{duplicate_report: %DuplicateReport{}, ...}} + iex> create_reverse_search(actor) + {:ok, %SearchResult{images: nil}} """ - def accept_reverse_duplicate_report(%DuplicateReport{} = duplicate_report, user) do - new_report = - DuplicateReport - |> where(duplicate_of_image_id: ^duplicate_report.image_id) - |> where(image_id: ^duplicate_report.duplicate_of_image_id) - |> limit(1) - |> Repo.one() - - new_report = - if new_report do - new_report - else - %DuplicateReport{ - image_id: duplicate_report.duplicate_of_image_id, - duplicate_of_image_id: duplicate_report.image_id, - reason: Enum.join([duplicate_report.reason, "(Reverse accepted)"], "\n"), - user_id: user.id - } - |> DuplicateReport.changeset(%{}) - |> Repo.insert!() - end - - Multi.new() - |> Multi.run(:reject_duplicate_report, fn _, %{} -> - reject_duplicate_report(duplicate_report, user) - end) - |> accept_duplicate_report(new_report, user) + @spec create_reverse_search(Actor.t()) :: + {:ok, SearchResult.t()} | {:error, :unauthorized} + def create_reverse_search(%Actor{} = actor) do + with :ok <- authorize(actor, :search, DuplicateReport) do + {:ok, + %SearchResult{ + images: nil, + changeset: SearchQuery.changeset(%SearchQuery{}) + }} + end end @doc """ - Claims a duplicate report for review by a user. + Runs a reverse image search for the uploaded image in `attrs`. + + The upload metadata, distance, and limit are validated before media analysis. + Results exclude hidden images unless the actor may view them, and carry the + normalized search changeset alongside the page. Invalid input returns the + rejected changeset explicitly. ## Examples - iex> claim_duplicate_report(duplicate_report, user) - {:ok, %DuplicateReport{}} + iex> create_reverse_search(actor, %{"distance" => "0.25"}, upload) + {:ok, %SearchResult{images: %Scrivener.Page{}}} + + iex> create_reverse_search(actor, %{"distance" => "bad"}, upload) + {:error, %Ecto.Changeset{}} """ - def claim_duplicate_report(%DuplicateReport{} = duplicate_report, user) do - duplicate_report - |> DuplicateReport.claim_changeset(user) - |> Repo.update() + @spec create_reverse_search(Actor.t(), map(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, SearchResult.t()} | {:error, :unauthorized | Ecto.Changeset.t()} + def create_reverse_search(%Actor{} = actor, attrs, upload) do + with :ok <- authorize(actor, :search, DuplicateReport), + {:ok, search_query} <- + %SearchQuery{} + |> SearchQuery.changeset(attrs) + |> Uploader.analyze_upload(upload) + |> Ecto.Changeset.apply_action(:create) do + analysis = SearchQuery.to_analysis(search_query) + intensities = PhilomenaMedia.Processors.intensities(analysis, search_query.uploaded_image) + + images = + actor + |> visible_images_query() + |> duplicate_query( + {intensities, search_query.image_aspect_ratio}, + dist: search_query.distance, + aspect_dist: search_query.distance, + limit: search_query.limit + ) + |> preload([:user, :intensity, :sources, tags: :aliases]) + |> Repo.paginate(page_size: 50) + + {:ok, + %SearchResult{ + images: images, + changeset: SearchQuery.changeset(search_query) + }} + end end @doc """ - Removes a user's claim on a duplicate report. + Accepts a duplicate report and merges its source image into its target. + + Write access, the distinct `:accept` ability, the report direction, and both + images are checked before row-locked state is changed. The report, competing + active reports, image merge, and moderation log commit atomically. Merge + indexing, notifications, thumbnails, and broadcasts run after commit. ## Examples - iex> unclaim_duplicate_report(duplicate_report) + iex> create_duplicate_report_accept(moderator, "42") {:ok, %DuplicateReport{}} + iex> create_duplicate_report_accept(user, "42") + {:error, :unauthorized} + """ - def unclaim_duplicate_report(%DuplicateReport{} = duplicate_report) do - duplicate_report - |> DuplicateReport.unclaim_changeset() - |> Repo.update() + @spec create_duplicate_report_accept(Actor.t(), Loader.integer_id()) :: + {:ok, map()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def create_duplicate_report_accept(%Actor{user: user} = actor, report_id) do + with :ok <- verify_write_access(actor), + {:ok, report} <- load_report(actor, :accept, report_id) do + Multi.new() + |> put_lock_image_pair_and_report(report, actor, :show, :accept) + |> put_reject_open_reports() + |> Multi.update(:duplicate_report, fn %{locked_duplicate_report: duplicate_report} -> + DuplicateReport.accept_changeset(duplicate_report, user) + end) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_source_image: source, locked_target_image: target} -> + { + "DuplicateReport.Accept:create", + Paths.image_path(source), + "Accepted duplicate report, merged #{source.id} into #{target.id}" + } + end + ) + |> Multi.merge(fn + %{locked_source_image: source, locked_target_image: target} -> + Images.put_merge_image(Multi.new(), source, target, user) + end) + |> Multi.transact() + |> case do + {:ok, %{duplicate_report: %DuplicateReport{} = duplicate_report}} -> + {:ok, duplicate_report} + + {:error, :duplicate_report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + {:error, :image, %Ecto.Changeset{}, %{duplicate_report: duplicate_report}} -> + {:error, DuplicateReport.add_image_acceptance_error(duplicate_report)} + + error -> + map_lock_errors(error) + end + end end @doc """ - Rejects a duplicate report. + Accepts a duplicate report in reverse and merges its target into its source. - Updates the duplicate report's state to rejected and records the user who rejected it. + The original report is rejected and a locked reverse-direction report is + inserted or accepted in the same transaction as the image merge and audit + log. Authorization and post-commit behavior match `accept_duplicate_report/2`. ## Examples - iex> reject_duplicate_report(duplicate_report, user) + iex> create_duplicate_report_accept_reverse(moderator, "42") {:ok, %DuplicateReport{}} """ - def reject_duplicate_report(%DuplicateReport{} = duplicate_report, user) do - duplicate_report - |> DuplicateReport.reject_changeset(user) - |> Repo.update() + @spec create_duplicate_report_accept_reverse(Actor.t(), Loader.integer_id()) :: + {:ok, map()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def create_duplicate_report_accept_reverse(%Actor{user: user} = actor, report_id) do + with :ok <- verify_write_access(actor), + {:ok, report} <- load_report(actor, :accept_reverse, report_id) do + Multi.new() + |> put_lock_image_pair_and_report( + report, + actor, + :show, + :accept_reverse + ) + |> put_reject_open_reports() + |> Multi.one(:existing_reverse_report, fn %{locked_duplicate_report: forward_report} -> + from reverse_report in DuplicateReport, + where: reverse_report.id != ^forward_report.id, + where: reverse_report.image_id == ^forward_report.duplicate_of_image_id, + where: reverse_report.duplicate_of_image_id == ^forward_report.image_id, + order_by: [desc: :id], + limit: 1 + end) + |> Multi.insert_or_update(:reverse_report, fn + %{existing_reverse_report: nil, locked_duplicate_report: duplicate_report} -> + %DuplicateReport{ + image_id: duplicate_report.duplicate_of_image_id, + duplicate_of_image_id: duplicate_report.image_id + } + |> DuplicateReport.reverse_accept_changeset(user, duplicate_report.reason) + + %{existing_reverse_report: reverse_report} -> + DuplicateReport.accept_changeset(reverse_report, user) + end) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_source_image: source, locked_target_image: target} -> + { + "DuplicateReport.AcceptReverse:create", + Paths.image_path(target), + "Reverse-accepted duplicate report, merged #{target.id} into #{source.id}" + } + end + ) + |> Multi.merge(fn + %{locked_source_image: source, locked_target_image: target} -> + Images.put_merge_image(Multi.new(), target, source, user) + end) + |> Multi.transact() + |> case do + {:ok, %{reverse_report: %DuplicateReport{} = reverse_report}} -> + {:ok, reverse_report} + + {:error, :reverse_report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + {:error, :image, %Ecto.Changeset{}, %{reverse_report: reverse_report}} -> + {:error, DuplicateReport.add_image_acceptance_error(reverse_report)} + + error -> + map_lock_errors(error) + end + end end @doc """ - Deletes a DuplicateReport. + Claims one open, unclaimed duplicate report for the acting staff member. - ## Examples + The report is reloaded under a row lock and authorized with `:claim`. A + repeated or racing claim returns a changeset; the audit log commits with the + state change. - iex> delete_duplicate_report(duplicate_report) - {:ok, %DuplicateReport{}} + ## Examples - iex> delete_duplicate_report(duplicate_report) - {:error, %Ecto.Changeset{}} + iex> create_duplicate_report_claim(moderator, "42") + {:ok, %DuplicateReport{state: "claimed"}} """ - def delete_duplicate_report(%DuplicateReport{} = duplicate_report) do - Repo.delete(duplicate_report) + @spec create_duplicate_report_claim(Actor.t(), Loader.integer_id()) :: + {:ok, DuplicateReport.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def create_duplicate_report_claim(%Actor{user: user} = actor, report_id) do + with :ok <- verify_write_access(actor), + {:ok, report} <- load_report(actor, :claim, report_id) do + Multi.new() + |> put_lock_image_pair_and_report(report, actor, :show, :claim) + |> Multi.update(:duplicate_report, fn %{locked_duplicate_report: duplicate_report} -> + DuplicateReport.claim_changeset(duplicate_report, user) + end) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "DuplicateReport.Claim:create", + "/duplicate_reports", + "Claimed a duplicate report" + ) + |> Multi.transact() + |> case do + {:ok, %{duplicate_report: %DuplicateReport{} = duplicate_report}} -> + {:ok, duplicate_report} + + {:error, :duplicate_report, %Ecto.Changeset{} = changeset, _steps} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking duplicate_report changes. + Releases one claimed duplicate report. + + The locked report is authorized with `:unclaim`. An already-open or otherwise + non-claimed report returns a changeset and does not write an audit log. ## Examples - iex> change_duplicate_report(duplicate_report) - %Ecto.Changeset{source: %DuplicateReport{}} + iex> delete_duplicate_report_claim(moderator, "42") + {:ok, %DuplicateReport{state: "open"}} """ - def change_duplicate_report(%DuplicateReport{} = duplicate_report) do - DuplicateReport.changeset(duplicate_report, %{}) + @spec delete_duplicate_report_claim(Actor.t(), Loader.integer_id()) :: + {:ok, DuplicateReport.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_duplicate_report_claim(%Actor{} = actor, report_id) do + with :ok <- verify_write_access(actor), + {:ok, report} <- load_report(actor, :unclaim, report_id) do + Multi.new() + |> put_lock_image_pair_and_report(report, actor, :show, :unclaim) + |> Multi.update(:duplicate_report, fn %{locked_duplicate_report: duplicate_report} -> + DuplicateReport.unclaim_changeset(duplicate_report) + end) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "DuplicateReport.Claim:delete", + "/duplicate_reports", + "Released a duplicate report" + ) + |> Multi.transact() + |> case do + {:ok, %{duplicate_report: %DuplicateReport{} = duplicate_report}} -> + {:ok, duplicate_report} + + {:error, :duplicate_report, %Ecto.Changeset{} = changeset, _steps} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Counts the number of duplicate reports in "open" state, - if the user has permission to view them. + Rejects one active duplicate report. + + The locked report is authorized with `:reject`; its state change and the + direction-bearing moderation log commit atomically. ## Examples - iex> count_duplicate_reports(admin) - 42 + iex> create_duplicate_report_reject(moderator, "42") + {:ok, %DuplicateReport{state: "rejected"}} - iex> count_duplicate_reports(user) - nil + """ + @spec create_duplicate_report_reject(Actor.t(), Loader.integer_id()) :: + {:ok, DuplicateReport.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def create_duplicate_report_reject(%Actor{user: user} = actor, report_id) do + with :ok <- verify_write_access(actor), + {:ok, report} <- load_report(actor, :reject, report_id) do + Multi.new() + |> put_lock_image_pair_and_report(report, actor, :show, :reject) + |> Multi.update(:duplicate_report, fn %{locked_duplicate_report: duplicate_report} -> + DuplicateReport.reject_changeset(duplicate_report, user) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{duplicate_report: duplicate_report} -> + { + "DuplicateReport.Reject:create", + "/duplicate_reports", + "Rejected duplicate report (#{duplicate_report.image_id} -> #{duplicate_report.duplicate_of_image_id})" + } + end) + |> Multi.transact() + |> case do + {:ok, %{duplicate_report: %DuplicateReport{} = duplicate_report}} -> + {:ok, duplicate_report} + + {:error, :duplicate_report, %Ecto.Changeset{} = changeset, _steps} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end + end + @doc """ + Rejects open duplicate reports involving `image_id` inside `multi`. + + Images composes this step when hiding an image. The DuplicateReports context + owns the report state update and keeps it coupled to the image transaction. """ - def count_duplicate_reports(user) do - if Canada.Can.can?(user, :index, DuplicateReport) do + @spec put_reject_image_reports(Multi.t(), Multi.name(), integer()) :: Multi.t() + def put_reject_image_reports(%Multi{} = multi, step, image_id) do + query = DuplicateReport |> where(state: "open") - |> Repo.aggregate(:count, :id) - else + |> where( + [report], + report.image_id == ^image_id or report.duplicate_of_image_id == ^image_id + ) + + Multi.update_all(multi, step, query, set: [state: "rejected"]) + end + + @doc """ + Counts open duplicate reports for the staff navigation counter. + + The count is authorized with `:index`; unauthorized actors receive `nil`. + + ## Examples + + iex> count_duplicate_reports(moderator) + 4 + + iex> count_duplicate_reports(user) nil + + """ + @spec count_duplicate_reports(Actor.t()) :: non_neg_integer() | nil + def count_duplicate_reports(%Actor{} = actor) do + case authorize(actor, :index, DuplicateReport) do + :ok -> + DuplicateReport + |> where(state: "open") + |> Repo.aggregate(:count) + + _error -> + nil end end end diff --git a/lib/philomena/duplicate_reports/comparison.ex b/lib/philomena/duplicate_reports/comparison.ex new file mode 100644 index 000000000..8fdf40412 --- /dev/null +++ b/lib/philomena/duplicate_reports/comparison.ex @@ -0,0 +1,114 @@ +defmodule Philomena.DuplicateReports.Comparison do + @moduledoc """ + Domain comparisons between the source and target images of a duplicate report. + + These predicates describe resolution, format, provenance, tag, version, and + merge eligibility independently of how a report is rendered. + """ + + @formats_order ~W(video/webm image/svg+xml image/png image/gif image/jpeg other) + + def largest_dimensions(images) do + images + |> Enum.map(&{&1.image_width, &1.image_height}) + |> Enum.max_by(fn {width, height} -> width * height end) + end + + def forward_merge?(%{image_id: image_id, duplicate_of_image_id: duplicate_of_image_id}), + do: duplicate_of_image_id > image_id + + def higher_res?(%{image: image, duplicate_of_image: target}), + do: target.image_width > image.image_width or target.image_height > image.image_height + + def same_res?(%{image: image, duplicate_of_image: target}), + do: target.image_width == image.image_width and target.image_height == image.image_height + + def same_format?(%{image: image, duplicate_of_image: target}), + do: target.image_mime_type == image.image_mime_type + + def better_format?(%{image: image, duplicate_of_image: target}) do + format_index(target.image_mime_type) < format_index(image.image_mime_type) + end + + def same_aspect_ratio?(%{image: image, duplicate_of_image: target}), + do: abs(target.image_aspect_ratio - image.image_aspect_ratio) <= 0.009 + + def neither_have_source?(%{image: image, duplicate_of_image: target}), + do: Enum.empty?(target.sources) and Enum.empty?(image.sources) + + def same_source?(%{image: image, duplicate_of_image: target}), + do: MapSet.equal?(MapSet.new(image.sources), MapSet.new(target.sources)) + + def similar_source?(%{image: image, duplicate_of_image: target}) do + MapSet.equal?( + MapSet.new(image.sources, &URI.parse(&1.source).host), + MapSet.new(target.sources, &URI.parse(&1.source).host) + ) + end + + def source_on_target?(%{image: image, duplicate_of_image: target}), + do: Enum.any?(target.sources) and Enum.empty?(image.sources) + + def source_on_source?(%{image: image, duplicate_of_image: target}), + do: Enum.empty?(target.sources) and Enum.any?(image.sources) + + def same_artist_tags?(%{image: image, duplicate_of_image: target}), + do: MapSet.equal?(artist_tags(image), artist_tags(target)) + + def more_artist_tags_on_target?(%{image: image, duplicate_of_image: target}), + do: proper_subset?(artist_tags(image), artist_tags(target)) + + def more_artist_tags_on_source?(%{image: image, duplicate_of_image: target}), + do: proper_subset?(artist_tags(target), artist_tags(image)) + + def same_rating_tags?(%{image: image, duplicate_of_image: target}), + do: MapSet.equal?(rating_tags(image), rating_tags(target)) + + def target_is_edit?(%{duplicate_of_image: target}), do: edit?(target) + def source_is_edit?(%{image: image}), do: edit?(image) + + def both_are_edits?(%{image: image, duplicate_of_image: target}), + do: edit?(image) and edit?(target) + + def target_is_alternate_version?(%{duplicate_of_image: target}), + do: alternate_version?(target) + + def source_is_alternate_version?(%{image: image}), do: alternate_version?(image) + + def both_are_alternate_versions?(%{image: image, duplicate_of_image: target}), + do: alternate_version?(image) and alternate_version?(target) + + def mergeable?(%{image: image, duplicate_of_image: target} = report) do + same_rating_tags?(report) and not image.hidden_from_users and + not target.hidden_from_users and image.approved and target.approved + end + + def source_approved?(%{image: image}), do: image.approved + def target_approved?(%{duplicate_of_image: image}), do: image.approved + + defp format_index(mime_type) do + Enum.find_index(@formats_order, &(mime_type == &1)) || length(@formats_order) - 1 + end + + defp artist_tags(%{tags: tags}) do + tags + |> Enum.filter(&(&1.namespace == "artist")) + |> Enum.map(& &1.name) + |> MapSet.new() + end + + defp rating_tags(%{tags: tags}) do + tags + |> Enum.filter(&(&1.category == "rating")) + |> Enum.map(& &1.name) + |> MapSet.new() + end + + defp edit?(%{tags: tags}), do: Enum.any?(tags, &(&1.name == "edit")) + + defp alternate_version?(%{tags: tags}), + do: Enum.any?(tags, &(&1.name == "alternate version")) + + defp proper_subset?(first, second), + do: MapSet.subset?(first, second) and not MapSet.equal?(first, second) +end diff --git a/lib/philomena/duplicate_reports/duplicate_report.ex b/lib/philomena/duplicate_reports/duplicate_report.ex index c5dd21a6a..fb73e300f 100644 --- a/lib/philomena/duplicate_reports/duplicate_report.ex +++ b/lib/philomena/duplicate_reports/duplicate_report.ex @@ -5,6 +5,8 @@ defmodule Philomena.DuplicateReports.DuplicateReport do alias Philomena.Images.Image alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "duplicate_reports" do belongs_to :image, Image belongs_to :duplicate_of_image, Image @@ -17,46 +19,106 @@ defmodule Philomena.DuplicateReports.DuplicateReport do timestamps(inserted_at: :created_at, type: :utc_datetime) end - @doc false - def changeset(duplicate_report, attrs) do - duplicate_report - |> cast(attrs, []) - |> validate_required([]) + def open_states do + ~w(open claimed) + end + + def valid_states do + ~w(open claimed accepted rejected) end @doc false - def creation_changeset(duplicate_report, attrs, attribution) do + def creation_changeset(duplicate_report, attrs, user \\ nil) do duplicate_report |> cast(attrs, [:reason]) - |> put_assoc(:user, attribution[:user]) + |> put_assoc(:user, user) |> validate_length(:reason, max: 250, count: :bytes) |> validate_source_is_not_target() end + @doc false def accept_changeset(duplicate_report, user) do change(duplicate_report) + |> validate_actionable() |> put_change(:modifier_id, user.id) |> put_change(:state, "accepted") end + @doc false + def reverse_accept_changeset(duplicate_report, user, reason) do + suffix = "\n(Reverse accepted)" + reason = String.byte_slice(reason, 0, 250 - byte_size(suffix)) + + duplicate_report + |> creation_changeset(%{reason: reason <> suffix}, user) + |> accept_changeset(user) + end + + @doc false def claim_changeset(duplicate_report, user) do change(duplicate_report) + |> validate_state("open", "must be open") + |> validate_unclaimed() |> put_change(:modifier_id, user.id) |> put_change(:state, "claimed") end + @doc false def unclaim_changeset(duplicate_report) do change(duplicate_report) + |> validate_state("claimed", "must be claimed") + |> validate_claimed() |> put_change(:modifier_id, nil) |> put_change(:state, "open") end + @doc false def reject_changeset(duplicate_report, user) do change(duplicate_report) + |> validate_actionable() |> put_change(:modifier_id, user.id) |> put_change(:state, "rejected") end + @doc false + def add_image_acceptance_error(duplicate_report) do + duplicate_report + |> change() + |> add_error(:image_id, "rejected the merge") + end + + defp validate_actionable(changeset) do + if get_field(changeset, :state) in ["open", "claimed"] do + changeset + else + add_error(changeset, :state, "must be open or claimed") + end + end + + defp validate_state(changeset, expected, message) do + if get_field(changeset, :state) == expected do + changeset + else + add_error(changeset, :state, message) + end + end + + defp validate_unclaimed(changeset) do + if is_nil(get_field(changeset, :modifier_id)) do + changeset + else + add_error(changeset, :modifier_id, "has already been claimed") + end + end + + defp validate_claimed(changeset) do + if is_nil(get_field(changeset, :modifier_id)) do + add_error(changeset, :modifier_id, "was not claimed") + else + changeset + end + end + defp validate_source_is_not_target(changeset) do source_id = get_field(changeset, :image_id) target_id = get_field(changeset, :duplicate_of_image_id) diff --git a/lib/philomena/duplicate_reports/query_builder.ex b/lib/philomena/duplicate_reports/query_builder.ex new file mode 100644 index 000000000..967524411 --- /dev/null +++ b/lib/philomena/duplicate_reports/query_builder.ex @@ -0,0 +1,36 @@ +defmodule Philomena.DuplicateReports.QueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.DuplicateReports.QueryForm + + @doc """ + Builds the staff duplicate-report query for the given parameters. + + ## Parameters + + * `states` - Filter by duplicate-report states; the default includes open + and claimed reports + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: + {:ok, Ecto.Query.t(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + DuplicateReport + |> where([report], report.state in ^query_form.states) + |> order_by([report], desc: report.created_at, desc: report.id) + + {:ok, query, query_form} + end + end +end diff --git a/lib/philomena/duplicate_reports/query_form.ex b/lib/philomena/duplicate_reports/query_form.ex new file mode 100644 index 000000000..d1f13d6b3 --- /dev/null +++ b/lib/philomena/duplicate_reports/query_form.ex @@ -0,0 +1,21 @@ +defmodule Philomena.DuplicateReports.QueryForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + alias Philomena.DuplicateReports.DuplicateReport + + embedded_schema do + field :states, {:array, :string}, default: DuplicateReport.open_states() + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + query_form + |> cast(attrs, [:states]) + |> validate_subset(:states, DuplicateReport.valid_states()) + end +end diff --git a/lib/philomena/duplicate_reports/search_result.ex b/lib/philomena/duplicate_reports/search_result.ex new file mode 100644 index 000000000..f5ebeb937 --- /dev/null +++ b/lib/philomena/duplicate_reports/search_result.ex @@ -0,0 +1,15 @@ +defmodule Philomena.DuplicateReports.SearchResult do + @moduledoc """ + The normalized reverse-image-search result rendered by HTML and JSON callers. + """ + + alias Philomena.Images.Image + + @enforce_keys [:images, :changeset] + defstruct [:images, :changeset] + + @type t :: %__MODULE__{ + images: Scrivener.Page.t(Image.t()) | nil, + changeset: Ecto.Changeset.t() + } +end diff --git a/lib/philomena/duplicate_reports/transaction_workflow.ex b/lib/philomena/duplicate_reports/transaction_workflow.ex new file mode 100644 index 000000000..6137ac05f --- /dev/null +++ b/lib/philomena/duplicate_reports/transaction_workflow.ex @@ -0,0 +1,214 @@ +defmodule Philomena.DuplicateReports.TransactionWorkflow do + @moduledoc """ + Composable locking steps for duplicate-report transactions. + + A duplicate report is identified by an ordered source/target pair, but the + operations that act on it are mutually exclusive for the unordered image + pair. Accepting a report can merge its images and reject other reports in + either direction; creating, claiming, unclaiming, or rejecting a report must + not race with that work. Historical duplicate report rows are therefore not + the serialization boundary: locking every row for a pair would be + unbounded, and a newly inserted row could otherwise appear after a report + query has completed. + + The image rows are the pair mutex. Every mutating duplicate report workflow + must lock the distinct image IDs in ascending order before reading or + changing report state. This serializes all compliant operations for the + unordered pair and prevents opposite-direction operations from deadlocking. + After the image locks are acquired, workflows reload the report state they + need. The subject report may also be locked as a convenient existence check, + but that lock is supplementary to the image pair lock. All locks and the + mutation must belong to the same `Philomena.Multi` transaction. + + `put_lock_image_pair/5` additionally authorizes both locked images, + `put_lock_image_pair_without_authorization/3` is for trusted internal work + such as automated report generation, and + `put_lock_image_pair_and_report/5` also reloads and locks one subject report + before authorizing it. Callers must not perform report mutations through a + path that skips the image-pair lock. + """ + + import Philomena.Authorization, only: [authorize: 3] + import Ecto.Query + + alias Philomena.Attribution.Actor + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.Images.Image + alias Philomena.IntegerId + alias Philomena.Multi + + @doc """ + Adds ordered row locks for both images and authorizes `actor` for `action` + on each image. + + Use this before creating a duplicate report or before composing another + report mutation that needs the unordered image pair as its serialization + boundary. The transaction receives `:locked_source_image`, + `:locked_target_image`, and `:authorize` steps. Missing images fail as + `:not_found`; failed authorization fails as `:unauthorized`. + + ## Examples + + iex> Multi.new() |> put_lock_image_pair(actor, 10, 20, :show) + %Multi{} + + """ + @spec put_lock_image_pair( + multi :: Multi.t(), + actor :: Actor.t(), + source_id :: IntegerId.integer_id(), + target_id :: IntegerId.integer_id(), + action :: atom() + ) :: Multi.t() + def put_lock_image_pair( + %Multi{} = multi, + %Actor{} = actor, + source_id, + target_id, + action + ) do + multi + |> lock_image_pair(source_id, target_id) + |> Multi.run(:authorize, fn _repo, + %{ + locked_source_image: source_image, + locked_target_image: target_image + } -> + with :ok <- authorize(actor, action, source_image), + :ok <- authorize(actor, action, target_image) do + {:ok, nil} + end + end) + end + + @doc """ + Adds ordered image pair locks without performing authorization. + + This is restricted to trusted internal workflows that already establish + their own safety conditions, such as automated duplicate-report generation. + It must still be composed in the same transaction as the report insert or + other mutation. The transaction receives `:locked_source_image` and + `:locked_target_image` steps. + + ## Examples + + iex> Multi.new() |> put_lock_image_pair_without_authorization(10, 20) + %Multi{} + + """ + @spec put_lock_image_pair_without_authorization( + multi :: Multi.t(), + source_id :: IntegerId.integer_id(), + target_id :: IntegerId.integer_id() + ) :: Multi.t() + def put_lock_image_pair_without_authorization(%Multi{} = multi, source_id, target_id) do + lock_image_pair(multi, source_id, target_id) + end + + @doc """ + Adds ordered image pair locks and a row lock for one duplicate report. + + The supplied report is only a locator for the image pair and report ID. The + report is reloaded after the image locks are acquired, with its original + direction verified. `image_action` is checked against both locked images and + `action` is checked against the locked report. The transaction receives + `:locked_source_image`, `:locked_target_image`, + `:locked_duplicate_report`, and `:authorize` steps. + + Use this for claim, unclaim, reject, accept, and reverse-accept workflows. + Accept workflows can then query all reports for the locked image pair + without locking the unbounded historical set; the image locks serialize + those queries with every compliant report mutation. + + ## Examples + + iex> Multi.new() |> put_lock_image_pair_and_report(report, actor, :show, :accept) + %Multi{} + + """ + @spec put_lock_image_pair_and_report( + multi :: Multi.t(), + duplicate_report :: DuplicateReport.t(), + actor :: Actor.t(), + image_action :: atom(), + action :: atom() + ) :: Multi.t() + def put_lock_image_pair_and_report( + %Multi{} = multi, + %DuplicateReport{image_id: source_id, duplicate_of_image_id: target_id} = duplicate_report, + %Actor{} = actor, + image_action, + action + ) do + # The argument duplicate report is used only as a locator for the image pair; + # it must be reloaded after locks are acquired. + duplicate_report_query = + from report in DuplicateReport, + where: + report.id == ^duplicate_report.id and + report.image_id == ^source_id and + report.duplicate_of_image_id == ^target_id + + multi + |> lock_image_pair(source_id, target_id) + |> Multi.lock_one(:locked_duplicate_report, duplicate_report_query) + |> Multi.run(:authorize, fn _repo, + %{ + locked_source_image: source_image, + locked_target_image: target_image, + locked_duplicate_report: duplicate_report + } -> + with :ok <- authorize(actor, image_action, source_image), + :ok <- authorize(actor, image_action, target_image), + :ok <- authorize(actor, action, duplicate_report) do + {:ok, nil} + end + end) + end + + @doc """ + Converts a transaction error from a locking workflow to its public error. + + Authorization and missing-row errors are reduced to `{:error, + :unauthorized}` and `{:error, :not_found}`, respectively. Other transaction + results are intentionally not handled by this helper. Use it only after + `Multi.transact/1` on a workflow that installed one of this module's locking + helpers; it does not translate changeset failures. + + ## Examples + + iex> map_lock_errors({:error, :authorize, :unauthorized, %{}}) + {:error, :unauthorized} + + """ + @spec map_lock_errors(Multi.failure()) :: {:error, :not_found | :unauthorized} + def map_lock_errors(result) do + case result do + {:error, _step, :unauthorized, _changes} -> + {:error, :unauthorized} + + {:error, _step, :not_found, _changes} -> + {:error, :not_found} + end + end + + defp lock_image_pair(%Multi{} = multi, source_id, target_id) do + # Image pair locking occurs in a consistent order to avoid deadlock. + [source_id, target_id] + |> Enum.uniq() + |> Enum.sort() + |> Enum.reduce(multi, fn image_id, multi -> + image_query = + from image in Image, + where: image.id == ^image_id + + Multi.lock_one(multi, {:locked_image, image_id}, image_query) + end) + |> Multi.run(:locked_source_image, fn _repo, %{{:locked_image, ^source_id} => source_image} -> + {:ok, source_image} + end) + |> Multi.run(:locked_target_image, fn _repo, %{{:locked_image, ^target_id} => target_image} -> + {:ok, target_image} + end) + end +end diff --git a/lib/philomena/duplicate_reports/uploader.ex b/lib/philomena/duplicate_reports/uploader.ex index 41fc49987..8d6bc6d4b 100644 --- a/lib/philomena/duplicate_reports/uploader.ex +++ b/lib/philomena/duplicate_reports/uploader.ex @@ -1,16 +1,15 @@ defmodule Philomena.DuplicateReports.Uploader do - @moduledoc """ - Upload and processing callback logic for SearchQuery images. - """ + @moduledoc false alias Philomena.DuplicateReports.SearchQuery alias PhilomenaMedia.Uploader - def analyze_upload(search_query, params) do + @doc false + def analyze_upload(search_query, upload) do Uploader.analyze_upload( search_query, "image", - params["image"], + upload, &SearchQuery.image_changeset/2 ) end diff --git a/lib/philomena/filters.ex b/lib/philomena/filters.ex index aa7f67f5b..d90c9d42e 100644 --- a/lib/philomena/filters.ex +++ b/lib/philomena/filters.ex @@ -1,38 +1,86 @@ defmodule Philomena.Filters do @moduledoc """ - The Filters context. + Image filters, viewer filter selection, and personal tag hide/spoiler settings. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Multi alias Philomena.Repo + alias Philomena.Loader alias Philomena.Filters.Filter + alias Philomena.Filters.ImageFilter + alias Philomena.Filters.FilterPage + alias Philomena.Filters.FilterSelection + alias Philomena.Filters.Query + alias Philomena.Filters.Visibility alias Philomena.Filters + alias Philomena.Attribution.Actor + alias Philomena.Schema.TagList + alias Philomena.Tags + alias Philomena.Tags.Tag + alias Philomena.Users + alias Philomena.Users.User alias PhilomenaQuery.Search alias Philomena.IndexWorker - @doc """ - Returns the list of filters. + defp ensure_current_filter(%User{current_filter: current_filter} = user) do + if current_filter do + current_filter + else + filter = default_filter() + {:ok, _user} = Users.set_current_filter(user, filter) + filter + end + end - ## Examples + defp filter_for_switch(_actor, nil), do: {:ok, default_filter()} + defp filter_for_switch(actor, id), do: load_and_authorize_filter(actor, id, :show) - iex> list_filters() - [%Filter{}, ...] + defp load_and_authorize_filter(actor, id, action, preloads \\ []) do + Loader.fetch_and_authorize(Filter, actor, action, id, preloads) + end - """ - def list_filters do - Repo.all(Filter) + defp authorize_filter_tag(actor, action, current_filter, tag_slug) do + with :ok <- authorize(actor, action, current_filter) do + Tag + |> where(slug: ^tag_slug) + |> Loader.one_and_authorize(actor, :show) + end + end + + defp tags_by_ids(ids) do + Tag + |> where([t], t.id in ^ids) + |> order_by(asc: :name) + |> Repo.all() + end + + defp put_reindex_filter(multi, step) do + Multi.on_commit(multi, fn %{^step => filter} -> reindex_filter(filter) end) + end + + defp reindex_filter_ids([]), do: [] + + defp reindex_filter_ids(filter_ids) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Filters", "id", filter_ids]) + filter_ids end @doc """ Returns the default filter. + This canonical system row is required in every deployment. + ## Examples iex> default_filter() %Filter{} """ + @spec default_filter() :: Filter.t() def default_filter do Filter |> where(system: true, name: "Default") @@ -40,263 +88,854 @@ defmodule Philomena.Filters do end @doc """ - Gets a single filter. + Loads the effective current and forced filters for `actor`. - Raises `Ecto.NoResultsError` if the Filter does not exist. + Signed-in actors use their account associations. When no current filter has + been selected, the canonical default is persisted first. Anonymous actors may + select a visible filter through `filter_id`. Malformed, missing, or forbidden + `filter_id`s fall back to the `default_filter/0`. Anonymous actors cannot have + a forced filter. ## Examples - iex> get_filter!(123) - %Filter{} + iex> load_selected_filters(actor, "42") + {:ok, %{current_filter: %Filter{}, forced_filter: nil}} + + """ + @spec load_selected_filters(Actor.t(), Loader.integer_id() | nil) :: + {:ok, %{current_filter: Filter.t(), forced_filter: Filter.t() | nil}} + def load_selected_filters(%Actor{user: nil} = actor, filter_id) do + case load_and_authorize_filter(actor, filter_id, :show) do + {:ok, filter} -> + {:ok, %{current_filter: filter, forced_filter: nil}} + + _ -> + {:ok, %{current_filter: default_filter(), forced_filter: nil}} + end + end + + def load_selected_filters(%Actor{user: %User{} = user}, _filter_id) do + user = Repo.preload(user, [:current_filter, :forced_filter]) + current_filter = ensure_current_filter(user) - iex> get_filter!(456) - ** (Ecto.NoResultsError) + {:ok, %{current_filter: current_filter, forced_filter: user.forced_filter}} + end + + @doc """ + Compiles the effective image-filter policy for `actor`. + The current filter supplies hidden and spoiler rules; a forced filter adds + hidden rules. Invalid stored expressions fail closed to an all-hidden filter. """ - def get_filter!(id), do: Repo.get!(Filter, id) + @spec compile_image_filter(Actor.t(), Filter.t() | nil, Filter.t() | nil) :: ImageFilter.t() + def compile_image_filter(%Actor{} = actor, current_filter, forced_filter) do + ImageFilter.compile(actor, current_filter, forced_filter) + end @doc """ - Creates a filter. + Returns the filters listed for `actor`: the viewer's own paginated filters + (`nil` for an anonymous visitor) and all system filters, each with `:user` + preloaded. Authorizes the filter `:index` action before either query. ## Examples - iex> create_filter(%{field: value}) - {:ok, %Filter{}} + iex> list_filters(actor, pagination) + {:ok, {%Scrivener.Page{}, [%Filter{}, ...]}} - iex> create_filter(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + @spec list_filters(Actor.t(), Repo.pagination_params()) :: + {:ok, {Scrivener.Page.t(Filter.t()) | nil, [Filter.t()]}} | {:error, :unauthorized} + def list_filters(%Actor{user: user} = actor, pagination) do + with :ok <- authorize(actor, :index, Filter) do + my_filters = + if user do + Filter + |> where(user_id: ^user.id) + |> order_by(asc: :id) + |> preload(:user) + |> Repo.paginate(pagination) + else + nil + end + + system_filters = + Filter + |> where(system: true) + |> order_by(asc: :id) + |> preload(:user) + |> Repo.all() + + {:ok, {my_filters, system_filters}} + end + end + + @doc """ + Returns the page of `actor`'s own filters after authorizing `:index_own`. + + Anonymous actors are unauthorized. Results are ordered by descending `:updated_at` and + paginated by `pagination`. + + ## Examples + + iex> user_filters(actor, pagination) + {:ok, %Scrivener.Page{}} + + """ + @spec user_filters(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Filter.t())} | {:error, :unauthorized} + def user_filters(%Actor{user: user} = actor, pagination) do + with :ok <- authorize(actor, :index_own, Filter) do + {:ok, + Filter + |> where(user_id: ^user.id) + |> order_by(asc: :id) + |> preload(:user) + |> Repo.paginate(pagination)} + end + end + + @doc """ + Returns the page of system filters for `actor`. + + Authorizes `:index_system` before selecting filters flagged `system: true`, + ordered by ascending id and paginated by `pagination`. + + ## Examples + + iex> system_filters(actor, pagination) + {:ok, %Scrivener.Page{}} """ - def create_filter(user, attrs \\ %{}) do - %Filter{user_id: user.id} - |> Filter.creation_changeset(attrs) - |> Repo.insert() - |> reindex_after_update() + @spec system_filters(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Filter.t())} | {:error, :unauthorized} + def system_filters(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :index_system, Filter) do + {:ok, + Filter + |> where(system: true) + |> order_by(asc: :id) + |> Repo.paginate(pagination)} + end end @doc """ - Updates a filter. + Loads the filter named by `id` on behalf of `actor`, with `user` preloaded. + + Public and system filters are visible to everyone. Private filters are visible + to their owner and staff with the corresponding `:show` grant. ## Examples - iex> update_filter(filter, %{field: new_value}) + iex> show_filter(actor, "1") {:ok, %Filter{}} - iex> update_filter(filter, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_filter(actor, "not-a-number") + {:error, :not_found} + + iex> show_filter(actor, unowned_private_filter_id) + {:error, :unauthorized} """ - def update_filter(%Filter{} = filter, attrs) do - filter - |> Filter.update_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec show_filter(Actor.t(), Loader.integer_id()) :: + {:ok, Filter.t()} | {:error, :not_found | :unauthorized} + def show_filter(%Actor{} = actor, id) do + load_and_authorize_filter(actor, id, :show, [:user]) end @doc """ - Makes a filter public. + Runs the filter search that `query_string` describes on behalf of `actor`. - Updates the filter to be publicly accessible by other users. + Compiles `query_string` against the filter search index and restricts results + to filters the viewer may see. Anonymous visitors see public and system + filters, members may also see their own private filters, and moderators/admins + see all filters. Results are sorted by name then descending id, paginated by + `pagination`, and loaded with `user` preloaded. ## Examples - iex> make_filter_public(filter) - {:ok, %Filter{}} + iex> query_filters(actor, "name:test", pagination) + {:ok, %Scrivener.Page{}} + + iex> query_filters(actor, "name:(", pagination) + {:error, "There was an error parsing your query."} """ - def make_filter_public(%Filter{} = filter) do - filter - |> Filter.public_changeset() - |> Repo.update() - |> reindex_after_update() + @spec query_filters(Actor.t(), String.t(), Search.pagination_params()) :: + {:ok, Scrivener.Page.t(Filter.t())} | {:error, String.t()} + def query_filters(%Actor{user: user} = actor, query_string, pagination) do + with :ok <- authorize(actor, :search, Filter), + {:ok, query} <- Query.compile(query_string, user: user) do + filters = + Filter + |> Search.search_definition( + %{ + query: %{ + bool: %{ + must: query, + filter: Visibility.search_filters(actor) + } + }, + sort: [ + %{name: :asc}, + %{id: :desc} + ] + }, + pagination + ) + |> Search.search_records(preload(Filter, [:user])) + + {:ok, filters} + end end @doc """ - Deletes a Filter. + Assembles the filter page named by `id` for `actor`. + + Loads the filter with `user` preloaded, authorizes `:show`, and loads the + filter's spoilered and hidden tags, each ordered by name. ## Examples - iex> delete_filter(filter) - {:ok, %Filter{}} + iex> show_filter_page(actor, "1") + {:ok, %FilterPage{}} - iex> delete_filter(filter) - {:error, %Ecto.Changeset{}} + iex> show_filter_page(actor, "999999999") + {:error, :not_found} + + iex> show_filter_page(actor, unowned_private_filter_id) + {:error, :unauthorized} """ - def delete_filter(%Filter{} = filter) do - filter - |> Filter.deletion_changeset() - |> Repo.delete() - |> case do - {:ok, filter} -> - unindex_filter(filter) + @spec show_filter_page(Actor.t(), Loader.integer_id()) :: + {:ok, FilterPage.t()} | {:error, :not_found | :unauthorized} + def show_filter_page(%Actor{} = actor, id) do + with {:ok, filter} <- show_filter(actor, id) do + {:ok, + %FilterPage{ + filter: filter, + spoilered_tags: tags_by_ids(filter.spoilered_tag_ids), + hidden_tags: tags_by_ids(filter.hidden_tag_ids) + }} + end + end + + @doc """ + Builds the changeset for a new filter on behalf of `actor`. - {:ok, filter} + Verifies write access, then authorizes `:new` (permitted for any signed-in + user). When `based_on_id` names a filter the actor may view, the new filter is + prefilled from it. - error -> - error + ## Examples + + iex> new_filter(actor, nil) + {:ok, %Ecto.Changeset{}} + + iex> new_filter(actor, "1") + {:ok, %Ecto.Changeset{}} + + iex> new_filter(actor, "999999999") + {:ok, %Ecto.Changeset{data: %Filter{}}} + + """ + @spec new_filter(Actor.t(), Loader.integer_id() | nil) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_filter(%Actor{} = actor, based_on_id) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Filter) do + base_filter = + case load_and_authorize_filter(actor, based_on_id, :show) do + {:ok, filter} -> + filter + + {:error, _reason} -> + nil + end + + {:ok, + base_filter + |> Filter.based_on() + |> Filter.changeset(Repo.preload(actor.user, :settings))} end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking filter changes. + Loads the filter named by `id` for editing on behalf of `actor`. + + Verifies write access, authorizes `:edit`, and assigns the spoilered and + hidden tag lists onto the filter. ## Examples - iex> change_filter(filter) - %Ecto.Changeset{source: %Filter{}} + iex> edit_filter(actor, "1") + {:ok, {%Filter{}, %Ecto.Changeset{}}} + + iex> edit_filter(actor, "999999999") + {:error, :not_found} + + iex> edit_filter(actor, unowned_filter_id) + {:error, :unauthorized} """ - def change_filter(%Filter{} = filter) do - Filter.changeset(filter, %{}) + @spec edit_filter(Actor.t(), Loader.integer_id()) :: + {:ok, {Filter.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_filter(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, filter} <- load_and_authorize_filter(actor, id, :edit, user: :settings) do + filter = + filter + |> TagList.assign_tag_list(:spoilered_tag_ids, :spoilered_tag_list) + |> TagList.assign_tag_list(:hidden_tag_ids, :hidden_tag_list) + + {:ok, {filter, Filter.changeset(filter, filter.user)}} + end end @doc """ - Returns a grouped list of recent and user filters. + Switches `actor`'s current filter to the one named by `id`. - Takes a user and returns a list of their recently used filters and personal filters, - grouped into "Recent Filters" and "Your Filters" categories. + This personal preference update is deliberately exempt from + `verify_write_access/1`; banned users are permitted to switch filters. + + Authorizes `:switch` before loading. `nil` explicitly selects the canonical default + filter. Malformed and missing non-nil IDs are not-found. + + For a signed-in actor, the selection is persisted to their account and added + to recent filters. Anonymous actors must persist the filter through the returned + Filter struct. Any applicable forced filter is independent and remains unchanged. ## Examples - iex> recent_and_user_filters(user) - [ - {"Recent Filters", [[key: "Filter 1", value: 1], ...]}, - {"Your Filters", [[key: "Filter 2", value: 2], ...]} - ] + iex> update_current_filter(actor, "1") + {:ok, %Filter{}} + + iex> update_current_filter(actor, "999999999") + {:error, :not_found} + + iex> update_current_filter(actor, nil) + {:ok, %Filter{name: "Default"}} """ - def recent_and_user_filters(user) do - recent_filter_ids = - [user.current_filter_id | user.recent_filter_ids] - |> Enum.take(10) + @spec update_current_filter(Actor.t(), Loader.integer_id() | nil) :: + {:ok, Filter.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def update_current_filter(%Actor{user: user} = actor, id) do + with :ok <- authorize(actor, :switch, Filter), + {:ok, filter} <- filter_for_switch(actor, id) do + if user do + Users.set_current_filter(user, filter) + end - user_filters = - Filter - |> select([f], %{id: f.id, name: f.name, recent: ^"f"}) - |> where(user_id: ^user.id) - |> limit(10) + {:ok, filter} + end + end - recent_filters = - Filter - |> select([f], %{id: f.id, name: f.name, recent: ^"t"}) - |> where([f], f.id in ^recent_filter_ids) + @doc """ + Creates a filter owned by `actor`. - union_all(recent_filters, ^user_filters) - |> Repo.all() - |> Enum.sort_by(fn f -> - Enum.find_index(user.recent_filter_ids, fn id -> f.id == id end) - end) - |> Enum.group_by( - fn - %{recent: "t"} -> "Recent Filters" - _user -> "Your Filters" - end, - fn %{id: id, name: name} -> - [key: name, value: id] + Verifies write access, authorizes `:create`, then inserts the filter and queues + it for reindexing. + + ## Examples + + iex> create_filter(actor, %{field: value}) + {:ok, %Filter{}} + + iex> create_filter(actor, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + iex> create_filter(anonymous_actor, %{field: value}) + {:error, :unauthorized} + + """ + @spec create_filter(Actor.t(), map()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized} + def create_filter(%Actor{user: user} = actor, attrs \\ %{}) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Filter) do + user = Repo.preload(user, :settings) + + filter_changeset = + %Filter{user_id: user.id} + |> Filter.creation_changeset(user, attrs) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([ + {:spoilered_filter_tags, Filter.tag_names(filter_changeset, :spoilered_tag_list), []}, + {:hidden_filter_tags, Filter.tag_names(filter_changeset, :hidden_tag_list), []} + ]) + |> Multi.insert(:filter, fn + %{ + canonical_tags: %{ + spoilered_filter_tags: spoilered_tags, + hidden_filter_tags: hidden_tags + } + } -> + Filter.put_tag_ids( + filter_changeset, + Enum.map(spoilered_tags, & &1.id), + Enum.map(hidden_tags, & &1.id) + ) + end) + |> put_reindex_filter(:filter) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} end - ) - |> Enum.to_list() - |> Enum.reverse() + end end @doc """ - Adds a tag to a filter's hidden tags list. + Updates the filter named by `id`, on behalf of `actor`. - Updates the filter to hide content with the specified tag. + Verifies write access, loads the filter, authorizes `:update`, then applies + `attrs`. ## Examples - iex> hide_tag(filter, tag) + iex> update_filter(actor, "1", %{"name" => "Renamed"}) {:ok, %Filter{}} + iex> update_filter(actor, "1", invalid_params) + {:error, %Ecto.Changeset{}} + + iex> update_filter(actor, "999999999", filter_params) + {:error, :not_found} + + iex> update_filter(actor, unowned_filter_id, filter_params) + {:error, :unauthorized} + """ - def hide_tag(filter, tag) do - hidden_tag_ids = Enum.uniq([tag.id | filter.hidden_tag_ids]) + @spec update_filter(Actor.t(), Loader.integer_id(), map()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def update_filter(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, filter} <- load_and_authorize_filter(actor, id, :update, user: :settings) do + filter_changeset = Filter.update_changeset(filter, filter.user, attrs) + + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([ + {:spoilered_filter_tags, Filter.tag_names(filter_changeset, :spoilered_tag_list), []}, + {:hidden_filter_tags, Filter.tag_names(filter_changeset, :hidden_tag_list), []} + ]) + |> Multi.update(:filter, fn + %{ + canonical_tags: %{ + spoilered_filter_tags: spoilered_tags, + hidden_filter_tags: hidden_tags + } + } -> + Filter.put_tag_ids( + filter_changeset, + Enum.map(spoilered_tags, & &1.id), + Enum.map(hidden_tags, & &1.id) + ) + end) + |> put_reindex_filter(:filter) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end - filter - |> Filter.hidden_tags_changeset(hidden_tag_ids) - |> Repo.update() - |> reindex_after_update() + @doc """ + Makes the filter named by `id` public, on behalf of `actor`. + + Verifies write access, loads the filter, authorizes the distinct `:publish` + action, then makes it public. Publishing an already-public filter is + idempotent. + + ## Examples + + iex> create_filter_public(actor, "1") + {:ok, %Filter{}} + + iex> create_filter_public(actor, unowned_filter_id) + {:error, :unauthorized} + + iex> create_filter_public(actor, "999999999") + {:error, :not_found} + + """ + @spec create_filter_public(Actor.t(), Loader.integer_id()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def create_filter_public(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, filter} <- load_and_authorize_filter(actor, id, :publish) do + filter_changeset = Filter.public_changeset(filter) + + Multi.new() + |> Multi.update(:filter, filter_changeset) + |> put_reindex_filter(:filter) + |> Multi.transact() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Removes a tag from a filter's hidden tags list. + Deletes the filter named by `id`, on behalf of `actor`. + + Verifies write access, loads the filter, authorizes `:delete`, then deletes it. + A filter referenced as a user's current or forced filter returns a rejected + changeset and cannot be deleted. ## Examples - iex> unhide_tag(filter, tag) + iex> delete_filter(actor, "1") {:ok, %Filter{}} + iex> delete_filter(actor, "999999999") + {:error, :not_found} + + iex> delete_filter(actor, unowned_filter_id) + {:error, :unauthorized} + """ - def unhide_tag(filter, tag) do - hidden_tag_ids = filter.hidden_tag_ids -- [tag.id] + @spec delete_filter(Actor.t(), Loader.integer_id()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def delete_filter(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, filter} <- load_and_authorize_filter(actor, id, :delete) do + filter_changeset = Filter.deletion_changeset(filter) + + Multi.new() + |> Multi.delete(:filter, filter_changeset) + |> Multi.on_commit(fn %{filter: filter} -> Search.delete_document(filter.id, Filter) end) + |> Multi.transact() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end - filter - |> Filter.hidden_tags_changeset(hidden_tag_ids) - |> Repo.update() - |> reindex_after_update() + @doc """ + Returns `actor`'s grouped recent and personal filter choices. + + Authorizes `:index_own` before querying. Anonymous actors are unauthorized. + + ## Examples + + iex> recent_and_user_filters(actor) + {:ok, + %FilterSelection{ + recent_filters: [%Filter{}, ...], + user_filters: [%Filter{}, ...] + }} + + """ + @spec recent_and_user_filters(Actor.t()) :: {:ok, FilterSelection.t()} | {:error, :unauthorized} + def recent_and_user_filters(%Actor{user: user} = actor) do + with :ok <- authorize(actor, :index_own, Filter) do + recent_filter_ids = + Enum.reject([user.current_filter_id | user.recent_filter_ids], &is_nil/1) + + positions = + recent_filter_ids + |> Enum.with_index() + |> Map.new() + + user_filter_query = + Filter + |> select([f], %{struct(f, [:id, :name]) | recent: false}) + |> where(user_id: ^user.id) + |> order_by(asc: :id) + |> limit(10) + + recent_filter_query = + Filter + |> select([f], %{struct(f, [:id, :name]) | recent: true}) + |> where([f], f.id in ^recent_filter_ids) + |> limit(10) + + {recent_filters, user_filters} = + recent_filter_query + |> union_all(^user_filter_query) + |> Repo.all() + |> Enum.split_with(& &1.recent) + + {:ok, + %FilterSelection{ + user_filters: user_filters, + recent_filters: Enum.sort_by(recent_filters, &Map.fetch!(positions, &1.id)) + }} + end end @doc """ - Adds a tag to a filter's spoilered tags list. + Adds the tag named by `tag_slug` to `filter`'s hidden tags on behalf + of `actor`. + + Rejects a banned actor or one without a fingerprint, authorizes `:hide_tag` + on the loaded filter, safely loads the tag, and authorizes the tag for + `:show`. System filters and filters owned by someone else cannot be changed. ## Examples - iex> spoiler_tag(filter, tag) + iex> create_filter_hide(actor, filter, tag_slug) {:ok, %Filter{}} - """ - def spoiler_tag(filter, tag) do - spoilered_tag_ids = Enum.uniq([tag.id | filter.spoilered_tag_ids]) + iex> create_filter_hide(banned_actor, filter, tag_slug) + {:error, :ban} - filter - |> Filter.spoilered_tags_changeset(spoilered_tag_ids) - |> Repo.update() - |> reindex_after_update() + iex> create_filter_hide(actor, filter, unknown_tag_slug) + {:error, :not_found} + + iex> create_filter_hide(actor, unowned_filter, tag_slug) + {:error, :unauthorized} + + """ + @spec create_filter_hide(Actor.t(), Filter.t(), String.t()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def create_filter_hide(%Actor{} = actor, %Filter{} = filter, tag_slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- authorize_filter_tag(actor, :hide_tag, filter, tag_slug) do + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, [tag.name], []}]) + |> Multi.update(:filter, fn %{canonical_tags: %{tag: [tag]}} -> + tag_ids = Enum.uniq([tag.id | filter.hidden_tag_ids]) + + Filter.hidden_tags_changeset(filter, tag_ids) + end) + |> put_reindex_filter(:filter) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Removes a tag from a filter's spoilered tags list. + Removes the tag named by `tag_slug` from `filter`'s hidden tags on behalf + of `actor`. + + Rejects a banned actor or one without a fingerprint, authorizes `:unhide_tag` + on the loaded filter, safely loads the tag, and authorizes the tag for + `:show`. System filters and filters owned by someone else cannot be changed. ## Examples - iex> unspoiler_tag(filter, tag) + iex> delete_filter_hide(actor, filter, tag_slug) {:ok, %Filter{}} + iex> delete_filter_hide(banned_actor, filter, tag_slug) + {:error, :ban} + + iex> delete_filter_hide(actor, filter, unknown_tag_slug) + {:error, :not_found} + + iex> delete_filter_hide(actor, unowned_filter, tag_slug) + {:error, :unauthorized} + """ - def unspoiler_tag(filter, tag) do - spoilered_tag_ids = filter.spoilered_tag_ids -- [tag.id] + @spec delete_filter_hide(Actor.t(), Filter.t(), String.t()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def delete_filter_hide(%Actor{} = actor, %Filter{} = filter, tag_slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- authorize_filter_tag(actor, :unhide_tag, filter, tag_slug) do + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, [tag.name], []}]) + |> Multi.update(:filter, fn %{canonical_tags: %{tag: [tag]}} -> + tag_ids = filter.hidden_tag_ids -- [tag.id] + + Filter.hidden_tags_changeset(filter, tag_ids) + end) + |> put_reindex_filter(:filter) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end - filter - |> Filter.spoilered_tags_changeset(spoilered_tag_ids) - |> Repo.update() - |> reindex_after_update() + @doc """ + Adds the tag named by `tag_slug` to `filter`'s spoilered tags on behalf + of `actor`. + + Rejects a banned actor or one without a fingerprint, authorizes `:spoiler_tag` + on the loaded filter, safely loads the tag, and authorizes the tag for + `:show`. System filters and filters owned by someone else cannot be changed. + + ## Examples + + iex> create_filter_spoiler(actor, filter, tag_slug) + {:ok, %Filter{}} + + iex> create_filter_spoiler(banned_actor, filter, tag_slug) + {:error, :ban} + + iex> create_filter_spoiler(actor, filter, unknown_tag_slug) + {:error, :not_found} + + iex> create_filter_spoiler(actor, unowned_filter, tag_slug) + {:error, :unauthorized} + + """ + @spec create_filter_spoiler(Actor.t(), Filter.t(), String.t()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def create_filter_spoiler(%Actor{} = actor, %Filter{} = filter, tag_slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- authorize_filter_tag(actor, :spoiler_tag, filter, tag_slug) do + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, [tag.name], []}]) + |> Multi.update(:filter, fn %{canonical_tags: %{tag: [tag]}} -> + tag_ids = Enum.uniq([tag.id | filter.spoilered_tag_ids]) + + Filter.spoilered_tags_changeset(filter, tag_ids) + end) + |> put_reindex_filter(:filter) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end - defp reindex_after_update(result) do - case result do - {:ok, filter} -> - reindex_filter(filter) + @doc """ + Removes the tag named by `tag_slug` from `filter`'s spoilered tags on behalf + of `actor`. + + Rejects a banned actor or one without a fingerprint, authorizes `:unspoiler_tag` + on the loaded filter, safely loads the tag, and authorizes the tag for + `:show`. System filters and filters owned by someone else cannot be changed. + + ## Examples + + iex> delete_filter_spoiler(actor, filter, tag_slug) + {:ok, %Filter{}} + + iex> delete_filter_spoiler(banned_actor, filter, tag_slug) + {:error, :ban} + + iex> delete_filter_spoiler(actor, filter, unknown_tag_slug) + {:error, :not_found} - {:ok, filter} + iex> delete_filter_spoiler(actor, unowned_filter, tag_slug) + {:error, :unauthorized} - error -> - error + """ + @spec delete_filter_spoiler(Actor.t(), Filter.t(), String.t()) :: + {:ok, Filter.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def delete_filter_spoiler(%Actor{} = actor, %Filter{} = filter, tag_slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- authorize_filter_tag(actor, :unspoiler_tag, filter, tag_slug) do + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, [tag.name], []}]) + |> Multi.update(:filter, fn %{canonical_tags: %{tag: [tag]}} -> + tag_ids = filter.spoilered_tag_ids -- [tag.id] + + Filter.spoilered_tags_changeset(filter, tag_ids) + end) + |> put_reindex_filter(:filter) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{filter: %Filter{} = filter}} -> + {:ok, filter} + + {:error, :filter, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Updates filter indexes when a user's name changes. + Replaces a tag ID in hidden and spoilered filter arrays within `multi`. + + Tag aliasing composes this function so failed updates roll back with + the alias transaction. + """ + @spec put_replace_tag_references(Multi.t(), Multi.name(), Multi.name(), integer(), integer()) :: + Multi.t() + def put_replace_tag_references(%Multi{} = multi, hidden_step, spoilered_step, old_id, new_id) do + hidden_filters = + Filter + |> where([f], fragment("? @> ARRAY[?]::integer[]", f.hidden_tag_ids, ^old_id)) + |> update([f], + set: [ + hidden_tag_ids: fragment("array_replace(?, ?, ?)", f.hidden_tag_ids, ^old_id, ^new_id) + ] + ) + + spoilered_filters = + Filter + |> where([f], fragment("? @> ARRAY[?]::integer[]", f.spoilered_tag_ids, ^old_id)) + |> update([f], + set: [ + spoilered_tag_ids: + fragment("array_replace(?, ?, ?)", f.spoilered_tag_ids, ^old_id, ^new_id) + ] + ) + + hidden_ids_step = {:reindex_filter_ids, hidden_step} + spoilered_ids_step = {:reindex_filter_ids, spoilered_step} + + multi + |> Multi.all(hidden_ids_step, select(exclude(hidden_filters, :update), [f], f.id)) + |> Multi.all(spoilered_ids_step, select(exclude(spoilered_filters, :update), [f], f.id)) + |> Multi.update_all(hidden_step, hidden_filters, []) + |> Multi.update_all(spoilered_step, spoilered_filters, []) + |> Multi.on_commit(fn %{^hidden_ids_step => hidden_ids, ^spoilered_ids_step => spoilered_ids} -> + reindex_filter_ids(Enum.uniq(hidden_ids ++ spoilered_ids)) + end) + end - Updates search indexes to reflect a user's new name. + @doc """ + Updates filter indexes when a user's name changes. ## Examples iex> user_name_reindex("old_name", "new_name") - :ok + [update_result, ...] """ + @spec user_name_reindex(String.t(), String.t()) :: [term()] def user_name_reindex(old_name, new_name) do data = Filters.SearchIndex.user_name_update_by_query(old_name, new_name) @@ -313,27 +952,13 @@ defmodule Philomena.Filters do %Filter{} """ + @spec reindex_filter(Filter.t()) :: Filter.t() def reindex_filter(%Filter{} = filter) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Filters", "id", [filter.id]]) filter end - @doc """ - Removes a filter from the search index. - - ## Examples - - iex> unindex_filter(filter) - %Filter{} - - """ - def unindex_filter(%Filter{} = filter) do - Search.delete_document(filter.id, Filter) - - filter - end - @doc """ Returns a list of associations to preload when indexing filters. @@ -343,6 +968,7 @@ defmodule Philomena.Filters do [:user] """ + @spec indexing_preloads() :: list() def indexing_preloads do [:user] end @@ -350,16 +976,15 @@ defmodule Philomena.Filters do @doc """ Performs a search reindex operation on filters matching the given criteria. - ## Parameters - - column: The database column to filter on (e.g., :id) - - condition: A list of values to match against the column + `column` is supplied by the trusted worker registry, not request input. ## Examples iex> perform_reindex(:id, [1, 2, 3]) - {:ok, [%Filter{}, ...]} + :ok """ + @spec perform_reindex(atom(), [term()]) :: :ok def perform_reindex(column, condition) do Filter |> preload(^indexing_preloads()) diff --git a/lib/philomena/filters/filter.ex b/lib/philomena/filters/filter.ex index db89c88c8..0e7327209 100644 --- a/lib/philomena/filters/filter.ex +++ b/lib/philomena/filters/filter.ex @@ -6,8 +6,11 @@ defmodule Philomena.Filters.Filter do alias Philomena.Schema.TagList alias Philomena.Images.Query alias Philomena.Users.User + alias Philomena.Tags.Tag alias Philomena.Repo + @type t :: %__MODULE__{} + schema "filters" do belongs_to :user, User @@ -23,17 +26,31 @@ defmodule Philomena.Filters.Filter do field :spoilered_tag_list, :string, virtual: true field :hidden_tag_list, :string, virtual: true + field :recent, :boolean, virtual: true timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(filter, attrs) do - user = - change(filter).data - |> Repo.preload(user: :settings) - |> Map.get(:user) + @spec based_on(t() | nil) :: t() + def based_on(filter) do + filter = filter || %__MODULE__{} + + %__MODULE__{ + name: filter.name, + description: filter.description, + public: filter.public, + hidden_complex_str: filter.hidden_complex_str, + spoilered_complex_str: filter.spoilered_complex_str, + hidden_tag_ids: filter.hidden_tag_ids, + spoilered_tag_ids: filter.spoilered_tag_ids + } + |> TagList.assign_tag_list(:spoilered_tag_ids, :spoilered_tag_list) + |> TagList.assign_tag_list(:hidden_tag_ids, :hidden_tag_list) + end + @doc false + def changeset(filter, user, attrs \\ %{}) do filter |> cast(attrs, [ :spoilered_tag_list, @@ -44,30 +61,45 @@ defmodule Philomena.Filters.Filter do :hidden_complex_str ]) |> validate_length(:description, max: 10_000, count: :bytes) - |> TagList.propagate_tag_list(:spoilered_tag_list, :spoilered_tag_ids) - |> TagList.propagate_tag_list(:hidden_tag_list, :hidden_tag_ids) |> validate_required([:name]) |> validate_my_downvotes(:spoilered_complex_str) |> validate_my_downvotes(:hidden_complex_str) - |> validate_query(:spoilered_complex_str, &Query.compile(&1, user: user, filter: true)) - |> validate_query(:hidden_complex_str, &Query.compile(&1, user: user, filter: true)) + |> validate_query(:spoilered_complex_str, with: &Query.compile(&1, user: user, filter: true)) + |> validate_query(:hidden_complex_str, with: &Query.compile(&1, user: user, filter: true)) |> unsafe_validate_unique([:user_id, :name], Repo) end - def creation_changeset(filter, attrs) do + @doc false + def tag_names(changeset, field) when field in [:spoilered_tag_list, :hidden_tag_list] do + changeset + |> get_field(field) + |> Tag.parse_tag_list() + end + + @doc false + def put_tag_ids(changeset, spoilered_tag_ids, hidden_tag_ids) do + changeset + |> put_change(:spoilered_tag_ids, spoilered_tag_ids) + |> put_change(:hidden_tag_ids, hidden_tag_ids) + end + + def creation_changeset(filter, user, attrs) do filter |> cast(attrs, [:public]) - |> changeset(attrs) + |> changeset(user, attrs) end - def update_changeset(filter, attrs) do - changeset(filter, strip_name_if_default(filter, attrs)) + def update_changeset(filter, user, attrs) do + filter + |> changeset(user, attrs) + |> validate_default_filter_name() end def deletion_changeset(filter) do filter |> change() |> foreign_key_constraint(:id, name: :fk_rails_d2b4c2768f) + |> foreign_key_constraint(:id, name: :users_forced_filter_id_fkey) end def public_changeset(filter) do @@ -86,15 +118,19 @@ defmodule Philomena.Filters.Filter do value = get_field(changeset, field) || "" if String.match?(value, ~r/my:downvotes/i) do - changeset - |> add_error(field, "cannot contain my:downvotes") + add_error(changeset, field, "cannot contain my:downvotes") else changeset end end - defp strip_name_if_default(%{system: true, name: "Default"}, attrs), - do: Map.delete(attrs, "name") + defp validate_default_filter_name(%{data: %{system: true, name: "Default"}} = changeset) do + if get_change(changeset, :name) do + add_error(changeset, :name, "cannot be changed for the system-wide default filter") + else + changeset + end + end - defp strip_name_if_default(_filter, attrs), do: attrs + defp validate_default_filter_name(changeset), do: changeset end diff --git a/lib/philomena/filters/filter_page.ex b/lib/philomena/filters/filter_page.ex new file mode 100644 index 000000000..4200840f5 --- /dev/null +++ b/lib/philomena/filters/filter_page.ex @@ -0,0 +1,20 @@ +defmodule Philomena.Filters.FilterPage do + @moduledoc """ + The assembled filter page: the filter with its `:user` preloaded, and its + spoilered and hidden tags, each ordered by name. + """ + + alias Philomena.Filters.Filter + alias Philomena.Tags.Tag + + @enforce_keys [:filter, :spoilered_tags, :hidden_tags] + defstruct filter: nil, + spoilered_tags: nil, + hidden_tags: nil + + @type t :: %__MODULE__{ + filter: Filter.t(), + spoilered_tags: [Tag.t()], + hidden_tags: [Tag.t()] + } +end diff --git a/lib/philomena/filters/filter_selection.ex b/lib/philomena/filters/filter_selection.ex new file mode 100644 index 000000000..0ee15f198 --- /dev/null +++ b/lib/philomena/filters/filter_selection.ex @@ -0,0 +1,16 @@ +defmodule Philomena.Filters.FilterSelection do + @moduledoc """ + The filter selection menu. Contains grouped, ordered recent and personal + filter choices for a given user. + """ + + alias Philomena.Filters.Filter + + @enforce_keys [:user_filters, :recent_filters] + defstruct [:user_filters, :recent_filters] + + @type t :: %__MODULE__{ + user_filters: [Filter.t()], + recent_filters: [Filter.t()] + } +end diff --git a/lib/philomena/filters/image_filter.ex b/lib/philomena/filters/image_filter.ex new file mode 100644 index 000000000..883af0b27 --- /dev/null +++ b/lib/philomena/filters/image_filter.ex @@ -0,0 +1,97 @@ +defmodule Philomena.Filters.ImageFilter do + @moduledoc """ + Generates the compiled image filter for a viewer. + + `query` is the OpenSearch clause used to exclude hidden images. The display + fields contain the current filter's hidden/spoiler rules. + """ + + alias Philomena.Attribution.Actor + alias Philomena.Filters.Filter + alias Philomena.Images.Query + alias PhilomenaQuery.Parse + + @enforce_keys [:query, :display_query, :display_tag_ids] + defstruct [:query, :display_query, :display_tag_ids, errors: []] + + @type error :: {:hidden_complex_str | :spoilered_complex_str, String.t()} + + @type t :: %__MODULE__{ + query: map(), + display_query: map(), + display_tag_ids: [integer()], + errors: [error()] + } + + @spec compile(Actor.t(), Filter.t() | nil, Filter.t() | nil) :: t() + def compile(%Actor{} = actor, current_filter, forced_filter) do + current = defaults(current_filter) + forced = defaults(forced_filter) + + with {:ok, current_hidden} <- + compile_expression( + actor, + current_filter, + :hidden_complex_str, + current.hidden_complex_str + ), + {:ok, forced_hidden} <- + compile_expression( + actor, + forced_filter, + :hidden_complex_str, + forced.hidden_complex_str + ), + {:ok, current_spoiler} <- + compile_expression( + actor, + current_filter, + :spoilered_complex_str, + current.spoilered_complex_str + ) do + hidden_query = %{bool: %{should: [current_hidden, forced_hidden]}} + + %__MODULE__{ + query: %{ + bool: %{ + should: [ + %{terms: %{tag_ids: current.hidden_tag_ids ++ forced.hidden_tag_ids}}, + hidden_query + ] + } + }, + display_query: %{bool: %{should: [hidden_query, current_spoiler]}}, + display_tag_ids: current.spoilered_tag_ids ++ current.hidden_tag_ids + } + else + {:error, _filter, field, message} -> + %__MODULE__{ + query: %{match_all: %{}}, + display_query: %{match_all: %{}}, + display_tag_ids: [], + errors: [{field, message}] + } + end + end + + defp compile_expression(actor, filter, field, expression) do + expression + |> Parse.String.normalize() + |> Query.compile(user: actor.user, filter: true) + |> case do + {:ok, query} -> {:ok, query} + {:error, reason} -> {:error, filter, field, reason} + end + end + + defp defaults(nil) do + %{ + hidden_tag_ids: [], + spoilered_tag_ids: [], + hidden_complex_str: nil, + spoilered_complex_str: nil + } + end + + defp defaults(%Filter{} = filter), do: filter +end diff --git a/lib/philomena/filters/visibility.ex b/lib/philomena/filters/visibility.ex new file mode 100644 index 000000000..79f3e6615 --- /dev/null +++ b/lib/philomena/filters/visibility.ex @@ -0,0 +1,53 @@ +defmodule Philomena.Filters.Visibility do + @moduledoc """ + OpenSearch query scopes for filter searches. + + These scopes intentionally mirror the `:show` rules in + `Philomena.Users.Ability`. + """ + + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Attribution.Actor + alias Philomena.Filters.Filter + + defp user_visibility_filters(nil), + do: [] + + defp user_visibility_filters(user), + do: [%{term: %{user_id: user.id}}] + + @doc """ + Generates an OpenSearch boolean `filter` clause to select filters visible to + `actor`. + + Moderators and administrators may see private filters. Users may see public + filters and non-anonymous users may also see their own filters. + + ## Examples + + iex> query_filters(admin_actor) + [] + + iex> query_filters(actor) + [%{term: %{public: true}}, ...] + + """ + def search_filters(%Actor{user: user} = actor) do + case authorize(actor, :search_all, Filter) do + :ok -> + [] + + {:error, :unauthorized} -> + %{ + bool: %{ + should: [ + %{term: %{public: true}}, + %{term: %{system: true}} + | user_visibility_filters(user) + ] + } + } + end + end +end diff --git a/lib/philomena/forums.ex b/lib/philomena/forums.ex index f54e663be..16346c77b 100644 --- a/lib/philomena/forums.ex +++ b/lib/philomena/forums.ex @@ -1,127 +1,419 @@ defmodule Philomena.Forums do @moduledoc """ - The Forums context. + Forum discovery, subscription state, and staff-managed forum settings. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Attribution.Actor + alias Philomena.Forums.{Forum, ForumIndex, ForumPage} + alias Philomena.Forums.Visibility + alias Philomena.Loader + alias Philomena.Multi alias Philomena.Repo + alias Philomena.Topics.Topic + + use Philomena.Subscriptions, id_name: :forum_id - alias Philomena.Forums.Forum + defp load_authorized_forum(actor, action, short_name) do + Forum + |> where([forum], forum.short_name == ^short_name) + |> Loader.one_and_authorize(actor, action) + end - use Philomena.Subscriptions, - id_name: :forum_id + defp visible_forums_query(actor) do + Forum + |> Visibility.visible_forums(actor) + |> order_by(asc: :name) + end @doc """ - Returns the list of forums. + Lists the forums visible to `actor`, ordered by name. ## Examples - iex> list_forums() + iex> list_forums(actor) [%Forum{}, ...] """ - def list_forums do - Repo.all(Forum) + @spec list_forums(Actor.t()) :: [Forum.t()] + def list_forums(%Actor{} = actor) do + actor + |> visible_forums_query() + |> Repo.all() end @doc """ - Gets a single forum. + Lists the forums visible to `actor`, ordered by name, with pagination and + aggregate topic count. - Raises `Ecto.NoResultsError` if the Forum does not exist. + The topic count includes only topics whose parent forum and topic are visible + to the actor. ## Examples - iex> get_forum!(123) - %Forum{} + iex> list_forums(actor) + %ForumIndex{forums: [%Forum{}], topic_count: 42} + + """ + @spec list_forums(Actor.t(), Repo.pagination_params()) :: ForumIndex.t() + def list_forums(%Actor{} = actor, pagination) do + forums = visible_forums_query(actor) + topic_count = Repo.aggregate(forums, :sum, :topic_count) + + forums = + forums + |> preload(last_post: [:user, topic: :forum]) + |> Repo.paginate(pagination) + + %ForumIndex{forums: forums, topic_count: topic_count} + end + + @doc """ + Loads every forum for the staff administration index. - iex> get_forum!(456) - ** (Ecto.NoResultsError) + ## Examples + + iex> list_admin_forums(admin_actor) + {:ok, [%Forum{}]} """ - def get_forum!(id), do: Repo.get!(Forum, id) + @spec list_admin_forums(Actor.t()) :: {:ok, [Forum.t()]} | {:error, :unauthorized} + def list_admin_forums(%Actor{} = actor) do + with :ok <- authorize(actor, :manage, Forum) do + {:ok, list_forums(actor)} + end + end @doc """ - Creates a forum. + Loads a forum visible to `actor` by short name. + + Malformed or unknown names are not-found; a real restricted forum is + unauthorized. ## Examples - iex> create_forum(%{field: value}) + iex> show_forum(actor, "dis") {:ok, %Forum{}} - iex> create_forum(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_forum(actor, "missing") + {:error, :not_found} """ - def create_forum(attrs \\ %{}) do - %Forum{} - |> Forum.changeset(attrs) - |> Repo.insert() + @spec show_forum(Actor.t(), String.t()) :: + {:ok, Forum.t()} | {:error, :not_found | :unauthorized} + def show_forum(%Actor{} = actor, short_name) do + load_authorized_forum(actor, :show, short_name) end @doc """ - Updates a forum. + Loads a visible forum and its topic page. ## Examples - iex> update_forum(forum, %{field: new_value}) + iex> show_forum_page(actor, "dis", pagination) + {:ok, %ForumPage{}} + + """ + @spec show_forum_page(Actor.t(), String.t(), Repo.pagination_params()) :: + {:ok, ForumPage.t()} | {:error, :not_found | :unauthorized} + def show_forum_page(%Actor{} = actor, short_name, pagination) do + with {:ok, forum} <- load_authorized_forum(actor, :show, short_name) do + topics = + Topic + |> where([topic], topic.forum_id == ^forum.id) + |> where(hidden_from_users: false) + |> order_by(desc: :sticky, desc: :last_replied_to_at, desc: :id) + |> preload([:poll, :forum, :user, last_post: :user]) + |> Repo.paginate(pagination) + + {:ok, + %ForumPage{ + forum: forum, + topics: topics, + watching: subscribed?(forum, actor.user) + }} + end + end + + @doc """ + Subscribes `actor` to a visible forum. Subscription management is + deliberately exempt from `verify_write_access/1`. + + ## Examples + + iex> create_forum_subscription(actor, "dis") {:ok, %Forum{}} - iex> update_forum(forum, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + @spec create_forum_subscription(Actor.t(), String.t()) :: + {:ok, Forum.t()} | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def create_forum_subscription(%Actor{} = actor, short_name) do + with {:ok, forum} <- load_authorized_forum(actor, :subscribe, short_name), + {:ok, _subscription} <- create_subscription(forum, actor.user) do + {:ok, forum} + end + end + + @doc """ + Idempotently unsubscribes `actor` from a visible forum. Subscription + management is deliberately exempt from `verify_write_access/1`. + + ## Examples + + iex> delete_forum_subscription(actor, "dis") + {:ok, %Forum{}} + + """ + @spec delete_forum_subscription(Actor.t(), String.t()) :: + {:ok, Forum.t()} | {:error, :not_found | :unauthorized} + def delete_forum_subscription(%Actor{} = actor, short_name) do + with {:ok, forum} <- load_authorized_forum(actor, :unsubscribe, short_name), + {:ok, _subscription} <- delete_subscription(forum, actor.user) do + {:ok, forum} + end + end + + @doc """ + Builds an authorized forum creation form. + + ## Examples + + iex> new_forum(admin_actor) + {:ok, %Ecto.Changeset{}} """ - def update_forum(%Forum{} = forum, attrs) do - forum - |> Forum.changeset(attrs) - |> Repo.update() + @spec new_forum(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_forum(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Forum) do + {:ok, Forum.changeset(%Forum{})} + end end @doc """ - Deletes a Forum. + Creates a forum on behalf of an authorized actor. ## Examples - iex> delete_forum(forum) + iex> create_forum(admin_actor, attrs) {:ok, %Forum{}} - iex> delete_forum(forum) + iex> create_forum(admin_actor, invalid_attrs) {:error, %Ecto.Changeset{}} """ - def delete_forum(%Forum{} = forum) do - Repo.delete(forum) + @spec create_forum(Actor.t(), map()) :: + {:ok, Forum.t()} | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def create_forum(%Actor{} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Forum) do + %Forum{} + |> Forum.changeset(attrs) + |> Repo.insert() + end + end + + @doc """ + Loads a forum edit form by short name. + + ## Examples + + iex> edit_forum(admin_actor, "dis") + {:ok, {%Forum{}, %Ecto.Changeset{}}} + + """ + @spec edit_forum(Actor.t(), String.t()) :: + {:ok, {Forum.t(), Ecto.Changeset.t()}} | {:error, :ban | :not_found | :unauthorized} + def edit_forum(%Actor{} = actor, short_name) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- load_authorized_forum(actor, :edit, short_name) do + {:ok, {forum, Forum.changeset(forum, %{})}} + end + end + + @doc """ + Updates a forum selected by short name. + + ## Examples + + iex> update_forum(admin_actor, "dis", attrs) + {:ok, %Forum{}} + + """ + @spec update_forum(Actor.t(), String.t(), map()) :: + {:ok, Forum.t()} | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_forum(%Actor{} = actor, short_name, attrs) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- load_authorized_forum(actor, :update, short_name) do + forum + |> Forum.changeset(attrs) + |> Repo.update() + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking forum changes. + Adds an update step that recalculates a forum's cached last visible post. + + Maintains `Forum.last_post_id` as the highest-ID post that is visible and + belongs to a non-hidden topic in that forum. The step reads the locked forum + from `forum_step`, which defaults to `:locked_forum`. Add it after a reply, + post visibility change, topic visibility change, or topic move; a move must + refresh both locked forums. ## Examples - iex> change_forum(forum) - %Ecto.Changeset{source: %Forum{}} + iex> (Multi.new() + ...> |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :hide) + ...> |> Multi.update(:topic, topic_changeset) + ...> |> Forums.put_refresh_forum_last_post()) + %Multi{} """ - def change_forum(%Forum{} = forum) do - Forum.changeset(forum, %{}) + @spec put_refresh_last_post(Multi.t(), Multi.name()) :: Multi.t() + def put_refresh_last_post(%Multi{} = multi, forum_step \\ :locked_forum) do + Multi.update_all( + multi, + {:refresh_forum_last_post, forum_step}, + fn %{^forum_step => forum} -> update_last_post_query(forum.id) end, + [] + ) end @doc """ - Returns an `m:Ecto.Query` which updates the last post for the given forum. + Adds forum counter updates for a topic transfer. + + Maintains `Forum.topic_count` and `Forum.post_count` for both forums. The + updated topic is read from `:topic`. When it is visible, its one-topic and + complete `post_count` contribution is moved from `:locked_source_forum` to + `:locked_target_forum`. A hidden topic contributes to neither forum, so no + counters change. Call this immediately after moving the topic and before + refreshing the last-post pointers of both locked forums. ## Examples - iex> update_forum_last_post_query(1) - #Ecto.Query<...> + iex> (Multi.new() + ...> |> put_source_and_target_forum_and_topic_locks(actor, "dis", :show, "gen", :show, "topic", :move) + ...> |> Multi.update(:topic, topic_changeset) + ...> |> Forums.put_topic_transfer_counters()) + %Multi{} """ - def update_forum_last_post_query(forum_id) do + @spec put_topic_transfer_counters(Multi.t()) :: Multi.t() + def put_topic_transfer_counters(%Multi{} = multi) do + Multi.merge(multi, fn + %{topic: %{hidden_from_users: true}} -> + # Hidden topics do not contribute to forum post count. + Multi.new() + + %{locked_source_forum: source, locked_target_forum: target, topic: topic} -> + Multi.new() + |> Multi.update_all( + :source_forum_count, + Forum + |> where(id: ^source.id) + |> update(inc: [post_count: ^(-topic.post_count), topic_count: -1]), + [] + ) + |> Multi.update_all( + :target_forum_count, + Forum + |> where(id: ^target.id) + |> update(inc: [post_count: ^topic.post_count, topic_count: 1]), + [] + ) + end) + end + + @doc """ + Adds counter updates for a topic becoming visible or hidden. + + Maintains `Forum.topic_count`, `Forum.post_count`, and the topic author's + `topics_count`. `visible?: true` adds one topic and that topic's complete + non-destroyed `post_count`; `false` removes the same contribution. The + transaction must contain `:locked_forum` and the updated `:topic`, and must + call this immediately after changing `Topic.hidden_from_users`. + + ## Examples + + iex> (Multi.new() + ...> |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :unhide) + ...> |> Multi.update(:topic, topic_changeset) + ...> |> Forums.put_topic_visibility_counters(visible?: true)) + %Multi{} + + """ + @spec put_topic_visibility_counters(Multi.t(), [{:visible?, boolean()}]) :: Multi.t() + def put_topic_visibility_counters(%Multi{} = multi, [{:visible?, visible?}]) do + scale = if visible?, do: 1, else: -1 + + Multi.update_all( + multi, + :forum_topic_visibility_count, + fn %{locked_forum: forum, topic: topic} -> + Forum + |> where(id: ^forum.id) + |> update(inc: [post_count: ^(scale * topic.post_count), topic_count: ^scale]) + end, + [] + ) + end + + @doc """ + Adds a forum post counter update for post creation or destruction. + + Maintains `Forum.post_count`. `visible?: true` increments it and `false` + decrements it, but only when `:locked_topic` is visible. Hidden topics do not + contribute posts to their forum. The transaction must contain + `:locked_forum` and `:locked_topic`, and call this after the post mutation. + Pair it with `put_post_topic_visibility_counters/2` whenever a post is created + or destroyed. + + ## Examples + + iex> (Multi.new() + ...> |> put_forum_and_topic_and_post_locks(actor, "dis", :show, "topic", :show, 1, :delete) + ...> |> Multi.update(:post, post_changeset) + ...> |> Topics.put_post_visibility_counters(visible?: false) + ...> |> Forums.put_post_visibility_counters(visible?: false)) + %Multi{} + + """ + @spec put_post_visibility_counters(Multi.t(), [{:visible?, boolean()}]) :: Multi.t() + def put_post_visibility_counters(%Multi{} = multi, [{:visible?, visible?}]) do + Multi.merge(multi, fn + %{locked_topic: %{hidden_from_users: true}} -> + # Hidden topics do not contribute to forum post count. + Multi.new() + + %{locked_forum: forum} -> + scale = if visible?, do: 1, else: -1 + + query = + Forum + |> where(id: ^forum.id) + |> update(inc: [post_count: ^scale]) + + Multi.update_all(Multi.new(), :forum_post_count, query, []) + end) + end + + defp update_last_post_query(forum_id) do Forum |> where(id: ^forum_id) |> update( set: [ last_post_id: fragment( - "SELECT max(posts.id) FROM posts JOIN topics ON posts.topic_id = topics.id WHERE topics.forum_id = ? AND topics.hidden_from_users IS FALSE AND posts.hidden_from_users IS FALSE", + """ + SELECT last_post_id FROM topics + WHERE forum_id = ? + AND hidden_from_users IS FALSE + ORDER BY last_replied_to_at DESC, id DESC + LIMIT 1 + """, ^forum_id ) ] diff --git a/lib/philomena/forums/forum.ex b/lib/philomena/forums/forum.ex index 94e30b68b..6ea6ab0d1 100644 --- a/lib/philomena/forums/forum.ex +++ b/lib/philomena/forums/forum.ex @@ -6,6 +6,8 @@ defmodule Philomena.Forums.Forum do alias Philomena.Topics.Topic alias Philomena.Forums.Subscription + @type t :: %__MODULE__{} + @derive {Phoenix.Param, key: :short_name} schema "forums" do belongs_to :last_post, Post @@ -23,7 +25,7 @@ defmodule Philomena.Forums.Forum do end @doc false - def changeset(forum, attrs) do + def changeset(forum, attrs \\ %{}) do forum |> cast(attrs, [:name, :short_name, :description, :access_level]) |> validate_required([:name, :short_name, :description, :access_level]) diff --git a/lib/philomena/forums/forum_index.ex b/lib/philomena/forums/forum_index.ex new file mode 100644 index 000000000..347036c99 --- /dev/null +++ b/lib/philomena/forums/forum_index.ex @@ -0,0 +1,12 @@ +defmodule Philomena.Forums.ForumIndex do + @moduledoc """ + Actor-visible forums and the number of topics visible within them. + """ + + alias Philomena.Forums.Forum + + @enforce_keys [:forums, :topic_count] + defstruct @enforce_keys + + @type t :: %__MODULE__{forums: Scrivener.Page.t(Forum.t()), topic_count: non_neg_integer()} +end diff --git a/lib/philomena/forums/forum_page.ex b/lib/philomena/forums/forum_page.ex new file mode 100644 index 000000000..b4d9740c5 --- /dev/null +++ b/lib/philomena/forums/forum_page.ex @@ -0,0 +1,17 @@ +defmodule Philomena.Forums.ForumPage do + @moduledoc """ + A forum, its paginated visible topics, and the viewer's subscription state. + """ + + alias Philomena.Forums.Forum + alias Philomena.Topics.Topic + + @enforce_keys [:forum, :topics, :watching] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + forum: Forum.t(), + topics: Scrivener.Page.t(Topic.t()), + watching: boolean() + } +end diff --git a/lib/philomena/forums/transaction_workflow.ex b/lib/philomena/forums/transaction_workflow.ex new file mode 100644 index 000000000..097c215fd --- /dev/null +++ b/lib/philomena/forums/transaction_workflow.ex @@ -0,0 +1,383 @@ +defmodule Philomena.Forums.TransactionWorkflow do + @moduledoc """ + Composable, locking transaction steps for forum hierarchy operations. + + Besides locking and authorization, these helpers maintain the denormalized + counters and last-post pointers used by forum and topic listings. Call them + in the same `Multi` as the mutation, after the row-changing step and while + the affected hierarchy rows remain locked. + + ## Counter invariants + + - `Topic.post_count` is the number of non-destroyed posts in that topic. A + hidden post still contributes to this count. + - `Forum.topic_count` is the number of non-hidden topics in the forum. + - `Forum.post_count` is the sum of `post_count` for non-hidden topics in the + forum. Consequently, hiding or restoring a topic transfers all of its + posts; destroying a post changes the forum count only when its topic is + visible. + - A visible topic also contributes one to its author's `topics_count`. + + Create a topic with `put_topic_visibility_counters(visible?: true)`, and + reverse that step when hiding it. Add `put_post_topic_visibility_counters/2` + whenever a post becomes or ceases to be non-destroyed, and pair it with + `put_post_forum_visibility_counters/2` for a visible topic. Move a topic + with `put_topic_transfer_counters/1`, which transfers its visible + contribution between the locked forums. + + ## Last-post invariants + + A topic's `last_post_id` and `last_replied_to_at` identify its newest post + visible to users. A forum's `last_post_id` identifies the newest visible + post in one of its non-hidden topics. Refresh the topic pointer after a + reply or a post visibility change, and refresh the forum pointer after a + reply, a post visibility change, a topic visibility change, or a topic move. + Topic creation refreshes both pointers; its initial post is included by the + topic insert. A move refreshes both the source and target forums. Hidden or + restored posts change pointers but not counters. A post must be hidden before + it can be destroyed, so destruction alone changes counters but not pointers; + a combined hide-and-destroy operation refreshes them for the hide. + + The locking helpers establish the serialization boundary for these updates: + lock the forum before its topic, and lock both forums in sorted order before + moving a topic. Do not use a counter or refresh helper with an unlocked, + stale parent row, or in a separate transaction from the mutation. + """ + + import Philomena.Authorization, only: [authorize: 3] + import Ecto.Query + + alias Philomena.Attribution.Actor + alias Philomena.Forums.Forum + alias Philomena.Topics.Topic + alias Philomena.Posts.Post + alias Philomena.Users.User + alias Philomena.IntegerId + + alias Philomena.Multi + + @doc """ + Adds a row lock for the forum identified by `forum_slug` and authorizes + `actor` for `action` on that forum. + + The lock and authorization are added to the supplied transaction as the + `:locked_forum` and `:authorize` steps. A missing forum is reported as + `:not_found`, and a failed authorization as `:unauthorized` by the + transaction's normal error tuple. Use this before a mutation that changes + forum-level counters or last-post pointers, so later cache steps read the + locked forum row. + + ## Examples + + iex> Multi.new() |> put_forum_lock(actor, "dis", :show) + %Multi{} + + """ + @spec put_forum_lock( + multi :: Multi.t(), + actor_or_user :: Actor.t() | User.t(), + forum_slug :: String.t(), + forum_action :: atom() + ) :: Multi.t() + def put_forum_lock(%Multi{} = multi, actor_or_user, forum_slug, forum_action) do + forum_query = + from forum in Forum, + as: :forum, + where: forum.short_name == ^forum_slug + + multi + |> Multi.lock_one(:locked_forum, forum_query) + |> Multi.run(:authorize, fn _repo, %{locked_forum: forum} -> + with :ok <- authorize(actor_or_user, forum_action, forum) do + {:ok, nil} + end + end) + end + + @doc """ + Adds row locks and authorization for a forum and one of its topics. + + The forum is locked first, followed by the topic selected beneath that + forum. The supplied transaction receives `:locked_forum`, `:locked_topic`, + and `:authorize` steps. `forum_action` and `topic_action` are checked only + after both rows have been locked. + + This is the required setup for a post or topic-visibility mutation whose + counter or last-post steps read `:locked_forum` and `:locked_topic`. + + ## Examples + + iex> Multi.new() |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :show) + %Multi{} + + """ + @spec put_forum_and_topic_locks( + multi :: Multi.t(), + actor_or_user :: Actor.t() | User.t(), + forum_slug :: String.t(), + forum_action :: atom(), + topic_slug :: String.t(), + topic_action :: atom() + ) :: + Multi.t() + def put_forum_and_topic_locks( + %Multi{} = multi, + actor_or_user, + forum_slug, + forum_action, + topic_slug, + topic_action + ) do + forum_query = + from forum in Forum, + as: :forum, + where: forum.short_name == ^forum_slug + + topic_query = + from topic in Topic, + as: :topic, + where: topic.slug == ^topic_slug, + where: + exists( + from forum in forum_query, + where: parent_as(:topic).forum_id == forum.id + ), + preload: :forum + + multi + |> Multi.lock_one(:locked_forum, forum_query) + |> Multi.lock_one(:locked_topic, topic_query) + |> Multi.run(:authorize, fn _repo, %{locked_forum: forum, locked_topic: topic} -> + with :ok <- authorize(actor_or_user, forum_action, forum), + :ok <- authorize(actor_or_user, topic_action, topic) do + {:ok, nil} + end + end) + end + + @doc """ + Adds consistent row locks and authorization for a topic move. + + Both the source and target forums are locked in sorted short-name order to + prevent concurrent moves in opposite directions from deadlocking. Duplicate + forum names are locked once. Forums are exposed as `:locked_source_forum` and + `:locked_target_forum`. The topic is locked beneath the source forum, and + all three requested actions are checked in the `:authorize` step. Use this + before `put_topic_transfer_counters/1` and both forum last-post refreshes, + so concurrent opposite-direction moves cannot leave either forum cache + stale. + + ## Examples + + iex> Multi.new() |> put_source_and_target_forum_and_topic_locks(actor, "dis", :show, "general", :show, "topic", :move) + %Multi{} + + """ + @spec put_source_and_target_forum_and_topic_locks( + multi :: Multi.t(), + actor_or_user :: Actor.t() | User.t(), + source_forum_slug :: String.t(), + source_forum_action :: atom(), + target_forum_slug :: String.t(), + target_forum_action :: atom(), + topic_slug :: String.t(), + topic_action :: atom() + ) :: Multi.t() + def put_source_and_target_forum_and_topic_locks( + %Multi{} = multi, + actor_or_user, + source_forum_slug, + source_forum_action, + target_forum_slug, + target_forum_action, + topic_slug, + topic_action + ) do + topic_query = + from topic in Topic, + as: :topic, + where: topic.slug == ^topic_slug, + where: + exists( + from forum in Forum, + as: :forum, + where: forum.short_name == ^source_forum_slug, + where: parent_as(:topic).forum_id == forum.id + ), + preload: [:forum] + + # Concurrent moves between the same forums need to use a consistent locking order. + # Forums are deduplicated and ordered by short_name to achieve this. + + [source_forum_slug, target_forum_slug] + |> Enum.uniq() + |> Enum.sort() + |> Enum.reduce(multi, fn short_name, multi -> + forum_query = + from forum in Forum, + where: forum.short_name == ^short_name + + Multi.lock_one(multi, {:locked_forum, short_name}, forum_query) + end) + |> Multi.run( + :locked_source_forum, + fn _repo, %{{:locked_forum, ^source_forum_slug} => forum} -> + {:ok, forum} + end + ) + |> Multi.run( + :locked_target_forum, + fn _repo, %{{:locked_forum, ^target_forum_slug} => forum} -> + {:ok, forum} + end + ) + |> Multi.lock_one(:locked_topic, topic_query) + |> Multi.run( + :authorize, + fn _repo, + %{ + locked_source_forum: source_forum, + locked_target_forum: target_forum, + locked_topic: topic + } -> + with :ok <- authorize(actor_or_user, source_forum_action, source_forum), + :ok <- authorize(actor_or_user, target_forum_action, target_forum), + :ok <- authorize(actor_or_user, topic_action, topic) do + {:ok, nil} + end + end + ) + end + + @doc """ + Adds row locks and authorization for a post-scoped topic operation. + + The forum, topic, and post are selected using all supplied route members and + are locked in that order. The transaction receives `:locked_forum`, + `:locked_topic`, `:locked_post`, and `:authorize` steps; the forum, topic, + and post actions are checked after the locks are acquired. + + This is the required setup for a post hide, restore, or destruction followed + by its affected counter and last-post cache steps. + + ## Examples + + iex> Multi.new() |> put_forum_and_topic_and_post_locks(actor, "dis", :show, "topic", :show, 1, :hide) + %Multi{} + + """ + @spec put_forum_and_topic_and_post_locks( + multi :: Multi.t(), + actor_or_user :: Actor.t() | User.t(), + forum_slug :: String.t(), + forum_action :: atom(), + topic_slug :: String.t(), + topic_action :: atom(), + post_id :: IntegerId.integer_id(), + post_action :: atom() + ) :: Multi.t() + def put_forum_and_topic_and_post_locks( + %Multi{} = multi, + actor_or_user, + forum_slug, + forum_action, + topic_slug, + topic_action, + post_id, + post_action + ) do + forum_query = + from forum in Forum, + as: :forum, + where: forum.short_name == ^forum_slug + + topic_query = + from topic in Topic, + as: :topic, + where: topic.slug == ^topic_slug, + where: + exists( + from forum in forum_query, + where: parent_as(:topic).forum_id == forum.id + ) + + # The user is preloaded for every post. + # Approval is currently the only action that reads it. + post_query = + from post in Post, + as: :post, + where: post.id == ^post_id, + where: + exists( + from topic in topic_query, + where: parent_as(:post).topic_id == topic.id + ), + preload: [:user, topic: :forum] + + multi + |> Multi.lock_one(:locked_forum, forum_query) + |> Multi.lock_one(:locked_topic, topic_query) + |> Multi.lock_one(:locked_post, post_query) + |> Multi.run(:authorize, fn + _repo, %{locked_forum: forum, locked_topic: topic, locked_post: post} -> + with :ok <- authorize(actor_or_user, forum_action, forum), + :ok <- authorize(actor_or_user, topic_action, topic), + :ok <- authorize(actor_or_user, post_action, post) do + {:ok, nil} + end + end) + end + + @doc """ + Converts a transaction error from a locking workflow to its public error. + + Authorization and missing-row errors are reduced to `{:error, + :unauthorized}` and `{:error, :not_found}`, respectively. Other transaction + results are intentionally not handled by this helper. Use it only after + `Multi.transact/1` on a workflow that installed one of this module's locking + helpers; it does not translate cache-update or changeset failures. + + ## Examples + + iex> map_lock_errors({:error, :authorize, :unauthorized, %{}}) + {:error, :unauthorized} + + """ + @spec map_lock_errors(Multi.failure()) :: {:error, :not_found | :unauthorized} + def map_lock_errors(result) do + case result do + {:error, _step, :unauthorized, _changes} -> + {:error, :unauthorized} + + {:error, _step, :not_found, _changes} -> + {:error, :not_found} + end + end + + @doc """ + Adds a query step that finds the highest post position in the locked topic. + + The result is stored under `:max_topic_position` and is `nil` when the topic + has no posts. Use it after locking the topic and before inserting a reply, + so the new post's `topic_position` follows the current maximum without + concurrent replies sharing a position. + + ## Examples + + iex> (Multi.new() + ...> |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :create_post) + ...> |> put_max_topic_position()) + %Multi{} + + """ + @spec put_max_topic_position(Multi.t()) :: Multi.t() + def put_max_topic_position(%Multi{} = multi) do + Multi.one(multi, :max_topic_position, fn %{locked_topic: topic} -> + Post + |> where(topic_id: ^topic.id) + |> order_by(desc: :topic_position) + |> select([p], p.topic_position) + |> limit(1) + end) + end +end diff --git a/lib/philomena/forums/visibility.ex b/lib/philomena/forums/visibility.ex new file mode 100644 index 000000000..c2f44628b --- /dev/null +++ b/lib/philomena/forums/visibility.ex @@ -0,0 +1,183 @@ +defmodule Philomena.Forums.Visibility do + @moduledoc """ + Database query scopes for forum hierarchy collection reads. + + These scopes intentionally mirror the `:show` rules in + `Philomena.Users.Ability`. Collection endpoints use them before counting and + pagination so authorization cost is bounded by the requested page. + """ + + import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Attribution.Actor + alias Philomena.Posts.Post + alias Philomena.Users.User + + @doc """ + Restricts a forum query to the access levels visible to `actor`. + + ## Examples + + iex> visible_forums(Forum, actor) + #Ecto.Query<...> + + """ + @spec visible_forums(Ecto.Queryable.t(), Actor.t()) :: Ecto.Query.t() + def visible_forums(queryable, %Actor{user: %User{role: role}}) + when role in ["admin", "moderator"], + do: from(forum in queryable) + + def visible_forums(queryable, %Actor{user: %User{role: "assistant"}}), + do: from(forum in queryable, where: forum.access_level in ["normal", "assistant"]) + + def visible_forums(queryable, %Actor{}), + do: from(forum in queryable, where: forum.access_level == "normal") + + @doc """ + Restricts a topic query to topics visible to `actor`. + + The caller remains responsible for constraining the query to authorized + parent forums. + + ## Examples + + iex> visible_topics(Topic, actor) + #Ecto.Query<...> + + """ + @spec visible_topics(Ecto.Queryable.t(), Actor.t()) :: Ecto.Query.t() + def visible_topics(queryable, %Actor{user: %User{role: role}}) + when role in ["admin", "moderator", "assistant"], + do: from(topic in queryable) + + def visible_topics(queryable, %Actor{}), + do: from(topic in queryable, where: topic.hidden_from_users == false) + + @doc """ + Restricts a post collection query to posts visible to `actor`. + + Moderators, administrators, and topic-moderator assistants may see hidden + posts and unavailable moderation states. Other actors receive approved, + non-destroyed posts plus their own pending posts by account or IP. The caller + remains responsible for constraining and authorizing the parent forum and + topic. + + ## Examples + + iex> visible_posts(Post, actor) + #Ecto.Query<...> + + """ + @spec visible_posts(Ecto.Queryable.t(), Actor.t()) :: Ecto.Query.t() + def visible_posts(queryable, %Actor{} = actor) do + queryable + |> maybe_exclude_hidden_posts(actor) + |> available_posts(actor) + end + + @doc """ + Restricts a post query to approved, non-destroyed posts plus the actor's own + pending posts. Staff with post-moderation access retain unavailable posts. + + Unlike `visible_posts/2`, this scope leaves hidden-post authorization to a + member loader so forbidden existing records remain distinguishable from + missing records. + """ + @spec available_posts(Ecto.Queryable.t(), Actor.t()) :: Ecto.Query.t() + def available_posts(queryable, %Actor{} = actor) do + maybe_exclude_unavailable_posts(queryable, actor) + end + + @doc """ + Generates OpenSearch boolean `filter` clauses to select posts visible to + `actor`. + + The clauses mirror `visible_posts/2`, including forum access, hidden state, + approval ownership, IP ownership, and destroyed-content policy. + + ## Examples + + iex> search_filters(admin_actor) + [] + + iex> search_filters(actor) + [%{term: %{access_level: "normal"}}, ...] + + """ + @spec search_filters(Actor.t()) :: list() + def search_filters(%Actor{} = actor) do + search_access_filters(actor) ++ search_availability_filters(actor) + end + + defp maybe_exclude_hidden_posts(query, actor) do + if authorize(actor, :show, %Post{hidden_from_users: true}) == :ok do + query + else + where(query, [post], post.hidden_from_users == false) + end + end + + defp maybe_exclude_unavailable_posts(query, actor) do + if authorize(actor, :hide, %Post{}) == :ok do + query + else + visible_to_actor(query, actor) + end + end + + defp visible_to_actor(query, %Actor{user: %User{id: user_id}, ip: ip}) do + where( + query, + [post], + post.destroyed_content == false and + (post.approved == true or post.user_id == ^user_id or post.ip == ^ip) + ) + end + + defp visible_to_actor(query, %Actor{ip: ip}) do + where( + query, + [post], + post.destroyed_content == false and (post.approved == true or post.ip == ^ip) + ) + end + + defp search_access_filters(%Actor{user: %User{role: role}}) + when role in ["moderator", "admin"], + do: [] + + defp search_access_filters(%Actor{user: %User{role: "assistant"}}), + do: [%{terms: %{access_level: ["normal", "assistant"]}}] + + defp search_access_filters(%Actor{}), + do: [%{term: %{access_level: "normal"}}, %{term: %{hidden_from_users: false}}] + + defp search_availability_filters(actor) do + if authorize(actor, :hide, %Post{}) == :ok do + [] + else + [ + %{term: %{destroyed_content: false}}, + %{ + bool: %{ + should: availability_should(actor), + minimum_should_match: 1 + } + } + ] + end + end + + defp availability_should(%Actor{user: %User{id: user_id}, ip: ip}) do + [ + %{term: %{approved: true}}, + %{term: %{true_author_id: user_id}}, + %{term: %{ip: to_string(ip)}} + ] + end + + defp availability_should(%Actor{ip: ip}) do + [%{term: %{approved: true}}, %{term: %{ip: to_string(ip)}}] + end +end diff --git a/lib/philomena/galleries.ex b/lib/philomena/galleries.ex index eec87566a..e1383c21b 100644 --- a/lib/philomena/galleries.ex +++ b/lib/philomena/galleries.ex @@ -1,475 +1,1019 @@ defmodule Philomena.Galleries do @moduledoc """ - The Galleries context. + Gallery creation, presentation, image membership, subscriptions, account + erasure, and search-index coordination. + + ## Gallery/image locking + + Adding, removing, and reordering images lock every affected image before + locking the gallery. This hierarchy serializes those operations with image + hides and merges, which remove or migrate gallery interactions while holding + the image lock. Reordering locks the submitted image set in ascending ID + order before acquiring the gallery lock. + + Gallery deletion is a deliberate exception. The gallery must be locked first + to determine its complete membership, and locking its images afterwards would + invert the hierarchy. Deletion instead relies on the database cascade to lock + and remove the gallery's interaction rows. """ import Ecto.Query, warn: false - alias Ecto.Multi + + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Multi alias Philomena.Repo + alias Philomena.Loader + alias PhilomenaQuery.Batch alias PhilomenaQuery.Search + alias Philomena.Attribution.Actor alias Philomena.Galleries.Gallery + alias Philomena.Galleries.GalleryPage alias Philomena.Galleries.Interaction + alias Philomena.Galleries.QueryBuilder + alias Philomena.Galleries.QueryForm + alias Philomena.Galleries.ReorderForm alias Philomena.Galleries alias Philomena.IndexWorker - alias Philomena.GalleryReorderWorker + alias Philomena.Interactions alias Philomena.Notifications alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Search, as: ImageSearch + alias Philomena.Images.Search.Scope + alias Philomena.Users.User alias Philomena.Reports use Philomena.Subscriptions, on_delete: :clear_gallery_notification, id_name: :gallery_id + @gallery_selector_limit 100 + @gallery_preloads [:user, thumbnail: [:sources, tags: :aliases]] + + defp load_gallery(actor, gallery_id, action) do + Loader.fetch_and_authorize(Gallery, actor, action, gallery_id, @gallery_preloads) + end + + defp last_position(gallery_id) do + Interaction + |> where(gallery_id: ^gallery_id) + |> Repo.aggregate(:max, :position) + end + + defp notify_gallery(_repo, %{gallery: gallery}) do + Notifications.broadcast_gallery_image(gallery) + end + + defp put_reindex_gallery(%Multi{} = multi, step \\ :gallery) do + Multi.on_commit(multi, fn %{^step => gallery} -> reindex_gallery(gallery) end) + end + + defp cleanup_gallery(%Gallery{} = gallery) do + gallery_query = where(Gallery, id: ^gallery.id) + + Interaction + |> where(gallery_id: ^gallery.id) + |> Batch.query_batches(batch_size: 1_000, id_field: :image_id) + |> Enum.each(fn batch_query -> + Multi.new() + |> Multi.lock_one(:locked_gallery, gallery_query) + |> Multi.delete_all(:interactions, select(batch_query, [i], i.image_id)) + |> Multi.update_all( + :update_gallery, + fn %{interactions: {count, _image_ids}} -> + update(gallery_query, inc: [image_count: ^(-count)]) + end, + [] + ) + |> Multi.on_commit(fn %{interactions: {_count, image_ids}} -> + Images.reindex_images(image_ids) + end) + |> Multi.transact() + end) + end + + defp persist_gallery_deletion(%Gallery{} = gallery, %User{} = closing_user) do + cleanup_gallery(gallery) + + # Deletion must lock the gallery before discovering its members. It cannot + # then lock the unbounded image set without inverting the image-first + # hierarchy used by add/remove/reorder and image hide/merge workflows. The + # gallery FK cascade locks and deletes any raced interaction rows instead. + gallery_query = where(Gallery, id: ^gallery.id) + + interactions_query = + Interaction + |> where(gallery_id: ^gallery.id) + |> select([i], i.image_id) + + Multi.new() + |> Multi.lock_one(:locked_gallery, gallery_query) + |> Multi.delete_all(:interactions, interactions_query) + |> Reports.put_close_reports(:reports, closing_user, gallery_id: gallery.id) + |> Multi.delete(:gallery, fn %{locked_gallery: gallery} -> gallery end) + |> Multi.on_commit(fn %{gallery: gallery} -> unindex_gallery(gallery) end) + |> Multi.on_commit(fn %{interactions: {_, image_ids}} -> Images.reindex_images(image_ids) end) + |> Multi.transact() + |> case do + {:ok, %{gallery: %Gallery{} = gallery}} -> + {:ok, gallery} + + {:error, :locked_gallery, :not_found, _changes} -> + {:error, :not_found} + end + end + + defp gallery_image_ids(%Gallery{} = gallery, image_ids) do + Interaction + |> where([interaction], interaction.gallery_id == ^gallery.id) + |> where([interaction], interaction.image_id in ^image_ids) + |> select([interaction], interaction.image_id) + |> Repo.all() + end + + defp position_order(%{order_position_asc: true}), do: [asc: :position] + defp position_order(_gallery), do: [desc: :position] + + defp persist_reorder_positions(%Gallery{} = gallery, requested_image_ids) do + # Load the submitted subset in the gallery's current display order. + affected_interactions = + Interaction + |> where(gallery_id: ^gallery.id) + |> where([i], i.image_id in ^requested_image_ids) + |> order_by(^position_order(gallery)) + |> Repo.all() + + # If the requested order is [c, a], while the affected rows are currently + # [a, c] at positions [0, 2], this becomes %{0 => 0, 1 => 2}. + position_by_current_index = + affected_interactions + |> Enum.with_index() + |> Map.new(fn {interaction, index} -> {index, interaction.position} end) + + # The requested list describes the desired order of the submitted rows: + # [c, a] becomes %{c => 0, a => 1}. + requested_index_by_image_id = + requested_image_ids + |> Enum.with_index() + |> Map.new() + + # Pair each affected row's current index with its requested index. + # a moves to position_by_current_index[1] (2), and c moves to + # position_by_current_index[0] (0). + interaction_updates = + affected_interactions + |> Enum.with_index() + |> Enum.flat_map(fn {interaction, current_index} -> + requested_index = requested_index_by_image_id[interaction.image_id] + + if requested_index != current_index do + [ + %{ + id: interaction.id, + gallery_id: interaction.gallery_id, + image_id: interaction.image_id, + position: position_by_current_index[requested_index] + } + ] + else + # Rows already in the requested slot remain unchanged. + [] + end + end) + + Repo.insert_all( + Interaction, + interaction_updates, + on_conflict: {:replace, [:position]}, + conflict_target: [:id] + ) + + {:ok, nil} + end + + defp image_sort_direction(%{order_position_asc: true}), do: "asc" + defp image_sort_direction(_gallery), do: "desc" + + defp put_query(list, name, actor, scope, query, pagination) do + if pagination.page_number > 0 do + {:ok, {definition, _tags}} = + ImageSearch.search_string(actor, scope, query, pagination: pagination) + + Keyword.put(list, name, {definition, preload(Image, [:sources, tags: :aliases])}) + else + list + end + end + + defp reorder_window(%Actor{} = actor, %Scope{} = scope, %Gallery{} = gallery) do + query = "gallery_id:#{gallery.id}" + scope = %{scope | sf: query, sd: image_sort_direction(gallery)} + + limit = scope.pagination.page_size + offset = (scope.pagination.page_number - 1) * limit + + # The leading query will not be possible on the first page, so a map key + # with an empty page is inserted if no search was performed. + + [] + |> put_query(:images, actor, scope, query, scope.pagination) + |> put_query(:leading, actor, scope, query, %{page_number: offset - 1, page_size: 1}) + |> put_query(:trailing, actor, scope, query, %{page_number: offset + limit, page_size: 1}) + |> Search.msearch_records_with_hits() + |> Map.put_new(:leading, %Scrivener.Page{}) + end + + defp map_lock_errors(result) do + case result do + {:error, _step, :unauthorized, _changes} -> + {:error, :unauthorized} + + {:error, _step, :not_found, _changes} -> + {:error, :not_found} + end + end + @doc """ - Gets a single gallery. + Builds a change-tracking changeset for a new gallery, on behalf of `actor`. - Raises `Ecto.NoResultsError` if the Gallery does not exist. + Only a signed-in actor authorized to create galleries receives the changeset. ## Examples - iex> get_gallery!(123) - %Gallery{} + iex> new_gallery(actor) + {:ok, %Ecto.Changeset{}} - iex> get_gallery!(456) - ** (Ecto.NoResultsError) + iex> new_gallery(banned_actor) + {:error, :ban} """ - def get_gallery!(id), do: Repo.get!(Gallery, id) + @spec new_gallery(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_gallery(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Gallery) do + {:ok, Gallery.changeset(%Gallery{})} + end + end @doc """ - Creates a gallery. + Creates a gallery, on behalf of `actor`. ## Examples - iex> create_gallery(%{field: value}) + iex> create_gallery(user, gallery_params) {:ok, %Gallery{}} - iex> create_gallery(%{field: bad_value}) + iex> create_gallery(user, invalid_params) {:error, %Ecto.Changeset{}} + iex> create_gallery(banned_user, invalid_params) + {:error, :ban} + """ - def create_gallery(user, attrs \\ %{}) do - %Gallery{} - |> Gallery.creation_changeset(attrs, user) - |> Repo.insert() - |> reindex_after_update() + @spec create_gallery(Actor.t(), map()) :: + {:ok, Gallery.t()} | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def create_gallery(%Actor{user: user} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Gallery) do + Multi.new() + |> Multi.insert(:gallery, Gallery.creation_changeset(%Gallery{}, attrs, user)) + |> put_reindex_gallery() + |> Multi.transact() + |> case do + {:ok, %{gallery: %Gallery{} = gallery}} -> + {:ok, gallery} + + {:error, :gallery, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Updates a gallery. + Updates the gallery named by `gallery_id`, on behalf of `actor`. + + On success the gallery is updated and reindexed. ## Examples - iex> update_gallery(gallery, %{field: new_value}) + iex> update_gallery(user, "1", gallery_params) {:ok, %Gallery{}} - iex> update_gallery(gallery, %{field: bad_value}) + iex> update_gallery(user, "1", invalid_params) {:error, %Ecto.Changeset{}} + iex> update_gallery(banned_user, "1", gallery_params) + {:error, :ban} + + iex> update_gallery(other_user, "1", gallery_params) + {:error, :unauthorized} + + iex> update_gallery(admin, "999999999", gallery_params) + {:error, :not_found} + """ - def update_gallery(%Gallery{} = gallery, attrs) do - gallery - |> Gallery.changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec update_gallery(Actor.t(), Loader.integer_id(), map() | nil) :: + {:ok, Gallery.t()} | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_gallery(%Actor{} = actor, gallery_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, gallery} <- load_gallery(actor, gallery_id, :update) do + Multi.new() + |> Multi.update(:gallery, Gallery.changeset(gallery, attrs)) + |> put_reindex_gallery() + |> Multi.transact() + |> case do + {:ok, %{gallery: %Gallery{} = gallery}} -> + {:ok, gallery} + + {:error, :gallery, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Deletes a Gallery. + Deletes the gallery named by `gallery_id`, on behalf of `actor`. + + Loading and authorization follow `update_gallery/3`. ## Examples - iex> delete_gallery(gallery) + iex> delete_gallery(actor, "1") {:ok, %Gallery{}} - iex> delete_gallery(gallery) - {:error, %Ecto.Changeset{}} - """ - def delete_gallery(%Gallery{} = gallery, closing_user) do - images = - Interaction - |> where(gallery_id: ^gallery.id) - |> select([i], i.image_id) - |> Repo.all() + @spec delete_gallery(Actor.t(), Loader.integer_id()) :: + {:ok, Gallery.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_gallery(%Actor{} = actor, gallery_id) do + with :ok <- verify_write_access(actor), + {:ok, gallery} <- load_gallery(actor, gallery_id, :delete) do + persist_gallery_deletion(gallery, actor.user) + end + end - Multi.new() - |> Multi.update_all( - :reports, - Reports.close_report_query(closing_user, gallery_id: gallery.id), - [] - ) - |> Multi.delete(:gallery, gallery) - |> Repo.transaction() - |> case do - {:ok, %{gallery: gallery, reports: {_count, reports}}} -> - unindex_gallery(gallery) - Images.reindex_images(images) - Reports.reindex_reports(reports) + @doc """ + Loads the gallery named by `gallery_id` for editing, on + behalf of `actor`, pairing it with a change-tracking changeset for it. - {:ok, gallery} + Loading and authorization otherwise follow `update_gallery/3`, authorizing `:edit`. + + ## Examples + + iex> edit_gallery(user, "1") + {:ok, {%Gallery{}, %Ecto.Changeset{}}} - error -> - error + iex> edit_gallery(banned_user, "1") + {:error, :ban} + + iex> edit_gallery(other_user, "1") + {:error, :unauthorized} + + iex> edit_gallery(admin, "999999999") + {:error, :not_found} + + """ + @spec edit_gallery(Actor.t(), Loader.integer_id()) :: + {:ok, {Gallery.t(), Ecto.Changeset.t()}} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def edit_gallery(%Actor{} = actor, gallery_id) do + with :ok <- verify_write_access(actor), + {:ok, gallery} <- load_gallery(actor, gallery_id, :edit) do + {:ok, {gallery, Gallery.changeset(gallery)}} end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking gallery changes. + Loads a gallery by ID as a report target on behalf of `actor`. - ## Examples + Missing and malformed IDs are always not-found. A real gallery the actor may + not show is unauthorized. - iex> change_gallery(gallery) - %Ecto.Changeset{source: %Gallery{}} + ## Examples + iex> load_report_target(actor, "1") + {:ok, %Gallery{}} """ - def change_gallery(%Gallery{} = gallery) do - Gallery.changeset(gallery, %{}) + @spec load_report_target(Actor.t(), Loader.integer_id()) :: + {:ok, Gallery.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, gallery_id) do + load_gallery(actor, gallery_id, :show) end @doc """ - Updates gallery search indices when a user's name changes. + Runs the gallery listing search `params` describes, returning the record page + with its thumbnail preloads and a changeset for a new search. ## Examples - iex> user_name_reindex("old_username", "new_username") - :ok + iex> list_galleries(actor, %{"title" => "sunset"}, pagination) + {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} + + iex> list_galleries(actor, %{"include_image" => "abcd"}, pagination) + {:error, %Ecto.Changeset{}} """ - def user_name_reindex(old_name, new_name) do - data = Galleries.SearchIndex.user_name_update_by_query(old_name, new_name) + @spec list_galleries(Actor.t(), map(), Search.pagination_params()) :: + {:ok, Scrivener.Page.t(Gallery.t()), Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_galleries(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Gallery), + {:ok, query, form} <- QueryBuilder.build_query(params) do + galleries = + Gallery + |> Search.search_definition(query, pagination) + |> Search.search_records(preload(Gallery, ^@gallery_preloads)) - Search.update_by_query(Gallery, data.query, data.set_replacements, data.replacements) + {:ok, galleries, QueryForm.changeset(form)} + end end - defp reindex_after_update({:ok, gallery}) do - reindex_gallery(gallery) + @doc """ + Searches galleries on behalf of `actor`, with the query string `query_string` + and `pagination`, sorted by creation time descending. - {:ok, gallery} - end + An empty or missing `query_string` compiles to a match-none query, returning + an empty page. Results are preloaded with their creator. Returns + `{:ok, galleries}`, or `{:error, msg}` when `query_string` fails to compile. + + ## Examples + + iex> query_galleries(user, "title:sunset", pagination) + {:ok, %Scrivener.Page{}} - defp reindex_after_update(error) do - error + iex> query_galleries(user, ")", pagination) + {:error, "Imbalanced parentheses."} + + """ + @spec query_galleries(Actor.t(), String.t() | nil, Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Gallery.t())} | {:error, :unauthorized | String.t()} + def query_galleries(%Actor{user: user} = actor, query_string, pagination) do + with :ok <- authorize(actor, :search, Gallery) do + case Philomena.Galleries.Query.compile(query_string, user: user) do + {:ok, query} -> + galleries = + Gallery + |> Search.search_definition( + %{query: query, sort: %{created_at: :desc}}, + pagination + ) + |> Search.search_records(preload(Gallery, [:user])) + + {:ok, galleries} + + {:error, msg} -> + {:error, msg} + end + end end @doc """ - Queues a gallery for reindexing. + Assembles the `GalleryPage` for the viewer described by `scope`, from + `gallery_id`. - Adds the gallery to the indexing queue to update its search index. + The gallery's position order is merged into the scope's parameters so the + images list, and the previous/next page probes flanking it, run in gallery + order; interactions and subscription state are computed for the viewer, and + the viewer's notification for the gallery is cleared as a side effect (so the + caller must read any notification counts afterwards). ## Examples - iex> reindex_gallery(gallery) - %Gallery{} + iex> show_gallery(actor, user_scope, "1") + {:ok, %GalleryPage{}} + + iex> show_gallery(actor, user_scope, "999999999") + {:error, :not_found} + + iex> show_gallery(admin, admin_scope, "999999999") + {:error, :not_found} """ - def reindex_gallery(%Gallery{} = gallery) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", [gallery.id]]) + @spec show_gallery(Actor.t(), Scope.t(), Loader.integer_id()) :: + {:ok, GalleryPage.t()} | {:error, :unauthorized | :not_found} + def show_gallery(%Actor{} = actor, %Scope{} = scope, gallery_id) do + with {:ok, gallery} <- load_gallery(actor, gallery_id, :show) do + %{images: images, leading: leading, trailing: trailing} = + reorder_window(actor, scope, gallery) - gallery + watching = subscribed?(gallery, actor.user) + interactions = Interactions.user_interactions(actor, [images, leading, trailing]) + gallery_images = Enum.concat([leading, images, trailing]) + + clear_gallery_notification(gallery, actor.user) + + {:ok, + %GalleryPage{ + gallery: gallery, + images: images, + gallery_images: gallery_images, + gallery_prev: Enum.any?(leading), + gallery_next: Enum.any?(trailing), + interactions: interactions, + watching: watching + }} + end end @doc """ - Queues multiple galleries for reindexing by their ids. + Lists up to #{@gallery_selector_limit} of the signed-in `actor`'s galleries, + most recently updated first, pairing each with whether it already contains + `image`. + + Anonymous actors receive an empty list. The fixed bound keeps the image-page + gallery selector from growing without limit. ## Examples - iex> reindex_galleries([1, 2, 3]) - [1, 2, 3] + iex> gallery_choices_for_image(actor, image) + {:ok, [{%Gallery{}, true}, {%Gallery{}, false}]} - """ - def reindex_galleries([]), do: [] + iex> gallery_choices_for_image(anonymous_actor, image) + {:ok, []} - def reindex_galleries(gallery_ids) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", gallery_ids]) + """ + @spec gallery_choices_for_image(Actor.t(), Image.t()) :: + {:ok, [{Gallery.t(), boolean()}]} | {:error, :unauthorized} + def gallery_choices_for_image(%Actor{user: nil} = actor, %Image{}) do + with :ok <- authorize(actor, :index, Gallery), do: {:ok, []} + end - gallery_ids + def gallery_choices_for_image(%Actor{user: user} = actor, %Image{} = image) do + with :ok <- authorize(actor, :select_for_image, Gallery) do + choices = + Gallery + |> from(as: :gallery) + |> where(user_id: ^user.id) + |> join( + :inner_lateral, + [], + _ in subquery( + Interaction + |> where([interaction], interaction.image_id == ^image.id) + |> where([interaction], interaction.gallery_id == parent_as(:gallery).id) + |> select([interaction], %{exists: count(interaction.id) > 0}) + ), + on: true + ) + |> select([g, e], {g, e.exists}) + |> order_by(desc: :updated_at) + |> limit(^@gallery_selector_limit) + |> Repo.all() + + {:ok, choices} + end end @doc """ - Removes a gallery from the search index. + Adds the image named by `image_id` to the gallery named by + `gallery_id`, on behalf of `actor`. - Deletes the gallery's document from the search index. + The gallery and image are independently loaded and authorized. On success, + the image is added at the last position, notifications are broadcast, and + both search documents are queued for reindexing. Adding an existing + membership returns a gallery changeset error. ## Examples - iex> unindex_gallery(gallery) - %Gallery{} + iex> create_gallery_image(actor, "1", "42") + {:ok, %Gallery{}} + + iex> create_gallery_image(banned_actor, "1", "42") + {:error, :ban} + + iex> create_gallery_image(other_actor, "1", "42") + {:error, :unauthorized} + + iex> create_gallery_image(admin_actor, "999999999", "42") + {:error, :not_found} """ - def unindex_gallery(%Gallery{} = gallery) do - Search.delete_document(gallery.id, Gallery) + @spec create_gallery_image( + actor :: Actor.t(), + gallery_id :: Loader.integer_id(), + image_id :: Loader.integer_id() + ) :: + {:ok, Gallery.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_gallery_image(%Actor{} = actor, gallery_id, image_id) do + with :ok <- verify_write_access(actor), + {:ok, gallery_id} <- Loader.parse_id(gallery_id), + {:ok, image_id} <- Loader.parse_id(image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image_id)) + |> Multi.lock_one(:locked_gallery, where(Gallery, id: ^gallery_id)) + |> Multi.run(:authorize, fn _repo, %{locked_image: image, locked_gallery: gallery} -> + with :ok <- authorize(actor, :show, image), + :ok <- authorize(actor, :add_image, gallery) do + {:ok, nil} + end + end) + |> Multi.insert(:interaction, fn %{locked_gallery: gallery} -> + position = (last_position(gallery.id) || -1) + 1 - gallery + %Interaction{gallery_id: gallery.id} + |> Interaction.changeset(%{image_id: image_id, position: position}) + end) + |> Multi.update(:gallery, fn %{locked_gallery: gallery} -> + Gallery.add_image_changeset(gallery) + end) + |> Multi.run(:notification, ¬ify_gallery/2) + |> put_reindex_gallery() + |> Multi.run(:image, fn _repo, %{locked_image: image} -> {:ok, image} end) + |> Images.put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{gallery: %Gallery{} = gallery}} -> + {:ok, gallery} + + {:error, :interaction, _changeset, %{locked_gallery: gallery}} -> + {:error, Gallery.add_duplicate_image_error(gallery)} + + error -> + map_lock_errors(error) + end + end end @doc """ - Returns a list of associations to preload when indexing galleries. + Removes the image named by `image_id` from the gallery named + by `gallery_id`, on behalf of `actor`. + + Loading and authorization follow `add_image_to_gallery/3`. The gallery and + interaction rows are locked in that order. Removing an image that is not in + the gallery returns `{:error, :not_found}`. ## Examples - iex> indexing_preloads() - [:subscribers, :user, :interactions] + iex> delete_gallery_image(actor, "1", "42") + {:ok, %Gallery{}} """ - def indexing_preloads do - [:subscribers, :user, :interactions] + @spec delete_gallery_image( + actor :: Actor.t(), + gallery_id :: Loader.integer_id(), + image_id :: Loader.integer_id() + ) :: + {:ok, Gallery.t()} + | {:error, :ban | :unauthorized | :not_found} + | Ecto.Multi.failure() + def delete_gallery_image(%Actor{} = actor, gallery_id, image_id) do + with :ok <- verify_write_access(actor), + {:ok, gallery_id} <- Loader.parse_id(gallery_id), + {:ok, image_id} <- Loader.parse_id(image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image_id)) + |> Multi.lock_one(:locked_gallery, where(Gallery, id: ^gallery_id)) + |> Multi.run(:authorize, fn _repo, %{locked_image: image, locked_gallery: gallery} -> + with :ok <- authorize(actor, :show, image), + :ok <- authorize(actor, :remove_image, gallery) do + {:ok, nil} + end + end) + |> Multi.lock_one(:locked_interaction, fn %{locked_gallery: gallery} -> + Interaction + |> where(gallery_id: ^gallery.id) + |> where(image_id: ^image_id) + end) + |> Multi.delete(:interaction, fn %{locked_interaction: interaction} -> interaction end) + |> Multi.update(:gallery, fn %{locked_gallery: gallery} -> + Gallery.remove_image_changeset(gallery) + end) + |> put_reindex_gallery() + |> Multi.run(:image, fn _repo, %{locked_image: image} -> {:ok, image} end) + |> Images.put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{gallery: %Gallery{} = gallery}} -> + {:ok, gallery} + + error -> + map_lock_errors(error) + end + end end @doc """ - Reindexes galleries based on a column condition. + Reorders the gallery named by `gallery_id` to the order given by `image_ids`, + on behalf of `actor`. - Updates the search index for all galleries matching the given column condition. - Used for batch reindexing of galleries. + The IDs may be integers or decimal strings, must be unique, and must all + belong to the gallery. The list may contain only the images visible on a + paginated gallery page and may contain at most 250 IDs; omitted memberships + retain their existing positions. + Extra, duplicate, or malformed IDs return the rejected reorder changeset. + Successful reorders return the validated reorder form after the database + update commits. ## Examples - iex> perform_reindex(:id, [1, 2, 3]) - {:ok, [%Gallery{}, ...]} + iex> update_gallery_order(actor, "1", %{image_ids: [3, 1, 2]}) + {:ok, %ReorderForm{}} """ - def perform_reindex(column, condition) do - Gallery - |> preload(^indexing_preloads()) - |> where([g], field(g, ^column) in ^condition) - |> Search.reindex(Gallery) + @spec update_gallery_order(Actor.t(), Loader.integer_id(), map()) :: + {:ok, ReorderForm.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_gallery_order(%Actor{} = actor, gallery_id, params) do + with :ok <- verify_write_access(actor), + {:ok, gallery_id} <- Loader.parse_id(gallery_id), + {:ok, reorder_form} <- + %ReorderForm{} + |> ReorderForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + image_query = + from image in Image, + where: image.id in ^reorder_form.image_ids, + select: image.id, + order_by: [asc: :id] + + Multi.new() + |> Multi.lock_all(:locked_images, image_query) + |> Multi.lock_one(:locked_gallery, where(Gallery, id: ^gallery_id)) + |> Multi.run(:authorize, fn _repo, %{locked_gallery: gallery} -> + with :ok <- authorize(actor, :reorder, gallery) do + {:ok, nil} + end + end) + |> Multi.run(:reorder_form, fn _repo, %{locked_gallery: gallery} -> + valid_image_ids = gallery_image_ids(gallery, reorder_form.image_ids) + + reorder_form + |> ReorderForm.membership_changeset(gallery, valid_image_ids) + |> Ecto.Changeset.apply_action(:update) + end) + |> Multi.run(:reorder, fn _repo, %{locked_gallery: gallery, reorder_form: reorder_form} -> + persist_reorder_positions(gallery, reorder_form.image_ids) + end) + |> Multi.on_commit(fn %{reorder_form: reorder_form} -> + Images.reindex_images(reorder_form.image_ids) + end) + |> Multi.transact() + |> case do + {:ok, %{reorder_form: %ReorderForm{} = reorder_form}} -> + {:ok, reorder_form} + + {:error, :reorder_form, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Adds the specified image to the gallery, updates image count, triggers - notifications, and performs necessary reindexing. + Clears `user`'s unread notifications for the gallery named by + `gallery_id`. - The image is added at the last position. + Any authenticated actor may mark a visible gallery read. This personal + read-state operation is deliberately exempt from the content write-access + gate. Loading uses the shared member contract, so malformed and missing IDs + are always not-found. ## Examples - iex> add_image_to_gallery(gallery, image) - {:ok, - %{ - gallery: %Gallery{}, - interaction: %Interaction{}, - image_count: 1, - notification: %Notification{} - }} + iex> create_gallery_read(user, "1") + {:ok, %Gallery{}} - """ - def add_image_to_gallery(gallery, image) do - Multi.new() - |> Multi.run(:gallery, fn repo, %{} -> - gallery = - Gallery - |> where(id: ^gallery.id) - |> lock("FOR UPDATE") - |> repo.one() + iex> create_gallery_read(user, "nonexistent") + {:error, :not_found} + """ + @spec create_gallery_read(Actor.t(), Loader.integer_id()) :: + {:ok, Gallery.t()} | {:error, :unauthorized | :not_found} + def create_gallery_read(%Actor{user: user} = actor, gallery_id) do + with {:ok, gallery} <- load_gallery(actor, gallery_id, :mark_read) do + clear_gallery_notification(gallery, user) {:ok, gallery} - end) - |> Multi.run(:interaction, fn repo, %{} -> - position = (last_position(gallery.id) || -1) + 1 + end + end - %Interaction{gallery_id: gallery.id} - |> Interaction.changeset(%{"image_id" => image.id, "position" => position}) - |> repo.insert() - end) - |> Multi.run(:image_count, fn repo, %{} -> - now = DateTime.utc_now() + @doc """ + Subscribes `user` to the gallery named by `gallery_id`. - {count, nil} = - Gallery - |> where(id: ^gallery.id) - |> repo.update_all(inc: [image_count: 1], set: [updated_at: now]) + Subscription management is deliberately exempt from + `verify_write_access/1`; gallery visibility and subscription authorization + still apply. - {:ok, count} - end) - |> Multi.run(:notification, ¬ify_gallery/2) - |> Repo.transaction() - |> case do - {:ok, result} -> - Images.reindex_image(image) - reindex_gallery(gallery) + ## Examples - {:ok, result} + iex> create_gallery_subscription(user, "1") + {:ok, %Gallery{}} - error -> - error + """ + @spec create_gallery_subscription(Actor.t(), Loader.integer_id()) :: + {:ok, Gallery.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_gallery_subscription(%Actor{} = actor, gallery_id) do + with {:ok, gallery} <- load_gallery(actor, gallery_id, :subscribe), + {:ok, _subscription} <- create_subscription(gallery, actor.user) do + {:ok, gallery} end end @doc """ - Removes the specified image from the gallery, updates image count, - and performs necessary reindexing. + Unsubscribes `user` from the gallery named by `gallery_id`. ## Examples - iex> remove_image_from_gallery(gallery, image) - {:ok, - %{ - gallery: %Gallery{}, - interaction: 1, - image_count: 0 - }} + iex> delete_gallery_subscription(user, "1") + {:ok, %Gallery{}} """ - def remove_image_from_gallery(gallery, image) do - Multi.new() - |> Multi.run(:gallery, fn repo, %{} -> - gallery = - Gallery - |> where(id: ^gallery.id) - |> lock("FOR UPDATE") - |> repo.one() - + @spec delete_gallery_subscription(Actor.t(), Loader.integer_id()) :: + {:ok, Gallery.t()} | {:error, :unauthorized | :not_found} + def delete_gallery_subscription(%Actor{} = actor, gallery_id) do + with {:ok, gallery} <- load_gallery(actor, gallery_id, :unsubscribe) do + # Deletion is idempotent and cannot fail; the hard match crashes if it does. + {:ok, _subscription} = delete_subscription(gallery, actor.user) {:ok, gallery} - end) - |> Multi.run(:interaction, fn repo, %{} -> - {count, nil} = - Interaction - |> where(gallery_id: ^gallery.id, image_id: ^image.id) - |> repo.delete_all() + end + end - {:ok, count} - end) - |> Multi.run(:image_count, fn repo, %{interaction: interaction_count} -> - now = DateTime.utc_now() + @doc """ + Removes all gallery memberships for an image within `multi`. + Image hiding composes this operation after taking the image lock. Gallery + counters and interaction rows therefore change atomically in the caller's + transaction. + + Affected galleries are reindexed after the transaction commits. + """ + @spec put_remove_image_interactions(Multi.t(), Image.t()) :: Multi.t() + def put_remove_image_interactions(%Multi{} = multi, %Image{} = image) do + galleries = + Gallery + |> join(:inner, [g], gi in assoc(g, :interactions), on: gi.image_id == ^image.id) + |> update(inc: [image_count: -1]) + |> select([g], g.id) + + multi + |> Multi.update_all(:galleries, galleries, []) + |> Multi.delete_all(:gallery_interactions, where(Interaction, image_id: ^image.id), []) + |> Multi.on_commit(fn %{galleries: {_, gallery_ids}} -> reindex_galleries(gallery_ids) end) + end + + @doc """ + Migrates an image's gallery memberships to another image within `multi`. + + Existing target memberships win; leftover source memberships are deleted and + affected gallery counters are adjusted. Image merge workflows must hold both + image locks before composing this operation. + + Affected galleries are reindexed after the transaction commits. + """ + @spec put_migrate_image_interactions(Multi.t(), Image.t(), Image.t()) :: Multi.t() + def put_migrate_image_interactions(%Multi{} = multi, %Image{} = source, %Image{} = target) do + target_gallery_ids = + Interaction + |> where(image_id: ^target.id) + |> select([gi], gi.gallery_id) + + migratable = + Interaction + |> where(image_id: ^source.id) + |> where([gi], gi.gallery_id not in subquery(target_gallery_ids)) + |> update(set: [image_id: ^target.id]) + |> select([gi], gi.gallery_id) + + leftover = + Interaction + |> where(image_id: ^source.id) + |> select([gi], gi.gallery_id) + + multi + |> Multi.update_all(:migrated_gallery_interactions, migratable, []) + |> Multi.delete_all(:gallery_interactions, leftover, []) + |> Multi.run(:galleries, fn repo, %{gallery_interactions: {_count, gallery_ids}} -> {count, nil} = Gallery - |> where(id: ^gallery.id) - |> repo.update_all(inc: [image_count: -interaction_count], set: [updated_at: now]) + |> where([g], g.id in ^gallery_ids) + |> repo.update_all(inc: [image_count: -1]) - {:ok, count} + {:ok, {count, gallery_ids}} + end) + |> Multi.on_commit(fn %{ + migrated_gallery_interactions: {_, migrated_gallery_ids}, + galleries: {_, removed_gallery_ids} + } -> + reindex_galleries(Enum.uniq(migrated_gallery_ids ++ removed_gallery_ids)) end) - |> Repo.transaction() - |> case do - {:ok, result} -> - Images.reindex_image(image) - reindex_gallery(gallery) - - {:ok, result} - - error -> - error - end end - defp notify_gallery(_repo, %{gallery: gallery}) do - Notifications.create_gallery_image_notification(gallery) + @doc false + @spec clear_gallery_notification(Gallery.t(), User.t() | nil) :: :ok + def clear_gallery_notification(%Gallery{} = gallery, user) do + Notifications.clear_gallery_image(gallery, user) + :ok end - defp last_position(gallery_id) do - Interaction - |> where(gallery_id: ^gallery_id) - |> Repo.aggregate(:max, :position) + @doc """ + Updates gallery search indices when a user's name changes. + + ## Examples + + iex> user_name_reindex("old_username", "new_username") + :ok + + """ + @spec user_name_reindex(String.t(), String.t()) :: term() + def user_name_reindex(old_name, new_name) do + data = Galleries.SearchIndex.user_name_update_by_query(old_name, new_name) + + Search.update_by_query(Gallery, data.query, data.set_replacements, data.replacements) end @doc """ - Queues a gallery reorder operation. - Returns the gallery struct unchanged, for use in a pipeline. + Queues a gallery for reindexing. + + Adds the gallery to the indexing queue to update its search index. ## Examples - iex> reorder_gallery(gallery, [1, 2, 3]) + iex> reindex_gallery(gallery) %Gallery{} """ - def reorder_gallery(gallery, image_ids) do - Exq.enqueue(Exq, "indexing", GalleryReorderWorker, [gallery.id, image_ids]) + @spec reindex_gallery(Gallery.t()) :: Gallery.t() + def reindex_gallery(%Gallery{} = gallery) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", [gallery.id]]) gallery end @doc """ - Performs the actual reordering of images in a gallery. - - Reorders the gallery's images according to the provided image IDs list, updating - positions while maintaining relative order for unspecified images. Handles position - updates efficiently and reindexes only the affected images. + Queues multiple galleries for reindexing by their ids. ## Examples - iex> perform_reorder(gallery_id, [3, 1, 2]) - :ok + iex> reindex_galleries([1, 2, 3]) + [1, 2, 3] """ - def perform_reorder(gallery_id, image_ids) do - gallery = get_gallery!(gallery_id) + @spec reindex_galleries([integer()]) :: [integer()] + def reindex_galleries([]), do: [] - interactions = - Interaction - |> where([gi], gi.image_id in ^image_ids and gi.gallery_id == ^gallery.id) - |> order_by(^position_order(gallery)) - |> Repo.all() + def reindex_galleries(gallery_ids) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Galleries", "id", gallery_ids]) - interaction_positions = - interactions - |> Enum.with_index() - |> Map.new(fn {interaction, index} -> {index, interaction.position} end) + gallery_ids + end - images_present = Map.new(interactions, &{&1.image_id, true}) + @doc """ + Removes a gallery from the search index. - requested = - image_ids - |> Enum.filter(&images_present[&1]) - |> Enum.with_index() - |> Map.new() + Deletes the gallery's document from the search index. - changes = - interactions - |> Enum.with_index() - |> Enum.flat_map(fn {interaction, current_index} -> - new_index = requested[interaction.image_id] + ## Examples - if new_index == current_index do - [] - else - [ - [ - id: interaction.id, - position: interaction_positions[new_index] - ] - ] - end - end) + iex> unindex_gallery(gallery) + %Gallery{} - changes - |> Enum.each(fn change -> - id = Keyword.fetch!(change, :id) - change = Keyword.delete(change, :id) + """ + @spec unindex_gallery(Gallery.t()) :: Gallery.t() + def unindex_gallery(%Gallery{} = gallery) do + Search.delete_document(gallery.id, Gallery) - Interaction - |> where([i], i.id == ^id) - |> Repo.update_all(set: change) - end) + gallery + end - # Do the update in a single statement - # Repo.insert_all( - # Interaction, - # changes, - # on_conflict: {:replace, [:position]}, - # conflict_target: [:id] - # ) + @doc """ + Returns a list of associations to preload when indexing galleries. - # Now update all the associated images - Images.reindex_images(Map.keys(requested)) + ## Examples - :ok - end + iex> indexing_preloads() + [:subscribers, :user, :interactions] - defp position_order(%{order_position_asc: true}), do: [asc: :position] - defp position_order(_gallery), do: [desc: :position] + """ + @spec indexing_preloads() :: [atom()] + def indexing_preloads do + [:subscribers, :user, :interactions] + end @doc """ - Removes all gallery notifications for a given gallery and user. + Reindexes galleries based on a column condition. + + Updates the search index for all galleries matching the given column condition. + Used for batch reindexing of galleries. ## Examples - iex> clear_gallery_notification(gallery, user) - :ok + iex> perform_reindex(:id, [1, 2, 3]) + {:ok, [%Gallery{}, ...]} """ - def clear_gallery_notification(%Gallery{} = gallery, user) do - Notifications.clear_gallery_image_notification(gallery, user) - :ok + @spec perform_reindex(atom(), [term()]) :: term() + def perform_reindex(column, condition) do + Gallery + |> preload(^indexing_preloads()) + |> where([g], field(g, ^column) in ^condition) + |> Search.reindex(Gallery) end end diff --git a/lib/philomena/galleries/gallery.ex b/lib/philomena/galleries/gallery.ex index 17a7726e5..edc6a5be2 100644 --- a/lib/philomena/galleries/gallery.ex +++ b/lib/philomena/galleries/gallery.ex @@ -4,15 +4,19 @@ defmodule Philomena.Galleries.Gallery do alias Philomena.Images.Image alias Philomena.Users.User + alias Philomena.Reports.Report alias Philomena.Galleries.Interaction alias Philomena.Galleries.Subscription + @type t :: %__MODULE__{} + schema "galleries" do belongs_to :thumbnail, Image, source: :thumbnail_id belongs_to :user, User has_many :interactions, Interaction has_many :subscriptions, Subscription has_many :subscribers, through: [:subscriptions, :user] + has_many :reports, Report field :title, :string field :spoiler_warning, :string, default: "" @@ -25,7 +29,7 @@ defmodule Philomena.Galleries.Gallery do end @doc false - def changeset(gallery, attrs) do + def changeset(gallery, attrs \\ %{}) do gallery |> cast(attrs, [ :thumbnail_id, @@ -42,6 +46,23 @@ defmodule Philomena.Galleries.Gallery do |> foreign_key_constraint(:thumbnail_id, name: :fk_rails_792181eb40) end + @doc false + def add_image_changeset(%__MODULE__{} = gallery) do + change(gallery, image_count: gallery.image_count + 1) + end + + @doc false + def remove_image_changeset(%__MODULE__{} = gallery) do + change(gallery, image_count: gallery.image_count - 1) + end + + @doc false + def add_duplicate_image_error(%__MODULE__{} = gallery) do + gallery + |> changeset() + |> add_error(:interactions, "image is already in this gallery") + end + @doc false def creation_changeset(gallery, attrs, user) do changeset(gallery, attrs) diff --git a/lib/philomena/galleries/gallery_page.ex b/lib/philomena/galleries/gallery_page.ex new file mode 100644 index 000000000..56afa5d17 --- /dev/null +++ b/lib/philomena/galleries/gallery_page.ex @@ -0,0 +1,45 @@ +defmodule Philomena.Galleries.GalleryPage do + @moduledoc """ + Everything the gallery page needs for one viewer: the gallery, the + visible page of its images (paired with their search hits), the images on + the adjacent pages folded into one ordered list, whether + those adjacent pages exist, the viewer's interactions, and their + subscription state. + + `gallery_images` is the concatenation of the previous page, this page, and + the next page, each entry a `{image, hit}` tuple; `gallery_prev` and + `gallery_next` report only whether those neighbouring pages hold anything. + """ + + alias Philomena.Galleries.Gallery + alias Philomena.Images.Image + + @enforce_keys [ + :gallery, + :images, + :gallery_images, + :gallery_prev, + :gallery_next, + :interactions, + :watching + ] + defstruct [ + :gallery, + :images, + :gallery_images, + :gallery_prev, + :gallery_next, + :interactions, + :watching + ] + + @type t :: %__MODULE__{ + gallery: Gallery.t(), + images: Scrivener.Page.t(), + gallery_images: [{Image.t(), map()}], + gallery_prev: boolean(), + gallery_next: boolean(), + interactions: list(), + watching: boolean() + } +end diff --git a/lib/philomena/galleries/query_builder.ex b/lib/philomena/galleries/query_builder.ex new file mode 100644 index 000000000..bf96a4de6 --- /dev/null +++ b/lib/philomena/galleries/query_builder.ex @@ -0,0 +1,95 @@ +defmodule Philomena.Galleries.QueryBuilder do + @moduledoc false + + alias Philomena.Galleries.QueryForm + + @doc """ + Builds a gallery search query based on the given parameters. + + ## Parameters + + * `params` - Map of optional search parameters: + * `title` - Filter by title + * `creator` - Filter by creator name + * `included_image` - Filter by galleries containing the image ID + * `description` - Filter by description + * `sf` - Sort field: + * `created_at` - The gallery's creation date + * `updated_at` - The gallery's last update date + * `image_count` - The number of images in the gallery + * `subscriber_count` - The number of subscribers + * `_score` - Relevance + * `sd` - Sort direction: + * `asc` - Results ascending by `sf` + * `desc` - Results descending by `sf` + + Returns `{:ok, query, query_form}` with an OpenSearch query body for `Galleries` + that can be used with `PhilomenaQuery.Search`, or `{:error, changeset}` if the + provided parameters are invalid. + """ + @spec build_query(map()) :: {:ok, map(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + [] + |> maybe_query_title(query_form) + |> maybe_query_creator(query_form) + |> maybe_query_include_image(query_form) + |> maybe_query_description(query_form) + |> combine_clauses() + |> apply_sort(query_form) + + {:ok, query, query_form} + end + end + + defp maybe_query_title(query, %QueryForm{title: title}) do + if title do + [%{wildcard: %{title: "*#{String.downcase(title)}*"}} | query] + else + query + end + end + + defp maybe_query_creator(query, %QueryForm{creator: creator}) do + if creator do + [%{term: %{creator: String.downcase(creator)}} | query] + else + query + end + end + + defp maybe_query_include_image(query, %QueryForm{include_image: image_id}) do + if image_id do + [%{term: %{image_ids: image_id}} | query] + else + query + end + end + + defp maybe_query_description(query, %QueryForm{description: description}) do + if description do + [%{match_phrase: %{description: description}} | query] + else + query + end + end + + defp combine_clauses([]), do: %{match_all: %{}} + defp combine_clauses(clauses), do: %{bool: %{must: clauses}} + + defp apply_sort(query, %QueryForm{sf: sf, sd: sd}) do + %{ + query: query, + sort: + if sf == "created_at" do + [%{created_at: sd}, %{id: sd}] + else + [%{sf => sd}, %{created_at: sd}, %{id: sd}] + end + } + end +end diff --git a/lib/philomena/galleries/query_form.ex b/lib/philomena/galleries/query_form.ex new file mode 100644 index 000000000..eb6ae5fb3 --- /dev/null +++ b/lib/philomena/galleries/query_form.ex @@ -0,0 +1,24 @@ +defmodule Philomena.Galleries.QueryForm do + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :title, :string + field :creator, :string + field :include_image, :integer + field :description, :string + field :sf, :string, default: "created_at" + field :sd, :string, default: "desc" + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + query_form + |> cast(attrs, [:title, :creator, :include_image, :description, :sf, :sd]) + |> validate_inclusion(:sf, ~w(created_at updated_at image_count subscriber_count _score)) + |> validate_inclusion(:sd, ~w(asc desc)) + |> validate_required([:sf, :sd]) + end +end diff --git a/lib/philomena/galleries/reorder_form.ex b/lib/philomena/galleries/reorder_form.ex new file mode 100644 index 000000000..4a36fc9f0 --- /dev/null +++ b/lib/philomena/galleries/reorder_form.ex @@ -0,0 +1,45 @@ +defmodule Philomena.Galleries.ReorderForm do + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @max_image_ids 250 + + alias Philomena.Galleries.Gallery + + embedded_schema do + belongs_to :gallery, Gallery + + field :image_ids, {:array, :integer} + end + + @doc false + def changeset(%__MODULE__{} = reorder_form, attrs \\ %{}) do + reorder_form + |> cast(attrs, [:image_ids]) + |> validate_required(:image_ids) + |> validate_length(:image_ids, min: 1, max: @max_image_ids) + |> validate_change(:image_ids, fn :image_ids, image_ids -> + if Enum.uniq(image_ids) == image_ids do + [] + else + [image_ids: "must contain unique image IDs"] + end + end) + end + + @doc false + def membership_changeset(%__MODULE__{} = reorder_form, %Gallery{} = gallery, valid_image_ids) do + changeset = + reorder_form + |> Map.put(:gallery, gallery) + |> change() + + if Enum.sort(valid_image_ids) == Enum.sort(reorder_form.image_ids) do + changeset + else + add_error(changeset, :image_ids, "must belong to the gallery") + end + end +end diff --git a/lib/philomena/image_faves.ex b/lib/philomena/image_faves.ex index 69d0f1ed7..aa3d8abcc 100644 --- a/lib/philomena/image_faves.ex +++ b/lib/philomena/image_faves.ex @@ -1,60 +1,137 @@ defmodule Philomena.ImageFaves do @moduledoc """ - The ImageFaves context. + Transaction steps for favorite rows owned by `Philomena.Images`. + + This module performs no authorization. Its functions require an + already-loaded image and user after the owning context has enforced + prerequisites. """ import Ecto.Query, warn: false - alias Ecto.Multi + alias Philomena.Multi alias Philomena.ImageFaves.ImageFave - alias Philomena.UserStatistics + alias Philomena.Images alias Philomena.Images.Image + alias Philomena.UserStatistics + alias Philomena.Users.User + alias Philomena.Repo + alias PhilomenaQuery.Batch + + defp delete_fave_steps(multi, image, user) do + fave_query = + ImageFave + |> where(image_id: ^image.id) + |> where(user_id: ^user.id) + + multi + |> Multi.delete_all(:unfave, fave_query) + |> Images.put_image_counter_delta(:dec_faves_count, image.id, :faves_count, fn + %{unfave: {faves, nil}} -> -faves + end) + |> Multi.merge(fn %{unfave: {faves, nil}} -> + UserStatistics.put_increment(Multi.new(), user, :image_faves_count, -faves) + end) + end @doc """ - Creates a image_hide. + Adds favorite steps for a loaded image and user to `multi`. + + The caller must have authorized the loaded image. The steps first remove any + existing favorite, adjusting `faves_count` and the user's image-fave + statistic by the number of rows removed, and then insert one row and add one + to both counters. Repeated execution is therefore idempotent. The changes are + named `:unfave`, `:dec_faves_count`, `:fave`, `:inc_faves_count`, and + `:inc_fave_stat`. + + ## Examples + + iex> (Multi.new() + ...> |> put_fave_for_loaded_image(image, user) + ...> |> Multi.transact()) + {:ok, %{fave: %ImageFave{}}} """ - def create_fave_transaction(image, user) do + @spec put_fave_for_loaded_image(Multi.t(), Image.t(), User.t()) :: Multi.t() + def put_fave_for_loaded_image(%Multi{} = multi, %Image{} = image, %User{} = user) do fave = %ImageFave{image_id: image.id, user_id: user.id} |> ImageFave.changeset(%{}) - image_query = - Image - |> where(id: ^image.id) - - Multi.new() + multi + |> delete_fave_steps(image, user) |> Multi.insert(:fave, fave) - |> Multi.update_all(:inc_faves_count, image_query, inc: [faves_count: 1]) - |> Multi.run(:inc_fave_stat, fn _repo, _changes -> - UserStatistics.inc_stat(user, :image_faves_count, 1) - end) + |> Images.put_image_counter_delta(:inc_faves_count, image.id, :faves_count, 1) + |> UserStatistics.put_increment(user, :image_faves_count, 1) end @doc """ - Deletes a ImageFave. + Adds favorite deletion steps for a loaded image and user to `multi`. + + The caller must have authorized the loaded image.`:dec_faves_count` adjusts + the image and user counters by that exact number, so deleting an absent + favorite changes nothing. + + ## Examples + + iex> (Multi.new() + ...> |> delete_fave_for_loaded_image(image, user) + ...> |> Multi.transact()) + {:ok, %{unfave: {0, nil}}} """ - def delete_fave_transaction(image, user) do - fave_query = - ImageFave - |> where(image_id: ^image.id) - |> where(user_id: ^user.id) + @spec delete_fave_for_loaded_image(Multi.t(), Image.t(), User.t()) :: Multi.t() + def delete_fave_for_loaded_image(%Multi{} = multi, %Image{} = image, %User{} = user) do + delete_fave_steps(multi, image, user) + end - image_query = - Image - |> where(id: ^image.id) + @doc """ + Deletes all of a user's favorites in batches and returns `{count, image_ids}`. - Multi.new() - |> Multi.delete_all(:unfave, fave_query) - |> Multi.run(:dec_faves_count, fn repo, %{unfave: {faves, nil}} -> - {count, nil} = - image_query - |> repo.update_all(inc: [faves_count: -faves]) + Image and user counters are adjusted by their owning contexts afterward. + """ + @spec delete_user_faves!(integer()) :: {non_neg_integer(), [integer()]} + def delete_user_faves!(user_id) when is_integer(user_id) do + ImageFave + |> where(user_id: ^user_id) + |> Batch.query_batches(id_field: :image_id) + |> Enum.reduce({0, []}, fn batch, {count, image_ids} -> + ids = Repo.all(select(batch, [fave], fave.image_id)) + {deleted, _} = Repo.delete_all(batch) + {count + deleted, ids ++ image_ids} + end) + |> then(fn {count, image_ids} -> {count, Enum.uniq(image_ids)} end) + end + + @doc """ + Inserts image favorite interactions for a merge target inside `multi`. + + The source interaction snapshot is expected at `:interaction_source`. + """ + @spec put_migrate_image_interactions(Multi.t(), Image.t()) :: Multi.t() + def put_migrate_image_interactions(%Multi{} = multi, %Image{} = target) do + multi + |> Multi.run( + :interaction_faves, + fn repo, %{interaction_source: %{source: source, created_at: created_at}} -> + rows = + Enum.map(source.favers, &%{image_id: target.id, user_id: &1.id, created_at: created_at}) - UserStatistics.inc_stat(user, :image_faves_count, -faves) + {count, inserted} = + repo.insert_all(ImageFave, rows, + on_conflict: :nothing, + returning: [:user_id] + ) - {:ok, count} + {:ok, {count, inserted}} + end + ) + |> Multi.merge(fn %{interaction_faves: {_count, rows}} -> + UserStatistics.put_bulk_increment( + Multi.new(), + Enum.map(rows, & &1.user_id), + :image_faves_count + ) end) end end diff --git a/lib/philomena/image_features.ex b/lib/philomena/image_features.ex deleted file mode 100644 index 49a8235a1..000000000 --- a/lib/philomena/image_features.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Philomena.ImageFeatures do - @moduledoc """ - The ImageFeatures context. - """ - - import Ecto.Query, warn: false - alias Philomena.Repo - - alias Philomena.ImageFeatures.ImageFeature - - @doc """ - Returns the list of image_features. - - ## Examples - - iex> list_image_features() - [%ImageFeature{}, ...] - - """ - def list_image_features do - Repo.all(ImageFeature) - end - - @doc """ - Gets a single image_feature. - - Raises `Ecto.NoResultsError` if the Image feature does not exist. - - ## Examples - - iex> get_image_feature!(123) - %ImageFeature{} - - iex> get_image_feature!(456) - ** (Ecto.NoResultsError) - - """ - def get_image_feature!(id), do: Repo.get!(ImageFeature, id) - - @doc """ - Creates a image_feature. - - ## Examples - - iex> create_image_feature(%{field: value}) - {:ok, %ImageFeature{}} - - iex> create_image_feature(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_image_feature(attrs \\ %{}) do - %ImageFeature{} - |> ImageFeature.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a image_feature. - - ## Examples - - iex> update_image_feature(image_feature, %{field: new_value}) - {:ok, %ImageFeature{}} - - iex> update_image_feature(image_feature, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_image_feature(%ImageFeature{} = image_feature, attrs) do - image_feature - |> ImageFeature.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a ImageFeature. - - ## Examples - - iex> delete_image_feature(image_feature) - {:ok, %ImageFeature{}} - - iex> delete_image_feature(image_feature) - {:error, %Ecto.Changeset{}} - - """ - def delete_image_feature(%ImageFeature{} = image_feature) do - Repo.delete(image_feature) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking image_feature changes. - - ## Examples - - iex> change_image_feature(image_feature) - %Ecto.Changeset{source: %ImageFeature{}} - - """ - def change_image_feature(%ImageFeature{} = image_feature) do - ImageFeature.changeset(image_feature, %{}) - end -end diff --git a/lib/philomena/image_features/image_feature.ex b/lib/philomena/image_features/image_feature.ex index 4411391a0..8f423ad6f 100644 --- a/lib/philomena/image_features/image_feature.ex +++ b/lib/philomena/image_features/image_feature.ex @@ -7,6 +7,8 @@ defmodule Philomena.ImageFeatures.ImageFeature do @primary_key false + @type t :: %__MODULE__{} + schema "image_features" do belongs_to :image, Image belongs_to :user, User @@ -15,7 +17,7 @@ defmodule Philomena.ImageFeatures.ImageFeature do end @doc false - def changeset(image_feature, attrs) do + def changeset(image_feature, attrs \\ %{}) do image_feature |> cast(attrs, []) |> validate_required([]) diff --git a/lib/philomena/image_hides.ex b/lib/philomena/image_hides.ex index 3a6dc79b4..caed6b0c6 100644 --- a/lib/philomena/image_hides.ex +++ b/lib/philomena/image_hides.ex @@ -1,54 +1,100 @@ defmodule Philomena.ImageHides do @moduledoc """ - The ImageHides context. + Transaction steps for hide rows owned by `Philomena.Images`. + + This module performs no authorization. Its functions require an + already-loaded image and user after the owning context has enforced + prerequisites. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Images.Image + alias Philomena.Multi alias Philomena.ImageHides.ImageHide + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Users.User + + defp delete_hide_steps(multi, image, user) do + hide_query = + ImageHide + |> where(image_id: ^image.id) + |> where(user_id: ^user.id) + + multi + |> Multi.delete_all(:unhide, hide_query) + |> Images.put_image_counter_delta(:dec_hides_count, image.id, :hides_count, fn + %{unhide: {hides, nil}} -> -hides + end) + end @doc """ - Creates a image_hide. + Adds hide steps for a loaded image and user to `multi`. + + The caller must have authorized the loaded image. The steps first remove any + existing favorite, adjusting `hides_count` by the number of rows removed, and + then insert one row and add one to both counters. Repeated execution is + therefore idempotent. The changes are named `:unhide`, `:dec_hides_count`, + `:hide`, and `:inc_hides_count` + + ## Examples + + iex> (Multi.new() + ...> |> put_hide_for_loaded_image(image, user) + ...> |> Multi.transact()) + {:ok, %{hide: %ImageHide{}}} """ - def create_hide_transaction(image, user) do + @spec put_hide_for_loaded_image(Multi.t(), Image.t(), User.t()) :: Multi.t() + def put_hide_for_loaded_image(%Multi{} = multi, %Image{} = image, %User{} = user) do hide = %ImageHide{image_id: image.id, user_id: user.id} |> ImageHide.changeset(%{}) - image_query = - Image - |> where(id: ^image.id) - - Multi.new() + multi + |> delete_hide_steps(image, user) |> Multi.insert(:hide, hide) - |> Multi.update_all(:inc_hides_count, image_query, inc: [hides_count: 1]) + |> Images.put_image_counter_delta(:inc_hides_count, image.id, :hides_count, 1) end @doc """ - Deletes a ImageHide. + Adds hide deletion steps for a loaded image and user to `multi`. + + The caller must have authorized the loaded image. `:dec_hides_count` adjusts + the image counter by that exact number, so deleting an absent hide changes + nothing. + + ## Examples + + iex> (Multi.new() + ...> |> delete_hide_for_loaded_image(image, user) + ...> |> Multi.transact()) + {:ok, %{unhide: {0, nil}}} """ - def delete_hide_transaction(image, user) do - hide_query = - ImageHide - |> where(image_id: ^image.id) - |> where(user_id: ^user.id) + @spec delete_hide_for_loaded_image(Multi.t(), Image.t(), User.t()) :: Multi.t() + def delete_hide_for_loaded_image(%Multi{} = multi, %Image{} = image, %User{} = user) do + delete_hide_steps(multi, image, user) + end - image_query = - Image - |> where(id: ^image.id) + @doc """ + Inserts image hide interactions for a merge target inside `multi`. - Multi.new() - |> Multi.delete_all(:unhide, hide_query) - |> Multi.run(:dec_hides_count, fn repo, %{unhide: {hides, nil}} -> - {count, nil} = - image_query - |> repo.update_all(inc: [hides_count: -hides]) + The source interaction snapshot is expected at `:interaction_source`. + """ + @spec put_migrate_image_interactions(Multi.t(), Image.t()) :: Multi.t() + def put_migrate_image_interactions(%Multi{} = multi, %Image{} = target) do + Multi.run( + multi, + :interaction_hides, + fn repo, %{interaction_source: %{source: source, created_at: created_at}} -> + rows = + Enum.map(source.hiders, &%{image_id: target.id, user_id: &1.id, created_at: created_at}) - {:ok, count} - end) + {count, nil} = repo.insert_all(ImageHide, rows, on_conflict: :nothing) + + {:ok, count} + end + ) end end diff --git a/lib/philomena/image_intensities.ex b/lib/philomena/image_intensities.ex index 34c311d50..95bcda325 100644 --- a/lib/philomena/image_intensities.ex +++ b/lib/philomena/image_intensities.ex @@ -1,91 +1,37 @@ defmodule Philomena.ImageIntensities do @moduledoc """ - The ImageIntensities context. - """ + Persistence for image intensity data derived by the media pipeline. - import Ecto.Query, warn: false - alias Philomena.Repo + Intensities are owned by an already-loaded image and are not caller-managed + or request-authorized records. + """ alias Philomena.ImageIntensities.ImageIntensity + alias Philomena.Images.Image + alias Philomena.Repo + alias PhilomenaMedia.Intensities @doc """ - Gets a single image_intensity. - - Raises `Ecto.NoResultsError` if the Image intensity does not exist. - - ## Examples - - iex> get_image_intensity!(123) - %ImageIntensity{} - - iex> get_image_intensity!(456) - ** (Ecto.NoResultsError) - - """ - def get_image_intensity!(id), do: Repo.get!(ImageIntensity, id) + Stores the derived intensities for a loaded image. - @doc """ - Creates a image_intensity. + There is exactly one row per image, enforced by the database, and the + row is deleted by the image foreign key when its image is deleted. ## Examples - iex> create_image_intensity(%{field: value}) - {:ok, %ImageIntensity{}} - - iex> create_image_intensity(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> put_for_loaded_image(image, %Intensities{nw: 0.1, ne: 0.2, sw: 0.3, se: 0.4}) + {:ok, %ImageIntensity{image_id: 42}} """ - def create_image_intensity(image, attrs \\ %PhilomenaMedia.Intensities{}) do + @spec put_for_loaded_image(Image.t(), Intensities.t()) :: + {:ok, ImageIntensity.t()} | {:error, Ecto.Changeset.t()} + def put_for_loaded_image(%Image{} = image, %Intensities{} = intensities) do %ImageIntensity{image_id: image.id} - |> ImageIntensity.changeset(Map.from_struct(attrs)) - |> Repo.insert() - end - - @doc """ - Updates a image_intensity. - - ## Examples - - iex> update_image_intensity(image_intensity, %{field: new_value}) - {:ok, %ImageIntensity{}} - - iex> update_image_intensity(image_intensity, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_image_intensity(%ImageIntensity{} = image_intensity, attrs) do - image_intensity - |> ImageIntensity.changeset(Map.from_struct(attrs)) - |> Repo.update() - end - - @doc """ - Deletes a ImageIntensity. - - ## Examples - - iex> delete_image_intensity(image_intensity) - {:ok, %ImageIntensity{}} - - iex> delete_image_intensity(image_intensity) - {:error, %Ecto.Changeset{}} - - """ - def delete_image_intensity(%ImageIntensity{} = image_intensity) do - Repo.delete(image_intensity) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking image_intensity changes. - - ## Examples - - iex> change_image_intensity(image_intensity) - %Ecto.Changeset{source: %ImageIntensity{}} - - """ - def change_image_intensity(%ImageIntensity{} = image_intensity) do - ImageIntensity.changeset(image_intensity, %{}) + |> ImageIntensity.changeset(Map.from_struct(intensities)) + |> Repo.insert( + conflict_target: [:image_id], + on_conflict: {:replace, [:nw, :ne, :sw, :se]}, + returning: true + ) end end diff --git a/lib/philomena/image_intensities/image_intensity.ex b/lib/philomena/image_intensities/image_intensity.ex index 89500c818..2a79adab9 100644 --- a/lib/philomena/image_intensities/image_intensity.ex +++ b/lib/philomena/image_intensities/image_intensity.ex @@ -6,6 +6,8 @@ defmodule Philomena.ImageIntensities.ImageIntensity do @primary_key false + @type t :: %__MODULE__{} + schema "image_intensities" do belongs_to :image, Image, primary_key: true diff --git a/lib/philomena/image_votes.ex b/lib/philomena/image_votes.ex index 74a525b9d..183028949 100644 --- a/lib/philomena/image_votes.ex +++ b/lib/philomena/image_votes.ex @@ -1,76 +1,153 @@ defmodule Philomena.ImageVotes do @moduledoc """ - The ImageVotes context. + Transaction steps for vote rows owned by `Philomena.Images`. + + This module performs no authorization. Its functions require an + already-loaded image and user after the owning context has enforced + prerequisites. """ import Ecto.Query, warn: false - alias Ecto.Multi + alias Philomena.Multi + alias Philomena.Images + alias Philomena.Images.Image alias Philomena.ImageVotes.ImageVote alias Philomena.UserStatistics - alias Philomena.Images.Image + alias Philomena.Users.User + alias Philomena.Repo + alias PhilomenaQuery.Batch + + defp delete_vote_steps(multi, image, user) do + user_vote_query = + ImageVote + |> where(image_id: ^image.id) + |> where(user_id: ^user.id) + + multi + |> Multi.delete_all(:unupvote, where(user_vote_query, up: true)) + |> Multi.delete_all(:undownvote, where(user_vote_query, up: false)) + |> Images.put_image_counter_delta(:dec_upvotes_count, image.id, :upvotes_count, fn + %{unupvote: {upvotes, nil}} -> -upvotes + end) + |> Images.put_image_counter_delta(:dec_downvotes_count, image.id, :downvotes_count, fn + %{undownvote: {downvotes, nil}} -> -downvotes + end) + |> Images.put_image_counter_delta(:dec_score, image.id, :score, fn + %{unupvote: {upvotes, nil}, undownvote: {downvotes, nil}} -> downvotes - upvotes + end) + |> Multi.merge(fn %{unupvote: {upvotes, nil}, undownvote: {downvotes, nil}} -> + UserStatistics.put_increment(Multi.new(), user, :image_votes_count, -(upvotes + downvotes)) + end) + end @doc """ - Creates a image_vote. + Adds vote steps for a loaded image and user to `multi`. + + The caller must have authorized the image. Any existing direction is removed + before the requested direction is inserted, with score, image direction counters, + and the user's vote statistic adjusted by the actual row deltas. Repeated votes + and direction changes are idempotent. The changes are named `:unupvote`, + `:undownvote`, `:dec_votes_count`, `:vote`, `:inc_vote_count`, and `:inc_vote_stat`. + + ## Examples + + iex> (Multi.new() + ...> |> put_vote_for_loaded_image(image, user, true) + ...> |> Multi.transact()) + {:ok, %{vote: %ImageVote{up: true}}} """ - def create_vote_transaction(image, user, up) do + @spec put_vote_for_loaded_image(Multi.t(), Image.t(), User.t(), boolean()) :: Multi.t() + def put_vote_for_loaded_image(%Multi{} = multi, %Image{} = image, %User{} = user, up) + when is_boolean(up) do vote = %ImageVote{image_id: image.id, user_id: user.id, up: up} |> ImageVote.changeset(%{}) - image_query = - Image - |> where(id: ^image.id) - upvotes = if up, do: 1, else: 0 downvotes = if up, do: 0, else: 1 - Multi.new() + multi + |> delete_vote_steps(image, user) |> Multi.insert(:vote, vote) - |> Multi.update_all(:inc_vote_count, image_query, - inc: [upvotes_count: upvotes, downvotes_count: downvotes, score: upvotes - downvotes] - ) - |> Multi.run(:inc_vote_stat, fn _repo, _changes -> - UserStatistics.inc_stat(user, :image_votes_count, 1) + |> Images.put_image_counter_delta(:inc_upvotes_count, image.id, :upvotes_count, upvotes) + |> Images.put_image_counter_delta(:inc_downvotes_count, image.id, :downvotes_count, downvotes) + |> Images.put_image_counter_delta(:inc_score, image.id, :score, upvotes - downvotes) + |> UserStatistics.put_increment(user, :image_votes_count, 1) + end + + @doc """ + Adds vote deletion steps for a loaded image and user to `multi`. + + The caller must have authorized the loaded image. The two delete changes + report the removed direction, and `:dec_votes_count` adjusts score, image + counters, and the user statistic by those exact deltas. Deleting an absent + vote changes nothing. + + ## Examples + + iex> (Multi.new() + ...> |> delete_vote_for_loaded_image(image, user) + ...> |> Multi.transact()) + {:ok, %{unupvote: {0, nil}, undownvote: {0, nil}}} + + """ + @spec delete_vote_for_loaded_image(Multi.t(), Image.t(), User.t()) :: Multi.t() + def delete_vote_for_loaded_image(%Multi{} = multi, %Image{} = image, %User{} = user) do + delete_vote_steps(multi, image, user) + end + + @doc """ + Deletes all of a user's votes in batches and returns `{count, image_ids}`. + + Image counters and the user's lifetime counter are adjusted separately by + their owning contexts. + """ + @spec delete_user_votes!(integer(), boolean()) :: {non_neg_integer(), [integer()]} + def delete_user_votes!(user_id, up) when is_integer(user_id) and is_boolean(up) do + query = where(ImageVote, user_id: ^user_id, up: ^up) + + query + |> Batch.query_batches(id_field: :image_id) + |> Enum.reduce({0, []}, fn batch, {count, image_ids} -> + ids = Repo.all(select(batch, [vote], vote.image_id)) + {deleted, _} = Repo.delete_all(batch) + {count + deleted, ids ++ image_ids} end) + |> then(fn {count, image_ids} -> {count, Enum.uniq(image_ids)} end) end @doc """ - Deletes a ImageVote. + Inserts image vote interactions for a merge target inside `multi`. + The source interaction snapshot is expected at `:interaction_source`. """ - def delete_vote_transaction(image, user) do - upvote_query = - ImageVote - |> where(image_id: ^image.id) - |> where(user_id: ^user.id) - |> where(up: true) + @spec put_migrate_image_interactions(Multi.t(), Image.t(), Multi.name(), boolean()) :: Multi.t() + def put_migrate_image_interactions(%Multi{} = multi, %Image{} = target, step, up) + when is_boolean(up) do + multi + |> Multi.run(step, fn repo, + %{interaction_source: %{source: source, created_at: created_at}} -> + voters = if up, do: source.upvoters, else: source.downvoters - downvote_query = - ImageVote - |> where(image_id: ^image.id) - |> where(user_id: ^user.id) - |> where(up: false) - - image_query = - Image - |> where(id: ^image.id) - - Multi.new() - |> Multi.delete_all(:unupvote, upvote_query) - |> Multi.delete_all(:undownvote, downvote_query) - |> Multi.run(:dec_votes_count, fn repo, - %{unupvote: {upvotes, nil}, undownvote: {downvotes, nil}} -> - {count, nil} = - image_query - |> repo.update_all( - inc: [upvotes_count: -upvotes, downvotes_count: -downvotes, score: downvotes - upvotes] - ) + rows = + Enum.map(voters, &%{image_id: target.id, user_id: &1.id, created_at: created_at, up: up}) - UserStatistics.inc_stat(user, :image_votes_count, -(upvotes + downvotes)) + {count, inserted} = + repo.insert_all(ImageVote, rows, + on_conflict: :nothing, + returning: [:user_id] + ) - {:ok, count} + {:ok, {count, inserted}} + end) + |> Multi.merge(fn %{^step => {_count, rows}} -> + UserStatistics.put_bulk_increment( + Multi.new(), + Enum.map(rows, & &1.user_id), + :image_votes_count + ) end) end end diff --git a/lib/philomena/images.ex b/lib/philomena/images.ex index dc0602708..41bef896c 100644 --- a/lib/philomena/images.ex +++ b/lib/philomena/images.ex @@ -1,31 +1,54 @@ defmodule Philomena.Images do @moduledoc """ - The Images context. + Image browsing, uploads, metadata, moderation, interactions, and indexing. + + Request-facing operations accept an actor and load image locators before + authorizing the requested action. Worker and cross-context services are + named separately from that controller boundary. """ import Ecto.Query, warn: false + + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + require Logger - alias Ecto.Multi + alias Philomena.Multi alias Philomena.Repo alias PhilomenaQuery.Search alias Philomena.ThumbnailWorker alias Philomena.ImagePurgeWorker - alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.DuplicateReports + alias Philomena.DnpEntries alias Philomena.Images.Image + alias Philomena.Images.Filtering alias Philomena.Images.Uploader alias Philomena.Images.Tagging alias Philomena.Images.Thumbnailer alias Philomena.Images.Source + alias Philomena.Images.SourceInputForm + alias Philomena.Images.SourceDiffer + alias Philomena.Images.TagDiffer + alias Philomena.Images.TagInputForm + alias Philomena.Images.BatchTagForm + alias Philomena.Images.VoteForm + alias Philomena.Images.Subscription alias Philomena.Images + alias Philomena.IntegerId alias Philomena.IndexWorker + alias Philomena.Loader + alias Philomena.RateLimiter + alias Philomena.Attribution.Actor + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths alias Philomena.ImageFeatures.ImageFeature - alias Philomena.SourceChanges.SourceChange - alias Philomena.Notifications.ImageCommentNotification - alias Philomena.Notifications.ImageMergeNotification + alias Philomena.ImageVotes + alias Philomena.ImageHides + alias Philomena.ImageFaves + alias Philomena.SourceChanges alias Philomena.TagChanges - alias Philomena.TagChanges.TagChange alias Philomena.TagChanges.Limits alias Philomena.Tags alias Philomena.UserStatistics @@ -35,108 +58,372 @@ defmodule Philomena.Images do alias Philomena.Reports alias Philomena.Comments alias Philomena.Galleries - alias Philomena.Galleries.Gallery - alias Philomena.Galleries.Interaction - alias Philomena.Users.User + alias Philomena.Images.ImagePage + alias Philomena.Images.Query, as: ImageQuery + alias Philomena.Images.Search, as: ImageSearch + alias Philomena.Images.Search.Scope + alias Philomena.SourceChanges.SourceChange alias Philomena.Users + alias Philomena.Users.User + alias PhilomenaWeb.Api.Json.ImageView + alias PhilomenaWeb.Endpoint + alias PhilomenaQuery.Batch + + @source_update_window 5 + @tag_update_window 5 + @batch_tag_size 1_000 use Philomena.Subscriptions, on_delete: :clear_image_notification, id_name: :image_id - @doc """ - Gets a single image. + ## Shared locators - Raises `Ecto.NoResultsError` if the Image does not exist. + defp load_image_member(%Actor{} = actor, action, image_id, preloads \\ []) do + Loader.fetch_and_authorize(Image, actor, action, image_id, preloads) + end - ## Examples + ## Query helpers - iex> get_image!(123) - %Image{} + defp maybe_exclude_viewer_hides(query, %Actor{user: nil}, _include_hidden?), do: query + defp maybe_exclude_viewer_hides(query, %Actor{}, true), do: query - iex> get_image!(456) - ** (Ecto.NoResultsError) + defp maybe_exclude_viewer_hides(query, %Actor{user: user}, false) do + where( + query, + [image], + fragment( + "NOT EXISTS(SELECT 1 FROM image_hides WHERE image_id = ? AND user_id = ?)", + image.id, + ^user.id + ) + ) + end - """ - def get_image!(id) do - Repo.one!(Image |> where(id: ^id) |> preload(:tags)) + defp custom_ordering?(%{sf: sf}) when sf not in [nil, "id", "first_seen_at"], do: true + defp custom_ordering?(_scope), do: false + + defp maybe_jump_to_last_page( + %Actor{ + user: %{ + settings: %{comments_newest_first: false, comments_always_jump_to_last: true} + } + } = actor, + image, + scrivener + ) do + Keyword.merge(scrivener, page: Comments.last_comment_page(actor, image, scrivener)) end - @doc """ - Gets the tag list for a single image. - """ - def tag_list(%Image{tags: tags}) do - tags - |> Tag.display_order() - |> Enum.map_join(", ", & &1.name) + defp maybe_jump_to_last_page(_actor, _image, scrivener), do: scrivener + + ## Event broadcasting + + defp broadcast_image_create(image) do + Endpoint.broadcast!( + "firehose", + "image:create", + ImageView.render("show.json", %{image: image, interactions: []}) + ) end - @typedoc """ - Result of the `create_image/3` function. The image was created in a DB but an - upload process could still running in the background with its PID given in the - `upload_pid` field. - """ - @type image_upload :: %{ - image: %Image{}, - upload_pid: pid - } + defp broadcast_image_update(image) do + Endpoint.broadcast!( + "firehose", + "image:update", + ImageView.render("show.json", %{image: image, interactions: []}) + ) + end - @doc """ - Creates a image. + defp broadcast_description_update(image, old_description) do + Endpoint.broadcast!( + "firehose", + "image:description_update", + %{image_id: image.id, added: image.description, removed: old_description} + ) - ## Examples + broadcast_image_update(image) + end - iex> create_image(%{field: value}) - {:ok, %Image{}} + defp broadcast_source_update(image, added, removed) do + Endpoint.broadcast!( + "firehose", + "image:source_update", + %{image_id: image.id, added: [added], removed: [removed]} + ) - iex> create_image(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + broadcast_image_update(image) + end - """ - @spec create_image(Users.principal(), %{String.t() => any()}) :: - {:ok, image_upload()} | Ecto.Multi.failure() - def create_image(attribution, attrs \\ %{}) do - tags = Tags.get_or_create_tags(attrs["tag_input"]) - sources = attrs["sources"] + defp broadcast_tag_update(image, added, removed) do + Endpoint.broadcast!( + "firehose", + "image:tag_update", + %{ + image_id: image.id, + added: Enum.map(added, & &1.name), + removed: Enum.map(removed, & &1.name) + } + ) - image = - %Image{} - |> Image.creation_changeset(attrs, attribution) - |> Image.source_changeset(attrs, [], sources) - |> Image.tag_changeset(attrs, [], tags) - |> Image.dnp_changeset(attribution[:user]) - |> Uploader.analyze_upload(attrs) + broadcast_image_update(image) + end + + defp broadcast_image_merge(image, duplicate_of_image) do + Endpoint.broadcast!( + "firehose", + "image:merge", + %{ + image: ImageView.render("image.json", %{image: image}), + duplicate_of_image: ImageView.render("image.json", %{image: duplicate_of_image}) + } + ) + end + + defp broadcast_batch_update(image_ids, added_tags, removed_tags) do + Endpoint.broadcast!( + "firehose", + "image:batch_tag_update", + %{ + image_ids: image_ids, + added: Enum.map(added_tags, & &1.name), + removed: Enum.map(removed_tags, & &1.name) + } + ) + end + + ## Moderation and lifecycle + + defp put_hide_image(multi, changeset, image, user) do + multi + |> Multi.update(:image, changeset) + |> Reports.put_close_reports(:reports, user, image_id: image.id) + |> Multi.run(:tags, fn _repo, %{image: image} -> + image = Repo.preload(image, :tags, force: true) + {:ok, image.tags} + end) + |> Tags.put_image_count_delta( + :tag_image_counts, + fn %{tags: tags} -> Enum.map(tags, & &1.id) end, + -1 + ) + |> Multi.on_commit(fn %{image: image} -> + spawn(fn -> + Thumbnailer.hide_thumbnails(image, image.hidden_image_key) + purge_files(image, image.hidden_image_key) + end) + + Comments.reindex_comments_on_image(image) + reindex_image(image) + end) + end + + ## Metadata editing + + defp put_lock_image(%Multi{} = multi, %Actor{} = actor, image_id, action, preloads) do + image_query = + Image + |> where(id: ^image_id) + |> preload(^preloads) + + multi + |> Multi.lock_one(:locked_image, image_query) + |> Multi.run(:authorize, fn _repo, %{locked_image: image} -> + with :ok <- authorize(actor, action, image) do + {:ok, nil} + end + end) + end + + defp map_lock_errors(result) do + case result do + {:error, :locked_image, :not_found, _changes} -> + {:error, :not_found} + + {:error, :authorize, :unauthorized, _changes} -> + {:error, :unauthorized} + end + end + + defp update_loaded_sources(%Image{} = image, %Actor{} = actor, %SourceInputForm{} = form) do + %{added: added_sources, removed: removed_sources} = + SourceDiffer.diff_inputs(form.old_sources, form.sources) Multi.new() - |> Multi.insert(:image, image) - |> Multi.run(:added_tag_count, fn repo, %{image: image} -> - tag_ids = image.added_tags |> Enum.map(& &1.id) + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :source_update, @source_update_window) end, + fn -> RateLimiter.rollback_action(actor, :source_update) end + ) + |> put_lock_image(actor, image.id, :edit_metadata, [:sources]) + |> Multi.run(:image, fn repo, %{locked_image: image} -> + changeset = Image.source_changeset(image, added_sources, removed_sources) - count = Tags.update_image_counts(repo, 1, tag_ids) + if Image.meaningful_source_update?(changeset) do + repo.update(changeset) + else + {:error, :no_change} + end + end) + |> SourceChanges.put_record_image_changes(actor) + |> UserStatistics.put_increment(actor.user, :metadata_updates_count) + |> put_reindex_image(:image) + |> Multi.on_commit(fn %{image: %{added_sources: added, removed_sources: removed} = image} -> + image = Repo.preload(image, [:user, :sources, tags: :aliases]) + broadcast_source_update(image, added, removed) + end) + |> Multi.transact() + |> case do + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} - {:ok, count} + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + + {:error, :image, :no_change, _changes} -> + {:error, :no_change} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end + + defp update_loaded_tags(%Image{} = image, %Actor{} = actor, %TagInputForm{} = form) do + %{added: added_tag_names, removed: removed_tag_names} = + TagDiffer.diff_inputs( + form.old_tag_input, + form.tag_input + ) + + Multi.new() + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :tag_update, @tag_update_window) end, + fn -> RateLimiter.rollback_action(actor, :tag_update) end + ) + |> put_lock_image(actor, image.id, :edit_metadata, [:tags, :locked_tags]) + |> Tags.put_canonicalize_tag_name_sets([ + {:removed_tags, removed_tag_names, []}, + {:added_tags, added_tag_names, allow_insert_new?: true, expand_implications?: true} + ]) + |> Multi.run(:image, fn + repo, + %{ + locked_image: image, + canonical_tags: %{added_tags: added_tags, removed_tags: removed_tags} + } -> + changeset = Image.tag_changeset(image, added_tags, removed_tags, image.locked_tags) + + if Image.meaningful_tag_update?(changeset) do + repo.update(changeset) + else + {:error, :no_change} + end + end) + |> Multi.run(:check_limits, fn _repo, %{image: image} -> + record_tag_change_limits(image, actor) + end) + |> Multi.on_rollback(fn + %{check_limits: {tag_changed_count, rating_changed_count}} -> + %Actor{ip: ip, user: user} = actor + Limits.rollback_action(user, ip, tag_changed_count, rating_changed_count) + + _ -> + :ok end) - |> maybe_subscribe_on(:image, attribution[:user], :watch_on_upload) - |> Repo.transaction() + |> TagChanges.put_tag_change(actor) + |> Tags.put_image_tag_count_changes() + |> UserStatistics.put_increment(actor.user, :metadata_updates_count) + |> put_reindex_image(:image) + |> Multi.on_commit(fn %{image: %{added_tags: added, removed_tags: removed} = image} -> + image = Repo.preload(image, [:user, :sources, tags: :aliases]) + Comments.reindex_comments_on_image(image) + broadcast_tag_update(image, added, removed) + end) + |> Multi.transact_with_automatic_retry() |> case do - {:ok, %{image: image}} -> - upload_pid = async_upload(image, attrs["image"]) - reindex_image(image) - Tags.reindex_tags(image.added_tags) - maybe_approve_image(image, attribution[:user]) - - # Return the upload PID along with the created image so that the caller - # can control the lifecycle of the upload if needed. It's useful, for - # example for the seeding process to know when to delete the temp file - # used for uploading. - {:ok, %{image: image, upload_pid: upload_pid}} - - result -> - result + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} + + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + + {:error, :image, :no_change, _changes} -> + {:error, :no_change} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + {:error, :check_limits, _reason, _changes} -> + {:error, :rate_limited} + + error -> + map_lock_errors(error) + end + end + + defp record_tag_change_limits(image, %Actor{ip: ip, user: user}) do + tag_changed_count = length(image.added_tags) + length(image.removed_tags) + rating_changed_count = if(image.ratings_changed, do: 1, else: 0) + + case Limits.record_action(user, ip, tag_changed_count, rating_changed_count) do + :ok -> {:ok, {tag_changed_count, rating_changed_count}} + error -> error + end + end + + ## Forms and uploads + + defp image_interaction_allowed?(%Actor{user: nil}, _image), do: false + defp image_interaction_allowed?(_actor, %Image{hidden_from_users: true}), do: false + + defp image_interaction_allowed?(actor, image) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :vote, image), + :ok <- Filtering.verify_not_forced(actor, image) do + true + else + _error -> false + end + end + + defp comment_changeset_for(_actor, %Image{hidden_from_users: true}), do: nil + + defp comment_changeset_for(actor, image) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create_comment, image), + :ok <- Filtering.verify_not_forced(actor, image) do + Comments.new_comment_changeset() + else + _error -> nil + end + end + + defp image_changeset_for(actor, image, action) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, action, image), + :ok <- Filtering.verify_not_forced(actor, image) do + change_image(%{image | sources: sources_for_edit(image.sources)}) + else + _error -> nil + end + end + + defp uploader_changeset_for(actor, image) do + case authorize(actor, :show, :identity_metadata) do + :ok -> + image_changeset_for(actor, image, :update_uploader) + + _error -> + nil end end - defp async_upload(image, plug_upload) do + defp sources_for_edit([]), do: [%Source{}] + defp sources_for_edit(sources), do: sources + + defp async_upload(image, upload) do linked_pid = spawn(fn -> # Make sure task will finish before VM exit @@ -152,7 +439,7 @@ defmodule Philomena.Images do end) # Give the upload to the linked process - Plug.Upload.give_away(plug_upload, linked_pid, self()) + Plug.Upload.give_away(upload.path, linked_pid, self()) # Free up the linked process send(linked_pid, :ready) @@ -176,1155 +463,2909 @@ defmodule Philomena.Images do Logger.error("Aborting upload of #{image.id} after #{retry_count} retries") end - @doc """ - Approves an image for public viewing. + ## Approval and verification - This will make the image visible to users and update necessary statistics. + defp maybe_approve_image(changeset, nil), do: changeset - ## Examples + defp maybe_approve_image(changeset, %User{verified: false, role: "user"}), do: changeset - iex> approve_image(image) - {:ok, %Image{}} - """ - def approve_image(image) do - image - |> Repo.preload(:user) - |> Image.approve_changeset() - |> Repo.update() - |> case do - {:ok, image} -> - reindex_image(image) - increment_user_stats(image.user) - maybe_suggest_user_verification(image.user) + defp maybe_approve_image(changeset, _user) do + Image.approve_changeset(changeset) + end - {:ok, image} + defp put_approval_steps(%Multi{} = multi) do + multi + |> UserStatistics.put_increment( + fn %{image: image} -> + if image.approved, do: image.user_id + end, + :images_count + ) + |> Multi.merge(fn + %{image: %{approved: true, user_id: user_id}} when user_id != nil -> + put_suggest_user_verification(Multi.new(), user_id) - error -> - error - end + _changes -> + Multi.new() + end) end - defp maybe_approve_image(_image, nil), do: false + defp put_suggest_user_verification(%Multi{} = multi, user_id) do + multi + |> Multi.one(:verification_candidate, where(User, id: ^user_id)) + |> Multi.merge(fn + %{verification_candidate: %{images_count: 5, verified: false}} -> + # This deliberately fires only at the fifth approved image so later + # uploads cannot create duplicate verification reports. Users who cross + # the threshold through maintenance updates are handled manually rather + # than adding an existence query to every approval transaction. + Reports.put_create_system_report( + Multi.new(), + "Verification", + "User has uploaded enough approved images to be considered for verification.", + :reported_user_id, + user_id + ) - defp maybe_approve_image(_image, %User{verified: false, role: role}) when role == "user", - do: false + _changes -> + Multi.new() + end) + end - defp maybe_approve_image(image, _user), do: approve_image(image) + ## Media processing - defp increment_user_stats(nil), do: false + defp repair_image(%Image{} = image) do + Image + |> where(id: ^image.id) + |> Repo.update_all(set: [thumbnails_generated: false, processed: false]) - defp increment_user_stats(%User{} = user) do - UserStatistics.inc_stat(user, :images_count) + enqueue_image_repair(image) end - defp maybe_suggest_user_verification(%User{id: id, images_count: 5, verified: false}) do - Reports.create_system_report( - "Verification", - "User has uploaded enough approved images to be considered for verification.", - reported_user_id: id - ) + defp enqueue_image_repair(image) do + Exq.enqueue(Exq, queue(image.image_mime_type), ThumbnailWorker, [image.id]) + + image end - defp maybe_suggest_user_verification(_user), do: false + defp queue("video/webm"), do: "videos" + defp queue(_mime_type), do: "images" - @doc """ - Counts the number of images pending approval that a user can moderate. + defp purge_files(image, hidden_key) do + files = + if is_nil(hidden_key) do + Thumbnailer.thumbnail_urls(image, nil) + else + Thumbnailer.thumbnail_urls(image, hidden_key) ++ + Thumbnailer.thumbnail_urls(image, nil) + end - ## Examples + Exq.enqueue(Exq, "indexing", ImagePurgeWorker, [files]) + end - iex> count_pending_approvals(admin) - 42 + ## Bulk operations - iex> count_pending_approvals(user) - nil + defp reversion_pairs(changes, locked_image_ids, families) do + changes + |> Enum.group_by(& &1.image_id) + |> Enum.filter(fn {image_id, _image_changes} -> image_id in locked_image_ids end) + |> Enum.reduce({[], []}, fn {image_id, image_changes}, {added_pairs, removed_pairs} -> + # Map each tag change to the relevant reversion operation. + # Added tags should be removed; removed tags should be added. + # Only one operation may occur per alias family. + operations = + image_changes + |> Enum.flat_map(& &1.tag_changes) + |> Enum.map(fn %{tag_id: tag_id, added: added} -> + families + |> Map.fetch!(tag_id) + |> Map.put(:added, added) + end) + |> Enum.uniq_by(& &1.canonical_id) - """ - def count_pending_approvals(user) do - if Canada.Can.can?(user, :approve, %Image{}) do - Image - |> where(approved: false) - |> Repo.aggregate(:count) - else - nil - end - end + removed = + operations + |> Enum.filter(& &1.added) + |> Enum.flat_map(fn op -> Enum.map(op.tag_ids, &%{image_id: image_id, tag_id: &1}) end) - @doc """ - Marks the given image as the current featured image. + added = + operations + |> Enum.reject(& &1.added) + |> Enum.map(&%{image_id: image_id, tag_id: &1.canonical_id}) - ## Examples + { + Enum.concat(added, added_pairs), + Enum.concat(removed, removed_pairs) + } + end) + end - iex> feature_image(user, image) - {:ok, %ImageFeature{}} + defp batch_tag_pairs(image_ids, added_tags, removed_tags) do + # Compute cartesian products of image IDs and tag IDs. + added = + for image_id <- image_ids, tag <- added_tags do + %{image_id: image_id, tag_id: tag.id} + end - """ - def feature_image(featurer, %Image{} = image) do - %ImageFeature{user_id: featurer.id, image_id: image.id} - |> ImageFeature.changeset(%{}) - |> Repo.insert() + removed = + for image_id <- image_ids, tag <- removed_tags do + %{image_id: image_id, tag_id: tag.id} + end + + {added, removed} end - @doc """ - Destroys the contents of an image (hard deletion) by marking it as hidden - and deleting up associated files. + defp put_perform_batch_update(%Multi{} = multi, attributes) do + multi + |> Multi.insert_all( + :inserted_taggings, + Tagging, + fn %{locked_image_ids: image_ids, pairs: {added, _removed}} -> + # Scope insertions to existing, requested images. + Enum.filter(added, &(&1.image_id in image_ids)) + end, + on_conflict: :nothing, + returning: [:image_id, :tag_id] + ) + |> Multi.delete_all(:deleted_taggings, fn + %{locked_image_ids: image_ids, pairs: {_added, removed}} -> + # Scope deletions to existing, requested images. + removed + |> Enum.filter(&(&1.image_id in image_ids)) + |> case do + [] -> + # The values API rejects an empty list. + from t in Tagging, + where: false, + select: [t.image_id, t.tag_id] + + pairs -> + from t in Tagging, + join: pair in values(pairs, %{image_id: :integer, tag_id: :integer}), + on: t.image_id == pair.image_id and t.tag_id == pair.tag_id, + select: [t.image_id, t.tag_id] + end + end) + |> TagChanges.put_batch_tag_changes(:inserted_taggings, :deleted_taggings, attributes) + |> Tags.put_batch_image_count_changes(:inserted_taggings, :deleted_taggings, :visible_images) + |> Multi.on_commit(fn %{locked_image_ids: image_ids} -> + reindex_images(image_ids) + Comments.reindex_comments_on_images(image_ids) + end) + end - This will: - 1. Mark the image as removed in the database - 2. Purge associated files - 3. Remove thumbnails + defp put_batch_revert_tag_changes(%Multi{} = multi, changes, attributes) do + image_ids = + changes + |> Enum.map(& &1.image_id) + |> Enum.uniq() - ## Examples + tag_ids = + changes + |> Enum.flat_map(fn %{tag_changes: tag_changes} -> tag_changes end) + |> Enum.map(& &1.tag_id) + |> Enum.uniq() - iex> destroy_image(image) - {:ok, %Image{}} + image_query = + Image + |> where([i], i.id in ^image_ids) + |> order_by([i], asc: i.id) - """ - def destroy_image(%Image{} = image) do - image - |> Image.remove_image_changeset() - |> Repo.update() - |> case do - {:ok, image} -> - purge_files(image, image.hidden_image_key) - Thumbnailer.destroy_thumbnails(image) + multi + |> Multi.lock_all(:locked_image_ids, select(image_query, [i], i.id)) + |> Multi.all(:visible_images, where(image_query, [i], i.hidden_from_users == false)) + |> Tags.put_lock_tag_alias_families(tag_ids) + |> Multi.run(:pairs, fn + _repo, %{locked_image_ids: image_ids, tag_alias_families: families} -> + {:ok, reversion_pairs(changes, image_ids, families)} + end) + |> put_perform_batch_update(attributes) + end - {:ok, image} + defp put_batch_tag(%Multi{} = multi, image_ids, added_names, removed_names, attributes) do + image_query = + Image + |> where([i], i.id in ^image_ids) + |> order_by(asc: :id) - error -> - error - end + multi + |> Multi.lock_all(:locked_image_ids, select(image_query, [i], i.id)) + |> Multi.all(:visible_images, where(image_query, hidden_from_users: false)) + |> Tags.put_canonicalize_tag_name_sets([ + {:removed_tags, removed_names, []}, + {:added_tags, added_names, expand_implications?: true} + ]) + |> Multi.run(:changes, fn + _repo, %{canonical_tags: %{added_tags: added_tags, removed_tags: removed_tags}} -> + if Enum.empty?(added_tags) and Enum.empty?(removed_tags) do + {:error, :no_change} + else + {:ok, nil} + end + end) + |> Multi.run(:pairs, fn + _repo, + %{ + locked_image_ids: image_ids, + canonical_tags: %{added_tags: added_tags, removed_tags: removed_tags} + } -> + {:ok, batch_tag_pairs(image_ids, added_tags, removed_tags)} + end) + |> put_perform_batch_update(attributes) + |> Multi.on_commit(fn + %{ + locked_image_ids: image_ids, + canonical_tags: %{added_tags: added_tags, removed_tags: removed_tags} + } -> + broadcast_batch_update(image_ids, added_tags, removed_tags) + end) end + ## Voting and hiding + + defp deleted_vote_type(%{undownvote: {1, _}}), do: "downvote" + defp deleted_vote_type(%{unupvote: {1, _}}), do: "upvote" + defp deleted_vote_type(_changes), do: "vote" + + @doc group: "Browsing and discovery" @doc """ - Locks or unlocks comments on an image. + Loads the most recent featured image visible to `actor`. + + Hidden images are always excluded. When `include_hidden?` is false, an + authenticated actor's personally hidden images are also excluded. The next + eligible historical feature is returned when the newest one is excluded. ## Examples - iex> lock_comments(image, true) + iex> show_featured_image(actor, false) {:ok, %Image{}} + iex> show_featured_image(actor, false) + {:error, :not_found} + """ - def lock_comments(%Image{} = image, locked) do - image - |> Image.lock_comments_changeset(locked) - |> Repo.update() - |> reindex_after_update() + @spec show_featured_image(Actor.t(), boolean()) :: {:ok, Image.t()} | {:error, :not_found} + def show_featured_image(%Actor{} = actor, include_hidden?) when is_boolean(include_hidden?) do + with :ok <- authorize(actor, :index, Image) do + Image + |> maybe_exclude_viewer_hides(actor, include_hidden?) + |> join(:inner, [i], f in ImageFeature, on: [image_id: i.id]) + |> where([i], i.hidden_from_users == false) + |> order_by([_i, f], desc: f.created_at) + |> limit(1) + |> preload([:user, :intensity, :sources, tags: :aliases]) + |> Repo.one() + |> case do + nil -> + {:error, :not_found} + + image -> + {:ok, image} + end + end end + @doc group: "Browsing and discovery" @doc """ - Locks or unlocks the description of an image. + Loads the default image listing page for the viewer's search `scope`. + + Applies the front-page upload delay, the scope's filter and visibility + switches, and the parameter-driven sort, then runs the search. Returns the + record page with the standard listing preloads. ## Examples - iex> lock_description(image, true) - {:ok, %Image{}} + iex> list_images(actor, scope) + %Scrivener.Page{} """ - def lock_description(%Image{} = image, locked) do - image - |> Image.lock_description_changeset(locked) - |> Repo.update() - |> reindex_after_update() + @spec list_images(Actor.t(), Scope.t()) :: Scrivener.Page.t() + def list_images(%Actor{} = actor, scope) do + :ok = authorize(actor, :index, Image) + {definition, _tags} = ImageSearch.default_query(actor, scope) + + ImageSearch.execute(definition) + end + + @doc group: "Browsing and discovery" + @doc """ + Runs the search the scope's "q" parameter describes for `actor`. + + Compiles "q" against the viewer's filter and visibility switches and executes + it. The raw `Tag` records the query names come back alongside the page. + + Options: + + * `:preload` - the associations loaded onto the result records; defaults + to the standard listing preloads. + * `:hits` - whether each entry is paired with its search hit. A custom + sort field (anything under "sf" other than `id`/`first_seen_at`) needs + its sort cursor, so by default the page is loaded with hits exactly + then; pass `false` to always load records alone. + + Returns `{:ok, %{images: page, tags: tags}}`, or the compiler's + `{:error, msg}` for a malformed query. + + ## Examples + + iex> query_images(actor, scope) + {:ok, %{images: %Scrivener.Page{}, tags: [%Tag{}]}} + + iex> query_images(actor, bad_query_scope) + {:error, "There was an error parsing your query."} + + """ + @spec query_images(Actor.t(), Scope.t(), Keyword.t()) :: + {:ok, %{images: Scrivener.Page.t(), tags: [Tag.t()]}} | {:error, String.t()} + def query_images(%Actor{} = actor, scope, opts \\ []) do + with :ok <- authorize(actor, :index, Image), + {:ok, {definition, tags}} <- + ImageSearch.search_string(actor, scope, scope.q) do + preload = Keyword.get(opts, :preload, [:sources, tags: :aliases]) + hits = Keyword.get(opts, :hits, custom_ordering?(scope)) + + images = ImageSearch.execute(definition, preload: preload, hits: hits) + + {:ok, %{images: images, tags: tags}} + end end + @doc group: "Browsing and discovery" @doc """ - Locks or unlocks the tags on an image. + Loads an image representation for the JSON API or oEmbed on behalf of + `actor`. + + The image carries the associations required by the API renderer. + Missing IDs are actor-independent. ## Examples - iex> lock_tags(image, true) + iex> show_api_image(actor, "1") {:ok, %Image{}} + iex> show_api_image(actor, "missing") + {:error, :not_found} + """ - def lock_tags(%Image{} = image, locked) do - image - |> Image.lock_tags_changeset(locked) - |> Repo.update() - |> reindex_after_update() + @spec show_api_image(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :not_found} + def show_api_image(%Actor{} = _actor, image_id) do + Loader.fetch(Image, image_id, [:user, :intensity, :sources, tags: :aliases]) + end + + @doc group: "Browsing and discovery" + @doc """ + Runs the "my:watched" search for the viewer scope, with the watched-feed + preloads, and returns the record page. + + ## Examples + + iex> list_watched_images(actor, scope) + {:ok, %Scrivener.Page{}} + + """ + @spec list_watched_images(Actor.t(), Scope.t()) :: + {:ok, Scrivener.Page.t()} | {:error, :unauthorized | String.t()} + def list_watched_images(%Actor{} = actor, scope) do + with :ok <- authorize(actor, :index_watched, Image), + {:ok, {definition, _tags}} <- ImageSearch.search_string(actor, scope, "my:watched") do + {:ok, ImageSearch.execute(definition)} + end + end + + @doc group: "Browsing and discovery" + @doc """ + Loads the image named by `id` for showing, on behalf of `actor`. + + The image carries its preloads plus virtual fields for these counts: distinct + tag changes, tags touched by those changes, and source changes. An image merged + into a duplicate is redirected for non-staff viewers, returning + `{:duplicate_of, target_image_id}` so the caller can redirect to the target. A + malformed or unknown id is `{:error, :not_found}`. + + ## Examples + + iex> show_image(actor, "1") + {:ok, %Image{tag_change_count: 2, tag_change_tag_count: 5, source_change_count: 1}} + + iex> show_image(actor, "2") + {:duplicate_of, 42} + + iex> show_image(actor, "bad") + {:error, :not_found} + + """ + @spec show_image(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} + | {:duplicate_of, IntegerId.integer_id()} + | {:error, :not_found} + def show_image(%Actor{} = actor, id) do + with {:ok, image} <- + Image + |> from(as: :image) + |> join(:inner_lateral, [], subquery(TagChanges.count_query()), on: true) + |> join(:inner_lateral, [], subquery(SourceChanges.count_query()), on: true) + |> preload([:deleter, :locked_tags, :sources, user: [awards: :badge], tags: :aliases]) + |> select([image, tag_changes, source_changes], %{ + image + | tag_change_count: tag_changes.change_count, + tag_change_tag_count: tag_changes.tag_count, + source_change_count: source_changes.count + }) + |> Loader.fetch(id) do + cond do + authorize(actor, :show, image) == :ok -> + {:ok, image} + + not is_nil(image.duplicate_id) -> + {:duplicate_of, image.duplicate_id} + + true -> + {:ok, image} + end + end + end + + @doc group: "Browsing and discovery" + @doc """ + Assembles the `ImagePage` for `actor`: the visible page of comments, + the viewer's subscription state, their galleries paired with membership of + this image, their interactions, and changesets for each action available on + the page. + + Clears the viewer's notification for the image as a side effect, so the + caller must read any notification counts afterwards. `comment_pagination` + is the `page`/`page_size` keyword list. Viewers who read oldest-first and + prefer jumping to the newest comments land on the last page unless they + asked for a specific one. Interaction controls and comment changesets are + omitted when the actor is banned, lacks write access, the image is hidden or + forced-filtered, or the corresponding action is forbidden. Moderation + changesets follow their own action authorization so authorized staff can + still render management controls for hidden images. + + ## Examples + + iex> show_image_page(actor, image, page: 1, page_size: 25) + %ImagePage{} + + """ + @spec show_image_page(Actor.t(), Image.t(), Repo.pagination_params()) :: ImagePage.t() + def show_image_page(%Actor{user: user} = actor, %Image{} = image, comment_pagination) do + clear_image_notification(image, user) + + comment_pagination = maybe_jump_to_last_page(actor, image, comment_pagination) + {:ok, gallery_choices} = Galleries.gallery_choices_for_image(actor, image) + + can_interact = image_interaction_allowed?(actor, image) + + %ImagePage{ + image: image, + comments: Comments.list_image_comments(actor, image, comment_pagination), + watching: subscribed?(image, user), + can_interact: can_interact, + user_galleries: gallery_choices, + interactions: Interactions.user_interactions(actor, [image]), + comment_changeset: comment_changeset_for(actor, image), + description_changeset: image_changeset_for(actor, image, :edit_description), + tag_changeset: image_changeset_for(actor, image, :edit_metadata), + source_changeset: image_changeset_for(actor, image, :edit_metadata), + file_changeset: image_changeset_for(actor, image, :replace_file), + hide_changeset: image_changeset_for(actor, image, :hide), + feature_changeset: image_changeset_for(actor, image, :feature), + repair_changeset: image_changeset_for(actor, image, :repair), + hash_changeset: image_changeset_for(actor, image, :remove_hash), + uploader_changeset: uploader_changeset_for(actor, image) + } + end + + @doc group: "Browsing and discovery" + @doc """ + Finds the image adjacent to the one `image_id` names in the listing the + scope's parameters describe, for prev/next navigation, on behalf of + `actor`. + + The image locator is parsed and loaded before `:show` authorization, so both + malformed and unknown ids are `{:error, :not_found}`. The scope's "q" + parameter (blank means everything) is compiled for the viewer; malformed + queries return the parser error instead of navigating an unfiltered result. + + Returns `{:ok, {image, {adjacent, hit}}}`, where the hit carries the sort cursor + for the caller to reuse, or `{:ok, {image, nil}}` at the end of the sequence. + + ## Examples + + iex> list_image_navigation(actor, scope, "42") + {:ok, {%Image{}, {%Image{}, %{"sort" => [...]}}}} + + """ + @spec list_image_navigation(Actor.t(), Scope.t(), IntegerId.integer_id()) :: + {:ok, {Image.t(), {Image.t(), map()} | nil}} + | {:error, :unauthorized | :not_found} + def list_image_navigation(%Actor{user: user} = actor, scope, image_id) do + with {:ok, image} <- load_image_member(actor, :show, image_id), + {:ok, query} <- ImageQuery.compile(scope.q || "*", user: user) do + {:ok, {image, ImageSearch.find_consecutive(actor, scope, image, query)}} + end + end + + @doc group: "Browsing and discovery" + @doc """ + Returns the 1-based page number on which the image `image_id` + names appears when all images are listed by descending id, on behalf of + `actor`. + + Loading and authorization follow `find_consecutive_image/3`. + + ## Examples + + iex> list_image_index_page(actor, scope, "42") + {:ok, 3} + + """ + @spec list_image_index_page(Actor.t(), Scope.t(), IntegerId.integer_id()) :: + {:ok, pos_integer()} | {:error, :unauthorized | :not_found} + def list_image_index_page(%Actor{} = actor, scope, image_id) do + with {:ok, image} <- load_image_member(actor, :show, image_id) do + pagination = %{scope.pagination | page_number: 1} + + {definition, _tags} = + ImageSearch.query(actor, scope, %{range: %{id: %{gt: image.id}}}, pagination: pagination) + + images = ImageSearch.execute(definition, preload: []) + + {:ok, div(images.total_entries, pagination.page_size) + 1} + end + end + + @doc group: "Browsing and discovery" + @doc """ + Loads images related to the one `image_id` names. Related images share its + lowest-population tags, weighted towards its most distinctive ones and the + favers it has in common. + + Loading and authorization follow `find_consecutive_image/3`; the image + carries the faves, sources, and tags the scoring reads. + + Returns `{:ok, {image, images}}` with the related images scored best-first. + + ## Examples + + iex> list_related_images(actor, scope, "42") + {:ok, {%Image{}, %Scrivener.Page{}}} + + """ + @spec list_related_images(Actor.t(), Scope.t(), IntegerId.integer_id()) :: + {:ok, {Image.t(), Scrivener.Page.t()}} | {:error, :unauthorized | :not_found} + def list_related_images(%Actor{} = actor, scope, image_id) do + with {:ok, image} <- + load_image_member(actor, :show, image_id, [:faves, :sources, tags: :aliases]) do + tags_to_match = + image.tags + |> Enum.reject(&(&1.category == "rating")) + |> Enum.sort_by(& &1.images_count) + |> Enum.take(10) + |> Enum.map(& &1.id) + + low_count_tags = + tags_to_match + |> Enum.take(5) + |> Enum.map(&%{term: %{tag_ids: &1}}) + + high_count_tags = + tags_to_match + |> Enum.take(-5) + |> Enum.map(&%{term: %{tag_ids: &1}}) + + favs_to_match = + image.faves + |> Enum.take(11) + |> Enum.map(&%{term: %{favourited_by_user_ids: &1.user_id}}) + + query = %{ + bool: %{ + must: [ + %{bool: %{should: low_count_tags, boost: 2}}, + %{bool: %{should: high_count_tags, boost: 3, minimum_should_match: "5%"}}, + %{bool: %{should: favs_to_match, boost: 0.2, minimum_should_match: "5%"}} + ], + must_not: %{term: %{id: image.id}} + } + } + + {definition, _tags} = + ImageSearch.query( + actor, + scope, + query, + sorts: &%{query: &1, sorts: [%{_score: :desc}]}, + pagination: %{scope.pagination | page_number: 1} + ) + + {:ok, {image, ImageSearch.execute(definition)}} + end + end + + @doc group: "Browsing and discovery" + @doc """ + Picks a random image id from the listing the scope's "q" parameter + describes (everything when absent), respecting the scope's filter and + visibility switches. + + Returns `{:ok, id}` or `{:ok, nil}` when nothing matches. A malformed query + returns `{:error, parser_message}`. + + ## Examples + + iex> list_random_images(actor, scope) + {:ok, 42} + + """ + @spec list_random_images(Actor.t(), Scope.t()) :: + {:ok, integer() | nil} | {:error, :unauthorized | String.t()} + def list_random_images(%Actor{} = actor, scope) do + with :ok <- authorize(actor, :index, Image), + {:ok, {definition, _tags}} <- + ImageSearch.search_string( + actor, + scope, + scope.q || "*", + pagination: %{page_size: 1}, + sorts: &ImageSearch.parse_sort(%{"sf" => "random"}, &1) + ) do + definition + |> ImageSearch.execute(preload: []) + |> Enum.to_list() + |> case do + [image] -> + {:ok, image.id} + + [] -> + {:ok, nil} + end + end + end + + @doc group: "Browsing and discovery" + @doc """ + Loads the image named by `image_id`, applying `preloads`, and authorizes + `actor` for `:show` on it. + + Returns `{:ok, image}`, `{:error, :unauthorized}`, or `{:error, :not_found}`. + + ## Examples + + iex> load_visible_image(actor, "1") + {:ok, %Image{}} + + iex> load_visible_image(actor, "999999999") + {:error, :not_found} + + """ + @spec load_visible_image(Actor.t(), IntegerId.integer_id(), list()) :: + {:ok, Image.t()} | {:error, :unauthorized | :not_found} + def load_visible_image(actor, image_id, preloads \\ []) do + load_image_member(actor, :show, image_id, preloads) + end + + @doc group: "Browsing and discovery" + @doc """ + Loads an image as a report target on behalf of `actor`. + + The image is authorized for `:show` and carries the sources and tag aliases + rendered by the shared report form. Missing IDs are always not-found. + + ## Examples + + iex> load_report_target(actor, "1") + {:ok, %Image{}} + """ + @spec load_report_target(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, image_id) do + load_image_member(actor, :show, image_id, [:sources, tags: :aliases]) + end + + @doc group: "Browsing and discovery" + @doc """ + Loads images by ID with the associations required by rich text references. + + Unknown IDs are omitted. Results are not guaranteed to follow the input + order; callers which need keyed access should index them by ID. + + ## Examples + + iex> list_images_by_ids([42, 999_999_999]) + [%Image{id: 42}] + + """ + @spec list_images_by_ids([integer()]) :: [Image.t()] + def list_images_by_ids(ids) when is_list(ids) do + Image + |> where([image], image.id in ^ids) + |> preload([:sources, tags: :aliases]) + |> Repo.all() + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Adds a loaded-image merge to `multi` without transacting it. + + The caller owns authorization and must lock the two images before merging + this service when their current state controls the operation. PostgreSQL + mutations join the caller's transaction; thumbnail work, indexing, and the + firehose broadcast run only after that transaction commits. + + ## Examples + + iex> put_merge_image(multi, source_image, target_image, moderator) + %Philomena.Multi{} + + """ + @spec put_merge_image(Multi.t(), Image.t(), Image.t(), User.t()) :: Multi.t() + def put_merge_image( + %Multi{} = multi, + %Image{} = image, + %Image{} = duplicate_of_image, + %User{} = user + ) do + image = + Repo.preload(image, [:user, :intensity, :sources, tags: :aliases]) + + duplicate_of_image = + Repo.preload(duplicate_of_image, [:user, :intensity, :sources, tags: :aliases]) + + subscriptions = + Subscription + |> where(image_id: ^image.id) + |> select([s], %{image_id: type(^duplicate_of_image.id, :integer), user_id: s.user_id}) + + source_changeset = + Image.merge_source_changeset(image, duplicate_of_image) + + multi + |> put_hide_image(source_changeset, image, user) + |> Galleries.put_migrate_image_interactions(image, duplicate_of_image) + |> Tags.put_copy_tags(image, duplicate_of_image) + |> Multi.update(:target_image, fn _changes -> + sources = + (image.sources ++ duplicate_of_image.sources) + |> Enum.map(fn s -> %Source{image_id: duplicate_of_image.id, source: s.source} end) + |> Enum.uniq() + |> Enum.take(15) + + duplicate_of_image + |> Image.first_seen_at_changeset([image, duplicate_of_image]) + |> Image.sources_changeset(sources) + end) + |> Comments.put_migrate_image_comments(image, duplicate_of_image) + |> put_image_counter_delta( + :migrated_comment_count, + duplicate_of_image.id, + :comments_count, + fn %{migrated_comments: {count, nil}} -> count end + ) + |> Multi.insert_all(:subscriptions, Subscription, subscriptions, on_conflict: :nothing) + |> Notifications.put_migrate_image_notifications(image, duplicate_of_image) + |> Interactions.migrate_loaded_images(image, duplicate_of_image) + |> Multi.run(:notification, fn _repo, _changes -> + Notifications.broadcast_image_merge(image, duplicate_of_image) + end) + |> Multi.on_commit(fn result -> + reindex_image(duplicate_of_image) + Comments.reindex_comments_on_image(duplicate_of_image) + broadcast_image_merge(result.image, duplicate_of_image) + end) + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Adds the inverse of a source change to `multi` without recording a new + source-change row. + + The image is locked and reindexed after the transaction commits. The + corresponding source-change row can be deleted by composing this operation + with `Philomena.SourceChanges.put_erase_source_change/2`. + + ## Examples + + iex> put_revert_source_change(multi, source_change) + %Philomena.Multi{} + + """ + @spec put_revert_source_change(Multi.t(), SourceChange.t()) :: Multi.t() + def put_revert_source_change(%Multi{} = multi, %SourceChange{} = source_change) do + {added_sources, removed_sources} = + if source_change.added do + {[], [source_change.source_url]} + else + {[source_change.source_url], []} + end + + image_query = + Image + |> where(id: ^source_change.image_id) + |> preload(:sources) + + multi + |> Multi.lock_one(:locked_image, image_query) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.source_changeset(image, added_sources, removed_sources) + end) + |> put_reindex_image(:image) + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Adds a denormalized image counter adjustment to `multi`. + + Image interactions and other contexts use this function instead of updating + the `images` table themselves. `amount_or_callback` reads the exact delta + from prior Multi changes when it is known only after a delete. + """ + @spec put_image_counter_delta( + Multi.t(), + Multi.name(), + integer(), + atom(), + integer() | (Multi.changes() -> integer()) + ) :: Multi.t() + def put_image_counter_delta( + %Multi{} = multi, + step, + image_id, + field, + amount_or_callback + ) + when is_atom(field) and is_integer(image_id) do + put_image_counter_deltas(multi, step, image_id, fn changes -> + cond do + is_function(amount_or_callback, 1) -> %{field => amount_or_callback.(changes)} + is_integer(amount_or_callback) -> %{field => amount_or_callback} + end + end) + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Adds multiple denormalized image counter adjustments to `multi`. + + The owner attaches one image reindex after commit for the complete update. + """ + @spec put_image_counter_deltas( + Multi.t(), + Multi.name(), + integer(), + (Multi.changes() -> %{atom() => integer()}) + ) :: Multi.t() + def put_image_counter_deltas(%Multi{} = multi, step, image_id, increments_callback) do + Multi.run(multi, step, fn repo, changes -> + increments = increments_callback.(changes) + + {count, _} = repo.update_all(where(Image, id: ^image_id), inc: Map.to_list(increments)) + + {:ok, count} + end) + |> Multi.on_commit(fn _changes -> reindex_images([image_id]) end) + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Adds deletion of image taggings represented by `query` to `multi`. + + Tag maintenance composes this function when removing or migrating a tag. + """ + @spec put_delete_taggings(Multi.t(), Multi.name(), Ecto.Query.t()) :: Multi.t() + def put_delete_taggings(%Multi{} = multi, step, %Ecto.Query{} = query) do + image_ids_step = {:tagging_image_ids, step} + image_ids_query = query |> exclude(:select) |> select([tagging], tagging.image_id) + + multi + |> Multi.all(image_ids_step, image_ids_query) + |> Multi.delete_all(step, query) + |> Multi.on_commit(fn %{^image_ids_step => image_ids} -> reindex_images(image_ids) end) + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Adds insertion of image taggings represented by `entries` to `multi`. + + Tag maintenance composes this function when migrating a tag. + """ + @spec put_insert_taggings(Multi.t(), Multi.name(), [map()] | Ecto.Query.t()) :: Multi.t() + def put_insert_taggings(%Multi{} = multi, step, entries) when is_list(entries) do + multi + |> Multi.insert_all(step, Tagging, entries, + on_conflict: :nothing, + returning: [:image_id, :tag_id] + ) + |> Multi.on_commit(fn %{^step => {_count, taggings}} -> + taggings + |> Enum.map(& &1.image_id) + |> reindex_images() + end) + end + + def put_insert_taggings(%Multi{} = multi, step, %Ecto.Query{} = query) do + image_ids_step = {:tagging_image_ids, step} + image_ids_query = query |> exclude(:select) |> select([tagging], tagging.image_id) + + multi + |> Multi.all(image_ids_step, image_ids_query) + |> Multi.insert_all(step, Tagging, query, + on_conflict: :nothing, + returning: [:image_id, :tag_id] + ) + |> Multi.on_commit(fn %{^image_ids_step => image_ids} -> reindex_images(image_ids) end) + end + + @doc group: "Cross-context transaction helpers" + @doc """ + Copies an image's taggings to another image inside `multi`. + + The inserted tag IDs are returned in `:copied_tag_ids` for Tags' counter + maintenance. + """ + @spec put_copy_taggings(Multi.t(), Image.t(), Image.t()) :: Multi.t() + def put_copy_taggings(%Multi{} = multi, %Image{} = source, %Image{} = target) do + source_taggings_query = + Tagging + |> where(image_id: ^source.id) + |> select([tagging], %{ + image_id: type(^target.id, :integer), + tag_id: tagging.tag_id + }) + + multi + |> Multi.all(:source_taggings, source_taggings_query) + |> Multi.insert_all( + :target_taggings, + Tagging, + fn %{source_taggings: source_taggings} -> source_taggings end, + on_conflict: :nothing, + returning: [:tag_id] + ) + |> Multi.run(:copied_tag_ids, fn _repo, %{target_taggings: {_count, taggings}} -> + {:ok, Enum.map(taggings, & &1.tag_id)} + end) + |> Multi.on_commit(fn _changes -> reindex_images([target.id]) end) + end + + @doc group: "Forms and uploads" + @doc """ + Returns an `%Ecto.Changeset{}` for tracking image changes. + + ## Examples + + iex> change_image(image) + %Ecto.Changeset{source: %Image{}} + + """ + @spec change_image(Image.t()) :: Ecto.Changeset.t() + def change_image(%Image{} = image) do + Image.changeset(image, %{}) + end + + @doc group: "Forms and uploads" + @doc """ + Gets the tag list for a single image. + """ + @spec tag_list(Image.t()) :: String.t() + def tag_list(%Image{tags: tags}) do + tags + |> Tag.display_order() + |> Enum.map_join(", ", & &1.name) + end + + @doc group: "Forms and uploads" + @doc """ + Builds the changeset for a new image upload, on behalf of `actor`. + + A banned actor is rejected with `{:error, :ban}`; everyone else gets the + changeset. + + ## Examples + + iex> new_image(actor) + {:ok, %Ecto.Changeset{}} + + iex> new_image(banned_actor) + {:error, :ban} + + """ + @spec new_image(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_image(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Image) do + {:ok, change_image(%Image{sources: [%Source{}]})} + end + end + + @image_create_window 5 + + @doc group: "Forms and uploads" + @doc """ + Uploads a new image on behalf of `actor`, who must pass the write-access + check: banned actors get `{:error, :ban}` and actors without a fingerprint + `{:error, :unauthorized}`. A non-exempt actor who has uploaded within the last + 5 seconds gets `{:error, :rate_limited}`. + + Upon success, the image row has been created and processing continues in the + background. Approved uploads increment the uploader's image count and may + create a verification report in the same transaction. + + ## Examples + + iex> create_image(actor, %{"tag_input" => "safe"}, upload) + {:ok, %{image: %Image{}, upload_pid: pid}} + + iex> create_image(banned_actor, params, upload) + {:error, :ban} + + """ + @spec create_image(Actor.t(), map() | nil, PhilomenaMedia.Upload.t() | nil) :: + {:ok, image_upload()} + | {:error, :ban | :unauthorized | :rate_limited | Ecto.Changeset.t()} + def create_image(%Actor{user: user} = actor, params, upload) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Image), + {:ok, tag_input_form} <- + %TagInputForm{} + |> TagInputForm.changeset(params) + |> TagInputForm.apply(%Image{}), + {:ok, source_input_form} <- + %SourceInputForm{} + |> SourceInputForm.changeset(params) + |> SourceInputForm.apply(%Image{}) do + %{added: added_tag_names} = TagDiffer.diff_inputs(nil, tag_input_form.tag_input) + %{added: added_sources} = SourceDiffer.diff_inputs(nil, source_input_form.sources) + + image_changeset = + %Image{} + |> Image.creation_changeset(params, actor) + |> Uploader.analyze_upload(upload) + |> maybe_approve_image(user) + + Multi.new() + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :image_create, @image_create_window) end, + fn -> RateLimiter.rollback_action(actor, :image_create) end + ) + |> Tags.put_canonicalize_tag_name_sets([ + {:added_tags, added_tag_names, allow_insert_new?: true, expand_implications?: true} + ]) + |> DnpEntries.put_dnp_tags(:dnp_tags, :canonical_tags) + |> Multi.insert(:image, fn + %{ + canonical_tags: %{added_tags: added_tags}, + dnp_tags: tags_with_dnp + } -> + image_changeset + |> Image.source_changeset(added_sources, []) + |> Image.tag_changeset(added_tags, []) + |> Image.dnp_changeset(user, tags_with_dnp) + end) + |> Tags.put_image_count_delta( + :added_tag_count, + fn %{image: image} -> Enum.map(image.added_tags, & &1.id) end, + 1 + ) + |> maybe_subscribe_on(:image, user, :watch_on_upload) + |> put_approval_steps() + |> put_reindex_image(:image) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{image: %Image{} = image}} -> + upload_pid = async_upload(image, upload) + + image = Repo.preload(image, tags: :aliases) + + broadcast_image_create(image) + + # Return the upload PID along with the created image so that the caller + # can control the lifecycle of the upload if needed. It's useful, for + # example for the seeding process to know when to delete the temp file + # used for uploading. + {:ok, %{image: image, upload_pid: upload_pid}} + + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + error + end + end + end + + @typedoc """ + Result of the `upload_image/3` function. The image was created in the DB but an + upload process could still be running in the background with its PID given in the + `upload_pid` field. + """ + @type image_upload :: %{ + image: %Image{}, + upload_pid: pid + } + + @doc group: "Moderation and lifecycle" + @doc """ + Returns the paginated approval queue for `actor`: unapproved images, oldest + first, with the listing preloads. + + Returns `{:ok, images}` as a `m:Scrivener.Page` or `{:error, :unauthorized}`. + + ## Examples + + iex> list_approval_queue(moderator, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_approval_queue(user, pagination) + {:error, :unauthorized} + + """ + @spec list_approval_queue(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t()} | {:error, :unauthorized} + def list_approval_queue(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :approve, %Image{}) do + images = + Image + |> where(approved: false) + |> order_by(asc: :id) + |> preload([:user, :sources, tags: [:aliases, :aliased_tag]]) + |> Repo.paginate(pagination) + + {:ok, images} + end + end + + @doc group: "Moderation and lifecycle" + @doc """ + Approves the image named by `image_id` for public viewing, on behalf of + `actor`. + + An image that is already approved returns an error changeset and is left + untouched. On success the image is made visible, statistics are updated, the + image is reindexed, and a moderation log is written attributing the approval + to `actor`. Approval at the uploader's fifth approved image also creates a + verification report in the same transaction. + + Returns `{:ok, image}` with the approved image. + + ## Examples + + iex> create_image_approve(moderator, "42") + {:ok, %Image{}} + + iex> create_image_approve(user, "42") + {:error, :unauthorized} + + """ + @spec create_image_approve(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_image_approve(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :approve, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> Image.approve_changeset(image) end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{locked_image: image} -> + {"Image.Approve:create", Paths.image_path(image), "Approved image #{image.id}"} + end) + |> put_approval_steps() + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + error + end + end + end + + @doc group: "Moderation and lifecycle" + @doc """ + Counts the number of images pending approval that a user can moderate. + + ## Examples + + iex> count_pending_approvals(admin) + 42 + + iex> count_pending_approvals(user) + nil + + """ + @spec count_pending_approvals(Actor.t()) :: non_neg_integer() | nil + def count_pending_approvals(%Actor{} = actor) do + if authorize(actor, :approve, %Image{}) == :ok do + Image + |> where(approved: false) + |> Repo.aggregate(:count) + else + nil + end + end + + @doc group: "Moderation and lifecycle" + @doc """ + Marks the image named by `image_id` as the current featured image, on behalf + of `actor`. + + The image is loaded by id and authorized for `:feature`. On success the feature is + recorded and a moderation log is written attributing it to `actor`. + + Returns `{:ok, feature}` with the created feature. + + ## Examples + + iex> create_image_feature(moderator, "42") + {:ok, %ImageFeature{}} + + iex> create_image_feature(user, "42") + {:error, :unauthorized} + + """ + @spec create_image_feature(Actor.t(), IntegerId.integer_id()) :: + {:ok, ImageFeature.t()} | {:error, :ban | :unauthorized | :not_found} + def create_image_feature(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :feature, image_id) do + feature_changeset = + %ImageFeature{user_id: actor.user.id, image_id: image.id} + |> ImageFeature.changeset() + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.insert(:feature, feature_changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{locked_image: image} -> + {"Image.Feature:create", Paths.image_path(image), "Featured image #{image.id}"} + end) + |> Multi.transact() + |> case do + {:ok, %{feature: %ImageFeature{} = feature}} -> + {:ok, feature} + end + end + end + + @doc group: "Moderation and lifecycle" + @doc """ + Hard-deletes the contents of the image named by `image_id`, on behalf of + `actor`, purging its stored file and thumbnails. + + The image is loaded by id and authorized for `:destroy`. Only an already-deleted + image (hidden from users) may be destroyed; a still-visible image is + `{:error, :not_deleted}`, left untouched. On success the file and thumbnails are + purged and a moderation log is written attributing the destruction to `actor`. + + Returns `{:ok, image}` with the destroyed image, or + `{:error, %Ecto.Changeset{}}` if the destruction is rejected. + + ## Examples + + iex> create_image_destroy(admin, "42") + {:ok, %Image{}} + + iex> create_image_destroy(moderator, "42") + {:error, :unauthorized} + + """ + @spec create_image_destroy(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def create_image_destroy(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :destroy, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.remove_image_changeset(image) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {"Image.Destroy:create", Paths.image_path(image), "Hard-deleted image #{image.id}"} + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + purge_files(image, image.hidden_image_key) + Thumbnailer.destroy_thumbnails(image) + + {:ok, image} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc group: "Moderation and lifecycle" + @doc """ + Locks (`locked?` true) or unlocks (`locked?` false) comments on the image + named by `image_id`, on behalf of `actor`. + + The image is loaded by id and authorized for `:lock_comments`. On success commenting + is toggled, the image is reindexed, and a moderation log is written attributing + the change to `actor`. + + Returns `{:ok, image}` with the updated image. + + ## Examples + + iex> update_image_comment_lock(moderator, "42", true) + {:ok, %Image{}} + + iex> update_image_comment_lock(user, "42", true) + {:error, :unauthorized} + + """ + @spec update_image_comment_lock(Actor.t(), IntegerId.integer_id(), boolean()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def update_image_comment_lock(%Actor{} = actor, image_id, locked?) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :lock_comments, image_id) do + {log_type, log_body} = + if locked? do + {"Image.CommentLock:create", "Locked comments on image #{image.id}"} + else + {"Image.CommentLock:delete", "Unlocked comments on image #{image.id}"} + end + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.lock_comments_changeset(image, locked?) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {log_type, Paths.image_path(image), log_body} + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + end + end + end + + @doc group: "Moderation and lifecycle" + @doc """ + Locks (`locked?` true) or unlocks (`locked?` false) description editing on the + image named by `image_id`, on behalf of `actor`. + + The image is loaded by id and authorized for `:lock_description`. On success description + editing is toggled, the image is reindexed, and a moderation log is written + attributing the change to `actor`. + + Returns `{:ok, image}` with the updated image. + + ## Examples + + iex> update_image_description_lock(moderator, "42", true) + {:ok, %Image{}} + + iex> update_image_description_lock(user, "42", true) + {:error, :unauthorized} + + """ + @spec update_image_description_lock(Actor.t(), IntegerId.integer_id(), boolean()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def update_image_description_lock(%Actor{} = actor, image_id, locked?) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :lock_description, image_id) do + {log_type, log_body} = + if locked? do + {"Image.DescriptionLock:create", "Locked description editing on image #{image.id}"} + else + {"Image.DescriptionLock:delete", "Unlocked description editing on image #{image.id}"} + end + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.lock_description_changeset(image, locked?) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {log_type, Paths.image_path(image), log_body} + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + end + end end + @doc group: "Moderation and lifecycle" @doc """ - Removes the original SHA-512 hash from an image, allowing users to upload - the same file again. + Locks (`locked?` true) or unlocks (`locked?` false) tag editing on the image + named by `image_id`, on behalf of `actor`. + + The image is loaded by id and authorized for `:lock_tags`. On success tag editing + is toggled, the image is reindexed, and a moderation log is written attributing + the change to `actor`. + + Returns `{:ok, image}` with the updated image. ## Examples - iex> remove_hash(image) + iex> update_image_tag_lock(moderator, "42", true) {:ok, %Image{}} + iex> update_image_tag_lock(user, "42", true) + {:error, :unauthorized} + """ - def remove_hash(%Image{} = image) do - image - |> Image.remove_hash_changeset() - |> Repo.update() - |> reindex_after_update() + @spec update_image_tag_lock(Actor.t(), IntegerId.integer_id(), boolean()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def update_image_tag_lock(%Actor{} = actor, image_id, locked?) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :lock_tags, image_id) do + {log_type, log_body} = + if locked? do + {"Image.TagLock:create", "Locked tags on image #{image.id}"} + else + {"Image.TagLock:delete", "Unlocked tags on image #{image.id}"} + end + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.lock_tags_changeset(image, locked?) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {log_type, Paths.image_path(image), log_body} + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + end + end end + @doc group: "Moderation and lifecycle" @doc """ - Updates the scratchpad notes on an image. + Loads the image named by `image_id` for moderation, on behalf of `actor`. + + The image is loaded by id and authorized for `:hide`; the write-access gate + is applied because callers use this loader to prepare mutations. + + Returns `{:ok, image}` with the loaded image, carrying the associations + named by `opts[:preload]` (none by default). ## Examples - iex> update_scratchpad(image, %{"scratchpad" => "New notes"}) + iex> load_hidable_image(moderator, "42") {:ok, %Image{}} + iex> load_hidable_image(user, "42") + {:error, :unauthorized} + """ - def update_scratchpad(%Image{} = image, attrs) do - image - |> Image.scratchpad_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec load_hidable_image(Actor.t(), IntegerId.integer_id(), Keyword.t()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def load_hidable_image(%Actor{} = actor, image_id, opts \\ []) do + with :ok <- verify_write_access(actor) do + load_image_member(actor, :hide, image_id, Keyword.get(opts, :preload, [])) + end end + @doc group: "Moderation and lifecycle" @doc """ - Removes all source change history for an image. + Repairs the image named by `image_id`, on behalf of `actor`, by regenerating + its thumbnails and purging its cached files. + + The image is loaded by id and authorized for `:repair`. On success the thumbnail + regeneration job is enqueued, the image's CDN files are purged, and a moderation + log is written attributing the repair to `actor`. + + Returns `{:ok, image}` with the loaded image. ## Examples - iex> remove_source_history(image) + iex> create_image_repair(moderator, "42") {:ok, %Image{}} + iex> create_image_repair(user, "42") + {:error, :unauthorized} + """ - def remove_source_history(%Image{} = image) do - image - |> Repo.preload(:source_changes) - |> Image.remove_source_history_changeset() - |> Repo.update() - |> reindex_after_update() + @spec create_image_repair(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def create_image_repair(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :repair, image_id) do + query = where(Image, id: ^image.id) + + Multi.new() + |> Multi.update_all(:image_repair, query, + set: [thumbnails_generated: false, processed: false] + ) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Image.Repair:create", + Paths.image_path(image), + "Repaired image #{image.id}" + ) + |> Multi.transact() + |> case do + {:ok, _changes} -> + enqueue_image_repair(image) + purge_files(image, image.hidden_image_key) + {:ok, image} + + error -> + error + end + end end + @doc group: "Moderation and lifecycle" @doc """ - Repairs an image by regenerating its thumbnails. - Returns the image struct unchanged, for use in a pipeline. + Hides (soft-deletes) the image named by `image_id` from public view, on behalf + of `actor`, recording the deletion reason from `attrs`. - This will: - 1. Mark the image as needing thumbnail regeneration - 2. Queue the thumbnail generation job + The image is loaded by id and authorized for `:hide`. On success the image is + hidden (its reports and duplicate reports closed, tag counts decremented, + thumbnails purged, everything reindexed) and a moderation log is written attributing + the deletion to `actor`. + + Returns `{:ok, image}` with the hidden image, or `{:error, changeset}` when + the hide is rejected (e.g. a blank deletion reason), leaving the image visible. ## Examples - iex> repair_image(image) - %Image{} + iex> create_image_hide(moderator, "42", %{"deletion_reason" => "Rule violation"}) + {:ok, %Image{}} - """ - def repair_image(%Image{} = image) do - Image - |> where(id: ^image.id) - |> Repo.update_all(set: [thumbnails_generated: false, processed: false]) + iex> create_image_hide(user, "42", %{"deletion_reason" => "Rule violation"}) + {:error, :unauthorized} - Exq.enqueue(Exq, queue(image.image_mime_type), ThumbnailWorker, [image.id]) + """ + @spec create_image_hide(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def create_image_hide(%Actor{user: user} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :hide, image_id) do + changeset_fun = fn %{locked_image: image} -> Image.hide_changeset(image, attrs, user) end + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> put_hide_image(changeset_fun, image, user) + |> Galleries.put_remove_image_interactions(image) + |> DuplicateReports.put_reject_image_reports(:duplicate_reports, image.id) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: hidden} -> + { + "Image.Delete:create", + Paths.image_path(hidden), + "Deleted image #{hidden.id} (#{hidden.deletion_reason})" + } + end) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} - image + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end - defp queue("video/webm"), do: "videos" - defp queue(_mime_type), do: "images" - + @doc group: "Moderation and lifecycle" @doc """ - Updates the file content of an image. + Restores (unhides) the image named by `image_id` from moderation hiding, on + behalf of `actor`. - This will: - 1. Update the image metadata - 2. Save the new file - 3. Generate new thumbnails - 4. Purge old files - 5. Reindex the image + The image is loaded by id and authorized for `:unhide`. Restoring an image that + is not hidden still succeeds (it is left visible). On success the image is + made visible, its content reindexed, and a moderation log is written + attributing the restore to `actor`. + + Returns `{:ok, image}` with the restored image. ## Examples - iex> update_file(image, %{"image" => upload}) + iex> delete_image_user_hide(moderator, "42") {:ok, %Image{}} - """ - def update_file(%Image{} = image, attrs) do - image - |> Image.changeset(attrs) - |> Uploader.analyze_upload(attrs) - |> Repo.update() - |> case do - {:ok, image} -> - Uploader.persist_upload(image) + iex> delete_image_user_hide(user, "42") + {:error, :unauthorized} - repair_image(image) - purge_files(image, image.hidden_image_key) - reindex_image(image) + """ + @spec delete_image_hide(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def delete_image_hide(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :unhide, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.run(:hidden_image_key, fn _repo, %{locked_image: image} -> + {:ok, image.hidden_image_key} + end) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.unhide_changeset(image) + end) + |> Multi.run(:tags, fn repo, %{image: image} -> + {:ok, repo.preload(image, :tags, force: true).tags} + end) + |> Tags.put_image_count_delta( + :tag_image_counts, + fn %{tags: tags} -> Enum.map(tags, & &1.id) end, + 1 + ) + |> put_reindex_image(:image) + |> Multi.on_commit(fn %{image: image, tags: tags} -> + Comments.reindex_comments_on_image(image) + {image, tags} + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {"Image.Delete:delete", Paths.image_path(image), "Restored image #{image.id}"} + end) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image, tags: _tags, hidden_image_key: key}} -> + spawn(fn -> Thumbnailer.unhide_thumbnails(image, key) end) + purge_files(image, key) - {:ok, image} + {:ok, image} - error -> - error + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end + @doc group: "Moderation and lifecycle" @doc """ - Updates a image. + Removes the vote cast by the user named by `user_id` on the image named by + `image_id`, on behalf of `actor`. + + The image is loaded by id and authorized for `:tamper`; the target user is then + loaded by id. A non-castable or unknown user is is `{:error, :not_found}`, checked + after image authorization. Removing a vote the user never cast still succeeds. + On success the image is reindexed and a moderation log recording the removed vote + type and target user is written. + + Returns `{:ok, image}` with the image. ## Examples - iex> update_image(image, %{field: new_value}) + iex> delete_user_vote(moderator, "42", "7") {:ok, %Image{}} - iex> update_image(image, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> delete_user_vote(user, "42", "7") + {:error, :unauthorized} """ - def update_image(%Image{} = image, attrs) do - image - |> Image.changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec delete_user_vote( + actor :: Actor.t(), + image_id :: IntegerId.integer_id(), + user_id :: IntegerId.integer_id() + ) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_vote(%Actor{} = actor, image_id, user_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :tamper, image_id), + {:ok, user} <- Loader.fetch(User, user_id) do + Multi.new() + |> ImageVotes.delete_vote_for_loaded_image(image, user) + |> ModerationLogs.put_log(:moderation_log, actor, fn changes -> + vote_type = deleted_vote_type(changes) + + { + "Image.Tamper:create", + Paths.image_path(image), + "Deleted #{vote_type} by #{user.name} on image #{image.id}" + } + end) + |> Multi.on_commit(fn _changes -> reindex_image(image) end) + |> Multi.transact() + |> case do + {:ok, _changes} -> {:ok, image} + error -> error + end + end + end + + @doc group: "Visibility and filtering" + @doc """ + Returns whether `image` matches the viewer's compiled hide/spoiler policy. + """ + @spec filter_or_spoiler_hits?(Image.t(), Philomena.Filters.ImageFilter.t()) :: boolean() + def filter_or_spoiler_hits?(%Image{} = image, image_filter) do + Filtering.filter_or_spoiler_hits?(image, image_filter) end + @doc group: "Visibility and filtering" @doc """ - Updates an image's description. + Verifies the Images-owned forced-filter prerequisite for a loaded image. + + This narrow cross-context service is used by comment actions after image + authorization succeeds. Controllers must call their owning action instead. ## Examples - iex> update_description(image, %{"description" => "New description"}) - {:ok, %Image{}} + iex> verify_forced_filter_access(actor, image) + :ok """ - def update_description(%Image{} = image, attrs) do - image - |> Image.description_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec verify_forced_filter_access(Actor.t(), Image.t()) :: + :ok | {:error, :forced_filter} + def verify_forced_filter_access(%Actor{} = actor, %Image{} = image) do + Filtering.verify_not_forced(actor, image) end + @doc group: "Metadata editing" @doc """ - Updates an image's sources with attribution tracking. + Clears the original SHA-512 hash of the image named by `image_id`, on behalf + of `actor`, allowing the same file to be uploaded again. - Handles both added and removed sources. Automatically determines the user's - intended source changes based on the provided previous image state. + The image is loaded by id and authorized for `:remove_hash`. On success the hash is + cleared, the image is reindexed, and a moderation log is written attributing the + change to `actor`. - This will update the image's sources, create source change records - for tracking, and reindex the image. + Returns `{:ok, image}` with the updated image. ## Examples - iex> update_sources( - ...> image, - ...> %{attribution: attrs}, - ...> %{ - ...> "old_sources" => %{}, - ...> "sources" => %{"0" => "http://example.com"} - ...> } - ...> ) - {:ok, - %{ - image: image, - added_source_changes: 1, - removed_source_changes: 0 - }} - - """ - def update_sources(%Image{} = image, attribution, attrs) do - old_sources = attrs["old_sources"] - new_sources = attrs["sources"] + iex> delete_image_hash(moderator, "42") + {:ok, %Image{}} - Multi.new() - |> Multi.run(:image, fn repo, _chg -> - image = repo.preload(image, [:sources]) + iex> delete_image_hash(user, "42") + {:error, :unauthorized} - image - |> Image.source_changeset(%{}, old_sources, new_sources) - |> repo.update() + """ + @spec delete_image_hash(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_image_hash(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :remove_hash, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> Image.remove_hash_changeset(image) end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {"Image.Hash:delete", Paths.image_path(image), "Cleared hash of image #{image.id}"} + end) + |> put_reindex_image(:image) + |> Multi.transact() |> case do - {:ok, image} -> - {:ok, {image, image.added_sources, image.removed_sources}} - - error -> - error + {:ok, %{image: %Image{} = image}} -> + {:ok, image} end - end) - |> Multi.run(:added_source_changes, fn repo, %{image: {image, added_sources, _removed}} -> - source_changes = - added_sources - |> Enum.map(&source_change_attributes(attribution, image, &1, true, attribution[:user])) + end + end - {count, nil} = repo.insert_all(SourceChange, source_changes) + @doc group: "Metadata editing" + @doc """ + Updates the moderation notes on the image named by `image_id`, on behalf of + `actor`, from `attrs` (a map with a `"scratchpad"` key). - {:ok, count} - end) - |> Multi.run(:removed_source_changes, fn repo, %{image: {image, _added, removed_sources}} -> - source_changes = - removed_sources - |> Enum.map(&source_change_attributes(attribution, image, &1, false, attribution[:user])) + The image is loaded by id and authorized for `:edit_scratchpad`. On success the notes are + updated, the image is reindexed, and a moderation log is written attributing the + change to `actor`. - {count, nil} = repo.insert_all(SourceChange, source_changes) + Returns `{:ok, image}` with the updated image. - {:ok, count} - end) - |> Repo.transaction() - end + ## Examples - defp source_change_attributes(attribution, image, source, added, user) do - now = DateTime.utc_now(:second) + iex> update_image_scratchpad(moderator, "42", %{"scratchpad" => "watch closely"}) + {:ok, %Image{}} - user_id = - case user do - nil -> nil - user -> user.id - end + iex> update_image_scratchpad(user, "42", %{"scratchpad" => "watch closely"}) + {:error, :unauthorized} - %{ - image_id: image.id, - source_url: source, - user_id: user_id, - created_at: now, - updated_at: now, - ip: attribution[:ip], - fingerprint: attribution[:fingerprint], - added: added - } + """ + @spec update_image_scratchpad(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_image_scratchpad(%Actor{} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :edit_scratchpad, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.scratchpad_changeset(image, attrs) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + { + "Image.Scratchpad:update", + Paths.image_path(image), + "Updated mod notes on image #{image.id} (#{image.scratchpad})" + } + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Metadata editing" @doc """ - Updates the locked tags on an image. + Deletes the source change history of the image named by `image_id`, on behalf + of `actor`. + + The image is loaded by id and authorized for `:remove_source_history`. On success the source history + is removed, the image is reindexed, and a moderation log is written attributing + the deletion to `actor`. - Locked tags can only be added or removed by privileged users. + Returns `{:ok, image}` with the updated image. ## Examples - iex> update_locked_tags(image, %{tag_input: "safe, validated"}) + iex> delete_image_source_history(moderator, "42") {:ok, %Image{}} - """ - def update_locked_tags(%Image{} = image, attrs) do - new_tags = Tags.get_or_create_tags(attrs["tag_input"]) + iex> delete_image_source_history(user, "42") + {:error, :unauthorized} - image - |> Repo.preload(:locked_tags) - |> Image.locked_tags_changeset(attrs, new_tags) - |> Repo.update() - |> reindex_after_update() + """ + @spec delete_image_source_history(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_image_source_history(%Actor{} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- + load_image_member(actor, :remove_source_history, image_id, [:source_changes]) do + query = Image |> where(id: ^image.id) |> preload(:source_changes) + + Multi.new() + |> Multi.lock_one(:locked_image, query) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.remove_source_history_changeset(image) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + { + "Image.SourceHistory:delete", + Paths.image_path(image), + "Deleted source history for image #{image.id}" + } + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + end + end end + @doc group: "Metadata editing" @doc """ - Updates an image's tags with attribution tracking. + Replaces the file content of the image named by `image_id`, on behalf of + `actor`, from `attrs` (a map with an `"image"` upload). - Handles both added and removed tags. Automatically determines the user's - intended tag changes based on the provided previous image state. + The image is loaded by id and authorized for `:replace_file`. On success the + file is replaced, thumbnails are regenerated in the image's current storage + path, old files are purged, the image is reindexed, and a moderation log is + written attributing the change to `actor`. - This will update the image's tags, create tag change records - for tracking, and reindex the image. + Returns `{:ok, image}` with the updated image, or + `{:error, %Ecto.Changeset{}}` when the replacement is rejected (e.g. no file, + or a file already uploaded as another image), leaving the image untouched. ## Examples - iex> update_tags( - ...> image, - ...> %{attribution: attrs}, - ...> %{ - ...> old_tag_input: "safe", - ...> tag_input: "safe, cute" - ...> } - ...> ) - {:ok, - %{ - image: image, - tag_changes: {1, 0} - }} - - """ - def update_tags(%Image{} = image, attribution, attrs) do - old_tags = Tags.get_or_create_tags(attrs["old_tag_input"]) - new_tags = Tags.get_or_create_tags(attrs["tag_input"]) + iex> update_image_file(moderator, "42", upload) + {:ok, %Image{}} - Multi.new() - |> Multi.run(:image, fn repo, _chg -> - image = repo.preload(image, [:tags, :locked_tags]) + iex> update_image_file(user, "42", upload) + {:error, :unauthorized} - image - |> Image.tag_changeset(%{}, old_tags, new_tags, image.locked_tags) - |> repo.update() + """ + @spec update_image_file(Actor.t(), IntegerId.integer_id(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_image_file(%Actor{} = actor, image_id, upload) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :replace_file, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + image + |> Image.changeset(%{}) + |> Uploader.analyze_upload(upload) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {"Image.File:update", Paths.image_path(image), "Updated file of image #{image.id}"} + end) + |> put_reindex_image(:image) + |> Multi.transact() |> case do - {:ok, image} -> - {:ok, {image, image.added_tags, image.removed_tags}} + {:ok, %{image: %Image{} = image}} -> + Uploader.persist_upload(image) + repair_image(image) + purge_files(image, image.hidden_image_key) - error -> - error + {:ok, image} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} end - end) - |> Multi.run(:check_limits, fn _repo, %{image: {image, _added, _removed}} -> - check_tag_change_limits_before_commit(image, attribution) - end) - |> Multi.run(:tag_changes, fn - _repo, %{image: {_image, [], []}} -> - {:ok, {0, 0}} - - _repo, %{image: {image, added_tags, removed_tags}} -> - TagChanges.create_tag_change( - image, - attribution, - added_tags, - removed_tags - ) - end) - |> Multi.run(:added_tag_count, fn - _repo, %{image: {%{hidden_from_users: true}, _added, _removed}} -> - {:ok, 0} + end + end - repo, %{image: {_image, added_tags, _removed}} -> - tag_ids = added_tags |> Enum.map(& &1.id) + @doc group: "Metadata editing" + @doc """ + Updates the description of the image named by `image_id`, on behalf of + `actor`, from `attrs` (a map with a `"description"` key). - count = Tags.update_image_counts(repo, 1, tag_ids) + Banned actors are rejected first with `{:error, :ban}` (a write with no + fingerprint is `{:error, :unauthorized}`). The image is then loaded by id and + authorized for `:edit_description`. The uploader may edit a non-hidden image + whose description editing is allowed, and staff may edit any image. - {:ok, count} - end) - |> Multi.run(:removed_tag_count, fn - _repo, %{image: {%{hidden_from_users: true}, _added, _removed}} -> - {:ok, 0} + Returns `{:ok, {image, old_description}}` with the updated image (its author, + sources, and tags preloaded) and the description it replaced. The context + broadcasts the description and image updates after persistence. Returns + `{:error, %Ecto.Changeset{}}` when the new + description is rejected (e.g. too long), leaving the image untouched. - repo, %{image: {_image, _added, removed_tags}} -> - tag_ids = removed_tags |> Enum.map(& &1.id) + ## Examples - count = Tags.update_image_counts(repo, -1, tag_ids) + iex> update_image_description(actor, "42", %{"description" => "New description"}) + {:ok, {%Image{}, "Old description"}} - {:ok, count} - end) - |> Repo.transaction() - |> case do - {:ok, %{image: {image, _added, _removed}}} = res -> - update_tag_change_limits_after_commit(image, attribution) + iex> update_image_description(actor, "42", %{"description" => "..."}) + {:error, :unauthorized} + + """ + @spec update_image_description(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, {Image.t(), String.t() | nil}} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_image_description(%Actor{} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image_id} <- Loader.parse_id(image_id) do + Multi.new() + |> put_lock_image(actor, image_id, :edit_description, [:user, :sources, tags: :aliases]) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.description_changeset(image, attrs) + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{locked_image: %Image{} = old_image, image: %Image{} = image}} -> + broadcast_description_update(image, old_image.description) + + {:ok, {image, old_image.description}} - res + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} - err -> - err + error -> + map_lock_errors(error) + end end end - defp check_tag_change_limits_before_commit(image, attribution) do - tag_changed_count = length(image.added_tags) + length(image.removed_tags) - rating_changed = image.ratings_changed - user = attribution[:user] - ip = attribution[:ip] + @doc group: "Metadata editing" + @doc """ + Updates the sources of the image named by `image_id`, on behalf of `actor`, + from `attrs` (`"old_sources"`/`"sources"` maps), + recording source change records attributed to the actor. + + Banned actors are rejected first with `{:error, :ban}` (a write with no + fingerprint is `{:error, :unauthorized}`), before the image is loaded. A + non-exempt actor who has updated metadata within the last 5 seconds gets + `{:error, :rate_limited}` during the transaction reservation. The image is + loaded by id (with its author, sources, and tags preloaded) and authorized + for `:edit_metadata` before that reservation. Sources are editable on a + non-hidden image by anyone (anonymous included). On success the sources are + updated and attributed, the actor's metadata-update stat is incremented when + sources actually changed, and the image is reindexed. + + Returns `{:ok, %{image: image, source_change_count: count}}`. The context + broadcasts the source and image updates after persistence. Returns + `{:error, %Ecto.Changeset{}}` when + the update is rejected (e.g. more than the allowed number of sources), leaving + the image untouched. + + ## Examples - cond do - Limits.limited_for_tag_count?(user, ip, tag_changed_count) -> - {:error, :limit_exceeded} + iex> update_image_sources(actor, "42", %{"old_sources" => %{}, "sources" => %{"0" => %{"source" => "http://example.com"}}}) + {:ok, %{image: %Image{}, source_change_count: 1}} - rating_changed and Limits.limited_for_rating_count?(user, ip) -> - {:error, :limit_exceeded} + """ + @spec update_image_sources(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, + %{ + image: Image.t(), + source_change_count: non_neg_integer() + }} + | {:error, :ban | :unauthorized | :not_found | :rate_limited | Ecto.Changeset.t()} + def update_image_sources(%Actor{} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :edit_metadata, image_id, [:sources]), + {:ok, source_input_form} <- + %SourceInputForm{} + |> SourceInputForm.changeset(attrs) + |> SourceInputForm.apply(image) do + case update_loaded_sources(image, actor, source_input_form) do + {:ok, %Image{} = image} -> + {:ok, + %{ + image: image, + source_change_count: SourceChanges.count_for_image(image) + }} + + {:error, :no_change} -> + {:ok, + %{ + image: image, + source_change_count: SourceChanges.count_for_image(image) + }} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, changeset} - true -> - {:ok, 0} + error -> + error + end end end + @doc group: "Metadata editing" @doc """ - Updates the tag change tracking after committing updates to an image. + Updates the locked tag list of the image named by `image_id`, on behalf of + `actor`, from `attrs` (a map with a `"tag_input"` key). - This updates the rate limit counters for total tag change count and rating change count - based on the changes made to the image. + The image is loaded by id and authorized for `:lock_tags`. A blank `tag_input` clears + the list. On success the locked tags are replaced, the image is reindexed, and a + moderation log is written attributing the change to `actor`. Only existing tags + are considered; aliases resolve to their canonical tags and implications are not + expanded. + + Returns `{:ok, image}` with the updated image. ## Examples - iex> update_tag_change_limits_after_commit(image, %{user: user, ip: "127.0.0.1"}) - :ok + iex> update_image_locked_tags(moderator, "42", %{"tag_input" => "safe, solo"}) + {:ok, %Image{}} + + iex> update_image_locked_tags(user, "42", %{"tag_input" => "safe, solo"}) + {:error, :unauthorized} """ - def update_tag_change_limits_after_commit(image, attribution) do - rating_changed_count = if(image.ratings_changed, do: 1, else: 0) - tag_changed_count = length(image.added_tags) + length(image.removed_tags) - user = attribution[:user] - ip = attribution[:ip] + @spec update_image_locked_tags(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_image_locked_tags(%Actor{} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :lock_tags, image_id, [:locked_tags]), + {:ok, tag_input_form} <- + %TagInputForm{} + |> TagInputForm.changeset(attrs) + |> TagInputForm.apply(image) do + tag_names = Tag.parse_tag_list(tag_input_form.tag_input) + + image_query = + Image + |> where(id: ^image.id) + |> preload(:locked_tags) + + Multi.new() + |> Multi.lock_one(:locked_image, image_query) + |> Tags.put_canonicalize_tag_name_sets([{:tags, tag_names, []}]) + |> Multi.update(:image, fn %{locked_image: image, canonical_tags: %{tags: tags}} -> + Image.locked_tags_changeset(image, attrs, tags) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + { + "Image.TagLock:update", + Paths.image_path(image), + "Updated list of locked tags on image #{image.id}" + } + end) + |> put_reindex_image(:image) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} - :ok = Limits.update_tag_count_after_update(user, ip, tag_changed_count) - :ok = Limits.update_rating_count_after_update(user, ip, rating_changed_count) - :ok + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Metadata editing" @doc """ - Changes the uploader of an image. + Updates the tags of the image named by `image_id`, on behalf of `actor`, from + `attrs` (`"old_tag_input"`/`"tag_input"`), recording tag + change records attributed to the actor. + + Banned actors are rejected first with `{:error, :ban}` (a write with no + fingerprint is `{:error, :unauthorized}`), before the image is loaded. A + non-exempt actor who has updated metadata within the last 5 seconds gets + `{:error, :rate_limited}` from the transaction reservation. The image is + loaded by id (with its author, locked tags, sources, and tags preloaded) and + authorized for `:edit_metadata` before that reservation - editable on a + non-hidden image whose tag editing is allowed by anyone (anonymous + included), so an image with tag editing disabled is + `{:error, :unauthorized}`. On success the tags are updated and attributed, + the image, its comments, and the affected tags are reindexed, and the + actor's metadata-update stat is incremented when tags actually changed. + + On success, returns `{:ok, %{image: image, tag_change_count: count, + tag_change_tag_count: tag_count}}`. The context broadcasts the tag and image + updates after persistence. + + ## Failure shapes + + - `{:error, %Ecto.Changeset{}}` when the update is rejected (e.g. the + image would drop below the minimum tag count) + - `{:error, :rate_limited}` from either of two independent + counters - the once-per-window check above, or the + in-transaction `TagChanges.Limits` check that caps the number of tag and + rating changes over ten minutes and rolls back at the + `:check_limits` step. + + All failures leave the image untouched. ## Examples - iex> update_uploader(image, %{"username" => "Admin"}) - {:ok, %Image{}} + iex> update_image_tags(actor, "42", %{"old_tag_input" => "safe", "tag_input" => "safe, cute"}) + {:ok, %{image: %Image{}, tag_change_count: 1, tag_change_tag_count: 1}} """ - def update_uploader(%Image{} = image, attrs) do - image - |> Image.uploader_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec update_image_tags(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, + %{ + image: Image.t(), + tag_change_count: non_neg_integer(), + tag_change_tag_count: non_neg_integer() + }} + | {:error, + :ban + | :unauthorized + | :not_found + | :rate_limited + | Ecto.Changeset.t()} + def update_image_tags(%Actor{} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :edit_metadata, image_id), + {:ok, tag_input_form} <- + %TagInputForm{} + |> TagInputForm.changeset(attrs) + |> TagInputForm.apply(image) do + case update_loaded_tags(image, actor, tag_input_form) do + {:ok, %Image{} = image} -> + {tag_change_count, tag_change_tag_count} = TagChanges.count_for_image(image) + + {:ok, + %{ + image: image, + tag_change_count: tag_change_count, + tag_change_tag_count: tag_change_tag_count + }} + + {:error, :no_change} -> + {tag_change_count, tag_change_tag_count} = TagChanges.count_for_image(image) + + {:ok, + %{ + image: image, + tag_change_count: tag_change_count, + tag_change_tag_count: tag_change_tag_count + }} + + {:error, :rate_limited} -> + {:error, :rate_limited} + + {:error, %Ecto.Changeset{} = changeset} -> + {:error, changeset} + + error -> + error + end + end end + @doc group: "Metadata editing" @doc """ - Updates the anonymous status of an image. + Reassigns the uploader of the image named by `image_id`, on behalf of `actor`, + from `image_params`. + + `image_params` is a map with a `"username"` key. A blank username clears the + uploader, anonymizing it. + + Authorization requires both `:show` on `:identity_metadata` and + `:update_uploader` on the loaded image. The identity capability is checked + first; malformed and missing image ids are otherwise `{:error, :not_found}`. + On success the uploader is reassigned, the image is + reindexed, and a moderation log is written attributing the change to `actor`. + + Returns `{:ok, image}` with the updated image (its new uploader and their awards + preloaded), `{:error, :invalid_params}` when `image_params` is not + a map, or `{:error, %Ecto.Changeset{}}` when the username names no user, both + leaving the image untouched. ## Examples - iex> update_anonymous(image, %{"anonymous" => "true"}) + iex> update_image_uploader(moderator, "42", %{"username" => "Admin"}) {:ok, %Image{}} + iex> update_image_uploader(user, "42", %{"username" => "Admin"}) + {:error, :unauthorized} + """ - def update_anonymous(%Image{} = image, attrs) do - image - |> Image.anonymous_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec update_image_uploader(Actor.t(), IntegerId.integer_id(), any()) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_image_uploader(%Actor{} = actor, image_id, image_params) do + with :ok <- authorize(actor, :show, :identity_metadata), + :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :update_uploader, image_id), + {:ok, image} <- + image + |> Image.username_changeset(image_params) + |> Ecto.Changeset.apply_action(:update) do + username = image.username + + uploader = + if username do + case Users.load_active_user_by_name(actor, username) do + {:ok, user} -> user + _error -> nil + end + end + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.uploader_changeset(image, username, uploader) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + { + "Image.Uploader:update", + Paths.image_path(image), + "Changed uploader of image #{image.id}" + } + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, Repo.preload(image, user: [awards: :badge])} + + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Metadata editing" @doc """ - Updates the hide reason for an image. + Sets or clears the anonymity status of the image named by `image_id`, + on behalf of `actor`. + + Authorization requires both `:show` on `:identity_metadata` and + `:update_anonymous` on the loaded image. The identity capability is checked + first; malformed and missing image ids are otherwise `{:error, :not_found}`. + On success the anonymity is toggled, the image is + reindexed, and a moderation log is written attributing the change to `actor`. + + Returns `{:ok, image}` with the updated image. ## Examples - iex> update_hide_reason(image, %{hide_reason: "Duplicate of #1234"}) + iex> update_image_anonymous(moderator, "42", true) {:ok, %Image{}} - iex> update_hide_reason(image, %{hide_reason: ""}) - {:ok, %Image{}} + iex> update_image_anonymous(user, "42", true) + {:error, :unauthorized} """ - def update_hide_reason(%Image{} = image, attrs) do - image - |> Image.hide_reason_changeset(attrs) - |> Repo.update() - |> reindex_after_update() - end - - defp reindex_after_update(result) do - case result do - {:ok, image} -> - reindex_image(image) - - {:ok, image} - - error -> - error + @spec update_anonymous(Actor.t(), IntegerId.integer_id(), boolean()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def update_anonymous(%Actor{} = actor, image_id, anonymous?) do + with :ok <- authorize(actor, :show, :identity_metadata), + :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :update_anonymous, image_id) do + log_type = if anonymous?, do: "Image.Anonymous:create", else: "Image.Anonymous:delete" + + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.anonymous_changeset(image, %{anonymous: anonymous?}) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + {log_type, Paths.image_path(image), "Updated anonymity of image #{image.id}"} + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} + end end end + @doc group: "Metadata editing" @doc """ - Hides an image from public view. + Updates the deletion reason of the image named by `image_id`, on behalf of + `actor`, from `attrs`. + + The image is loaded by id and authorized for `:update_hide_reason`. Only an already-hidden + image may have its reason changed; a visible image is `{:error, :not_deleted}`, + left untouched. On success the reason is updated, the image is reindexed, and + a moderation log is written attributing the change to `actor`. - This will: - 1. Mark the image as hidden - 2. Close all reports and duplicate reports - 3. Delete all gallery interactions containing the image - 4. Decrement all tag counts with the image - 5. Hide the image's thumbnails and purge them from the CDN - 6. Reindex the image and all of its comments + Returns `{:ok, image}` with the updated image, or `{:error, %Ecto.Changeset{}}` + when the new reason is rejected (e.g. blank), leaving the image untouched. ## Examples - iex> hide_image(image, moderator, %{reason: "Rule violation"}) - {:ok, - %{ - image: image, - tags: tags, - reports: {count, reports} - }} + iex> update_image_hide(moderator, "42", %{"deletion_reason" => "Duplicate"}) + {:ok, %Image{}} + + iex> update_image_hide(user, "42", %{"deletion_reason" => "Duplicate"}) + {:error, :unauthorized} """ - def hide_image(%Image{} = image, user, attrs) do - duplicate_reports = - DuplicateReport - |> where(state: "open") - |> where([d], d.image_id == ^image.id or d.duplicate_of_image_id == ^image.id) - |> update(set: [state: "rejected"]) + @spec update_image_hide(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_image_hide(%Actor{} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :update_hide_reason, image_id) do + Multi.new() + |> Multi.lock_one(:locked_image, where(Image, id: ^image.id)) + |> Multi.update(:image, fn %{locked_image: image} -> + Image.hide_reason_changeset(image, attrs) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{image: image} -> + { + "Image.Delete:update", + Paths.image_path(image), + "Changed deletion reason of #{image.id} (#{image.deletion_reason})" + } + end) + |> put_reindex_image(:image) + |> Multi.transact() + |> case do + {:ok, %{image: %Image{} = image}} -> + {:ok, image} - image - |> Image.hide_changeset(attrs, user) - |> hide_image_multi(image, user, Multi.new()) - |> remove_gallery_interactions_multi(image) - |> Multi.update_all(:duplicate_reports, duplicate_reports, []) - |> Repo.transaction() - |> process_after_hide() + {:error, :image, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Bulk operations" @doc """ - Merges one image into another, combining their metadata and content. - - This will: - 1. Hide the source image - 2. Replace the source image with the target image in galleries - 3. Update first_seen_at timestamp - 4. Copy tags to the target image - 5. Migrate sources, comments, subscriptions and interactions - 6. Send merge notifications - 7. Reindex both images, the affected galleries, and all of the comments + Applies a batch tag edit to `image_ids` on behalf of `actor`. - ## Parameters - - multi: Optional `m:Ecto.Multi` for transaction handling - - image: The source image to merge from - - duplicate_of_image: The target image to merge into - - user: The user performing the merge + Authorizes `:batch_update` against the tag model, parses the tag list and + image IDs, and runs the batch update in chunks of 1,000 IDs. Batch tagging is + a staff feature and is not rate-limited. Each successful chunk writes an + `"Admin.Batch.Tag:update"` moderation log and broadcasts its update to the + firehose. - ## Examples + On success returns `{:ok, result}` where `result` is a map with: - iex> merge_image(nil, source_image, target_image, moderator) - {:ok, - %{ - image: image, - tags: tags - }} + * `:succeeded` - the number of image IDs successfully updated; + * `:failed` - the number of image IDs in failed chunks or matching no image. + Returns `{:error, :unauthorized}` when `actor` may not batch-tag, or an + invalid `BatchTagForm` changeset when the parameters cannot be cast. """ - def merge_image(multi \\ nil, %Image{} = image, duplicate_of_image, user) do - multi = multi || Multi.new() + @spec update_batch_tags(Actor.t(), map()) :: + {:ok, %{succeeded: non_neg_integer(), failed: non_neg_integer()}} + | {:error, :unauthorized | Ecto.Changeset.t()} + def update_batch_tags(%Actor{} = actor, params) do + with :ok <- authorize(actor, :batch_update, Tag), + {:ok, form} <- + %BatchTagForm{} + |> BatchTagForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + tag_names = Tag.parse_tag_list(form.tag_list) + + added_names = Enum.reject(tag_names, &String.starts_with?(&1, "-")) + + removed_names = + tag_names + |> Enum.filter(&String.starts_with?(&1, "-")) + |> Enum.map(&String.replace_leading(&1, "-", "")) + + attributes = %{ + ip: actor.ip, + fingerprint: actor.fingerprint, + user_id: actor.user.id + } - image = - Repo.preload(image, [:user, :intensity, :sources, tags: :aliases]) + form.image_ids + |> Enum.chunk_every(@batch_tag_size) + |> Enum.reduce({0, 0}, fn image_ids, {succeeded, failed} -> + Multi.new() + |> put_batch_tag(image_ids, added_names, removed_names, attributes) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{locked_image_ids: image_ids} -> + { + "Admin.Batch.Tag:update", + Paths.profile_path(actor.user), + "Batch tagged '#{form.tag_list}' on #{Enum.count(image_ids)} images" + } + end) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{locked_image_ids: processed_ids}} -> + unprocessed_ids = image_ids -- processed_ids - duplicate_of_image = - Repo.preload(duplicate_of_image, [:user, :intensity, :sources, tags: :aliases]) + {succeeded + Enum.count(processed_ids), failed + Enum.count(unprocessed_ids)} - image - |> Image.merge_changeset(duplicate_of_image) - |> hide_image_multi(image, user, multi) - |> migrate_gallery_interactions_multi(image, duplicate_of_image) - |> Multi.run(:first_seen_at, fn _, %{} -> - update_first_seen_at( - duplicate_of_image, - image.first_seen_at, - duplicate_of_image.first_seen_at - ) - end) - |> Multi.run(:copy_tags, fn _, %{} -> - {:ok, Tags.copy_tags(image, duplicate_of_image)} - end) - |> Multi.run(:migrate_sources, fn _, %{} -> - {:ok, migrate_sources(image, duplicate_of_image)} - end) - |> Multi.run(:migrate_comments, fn _, %{} -> - {:ok, Comments.migrate_comments(image, duplicate_of_image)} - end) - |> Multi.run(:migrate_subscriptions, fn _, %{} -> - {:ok, migrate_subscriptions(image, duplicate_of_image)} - end) - |> Multi.run(:migrate_interactions, fn _, %{} -> - {:ok, Interactions.migrate_interactions(image, duplicate_of_image)} - end) - |> Multi.run(:notification, ¬ify_merge(&1, &2, image, duplicate_of_image)) - |> Repo.transaction() - |> process_after_hide() - |> case do - {:ok, result} -> - reindex_image(duplicate_of_image) - Comments.reindex_comments_on_image(duplicate_of_image) - reindex_merged_galleries(result) - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:merge", - %{ - image: PhilomenaWeb.Api.Json.ImageView.render("image.json", %{image: image}), - duplicate_of_image: - PhilomenaWeb.Api.Json.ImageView.render("image.json", %{image: duplicate_of_image}) - } - ) + _error -> + {succeeded, failed + Enum.count(image_ids)} + end + end) + |> then(fn {succeeded, failed} -> + {:ok, %{succeeded: succeeded, failed: failed}} + end) + end + end - {:ok, result} + @doc group: "Bulk operations" + @doc """ + Applies the net inverse of selected tag-change entries to multiple images. + + `changes` contains one or more ordered `:tag_changes` entries for each image. + Each entry is resolved through its locked alias family and the entries are + reduced to one physical tag edit per image. An entry that adds a tag is + inverted by removing its alias family; an entry that removes a tag is + inverted by adding the current canonical tag when the family is absent. At + most one new tag-change row is created for each affected image. + + Images are locked before tag aliases and current taggings are read, matching + the lock order used by batched alias migration. This allows a reversion to + observe either the source or canonical physical tagging without creating a + duplicate or losing an inverse removal. + """ + @spec batch_revert([map()], map()) :: {:ok, [integer()]} | :error + def batch_revert(changes, attributes) do + Multi.new() + |> put_batch_revert_tag_changes(changes, attributes) + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{locked_image_ids: image_ids}} -> + {:ok, image_ids} - error -> - error + _error -> + :error end end - defp hide_image_multi(changeset, image, user, multi) do - report_query = Reports.close_report_query(user, image_id: image.id) + @doc group: "Background jobs" + @doc """ + Executes the worker-side CDN purge operation for image files. - multi - |> Multi.update(:image, changeset) - |> Multi.update_all(:reports, report_query, []) - |> Multi.run(:tags, fn repo, %{image: image} -> - image = Repo.preload(image, :tags, force: true) + Calls the system purge-cache command to remove the specified files from the CDN cache. + + ## Examples - # I'm not convinced this is a good idea. It leads - # to way too much drift, and the index has to be - # maintained. - tag_ids = Enum.map(image.tags, & &1.id) + iex> perform_purge(["file1.jpg", "file2.jpg"]) + :ok - Tags.update_image_counts(repo, -1, tag_ids) + """ + @spec perform_purge([String.t()]) :: :ok + def perform_purge(files) do + {_out, 0} = System.cmd("purge-cache", [JSON.encode!(%{files: files})]) - {:ok, image.tags} - end) + :ok end - defp remove_gallery_interactions_multi(multi, image) do - galleries = - Gallery - |> join(:inner, [g], gi in assoc(g, :interactions), on: gi.image_id == ^image.id) - |> update(inc: [image_count: -1]) - |> select([g], g.id) - - gallery_interactions = where(Interaction, image_id: ^image.id) + @doc group: "Background jobs" + @doc """ + Persists metadata calculated by the thumbnail worker. - multi - |> Multi.update_all(:galleries, galleries, []) - |> Multi.delete_all(:gallery_interactions, gallery_interactions, []) + The worker supplies derived attributes and a processing stage. + """ + @spec update_thumbnail_metadata!(Image.t(), map(), :thumbnail | :process) :: Image.t() + def update_thumbnail_metadata!(%Image{} = image, attrs, :thumbnail) do + image + |> Image.thumbnail_changeset(attrs) + |> Repo.update!() end - defp migrate_gallery_interactions_multi(multi, image, duplicate_of_image) do - target_gallery_ids = - Interaction - |> where(image_id: ^duplicate_of_image.id) - |> select([gi], gi.gallery_id) + def update_thumbnail_metadata!(%Image{} = image, attrs, :process) do + image + |> Image.process_changeset(attrs) + |> Repo.update!() + end - # Galleries may contain at most one interaction per image, so the source - # image's interaction can only be repointed at the target image in - # galleries which do not already contain the target. - migratable = - Interaction - |> where(image_id: ^image.id) - |> where([gi], gi.gallery_id not in subquery(target_gallery_ids)) - |> update(set: [image_id: ^duplicate_of_image.id]) - |> select([gi], gi.gallery_id) + @doc group: "Background jobs" + @doc """ + Replaces attribution data on a user's images in batches. + """ + @spec wipe_user_attribution!(integer(), term(), String.t()) :: :ok + def wipe_user_attribution!(user_id, ip, fingerprint) do + Image + |> where(user_id: ^user_id) + |> Batch.query_batches() + |> Enum.each(&Repo.update_all(&1, set: [ip: ip, fingerprint: fingerprint])) - leftover = Interaction |> where(image_id: ^image.id) |> select([gi], gi.gallery_id) + :ok + end - multi - |> Multi.update_all(:migrated_gallery_interactions, migratable, []) - |> Multi.delete_all(:gallery_interactions, leftover, []) - |> Multi.run(:galleries, fn repo, %{gallery_interactions: {_count, gallery_ids}} -> - {count, nil} = - Gallery - |> where([g], g.id in ^gallery_ids) - |> repo.update_all(inc: [image_count: -1]) - - {:ok, {count, gallery_ids}} - end) + @doc group: "Background jobs" + @doc """ + Decrements vote counters for the supplied images after user vote cleanup. + """ + @spec decrement_vote_counters!([integer()], boolean()) :: {non_neg_integer(), nil} + def decrement_vote_counters!(image_ids, true) when is_list(image_ids) do + Repo.update_all(where(Image, [image], image.id in ^image_ids), + inc: [upvotes_count: -1, score: -1] + ) end - defp process_after_hide(result) do - case result do - {:ok, - %{ - image: image, - tags: tags, - reports: {_, reports}, - galleries: {_, gallery_ids} - } = result} -> - spawn(fn -> - Thumbnailer.hide_thumbnails(image, image.hidden_image_key) - purge_files(image, image.hidden_image_key) - end) + def decrement_vote_counters!(image_ids, false) when is_list(image_ids) do + Repo.update_all(where(Image, [image], image.id in ^image_ids), + inc: [downvotes_count: -1, score: 1] + ) + end - Comments.reindex_comments_on_image(image) - Reports.reindex_reports(reports) - Tags.reindex_tags(tags) - Galleries.reindex_galleries(gallery_ids) - reindex_image(image) - reindex_copied_tags(result) + @doc group: "Background jobs" + @doc """ + Decrements favorite counters for the supplied images after user favorite + cleanup. + """ + @spec decrement_fave_counters!([integer()]) :: {non_neg_integer(), nil} + def decrement_fave_counters!(image_ids) when is_list(image_ids) do + Repo.update_all(where(Image, [image], image.id in ^image_ids), inc: [faves_count: -1]) + end - {:ok, result} + @doc group: "Subscriptions and notifications" + @doc """ + Subscribes `actor` to the image named by `image_id`, so they are notified of + new comments on it. - error -> - error - end - end + The image is loaded by id and authorized for `:subscribe`. Subscribing is + idempotent and, as subscription management, is deliberately exempt from + `verify_write_access/1`. - defp reindex_copied_tags(%{copy_tags: tags}), do: Tags.reindex_tags(tags) - defp reindex_copied_tags(_result), do: nil + Returns `{:ok, image}`, or `{:error, %Ecto.Changeset{}}` if the subscription + insert is rejected. - defp reindex_merged_galleries(%{ - migrated_gallery_interactions: {_, migrated_gallery_ids}, - galleries: {_, removed_gallery_ids} - }) do - Galleries.reindex_galleries(Enum.uniq(migrated_gallery_ids ++ removed_gallery_ids)) - end + ## Examples - defp update_first_seen_at(image, time_1, time_2) do - min_time = - case DateTime.compare(time_1, time_2) do - :gt -> time_2 - _ -> time_1 - end + iex> create_image_subscription(user, "42") + {:ok, %Image{}} - Image - |> where(id: ^image.id) - |> Repo.update_all(set: [first_seen_at: min_time]) + iex> create_image_subscription(user, "999999999") + {:error, :not_found} - {:ok, image} + """ + @spec create_image_subscription(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_image_subscription(%Actor{} = actor, image_id) do + with {:ok, image} <- load_image_member(actor, :subscribe, image_id), + {:ok, _subscription} <- create_subscription(image, actor.user) do + {:ok, image} + end end + @doc group: "Subscriptions and notifications" @doc """ - Unhides an image, making it visible to users again. + Unsubscribes `actor` from the image named by `image_id`. - This will: - 1. Remove the hidden status from the image - 2. Increment tag counts - 3. Unhide thumbnails - 4. Reindex the image and related content + Loading and authorization mirror `subscribe_image/2`. Unsubscribing is + idempotent and cannot fail, so there is no changeset error shape. Like + subscription creation, deletion is deliberately exempt from + `verify_write_access/1`. - Returns {:ok, image} if successful, or returns the image unchanged if it's not hidden. + Returns `{:ok, image}`, `{:error, :unauthorized}`, or `{:error, :not_found}`. ## Examples - iex> unhide_image(hidden_image) - {:ok, %Image{hidden_from_users: false}} - - iex> unhide_image(visible_image) + iex> delete_image_subscription(user, "42") {:ok, %Image{}} """ - def unhide_image(%Image{hidden_from_users: true} = image) do - key = image.hidden_image_key + @spec delete_image_subscription(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :unauthorized | :not_found} + def delete_image_subscription(%Actor{} = actor, image_id) do + with {:ok, image} <- load_image_member(actor, :unsubscribe, image_id) do + # Deletion is idempotent and cannot fail; the hard match crashes if it does. + {:ok, _subscription} = delete_subscription(image, actor.user) + {:ok, image} + end + end - Multi.new() - |> Multi.update(:image, Image.unhide_changeset(image)) - |> Multi.run(:tags, fn repo, %{image: image} -> - image = Repo.preload(image, :tags, force: true) + @doc group: "Subscriptions and notifications" + @doc """ + Clears `actor`'s unread notifications for the image named by `image_id`. - tag_ids = Enum.map(image.tags, & &1.id) - query = where(Tag, [t], t.id in ^tag_ids) + This personal read-state operation is deliberately exempt from + `verify_write_access/1`. The image is loaded before `:mark_read` + authorization. Missing IDs are actor-independent. - repo.update_all(query, inc: [images_count: 1]) + Returns `{:ok, image}` after clearing `actor`'s image comment and image merge + notifications for it. - {:ok, image.tags} - end) - |> Repo.transaction() - |> case do - {:ok, %{image: image, tags: tags}} -> - spawn(fn -> - Thumbnailer.unhide_thumbnails(image, key) - end) + ## Examples - reindex_image(image) - purge_files(image, image.hidden_image_key) - Comments.reindex_comments_on_image(image) - Tags.reindex_tags(tags) + iex> create_image_read(user, "42") + {:ok, %Image{}} - {:ok, image} + iex> create_image_read(user, "nonexistent") + {:error, :not_found} - error -> - error + """ + @spec create_image_read(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :unauthorized | :not_found} + def create_image_read(%Actor{} = actor, image_id) do + with {:ok, image} <- load_image_member(actor, :mark_read, image_id) do + clear_image_notification(image, actor.user) + {:ok, image} end end - def unhide_image(image), do: {:ok, image} - + @doc group: "User interactions" @doc """ - Performs a batch update on multiple images, adding and removing tags. - - This function efficiently updates tags for multiple images at once, - handling tag changes, tag counts, and reindexing in a single transaction. - - ## Parameters - - image_ids: List of image IDs to update - - added_tags: List of tags to add to all images - - removed_tags: List of tags to remove from all images - - attributes: Attributes tag changes are created with - - ## Note + Records a personal hide of the image named by `image_id` for `actor`, so the + image is filtered out of `actor`'s browsing. This is the per-user hide + interaction, distinct from the moderator hide `hide_image/3`. - All the tags provided to this function must exist in the database. - If you're not sure if the tags exist or not, use Tags.get_or_create_tags first. + Banned actors are rejected first with `{:error, :ban}` (a write with no + fingerprint is `{:error, :unauthorized}`), before the image is loaded. The + image is then loaded by id and authorized for `:vote`. Hiding is + idempotent. - ## Return value - - On success, returns `{:ok, image_ids}` where `image_ids` are the ids of - the images the batch actually matched (existing, non-hidden images); - requested ids that matched no such image are absent from the list. + Returns `{:ok, image}` with the image reloaded. ## Examples - iex> batch_update([1, 2], [tag1], [tag2], %{user_id: user.id, ip: ip, fingerprint: "ffff"}) - {:ok, [1, 2]} + iex> create_image_user_hide(actor, "42") + {:ok, %Image{}} """ - def batch_update(image_ids, added_tags, removed_tags, attributes) do - batch_update( - Enum.map(image_ids, fn id -> - %{ - image_id: id, - added_tags: added_tags, - removed_tags: removed_tags - } - end), - attributes - ) + @spec create_image_user_hide(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def create_image_user_hide(actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :vote, image_id) do + Multi.new() + |> ImageHides.put_hide_for_loaded_image(image, actor.user) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, Repo.reload!(image)} + end + end end - def batch_update(changes, attributes) do - changes = merge_change_batches(changes) - - image_ids = - Image - |> where([i], i.id in ^Enum.map(changes, & &1.image_id) and i.hidden_from_users == false) - |> select([i], i.id) - |> Repo.all() + @doc group: "User interactions" + @doc """ + Removes `actor`'s personal hide of the image named by `image_id`. This is the + per-user unhide interaction, distinct from the moderator unhide `unhide_image/2`. - # Window insertions to the matched (existing, non-hidden) images, like - # the removals below: unmatched ids must never receive taggings, and - # ids naming no image at all would violate the foreign key. - matched_ids = MapSet.new(image_ids) + Loading, authorization, and ban semantics mirror `create_image_hide/2`. + Removing a hide is idempotent. - to_insert = - changes - |> Enum.filter(&MapSet.member?(matched_ids, &1.image_id)) - |> Enum.flat_map(fn change -> - Enum.map(change.added_tags, &%{tag_id: &1.id, image_id: change.image_id}) - end) + Returns `{:ok, image}` with the image reloaded. - to_delete_ids = - Enum.flat_map(changes, fn change -> - Enum.map(change.removed_tags, & &1.id) - end) + ## Examples - to_delete = - Tagging - |> where([t], t.image_id in ^image_ids and t.tag_id in ^to_delete_ids) - |> select([t], [t.image_id, t.tag_id]) + iex> delete_image_user_hide(actor, "42") + {:ok, %Image{}} - now = DateTime.utc_now(:second) - tag_attributes = %{name: "", slug: "", created_at: now, updated_at: now} + """ + @spec delete_image_user_hide(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_image_user_hide(actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- load_image_member(actor, :vote, image_id) do + Multi.new() + |> ImageHides.delete_hide_for_loaded_image(image, actor.user) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, Repo.reload!(image)} + end + end + end - Repo.transaction(fn -> - {_count, inserted} = - Repo.insert_all(Tagging, to_insert, - on_conflict: :nothing, - returning: [:image_id, :tag_id] - ) + @doc group: "User interactions" + @doc """ + Records `actor`'s fave of `image_id`, which also casts an implicit upvote + (replacing an existing downvote). Faving is idempotent. - {_count, deleted} = Repo.delete_all(to_delete) - - inserted = Enum.map(inserted, &[&1.image_id, &1.tag_id]) - - # Create tag change batches for every image ID. - new_tag_changes = - (inserted ++ deleted) - |> Enum.uniq_by(fn [image_id, _] -> image_id end) - |> Enum.map(fn [image_id, _] -> - {:ok, tc} = - %TagChange{ - image_id: image_id, - user_id: attributes[:user_id], - ip: attributes[:ip], - fingerprint: attributes[:fingerprint], - created_at: now - } - |> Repo.insert() - - {image_id, tc} - end) - |> Map.new() + Write access, image authorization, and forced-filter enforcement happen before + the transaction. - # Create tags belonging to tag changes. - added_changes = tag_change_data(inserted, new_tag_changes, true) - removed_changes = tag_change_data(deleted, new_tag_changes, false) + ## Examples - Repo.insert_all(TagChanges.Tag, added_changes ++ removed_changes) + iex> create_image_fave(actor, "42") + {:ok, %Image{}} - # In order to merge into the existing tables here in one go, insert_all - # is used with a query that is guaranteed to conflict on every row by - # using the primary key. This will update the image counts via the - # ON CONFLICT DO UPDATE clause. + """ + @spec create_image_fave(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | :forced_filter} + def create_image_fave(%Actor{user: user} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- + load_image_member(actor, :vote, image_id, [:sources, tags: :aliases]), + :ok <- Filtering.verify_not_forced(actor, image) do + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> ImageVotes.put_vote_for_loaded_image(image, user, true) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, Repo.reload!(image)} + end + end + end - added_upserts = tag_upsert_data(inserted, tag_attributes, true) - removed_upserts = tag_upsert_data(deleted, tag_attributes, false) + @doc group: "User interactions" + @doc """ + Removes `actor`'s fave of `image_id`, leaving any upvote in place. Unfaving is + idempotent and enforces the same prerequisites as `create_fave/2`. - Repo.insert_all(Tag, added_upserts ++ removed_upserts, - on_conflict: update(Tag, inc: [images_count: fragment("EXCLUDED.images_count")]), - conflict_target: [:id] - ) + Returns `{:ok, image}` with the image reloaded. - # Report the ids the batch actually matched back to the caller. - image_ids - end) - |> case do - {:ok, _} = result -> - reindex_images(image_ids) - Comments.reindex_comments_on_images(image_ids) - Tags.reindex_tags(Enum.flat_map(changes, &(&1.added_tags ++ &1.removed_tags))) - TagChanges.reindex_tag_changes_on_images(image_ids) + ## Examples - result + iex> delete_image_fave(actor, "42") + {:ok, %Image{}} - result -> - result + """ + @spec delete_image_fave(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | :forced_filter} + def delete_image_fave(%Actor{user: user} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- + load_image_member(actor, :vote, image_id, [:sources, tags: :aliases]), + :ok <- Filtering.verify_not_forced(actor, image) do + Multi.new() + |> ImageFaves.delete_fave_for_loaded_image(image, user) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, Repo.reload!(image)} + end end end - # Merge any change batches belonging to the same image ID into - # one single batch, then deduplicate added_tags by removing any - # which are slated for removal, which is the behavior of the - # mass tagger anyway (it inserts anything that needs to be inserted - # into image_taggings, and then deletes anything that needs to be deleted, - # so by not inserting what would be deleted anyway, we're just mimicking - # this behavior here, and ensuring that there are no duplicate tag changes - # per batch) - defp merge_change_batches(changes) do - changes - |> Enum.group_by(& &1.image_id) - |> Enum.map(fn {image_id, instances} -> - added = - instances - |> Enum.flat_map(& &1.added_tags) - |> Enum.uniq_by(& &1.id) - - removed = - instances - |> Enum.flat_map(& &1.removed_tags) - |> Enum.uniq_by(& &1.id) + @doc group: "User interactions" + @doc """ + Records `actor`'s vote on `image_id` from `attrs`, replacing any existing + vote. The `up` parameter accepts booleans and their form-encoded string + values. Invalid attributes return their vote form changeset. Voting is + idempotent. - %{ - image_id: image_id, - added_tags: Enum.reject(added, fn a -> Enum.any?(removed, &(&1.id == a.id)) end), - removed_tags: removed - } - end) - |> Enum.reject(&(Enum.empty?(&1.added_tags) && Enum.empty?(&1.removed_tags))) - end + Write access, image authorization, and forced-filter enforcement happen before + the transaction. - # Generate data for TagChanges.Tag struct. - defp tag_change_data(changes, tag_changes, added) do - Enum.map(changes, fn [image_id, tag_id] -> - %{id: id} = Map.get(tag_changes, image_id) + ## Examples - %{ - tag_change_id: id, - tag_id: tag_id, - added: added - } - end) - end + iex> create_image_vote(actor, "42", %{"up" => "1"}) + {:ok, %Image{}} - # Generate data for inserts/updates (hence, upserts) of the Tags.Tag struct. - defp tag_upsert_data(changes, tag_attributes, added) do - changes - |> Enum.group_by(fn [_image_id, tag_id] -> tag_id end) - |> Enum.map(fn {tag_id, instances} -> - Map.merge(tag_attributes, %{ - id: tag_id, - images_count: if(added, do: length(instances), else: -length(instances)) - }) - end) + """ + @spec create_image_vote(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, Image.t()} + | {:error, + :ban + | :unauthorized + | :not_found + | :forced_filter + | Ecto.Changeset.t()} + def create_image_vote(%Actor{user: user} = actor, image_id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, image} <- + load_image_member(actor, :vote, image_id, [:sources, tags: :aliases]), + :ok <- Filtering.verify_not_forced(actor, image), + {:ok, %{up: up}} <- + %VoteForm{} + |> VoteForm.changeset(attrs) + |> VoteForm.apply(image) do + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, up) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, Repo.reload!(image)} + end + end end + @doc group: "User interactions" @doc """ - Deletes a Image. + Removes `actor`'s vote on `image_id`. Unvoting is idempotent and enforces the + same prerequisites as `create_vote/3`. + + Returns `{:ok, image}` with the image reloaded. ## Examples - iex> delete_image(image) + iex> delete_image_vote(actor, "42") {:ok, %Image{}} - iex> delete_image(image) - {:error, %Ecto.Changeset{}} - """ - def delete_image(%Image{} = image) do - Repo.delete(image) + @spec delete_image_vote(Actor.t(), IntegerId.integer_id()) :: + {:ok, Image.t()} + | {:error, :ban | :unauthorized | :not_found | :forced_filter} + def delete_image_vote(%Actor{user: user} = actor, image_id) do + with :ok <- verify_write_access(actor), + {:ok, image} <- + load_image_member(actor, :vote, image_id, [:sources, tags: :aliases]), + :ok <- Filtering.verify_not_forced(actor, image) do + Multi.new() + |> ImageVotes.delete_vote_for_loaded_image(image, user) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, Repo.reload!(image)} + end + end end + @doc group: "User interactions" @doc """ - Returns an `%Ecto.Changeset{}` for tracking image changes. + Assembles the interaction listing for the image named by `image_id`, on behalf + of `actor`. + + The image is loaded by id and authorized for `:index` (visible for any + non-hidden image, and to staff for hidden ones). + + The image is returned with its faves preloaded. Votes and hides are loaded and + `has_votes` is `true` only when `actor` may `:tamper` with the image; + otherwise `has_votes` is `false` and those associations are not fetched. + + Returns `{:ok, {image, has_votes}}`. ## Examples - iex> change_image(image) - %Ecto.Changeset{source: %Image{}} + iex> list_image_faves(moderator, "42") + {:ok, {%Image{}, true}} + + iex> list_image_faves(user, "42") + {:ok, {%Image{}, false}} """ - def change_image(%Image{} = image) do - Image.changeset(image, %{}) + @spec list_image_faves(Actor.t(), IntegerId.integer_id()) :: + {:ok, {Image.t(), boolean()}} | {:error, :unauthorized | :not_found} + def list_image_faves(%Actor{} = actor, image_id) do + with {:ok, image} <- load_image_member(actor, :index, image_id, faves: :user) do + case authorize(actor, :tamper, image) do + :ok -> + {:ok, {Repo.preload(image, upvotes: :user, downvotes: :user, hides: :user), true}} + + {:error, :unauthorized} -> + {:ok, {image, false}} + end + end + end + + # Invoked dynamically by the shared subscription implementation. + @doc false + @spec clear_image_notification(Image.t(), User.t() | nil) :: :ok + def clear_image_notification(%Image{} = image, user) do + Notifications.clear_image_comment(image, user) + Notifications.clear_image_merge(image, user) + :ok end + @doc group: "Search indexing" @doc """ Updates image search indices when a user's name changes. @@ -1334,12 +3375,44 @@ defmodule Philomena.Images do :ok """ + @spec user_name_reindex(String.t(), String.t()) :: term() def user_name_reindex(old_name, new_name) do data = Images.SearchIndex.user_name_update_by_query(old_name, new_name) Search.update_by_query(Image, data.query, data.set_replacements, data.replacements) end + @doc group: "Search indexing" + @doc """ + Adds an after-commit image reindex step to a transaction workflow. + + The referenced step must resolve to an image. The indexing job is enqueued + only after the database transaction commits. + """ + @spec put_reindex_image(Multi.t(), Ecto.Multi.name()) :: Multi.t() + def put_reindex_image(%Multi{} = multi, step) do + Multi.on_commit(multi, fn %{^step => image} -> reindex_image(image) end) + end + + @doc group: "Search indexing" + @doc """ + Loads an image for an invariant-enforced indexing job. + + This worker service raises when the queued image no longer exists; request + paths must use an actor-scoped loader instead. + + ## Examples + + iex> load_image_for_reindex!(42) + %Image{} + + """ + @spec load_image_for_reindex!(integer()) :: Image.t() + def load_image_for_reindex!(image_id) when is_integer(image_id) do + Repo.one!(Image |> where(id: ^image_id) |> preload(:tags)) + end + + @doc group: "Search indexing" @doc """ Queues a single image for search index updates. Returns the image struct unchanged, for use in a pipeline. @@ -1350,12 +3423,14 @@ defmodule Philomena.Images do %Image{} """ + @spec reindex_image(Image.t()) :: Image.t() def reindex_image(%Image{} = image) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", [image.id]]) image end + @doc group: "Search indexing" @doc """ Queues all listed image IDs for search index updates. Returns the list unchanged, for use in a pipeline. @@ -1366,12 +3441,14 @@ defmodule Philomena.Images do [1, 2, 3] """ + @spec reindex_images([integer()]) :: [integer()] def reindex_images(image_ids) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Images", "id", image_ids]) image_ids end + @doc group: "Search indexing" @doc """ Returns the preload configuration for image indexing. @@ -1384,6 +3461,7 @@ defmodule Philomena.Images do [sources: query, user: query, ...] """ + @spec indexing_preloads() :: list() def indexing_preloads do user_query = select(User, [u], map(u, [:id, :name])) sources_query = select(Source, [s], map(s, [:image_id, :source])) @@ -1408,8 +3486,11 @@ defmodule Philomena.Images do ] end + @doc group: "Search indexing" @doc """ - Performs a search reindex operation on images matching the given criteria. + Performs the worker-side search reindex operation for images matching the + given criteria. PostgreSQL is the source of truth; OpenSearch is updated + asynchronously by the indexing jobs queued elsewhere in this context. ## Parameters - column: The database column to filter on (e.g., :id) @@ -1421,174 +3502,11 @@ defmodule Philomena.Images do :ok """ + @spec perform_reindex(atom(), [term()]) :: term() def perform_reindex(column, condition) do Image |> preload(^indexing_preloads()) |> where([i], field(i, ^column) in ^condition) |> Search.reindex(Image) end - - @doc """ - Purges image files from the CDN. - - Enqueues a job to purge both visible and hidden thumbnail paths for the given image. - - ## Examples - - iex> purge_files(image, "hidden_key") - :ok - - """ - def purge_files(image, hidden_key) do - files = - if is_nil(hidden_key) do - Thumbnailer.thumbnail_urls(image, nil) - else - Thumbnailer.thumbnail_urls(image, hidden_key) ++ - Thumbnailer.thumbnail_urls(image, nil) - end - - Exq.enqueue(Exq, "indexing", ImagePurgeWorker, [files]) - end - - @doc """ - Executes the actual purge operation for image files. - - Calls the system purge-cache command to remove the specified files from the CDN cache. - - ## Examples - - iex> perform_purge(["file1.jpg", "file2.jpg"]) - :ok - - """ - def perform_purge(files) do - {_out, 0} = System.cmd("purge-cache", [JSON.encode!(%{files: files})]) - - :ok - end - - alias Philomena.Images.Subscription - - @doc """ - Migrates subscriptions and notifications from one image to another. - - This function is used during image merging to transfer all subscriptions - and notifications from the source image to the target image. It handles: - - 1. User subscriptions - 2. Comment notifications - 3. Merge notifications - - Returns `{:ok, {comment_notification_count, merge_notification_count}}`. - - ## Parameters - - - source: The source image to migrate from - - target: The target image to migrate to - - ## Examples - - iex> migrate_subscriptions(source_image, target_image) - {:ok, {5, 2}} - - """ - def migrate_subscriptions(source, target) do - subscriptions = - Subscription - |> where(image_id: ^source.id) - |> select([s], %{image_id: type(^target.id, :integer), user_id: s.user_id}) - |> Repo.all() - - Repo.insert_all(Subscription, subscriptions, on_conflict: :nothing) - - comment_notifications = - from cn in ImageCommentNotification, - where: cn.image_id == ^source.id, - select: %{ - user_id: cn.user_id, - image_id: ^target.id, - comment_id: cn.comment_id, - read: cn.read, - created_at: cn.created_at, - updated_at: cn.updated_at - } - - merge_notifications = - from mn in ImageMergeNotification, - where: mn.target_id == ^source.id, - select: %{ - user_id: mn.user_id, - target_id: ^target.id, - source_id: mn.source_id, - read: mn.read, - created_at: mn.created_at, - updated_at: mn.updated_at - } - - {comment_notification_count, nil} = - Repo.insert_all(ImageCommentNotification, comment_notifications, on_conflict: :nothing) - - {merge_notification_count, nil} = - Repo.insert_all(ImageMergeNotification, merge_notifications, on_conflict: :nothing) - - Repo.delete_all(exclude(comment_notifications, :select)) - Repo.delete_all(exclude(merge_notifications, :select)) - - {:ok, {comment_notification_count, merge_notification_count}} - end - - @doc """ - Migrates source URLs from one image to another. - - This function is used during image merging to combine source URLs from both images. - It will: - - 1. Combine sources from both images - 2. Remove duplicates - 3. Take up to 15 sources (the system limit) - 4. Update the target image with the combined sources - - Returns the result of updating the target image with the combined sources. - - ## Parameters - - source: The source image containing sources to migrate - - target: The target image to receive the combined sources - - ## Examples - - iex> migrate_sources(source_image, target_image) - {:ok, %Image{}} - - """ - def migrate_sources(source, target) do - sources = - (source.sources ++ target.sources) - |> Enum.map(fn s -> %Source{image_id: target.id, source: s.source} end) - |> Enum.uniq() - |> Enum.take(15) - - target - |> Image.sources_changeset(sources) - |> Repo.update() - end - - defp notify_merge(_repo, _changes, source, target) do - Notifications.create_image_merge_notification(target, source) - end - - @doc """ - Removes all image notifications for a given image and user. - - ## Examples - - iex> clear_image_notification(image, user) - :ok - - """ - def clear_image_notification(%Image{} = image, user) do - Notifications.clear_image_comment_notification(image, user) - Notifications.clear_image_merge_notification(image, user) - :ok - end end diff --git a/lib/philomena/images/batch_tag_form.ex b/lib/philomena/images/batch_tag_form.ex new file mode 100644 index 000000000..7590001c4 --- /dev/null +++ b/lib/philomena/images/batch_tag_form.ex @@ -0,0 +1,22 @@ +defmodule Philomena.Images.BatchTagForm do + @moduledoc false + + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :tag_list, :string + field :image_ids, {:array, :integer} + end + + @doc false + def changeset(%__MODULE__{} = form, attrs \\ %{}) do + form + |> cast(attrs, [:image_ids, :tag_list]) + |> validate_required([:image_ids, :tag_list]) + |> update_change(:image_ids, &Enum.uniq/1) + end +end diff --git a/lib/philomena/images/dnp_validator.ex b/lib/philomena/images/dnp_validator.ex index 893f81b8d..7c1856ed6 100644 --- a/lib/philomena/images/dnp_validator.ex +++ b/lib/philomena/images/dnp_validator.ex @@ -1,11 +1,7 @@ defmodule Philomena.Images.DnpValidator do import Ecto.Changeset - import Ecto.Query - alias Philomena.Repo - alias Philomena.Tags.Tag - alias Philomena.DnpEntries.DnpEntry - def validate_dnp(changeset, uploader) do + def validate_dnp(changeset, uploader, tags_with_dnp) do tags = changeset |> get_field(:tags) @@ -13,14 +9,6 @@ defmodule Philomena.Images.DnpValidator do edit_present? = "edit" in tags - tags_with_dnp = - Tag - |> from(as: :tag) - |> where([t], t.name in ^tags) - |> where(exists(where(DnpEntry, [d], d.tag_id == parent_as(:tag).id))) - |> preload(dnp_entries: [tag: :verified_links]) - |> Repo.all() - changeset |> validate_artist_only(tags_with_dnp, uploader) |> validate_no_edits(tags_with_dnp, uploader, edit_present?) diff --git a/lib/philomena/images/filtering.ex b/lib/philomena/images/filtering.ex new file mode 100644 index 000000000..0cfc86f91 --- /dev/null +++ b/lib/philomena/images/filtering.ex @@ -0,0 +1,130 @@ +defmodule Philomena.Images.Filtering do + @moduledoc """ + In-memory image filtering owned by the Images domain. + + The same document shape supports presentation filtering and enforcement of a + signed-in user's forced filter. Request controllers must call an owning + context action rather than invoke this module to enforce access. + """ + + alias Philomena.Attribution.Actor + alias Philomena.Filters.Filter + alias Philomena.Filters.ImageFilter + alias Philomena.Images.{Image, Query} + alias Philomena.Repo + alias Philomena.Users.User + alias PhilomenaQuery.Parse.{Evaluator, String} + + defp load_forced_filter(%User{forced_filter_id: nil}), do: nil + defp load_forced_filter(%User{forced_filter_id: filter_id}), do: Repo.get(Filter, filter_id) + + defp matches_filter?(user, image, filter) do + matches_tag_filter?(image, filter.hidden_tag_ids) or + matches_complex_filter?(user, image, filter.hidden_complex_str) + end + + defp matches_tag_filter?(image, tag_ids) do + image.tags + |> MapSet.new(& &1.id) + |> MapSet.intersection(MapSet.new(tag_ids)) + |> Enum.any?() + end + + defp matches_complex_filter?(user, image, search_string) do + image + |> document() + |> Evaluator.hits?(compile_filter(user, search_string)) + end + + defp compile_filter(user, search_string) do + search_string + |> String.normalize() + |> Query.compile(user: user, filter: true) + |> case do + {:ok, query} -> query + _error -> %{match_all: %{}} + end + end + + @doc """ + Builds the document evaluated by image filter expressions. + + `image.tags` and their aliases must be preloaded. + + ## Examples + + iex> document(image) + %{id: 42, tags: "safe", ...} + + """ + @spec document(Image.t()) :: map() + def document(%Image{} = image) do + %{ + id: image.id, + tags: image.tags |> Enum.flat_map(&([&1] ++ &1.aliases)) |> Enum.map_join(", ", & &1.name), + tag_count: length(image.tags), + score: image.score, + faves: image.faves_count, + upvotes: image.upvotes_count, + downvotes: image.downvotes_count, + comment_count: image.comments_count, + created_at: image.created_at, + first_seen_at: image.first_seen_at, + source_url: image.source_url, + width: image.image_width, + height: image.image_height, + aspect_ratio: image.image_aspect_ratio, + sha512_hash: image.image_sha512_hash, + orig_sha512_hash: image.image_orig_sha512_hash, + description: image.description + } + end + + @doc """ + Returns whether an image matches the viewer's current hidden or spoiler + display policy. + + The image's tags and aliases must be preloaded. + """ + @spec filter_or_spoiler_hits?(Image.t(), ImageFilter.t()) :: boolean() + def filter_or_spoiler_hits?(%Image{} = image, %ImageFilter{} = image_filter) do + image_tag_ids = MapSet.new(image.tags, & &1.id) + display_tag_ids = MapSet.new(image_filter.display_tag_ids) + + not MapSet.disjoint?(image_tag_ids, display_tag_ids) or + Evaluator.hits?(document(image), image_filter.display_query) + end + + @doc """ + Verifies that `image` does not match `actor`'s forced filter. + + Anonymous actors and users without a forced filter are permitted. Hidden tag + IDs and the hidden complex expression are both enforced. Invalid stored filter + expressions fail closed. + + ## Examples + + iex> verify_not_forced(actor, image) + :ok + + iex> verify_not_forced(forced_actor, filtered_image) + {:error, :forced_filter} + + """ + @spec verify_not_forced(Actor.t(), Image.t()) :: :ok | {:error, :forced_filter} + def verify_not_forced(%Actor{user: nil}, %Image{}), do: :ok + + def verify_not_forced(%Actor{user: %User{} = user}, %Image{} = image) do + case load_forced_filter(user) do + nil -> + :ok + + filter -> + image = Repo.preload(image, tags: :aliases) + + if matches_filter?(user, image, filter), + do: {:error, :forced_filter}, + else: :ok + end + end +end diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 4ac156629..5f00da3d0 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -4,6 +4,7 @@ defmodule Philomena.Images.Image do import Bitwise import Ecto.Changeset + alias Philomena.Attribution.Actor alias Philomena.ImageIntensities.ImageIntensity alias Philomena.ImageVotes.ImageVote alias Philomena.ImageFaves.ImageFave @@ -16,13 +17,15 @@ defmodule Philomena.Images.Image do alias Philomena.Comments.Comment alias Philomena.SourceChanges.SourceChange alias Philomena.TagChanges.TagChange + alias Philomena.Reports.Report alias Philomena.Images.Image alias Philomena.Images.TagDiffer alias Philomena.Images.SourceDiffer alias Philomena.Images.TagValidator alias Philomena.Images.DnpValidator - alias Philomena.Repo + + @type t :: %__MODULE__{} schema "images" do belongs_to :user, User @@ -46,6 +49,7 @@ defmodule Philomena.Images.Image do has_one :intensity, ImageIntensity has_many :galleries, through: [:gallery_interactions, :image] has_many :sources, Source, on_replace: :delete + has_many :reports, Report field :image, :string field :image_name, :string @@ -95,6 +99,12 @@ defmodule Philomena.Images.Image do field :uploaded_image, :string, virtual: true field :removed_image, :string, virtual: true + field :tag_change_count, :integer, virtual: true + field :tag_change_tag_count, :integer, virtual: true + field :source_change_count, :integer, virtual: true + + field :username, :string, virtual: true + timestamps(inserted_at: :created_at, type: :utc_datetime) end @@ -114,11 +124,11 @@ defmodule Philomena.Images.Image do |> validate_required([]) end - def creation_changeset(image, attrs, attribution) do + def creation_changeset(image, attrs, %Actor{} = actor) do image |> cast(attrs, [:anonymous, :source_url, :description]) |> change(first_seen_at: DateTime.utc_now(:second)) - |> change(attribution) + |> change(Actor.to_changes(actor)) |> validate_length(:description, max: 50_000, count: :bytes) |> validate_format(:source_url, ~r/\Ahttps?:\/\//) end @@ -166,8 +176,10 @@ defmodule Philomena.Images.Image do ) |> check_dimensions() |> prepare_changes(fn changeset -> + # This is just to give a nicer validation failure error message within Ecto. + # The image sha512 field has a real unique constraint. sha512 = fetch_field!(changeset, :image_orig_sha512_hash) - other_image = Repo.get_by(Image, image_orig_sha512_hash: sha512) + other_image = changeset.repo.get_by(Image, image_orig_sha512_hash: sha512) if is_nil(other_image) or other_image.id == changeset.data.id do changeset @@ -199,40 +211,56 @@ defmodule Philomena.Images.Image do def remove_image_changeset(image) do image - |> change(removed_image: image.image) - |> change(image: nil) + |> change() + |> validate_hidden() + |> validate_not_destroyed() + |> change(destroyed_content: true, removed_image: image.image, image: nil) end - def source_changeset(image, attrs, old_sources, new_sources) do + def source_changeset(image, added_sources, removed_sources) do image - |> cast(attrs, []) - |> SourceDiffer.diff_input(old_sources, new_sources) + |> change() + |> SourceDiffer.apply(added_sources, removed_sources) |> validate_length(:sources, max: 15) end + def meaningful_source_update?(changeset) do + added = get_field(changeset, :added_sources) + removed = get_field(changeset, :removed_sources) + + not (Enum.empty?(added) and Enum.empty?(removed)) + end + def sources_changeset(image, new_sources) do change(image) |> put_assoc(:sources, new_sources) |> validate_length(:sources, max: 15) end - def tag_changeset(image, attrs, old_tags, new_tags, excluded_tags \\ []) do + def tag_changeset(image, added_tags, removed_tags, excluded_tags \\ []) do image - |> cast(attrs, []) - |> TagDiffer.diff_input(old_tags, new_tags, excluded_tags) + |> change() + |> TagDiffer.apply(added_tags, removed_tags, excluded_tags) |> TagValidator.validate_tags() end + def meaningful_tag_update?(changeset) do + added = get_field(changeset, :added_tags) + removed = get_field(changeset, :removed_tags) + + not (Enum.empty?(added) and Enum.empty?(removed)) + end + def locked_tags_changeset(image, attrs, locked_tags) do image |> cast(attrs, []) |> put_assoc(:locked_tags, locked_tags) end - def dnp_changeset(image, user) do + def dnp_changeset(image, user, tags_with_dnp) do image |> change() - |> DnpValidator.validate_dnp(user) + |> DnpValidator.validate_dnp(user, tags_with_dnp) end def thumbnail_changeset(image, attrs) do @@ -280,16 +308,26 @@ defmodule Philomena.Images.Image do image |> cast(attrs, [:deletion_reason]) |> validate_required([:deletion_reason]) + |> validate_hidden() end - def merge_changeset(image, duplicate_of_image) do - change(image) + def merge_source_changeset(image, duplicate_of_image) do + image + |> change() |> validate_not_hidden() + |> validate_merge_target(duplicate_of_image) |> put_change(:duplicate_id, duplicate_of_image.id) |> put_change(:hidden_image_key, create_key()) |> put_change(:hidden_from_users, true) end + def first_seen_at_changeset(image, candidate_images) do + candidate_images + |> Enum.map(& &1.first_seen_at) + |> Enum.min(DateTime) + |> then(&change(image, first_seen_at: &1)) + end + def unhide_changeset(image) do change(image) |> validate_hidden() @@ -326,24 +364,31 @@ defmodule Philomena.Images.Image do |> put_assoc(:source_changes, []) end - def uploader_changeset(image, attrs) do - change(image) + def username_changeset(image, attrs) do + cast(image, attrs, [:username]) + end + + def uploader_changeset(image, username, uploader) do + image + |> change() |> put_change(:ip, %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}) |> put_change(:fingerprint, "ffff") - |> put_uploader(attrs["username"]) + |> put_uploader(username, uploader) end # A blank username anonymizes the image. - defp put_uploader(changeset, username) when username in [nil, ""], - do: put_change(changeset, :user_id, nil) - - defp put_uploader(changeset, username) do - case Repo.get_by(User, name: username) do - nil -> add_error(changeset, :username, "does not name a known user") - user -> put_change(changeset, :user_id, user.id) + defp put_uploader(changeset, username, nil) do + if username do + add_error(changeset, :username, "does not name a known user") + else + put_change(changeset, :user_id, nil) end end + defp put_uploader(changeset, _username, uploader) do + put_change(changeset, :user_id, uploader.id) + end + def anonymous_changeset(image, attrs) do cast(image, attrs, [:anonymous]) end @@ -375,6 +420,12 @@ defmodule Philomena.Images.Image do end end + defp validate_merge_target(changeset, %__MODULE__{hidden_from_users: true}) do + add_error(changeset, :duplicate_id, "must refer to a visible image") + end + + defp validate_merge_target(changeset, %__MODULE__{}), do: changeset + defp validate_not_approved(changeset) do if get_field(changeset, :approved) do add_error(changeset, :approved, "must be false") @@ -382,4 +433,12 @@ defmodule Philomena.Images.Image do changeset end end + + defp validate_not_destroyed(changeset) do + if get_field(changeset, :destroyed_content) do + add_error(changeset, :destroyed_content, "must be false") + else + changeset + end + end end diff --git a/lib/philomena/images/image_page.ex b/lib/philomena/images/image_page.ex new file mode 100644 index 000000000..d93e8e557 --- /dev/null +++ b/lib/philomena/images/image_page.ex @@ -0,0 +1,68 @@ +defmodule Philomena.Images.ImagePage do + @moduledoc """ + The per-viewer data gathered for one image: the image, the visible + page of its comments, the viewer's subscription state and interactions, + the viewer's galleries paired with whether they already contain the image, + and changesets for each action available on the page. + + Comment and description bodies are carried in their raw form. + """ + + alias Philomena.Images.Image + + @enforce_keys [ + :image, + :comments, + :watching, + :can_interact, + :user_galleries, + :interactions, + :comment_changeset, + :description_changeset, + :tag_changeset, + :source_changeset, + :file_changeset, + :hide_changeset, + :feature_changeset, + :repair_changeset, + :hash_changeset, + :uploader_changeset + ] + defstruct [ + :image, + :comments, + :watching, + :can_interact, + :user_galleries, + :interactions, + :comment_changeset, + :description_changeset, + :tag_changeset, + :source_changeset, + :file_changeset, + :hide_changeset, + :feature_changeset, + :repair_changeset, + :hash_changeset, + :uploader_changeset + ] + + @type t :: %__MODULE__{ + image: Image.t(), + comments: Scrivener.Page.t(), + watching: boolean(), + can_interact: boolean(), + user_galleries: [{Philomena.Galleries.Gallery.t(), boolean()}], + interactions: list(), + comment_changeset: Ecto.Changeset.t() | nil, + description_changeset: Ecto.Changeset.t() | nil, + tag_changeset: Ecto.Changeset.t() | nil, + source_changeset: Ecto.Changeset.t() | nil, + file_changeset: Ecto.Changeset.t() | nil, + hide_changeset: Ecto.Changeset.t() | nil, + feature_changeset: Ecto.Changeset.t() | nil, + repair_changeset: Ecto.Changeset.t() | nil, + hash_changeset: Ecto.Changeset.t() | nil, + uploader_changeset: Ecto.Changeset.t() | nil + } +end diff --git a/lib/philomena/images/query.ex b/lib/philomena/images/query.ex index 039f62c21..c9402afaa 100644 --- a/lib/philomena/images/query.ex +++ b/lib/philomena/images/query.ex @@ -1,9 +1,15 @@ defmodule Philomena.Images.Query do + @moduledoc """ + Compiles the image search language into OpenSearch query bodies and exposes + parser-owned metadata needed by image listings. + """ + alias PhilomenaQuery.Parse.Parser alias Philomena.Repo alias Philomena.Filters.Filter alias Philomena.Tags.Tag import Ecto.Query + import Philomena.Authorization, only: [authorize: 3] defp gallery_id_transform(_ctx, value) do case Integer.parse(value) do @@ -21,7 +27,7 @@ defmodule Philomena.Images.Query do defp filter_id_transform(%{user: user} = ctx, value) do with {value, ""} <- Integer.parse(value), {:ok, filter} <- Map.fetch(ctx.filters, value), - true <- Canada.Can.can?(user, :show, filter) do + :ok <- authorize(user, :show, filter) do ctx = Map.merge(ctx, %{filter: true}) {:ok, @@ -223,6 +229,12 @@ defmodule Philomena.Images.Query do defp fields_for(%{role: role}) when role in ~W(moderator admin), do: moderator_fields() defp fields_for(_), do: raise(ArgumentError, "Unknown user role.") + @doc """ + Compiles the image search language for the optional user context. + + Returns the OpenSearch query body or a human-readable parser error. + """ + @spec compile(String.t() | nil, Keyword.t()) :: {:ok, map()} | {:error, String.t()} def compile(query_string, opts \\ []) do user = Keyword.get(opts, :user) watch = Keyword.get(opts, :watch, false) @@ -230,4 +242,20 @@ defmodule Philomena.Images.Query do parse(fields_for(user), %{user: user, watch: watch, filter: filter}, query_string) end + + @doc """ + Compiles an image query and returns the normalized tag names referenced by + its exact tag clauses alongside the query body. + """ + @spec compile_with_tag_names(String.t() | nil, Keyword.t()) :: + {:ok, %{query: map(), tag_names: [String.t()]}} | {:error, String.t()} + def compile_with_tag_names(query_string, opts \\ []) do + with {:ok, query} <- compile(query_string, opts) do + {:ok, + %{ + query: query, + tag_names: Parser.referenced_term_values(query, "tags") + }} + end + end end diff --git a/lib/philomena/images/search.ex b/lib/philomena/images/search.ex new file mode 100644 index 000000000..fcf206e9c --- /dev/null +++ b/lib/philomena/images/search.ex @@ -0,0 +1,414 @@ +defmodule Philomena.Images.Search do + @moduledoc """ + Search-backed image loading, scoped to a viewer. + + Builds OpenSearch definitions for image listings by combining a query with + the viewer's compiled filter, the deleted/hidden visibility switches, and the + requested sort order; loads the tags a tag search names; and finds + consecutive images for prev/next navigation. + + Query-building functions return `{definition, tags}`: an unexecuted search + definition plus the raw `Tag` records the query names. + Definitions are executed with `execute/2`, or batched by callers into + `PhilomenaQuery.Search.msearch_records/1` alongside definitions for other + schemas. + """ + + alias Philomena.Images.Image + alias Philomena.Attribution.Actor + alias Philomena.Images.Query + alias Philomena.Images.Search.Scope + alias Philomena.Repo + alias Philomena.Tags.Tag + alias PhilomenaQuery.Search + import Ecto.Query + import Philomena.Authorization, only: [authorize: 3] + + @allowed_sort_fields ~W( + id + updated_at + first_seen_at + aspect_ratio + faves + downvotes + upvotes + width + height + score + comment_count + tag_count + wilson_score + pixels + size + duration + hides + ) + + @order_for_dir %{ + "next" => %{"asc" => "asc", "desc" => "desc"}, + "prev" => %{"asc" => "desc", "desc" => "asc"} + } + + @type definition :: Search.search_definition() + @type query_result :: {definition(), [Tag.t()]} + @type option :: + {:pagination, map()} + | {:sorts, (map() -> %{query: map(), sorts: list()})} + | {:tag_names, [String.t()]} + + @doc """ + Builds the default image listing query for the viewer. + + Images uploaded less than three minutes ago (or without generated + thumbnails) are excluded unless the viewer has turned the upload delay off; + staff have a separate delay preference. + + Returns `{definition, tags}`. + """ + @spec default_query(Actor.t(), Scope.t(), [option()]) :: query_result() + # sobelow_skip ["SQL.Query"] + def default_query(actor, scope, options \\ []) do + body = + if delay_home_images?(actor.user), + do: %{ + bool: %{ + must: [%{range: %{created_at: %{lte: "now-3m"}}}], + must_not: [%{term: %{thumbnails_generated: false}}] + } + }, + else: %{match_all: %{}} + + query(actor, scope, body, options) + end + + @doc """ + Compiles a search-language string for the viewer and builds its query. + + Returns `{:ok, {definition, tags}}`, or the compiler's `{:error, msg}` for + a malformed query. + """ + @spec search_string(Actor.t(), Scope.t(), String.t() | nil, [option()]) :: + {:ok, query_result()} | {:error, String.t()} + # sobelow_skip ["SQL.Query"] + def search_string(actor, scope, search_string, options \\ []) do + case Query.compile_with_tag_names(search_string, user: actor.user) do + {:ok, %{query: tree, tag_names: tag_names}} -> + {:ok, query(actor, scope, tree, Keyword.put(options, :tag_names, tag_names))} + + {:error, _message} = error -> + error + end + end + + @doc """ + Builds a query definition from an already-compiled query body. + + Options: `:pagination` overrides the scope's window; `:sorts` replaces the + parameter-driven sort with a custom `body -> %{query:, sorts:}` function. + + Returns `{definition, tags}`. + """ + @spec query(Actor.t(), Scope.t(), map(), [option()]) :: query_result() + def query(actor, scope, body, options \\ []) do + pagination = Keyword.get(options, :pagination, scope.pagination) + sorts = Keyword.get(options, :sorts, &parse_sort(scope, &1)) + + tags = options |> Keyword.get(:tag_names, []) |> load_tags() + + filters = create_filters(actor, scope) + + %{query: query, sorts: sort} = sorts.(body) + + definition = + Search.search_definition( + Image, + %{ + query: %{ + bool: %{ + must: query, + must_not: filters + } + }, + sort: sort + }, + pagination + ) + + {definition, tags} + end + + @doc """ + Executes a definition, returning the record page. + + Records are loaded with the standard listing preloads + (`[:sources, tags: :aliases]`); pass `:preload` to override. With `hits: true` + each record is paired with its raw hit, for listings that need sort cursors. + """ + @spec execute(definition(), Keyword.t()) :: Enumerable.t() + def execute(definition, opts \\ []) do + preloads = Keyword.get(opts, :preload, [:sources, tags: :aliases]) + queryable = preload(Image, ^preloads) + + if opts[:hits] do + Search.search_records_with_hits(definition, queryable) + else + Search.search_records(definition, queryable) + end + end + + @doc """ + Maps the "sf"/"sd" parameters onto a sort order for `query_body`. + + Unlisted or missing fields sort by `first_seen_at`; `random`/`random:seed` + wrap the query in a seeded `function_score`; `gallery_id:n` sorts by the + image's position in that gallery. + + Returns `%{query:, sorts:}`. + """ + @spec parse_sort(map(), map()) :: %{query: map(), sorts: list()} + def parse_sort(%Scope{} = scope, query_body) do + sd = parse_sd(%{"sd" => scope.sd}) + + parse_sf(%{"sf" => scope.sf}, sd, query_body) + end + + def parse_sort(params, query_body) when is_map(params) do + sd = parse_sd(params) + + parse_sf(params, sd, query_body) + end + + @doc """ + Finds the image next to `image` in the listing the scope's parameters + describe, for prev/next navigation. + + `compiled_query` is the compiled body of the listing's search query; + `scope.rel` selects the direction and `scope.sort` + carries the sort cursor of the current image, when present. + + Returns the `{image, hit}` pair for the neighbouring image, or `nil` at + the end of the sequence. + """ + @spec find_consecutive(Actor.t(), Scope.t(), Image.t(), map()) :: {Image.t(), map()} | nil + def find_consecutive(actor, scope, image, compiled_query) do + sf = scope.sf || "first_seen_at" + + %{query: compiled_query, sorts: sorts} = parse_sort(scope, compiled_query) + + sorts = + sorts + |> Enum.flat_map(&Enum.to_list/1) + |> Enum.map(&apply_direction(&1, scope.rel)) + + search_after = + scope.sort + |> permit_list() + |> Enum.flat_map(&permit_value/1) + |> default_cursors(sf, image) + + maybe_search_after( + Image, + %{ + query: %{ + bool: %{ + must: compiled_query, + must_not: [%{term: %{id: image.id}} | create_filters(actor, scope)] + } + }, + sort: sorts, + search_after: search_after + }, + %{page_size: 1}, + Image, + length(sorts) == length(search_after) + ) + |> Enum.to_list() + |> case do + [] -> nil + [next_image] -> next_image + end + end + + defp delay_home_images?(nil), do: true + + defp delay_home_images?(user) when user.role != "user", + do: user.settings.staff_delay_home_images + + defp delay_home_images?(user), do: user.settings.delay_home_images + + defp create_filters(actor, scope) do + show_hidden? = authorize(actor, :hide, %Image{}) == :ok + del = scope.del + hidden = scope.hidden + + [ + scope.filter + ] + |> maybe_show_deleted(show_hidden?, del) + |> maybe_custom_hide(actor.user, hidden) + |> hide_non_approved() + end + + # The del switches are a staff tool: every viewer without the hide + # permission gets the hidden-image exclusion no matter what the + # parameter says, so the permission check must come before the + # parameter match. + + defp maybe_show_deleted(filters, false, _param), + do: [%{term: %{hidden_from_users: true}} | filters] + + defp maybe_show_deleted(filters, true, "1"), + do: filters + + defp maybe_show_deleted(filters, true, "only"), + do: [%{term: %{hidden_from_users: false}} | filters] + + defp maybe_show_deleted(filters, true, "deleted"), + do: [%{term: %{hidden_from_users: false}}, %{exists: %{field: :duplicate_id}} | filters] + + defp maybe_show_deleted(filters, true, _param), + do: [%{term: %{hidden_from_users: true}} | filters] + + # Allow users to reverse the effect of hiding images, + # if desired + + defp maybe_custom_hide(filters, %{id: _id}, true), + do: filters + + defp maybe_custom_hide(filters, %{id: id}, _param), + do: [%{term: %{hidden_by_user_ids: id}} | filters] + + defp maybe_custom_hide(filters, _user, _param), + do: filters + + # Hide all images that aren't approved from all search queries. + defp hide_non_approved(filters), + do: [%{term: %{approved: false}} | filters] + + defp load_tags([]), do: [] + + defp load_tags(tags) do + Tag + |> join(:left, [t], at in Tag, on: t.id == at.aliased_tag_id) + |> where([t, at], t.name in ^tags or at.name in ^tags) + |> preload([ + :aliases, + :aliased_tag, + :implied_tags, + :implied_by_tags, + :dnp_entries, + :channels, + public_links: :user, + hidden_links: :user + ]) + |> Repo.all() + |> Enum.uniq_by(& &1.id) + |> Enum.filter(&is_nil(&1.aliased_tag)) + |> Tag.display_order() + end + + defp parse_sd(%{"sd" => sd}) when sd in ~W(asc desc), do: sd + defp parse_sd(_params), do: "desc" + + defp parse_sf(%{"sf" => sf}, sd, query) when sf == "id" do + %{query: query, sorts: [%{"id" => sd}]} + end + + defp parse_sf(%{"sf" => sf}, sd, query) when sf in @allowed_sort_fields do + %{query: query, sorts: [%{sf => sd}, %{"id" => sd}]} + end + + defp parse_sf(%{"sf" => "_score"}, sd, query) do + %{query: query, sorts: [%{"_score" => sd}, %{"id" => sd}]} + end + + defp parse_sf(%{"sf" => "random"}, sd, query) do + random_query(:rand.uniform(4_294_967_296), sd, query) + end + + defp parse_sf(%{"sf" => <<"random:", seed::binary>>}, sd, query) do + case Integer.parse(seed) do + {seed, _rest} -> + random_query(seed, sd, query) + + _ -> + random_query(:rand.uniform(4_294_967_296), sd, query) + end + end + + defp parse_sf(%{"sf" => <<"gallery_id:", gallery::binary>>}, sd, query) do + case Integer.parse(gallery) do + {gallery, _rest} -> + %{ + query: query, + sorts: [ + %{ + "galleries.position" => %{ + order: sd, + nested: %{ + path: :galleries, + filter: %{ + term: %{"galleries.id" => gallery} + } + } + } + }, + %{"id" => "desc"} + ] + } + + _ -> + %{query: query, sorts: []} + end + end + + defp parse_sf(_params, sd, query) do + %{query: query, sorts: [%{"first_seen_at" => sd}, %{"id" => sd}]} + end + + defp random_query(seed, sd, query) do + %{ + query: %{ + function_score: %{ + query: query, + random_score: %{seed: seed, field: :id}, + boost_mode: :replace + } + }, + sorts: [%{"_score" => sd}, %{"id" => sd}] + } + end + + defp maybe_search_after(module, body, options, queryable, true) do + module + |> Search.search_definition(body, options) + |> Search.search_records_with_hits(queryable) + end + + defp maybe_search_after(_module, _body, _options, _queryable, _false) do + [] + end + + defp default_cursors([], "id", image), do: [image.id] + + defp default_cursors([], "first_seen_at", image), + do: [image.first_seen_at |> DateTime.to_unix(:millisecond), image.id] + + defp default_cursors(list, _sf, _image), do: list + + defp apply_direction({"galleries.position", sort_body}, rel) do + sort_body = update_in(sort_body.order, fn direction -> @order_for_dir[rel][direction] end) + + %{"galleries.position" => sort_body} + end + + defp apply_direction({field, direction}, rel) do + %{field => @order_for_dir[rel][direction]} + end + + defp permit_list(value) when is_list(value), do: value + defp permit_list(_value), do: [] + + defp permit_value(value) when is_binary(value) or is_number(value), do: [value] + defp permit_value(_value), do: [] +end diff --git a/lib/philomena/images/search/scope.ex b/lib/philomena/images/search/scope.ex new file mode 100644 index 000000000..a3eb18340 --- /dev/null +++ b/lib/philomena/images/search/scope.ex @@ -0,0 +1,56 @@ +defmodule Philomena.Images.Search.Scope do + @moduledoc """ + The image search scope: the compiled filter, search parameters, and the + default pagination window. The actor is passed separately to operations + that need authorization or viewer-specific behavior. + + Built once by the caller and passed to every image search function. Search + parameters are cast into the typed fields `q`, `sf`, `sd`, `sort`, `del`, + `hidden`, and `rel`; invalid values are discarded by `new/3`. `filter` is + the compiled OpenSearch query body of the viewer's active filter and is + excluded from every result set. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + filter: map(), + pagination: PhilomenaQuery.Search.pagination_params() + } + + embedded_schema do + field :filter, :map, virtual: true + field :pagination, :map, virtual: true + + field :q, :string + field :sf, :string + field :sd, :string + field :sort, {:array, :string} + field :del, :string + field :hidden, :boolean + field :rel, :string + end + + @spec new(map(), PhilomenaQuery.Search.pagination_params(), map()) :: t() + def new(filter, pagination, attrs \\ %{}) + when is_map(filter) and is_map(pagination) do + scope = + %__MODULE__{ + filter: filter, + pagination: pagination + } + + scope + |> cast(attrs, [:q, :sf, :sd, :sort, :del, :hidden, :rel]) + |> then(&keep_valid(scope, &1)) + |> apply_action!(:create) + end + + defp keep_valid(scope, %Ecto.Changeset{changes: changes, errors: errors}) do + error_keys = Keyword.keys(errors) + valid_changes = Map.drop(changes, error_keys) + + change(scope, valid_changes) + end +end diff --git a/lib/philomena/images/source.ex b/lib/philomena/images/source.ex index 3936383f8..49f5f4085 100644 --- a/lib/philomena/images/source.ex +++ b/lib/philomena/images/source.ex @@ -18,4 +18,17 @@ defmodule Philomena.Images.Source do |> validate_format(:source, ~r/\Ahttps?:\/\//) |> validate_length(:source, max: 255) end + + @doc false + def input_changeset(source, attrs) do + source + |> changeset(attrs) + |> ignore_if_blank() + end + + defp ignore_if_blank(%{valid?: false, changes: changes} = changeset) when changes == %{}, + do: %{changeset | action: :ignore} + + defp ignore_if_blank(changeset), + do: changeset end diff --git a/lib/philomena/images/source_differ.ex b/lib/philomena/images/source_differ.ex index 31a3b5b8a..c158d00b0 100644 --- a/lib/philomena/images/source_differ.ex +++ b/lib/philomena/images/source_differ.ex @@ -1,14 +1,24 @@ defmodule Philomena.Images.SourceDiffer do import Ecto.Changeset - def diff_input(changeset, old_sources, new_sources) do - old_set = MapSet.new(flatten_input(old_sources)) - new_set = MapSet.new(flatten_input(new_sources)) + def diff_inputs(old_sources, new_sources) do + old_set = MapSet.new(old_sources || [], & &1.source) + new_set = MapSet.new(new_sources || [], & &1.source) - source_set = MapSet.new(get_field(changeset, :sources), & &1.source) added_sources = MapSet.difference(new_set, old_set) removed_sources = MapSet.difference(old_set, new_set) + %{ + added: Enum.to_list(added_sources), + removed: Enum.to_list(removed_sources) + } + end + + def apply(changeset, added_source_list, removed_source_list) do + source_set = MapSet.new(get_field(changeset, :sources), & &1.source) + added_sources = MapSet.new(added_source_list) + removed_sources = MapSet.new(removed_source_list) + {sources, actually_added, actually_removed} = apply_changes(source_set, added_sources, removed_sources) @@ -45,24 +55,4 @@ defmodule Philomena.Images.SourceDiffer do defp source_params(sources) do %{sources: Enum.map(sources, &%{source: &1})} end - - defp flatten_input(input) when is_map(input) do - Enum.flat_map(Map.values(input), fn - %{"source" => source} -> - source = String.trim(source) - - if source != "" do - [source] - else - [] - end - - _ -> - [] - end) - end - - defp flatten_input(_input) do - [] - end end diff --git a/lib/philomena/images/source_input_form.ex b/lib/philomena/images/source_input_form.ex new file mode 100644 index 000000000..24f22e0d6 --- /dev/null +++ b/lib/philomena/images/source_input_form.ex @@ -0,0 +1,68 @@ +defmodule Philomena.Images.SourceInputForm do + use Ecto.Schema + + import Ecto.Changeset + + alias Philomena.Images.Source + + @type t :: %__MODULE__{} + + embedded_schema do + embeds_many :old_sources, Source + embeds_many :sources, Source + end + + @doc false + def changeset(%__MODULE__{} = form, attrs \\ %{}) do + form + |> cast(attrs, []) + |> cast_embed(:old_sources, with: &Source.input_changeset/2) + |> cast_embed(:sources, with: &Source.input_changeset/2) + end + + @doc false + def apply(form_changeset, image) do + if form_changeset.valid? do + {:ok, apply_changes(form_changeset)} + else + image_changeset = + image + |> change() + |> attach_old_sources_errors(form_changeset) + |> attach_sources_errors(form_changeset) + |> attach_errors(form_changeset) + + {:error, image_changeset} + end + end + + defp attach_old_sources_errors( + image_changeset, + %{changes: %{old_sources: old_sources_changes}} + ) do + if Enum.all?(old_sources_changes, & &1.valid?) do + image_changeset + else + add_error(image_changeset, :old_sources, "is invalid") + end + end + + defp attach_old_sources_errors(image_changeset, _changes), + do: image_changeset + + defp attach_sources_errors( + image_changeset, + %{changes: %{sources: sources_changes}} + ) do + put_assoc(image_changeset, :sources, sources_changes) + end + + defp attach_sources_errors(image_changeset, _changes), + do: image_changeset + + defp attach_errors(image_changeset, %{errors: errors}) do + Enum.reduce(errors, image_changeset, fn {field, {message, opts}}, image_changeset -> + add_error(image_changeset, field, message, opts) + end) + end +end diff --git a/lib/philomena/images/tag_differ.ex b/lib/philomena/images/tag_differ.ex index 831d28a40..b7b453a90 100644 --- a/lib/philomena/images/tag_differ.ex +++ b/lib/philomena/images/tag_differ.ex @@ -1,96 +1,73 @@ defmodule Philomena.Images.TagDiffer do import Ecto.Changeset - import Ecto.Query alias Philomena.Tags.Tag - alias Philomena.Repo - def diff_input(changeset, old_tags, new_tags, excluded_tags) do - excluded_ids = Enum.map(excluded_tags, & &1.id) + def diff_inputs(old_tag_input, tag_input) do + old_tag_names = + old_tag_input + |> Tag.parse_tag_list() + |> MapSet.new() - old_set = to_set(old_tags) - new_set = to_set(new_tags) + new_tag_names = + tag_input + |> Tag.parse_tag_list() + |> MapSet.new() - tags = changeset |> get_field(:tags) - added_tags = added_set(old_set, new_set, excluded_ids) - removed_tags = removed_set(old_set, new_set, excluded_ids) + added_tag_names = MapSet.difference(new_tag_names, old_tag_names) + removed_tag_names = MapSet.difference(old_tag_names, new_tag_names) - {tags, actually_added, actually_removed} = apply_changes(tags, added_tags, removed_tags) - - changeset - |> put_change(:added_tags, actually_added) - |> put_change(:removed_tags, actually_removed) - |> put_assoc(:tags, tags) + %{ + added: Enum.to_list(added_tag_names), + removed: Enum.to_list(removed_tag_names) + } end - defp added_set(old_set, new_set, excluded_ids) do - # new_tags - old_tags - added_set = - new_set - |> Map.drop(Map.keys(old_set)) - - implied_set = - added_set - |> Enum.flat_map(fn {_k, v} -> v.implied_tags end) - |> List.flatten() - |> to_set() + def apply(changeset, added_tag_list, removed_tag_list, excluded_tag_list) do + excluded_tag_set = to_set(excluded_tag_list) + added_tag_set = to_set(added_tag_list) + removed_tag_set = to_set(removed_tag_list) - added_and_implied_set = Map.merge(added_set, implied_set) + # It should never be possible for tag editing to modify membership + # of an excluded tag. + added_tag_set = Map.drop(added_tag_set, Map.keys(excluded_tag_set)) + removed_tag_set = Map.drop(removed_tag_set, Map.keys(excluded_tag_set)) - oc_set = - added_and_implied_set - |> Enum.filter(fn {_k, v} -> v.namespace == "oc" end) - |> get_oc_tag() - - added_and_implied_set - |> Map.merge(oc_set) - |> Map.drop(excluded_ids) - end + tags = get_field(changeset, :tags) + {tags, actually_added, actually_removed} = apply_changes(tags, added_tag_set, removed_tag_set) - defp removed_set(old_set, new_set, excluded_ids) do - # old_tags - new_tags - old_set - |> Map.drop(Map.keys(new_set)) - |> Map.drop(excluded_ids) - end - - defp get_oc_tag([]), do: Map.new() - - defp get_oc_tag(_any_oc_tag) do - Tag - |> where(name: "oc") - |> Repo.all() - |> to_set() - end - - defp to_set(tags) do - tags |> Map.new(&{&1.id, &1}) - end - - defp to_tag_list(set) do - set |> Enum.map(fn {_k, v} -> v end) + changeset + |> put_change(:added_tags, actually_added) + |> put_change(:removed_tags, actually_removed) + |> put_assoc(:tags, tags) end defp apply_changes(tags, added_set, removed_set) do - tag_set = tags |> to_set() + tag_set = to_set(tags) desired_tags = tag_set - |> Map.drop(Map.keys(removed_set)) |> Map.merge(added_set) + |> Map.drop(Map.keys(removed_set)) actually_added = - desired_tags - |> Map.drop(Map.keys(tag_set)) + Map.drop(desired_tags, Map.keys(tag_set)) actually_removed = - tag_set - |> Map.drop(Map.keys(desired_tags)) + Map.drop(tag_set, Map.keys(desired_tags)) - tags = desired_tags |> to_tag_list() - actually_added = actually_added |> to_tag_list() - actually_removed = actually_removed |> to_tag_list() + { + to_tag_list(desired_tags), + to_tag_list(actually_added), + to_tag_list(actually_removed) + } + end - {tags, actually_added, actually_removed} + defp to_set(tags) do + Map.new(tags, &{&1.id, &1}) + end + + defp to_tag_list(set) do + Enum.map(set, fn {_k, v} -> v end) end end diff --git a/lib/philomena/images/tag_input_form.ex b/lib/philomena/images/tag_input_form.ex new file mode 100644 index 000000000..a32ffc8d7 --- /dev/null +++ b/lib/philomena/images/tag_input_form.ex @@ -0,0 +1,35 @@ +defmodule Philomena.Images.TagInputForm do + use Ecto.Schema + + import Ecto.Changeset + + embedded_schema do + field :old_tag_input, :string + field :tag_input, :string + end + + @type t :: %__MODULE__{} + + @doc false + def changeset(%__MODULE__{} = form, attrs \\ %{}) do + cast(form, attrs, [:old_tag_input, :tag_input]) + end + + @doc false + def apply(form_changeset, image) do + if form_changeset.valid? do + {:ok, apply_changes(form_changeset)} + else + image_changeset = + Enum.reduce( + form_changeset.errors, + change(image), + fn {field, {message, opts}}, image_changeset -> + add_error(image_changeset, field, message, opts) + end + ) + + {:error, image_changeset} + end + end +end diff --git a/lib/philomena/images/thumbnailer.ex b/lib/philomena/images/thumbnailer.ex index 1beea1465..1cde081d8 100644 --- a/lib/philomena/images/thumbnailer.ex +++ b/lib/philomena/images/thumbnailer.ex @@ -11,6 +11,7 @@ defmodule Philomena.Images.Thumbnailer do alias Philomena.DuplicateReports alias Philomena.ImageIntensities + alias Philomena.Images alias Philomena.ImagePurgeWorker alias Philomena.Images.Image alias Philomena.Repo @@ -87,10 +88,10 @@ defmodule Philomena.Images.Thumbnailer do apply_edit_script(image, file, Processors.process(analysis, file, generated_sizes(image))) generate_dupe_reports(image) - recompute_meta(image, file, &Image.thumbnail_changeset/2) + recompute_meta(image, file, :thumbnail) file = apply_edit_script(image, file, Processors.post_process(analysis, file)) - recompute_meta(image, file, &Image.process_changeset/2) + recompute_meta(image, file, :process) end defp apply_edit_script(image, file, changes) do @@ -108,7 +109,7 @@ defmodule Philomena.Images.Thumbnailer do end defp apply_change(image, {:intensities, intensities}), - do: ImageIntensities.create_image_intensity(image, intensities) + do: ImageIntensities.put_for_loaded_image(image, intensities) defp apply_change(image, {:replace_original, new_file}) do full = "full.#{image.image_format}" @@ -131,18 +132,18 @@ defmodule Philomena.Images.Thumbnailer do end end - defp recompute_meta(image, file, changeset_fn) do + defp recompute_meta(image, file, stage) do {:ok, %{dimensions: {width, height}}} = Analyzers.analyze_path(file) - image - |> changeset_fn.(%{ + attrs = %{ "image_sha512_hash" => Sha512.file(file), "image_size" => File.stat!(file).size, "image_width" => width, "image_height" => height, "image_aspect_ratio" => width / height - }) - |> Repo.update!() + } + + Images.update_thumbnail_metadata!(image, attrs, stage) end defp download_image_file(image) do diff --git a/lib/philomena/images/uploader.ex b/lib/philomena/images/uploader.ex index 3c54a7db3..ab4dc5ff6 100644 --- a/lib/philomena/images/uploader.ex +++ b/lib/philomena/images/uploader.ex @@ -7,8 +7,8 @@ defmodule Philomena.Images.Uploader do alias Philomena.Images.Image alias PhilomenaMedia.Uploader - def analyze_upload(image, params) do - Uploader.analyze_upload(image, "image", params["image"], &Image.image_changeset/2) + def analyze_upload(image, upload) do + Uploader.analyze_upload(image, "image", upload, &Image.image_changeset/2) end def persist_upload(image) do diff --git a/lib/philomena/images/vote_form.ex b/lib/philomena/images/vote_form.ex new file mode 100644 index 000000000..60ad6fb62 --- /dev/null +++ b/lib/philomena/images/vote_form.ex @@ -0,0 +1,36 @@ +defmodule Philomena.Images.VoteForm do + use Ecto.Schema + + import Ecto.Changeset + + embedded_schema do + field :up, :boolean + end + + @type t :: %__MODULE__{} + + @doc false + def changeset(%__MODULE__{} = form, attrs) do + form + |> cast(attrs, [:up]) + |> validate_required(:up) + end + + @doc false + def apply(form_changeset, image) do + if form_changeset.valid? do + {:ok, apply_changes(form_changeset)} + else + image_changeset = + Enum.reduce( + form_changeset.errors, + change(image), + fn {field, {message, opts}}, image_changeset -> + add_error(image_changeset, field, message, opts) + end + ) + + {:error, image_changeset} + end + end +end diff --git a/lib/philomena_web/integer_id.ex b/lib/philomena/integer_id.ex similarity index 55% rename from lib/philomena_web/integer_id.ex rename to lib/philomena/integer_id.ex index 19b10f4da..c197b89eb 100644 --- a/lib/philomena_web/integer_id.ex +++ b/lib/philomena/integer_id.ex @@ -1,8 +1,9 @@ -defmodule PhilomenaWeb.IntegerId do +defmodule Philomena.IntegerId do @moduledoc """ - Parsing of integer ids taken straight from request paths and query strings. + Parsing of an id that may be any value (e.g. a string) before it is used in a + query. - Interpolating an unparsed path segment into `where(id: ^id)` raises rather + Interpolating an unparsed id into `where(id: ^id)` raises rather than returning no rows: `Ecto.Query.CastError` for a non-integer, and `DBConnection.EncodeError` for a value too large for the `integer` column. Callers use `parse/1` to turn both into an ordinary "no such row". @@ -12,6 +13,9 @@ defmodule PhilomenaWeb.IntegerId do @int_min -2_147_483_648 @int_max 2_147_483_647 + @typedoc "Type of acceptable integer ID inputs." + @type integer_id :: integer() | nonempty_binary() + @doc """ Parses an id that an `integer` column could hold. @@ -20,25 +24,37 @@ defmodule PhilomenaWeb.IntegerId do ## Examples - iex> PhilomenaWeb.IntegerId.parse("42") + iex> Philomena.IntegerId.parse("42") {:ok, 42} - iex> PhilomenaWeb.IntegerId.parse("not-a-number") + iex> Philomena.IntegerId.parse("-1") + {:ok, -1} + + iex> Philomena.IntegerId.parse("not-a-number") :error - iex> PhilomenaWeb.IntegerId.parse("99999999999999999999") + iex> Philomena.IntegerId.parse("99999999999999999999") :error """ - @spec parse(any()) :: {:ok, integer()} | :error + @spec parse(integer_id()) :: {:ok, integer()} | :error + def parse(id) + def parse(id) when is_integer(id) do - if in_range?(id), do: {:ok, id}, else: :error + if in_range?(id) do + {:ok, id} + else + :error + end end def parse(id) when is_binary(id) do case Integer.parse(id) do - {int, ""} -> parse(int) - _ -> :error + {int, ""} -> + parse(int) + + _ -> + :error end end diff --git a/lib/philomena/interactions.ex b/lib/philomena/interactions.ex index 8d2d9eca3..d556920fe 100644 --- a/lib/philomena/interactions.ex +++ b/lib/philomena/interactions.ex @@ -1,35 +1,44 @@ defmodule Philomena.Interactions do + @moduledoc """ + Image interaction loads and transaction steps used by authorized image merges. + """ + import Ecto.Query - alias Philomena.ImageHides.ImageHide + alias Philomena.Multi + alias Philomena.Attribution.Actor alias Philomena.ImageFaves.ImageFave - alias Philomena.ImageVotes.ImageVote + alias Philomena.ImageFaves + alias Philomena.ImageHides.ImageHide + alias Philomena.ImageHides + alias Philomena.Images alias Philomena.Images.Image + alias Philomena.ImageVotes.ImageVote + alias Philomena.ImageVotes alias Philomena.Repo - alias Ecto.Multi - - @doc """ - Gets all interactions for a list of images for a given user. - Returns an empty list if no user is provided. Otherwise returns a list of maps containing: - - image_id: The ID of the image - - user_id: The ID of the user - - interaction_type: One of "hidden", "faved", or "voted" - - value: For votes, either "up" or "down". Empty string for other interaction types. - - ## Parameters - * images - List of images or image IDs to get interactions for - * user - The user to get interactions for, or nil - """ - def user_interactions(_images, nil), - do: [] + @type interaction :: %{ + image_id: pos_integer(), + user_id: pos_integer(), + interaction_type: String.t(), + value: String.t() + } + + defp flatten_images(nil), do: [] + defp flatten_images(id) when is_integer(id), do: [id] + defp flatten_images(%{id: id}), do: [id] + defp flatten_images({%{id: id}, _hit}), do: [id] + defp flatten_images(enum), do: Enum.flat_map(enum, &flatten_images/1) + + defp interaction_ids(images) do + images + |> flatten_images() + |> Enum.uniq() + end - def user_interactions(images, user) do - ids = - images - |> flatten_images() - |> Enum.uniq() + defp interactions_for_user([], _user), do: [] + defp interactions_for_user(ids, user) do hide_interactions = ImageHide |> select([h], %{ @@ -74,100 +83,92 @@ defmodule Philomena.Interactions do |> where([v], v.image_id in ^ids) |> where(user_id: ^user.id, up: false) - [ - hide_interactions, - fave_interactions, - upvote_interactions, - downvote_interactions - ] - |> union_all_queries() + [hide_interactions, fave_interactions, upvote_interactions, downvote_interactions] + |> Enum.reduce(&union_all(&2, ^&1)) |> Repo.all() end + defp source_interactions(repo, source) do + source = repo.preload(source, [:hiders, :favers, :upvoters, :downvoters], force: true) + {:ok, %{source: source, created_at: DateTime.utc_now(:second)}} + end + + @doc """ + Lists `actor`'s interactions with all supplied images. + + `images` may be a page or another enumerable containing loaded images, + integer IDs, `{image, hit}` search results, nested lists, duplicates, + and `nil`. Duplicates and `nil` are ignored. Anonymous actors return `[]` + without querying. The result omits images with no interaction and uses + specific strings: `"hidden"`, `"faved"`, or `"voted"`, with + values `"up"`/`"down"` only given for votes. + + ## Examples + + iex> user_interactions(actor, [image, {other_image, hit}]) + [%{image_id: 42, user_id: 7, interaction_type: "voted", value: "up"}] + + iex> user_interactions(anonymous_actor, [image]) + [] + + """ + @spec user_interactions(Actor.t(), Enumerable.t()) :: [interaction()] + def user_interactions(%Actor{user: nil}, _images), do: [] + + def user_interactions(%Actor{user: user}, images) do + images + |> interaction_ids() + |> interactions_for_user(user) + end + @doc """ - Migrates all interactions from one image to another. + Adds interaction migration steps for loaded source and target images. - Copies all hides, faves, and votes from the source image to the target image. - Updates the target image's counters to reflect the new interactions. - All operations are performed in a single transaction. + The caller must authorize the merge in `Philomena.Images` and execute the + returned `Ecto.Multi`. Hides, faves, and votes absent from the target are + copied. When both images have the same user's interaction, the target row + wins. Target counters, score, and user fave/vote statistics increase only for + rows actually inserted. Source rows are unchanged. - ## Parameters - * source - The source Image struct to copy interactions from - * target - The target Image struct to copy interactions to + The added changes are named `:interaction_source`, `:interaction_hides`, + `:interaction_faves`, `:interaction_upvotes`, `:interaction_downvotes`, and + `:interaction_image`, keeping all copies and counter changes in the owner's + transaction. The fave and vote changes retain the inserted rows so their + user statistics can be incremented in bulk. + + ## Examples + + iex> (Multi.new() + ...> |> migrate_loaded_images(source, target) + ...> |> Multi.transact()) + {:ok, %{interaction_image: 1}} """ - def migrate_interactions(source, target) do - now = DateTime.utc_now(:second) - source = Repo.preload(source, [:hiders, :favers, :upvoters, :downvoters]) - - new_hides = Enum.map(source.hiders, &%{image_id: target.id, user_id: &1.id, created_at: now}) - new_faves = Enum.map(source.favers, &%{image_id: target.id, user_id: &1.id, created_at: now}) - - new_upvotes = - Enum.map( - source.upvoters, - &%{image_id: target.id, user_id: &1.id, created_at: now, up: true} - ) - - new_downvotes = - Enum.map( - source.downvoters, - &%{image_id: target.id, user_id: &1.id, created_at: now, up: false} - ) - - Multi.new() - |> Multi.run(:hides, fn repo, %{} -> - {count, nil} = repo.insert_all(ImageHide, new_hides, on_conflict: :nothing) - - {:ok, count} - end) - |> Multi.run(:faves, fn repo, %{} -> - {count, nil} = repo.insert_all(ImageFave, new_faves, on_conflict: :nothing) - - {:ok, count} - end) - |> Multi.run(:upvotes, fn repo, %{} -> - {count, nil} = repo.insert_all(ImageVote, new_upvotes, on_conflict: :nothing) - - {:ok, count} - end) - |> Multi.run(:downvotes, fn repo, %{} -> - {count, nil} = repo.insert_all(ImageVote, new_downvotes, on_conflict: :nothing) - - {:ok, count} - end) - |> Multi.run(:image, fn repo, - %{hides: hides, faves: faves, upvotes: upvotes, downvotes: downvotes} -> - image_query = where(Image, id: ^target.id) - - repo.update_all( - image_query, - inc: [ + @spec migrate_loaded_images(Multi.t(), Image.t(), Image.t()) :: Multi.t() + def migrate_loaded_images(%Multi{} = multi, %Image{} = source, %Image{} = target) do + multi + |> Multi.run(:interaction_source, fn repo, _changes -> source_interactions(repo, source) end) + |> ImageHides.put_migrate_image_interactions(target) + |> ImageFaves.put_migrate_image_interactions(target) + |> ImageVotes.put_migrate_image_interactions(target, :interaction_upvotes, true) + |> ImageVotes.put_migrate_image_interactions(target, :interaction_downvotes, false) + |> Images.put_image_counter_deltas( + :interaction_image, + target.id, + fn %{ + interaction_hides: hides, + interaction_faves: {faves, _}, + interaction_upvotes: {upvotes, _}, + interaction_downvotes: {downvotes, _} + } -> + %{ hides_count: hides, faves_count: faves, upvotes_count: upvotes, downvotes_count: downvotes, score: upvotes - downvotes - ] - ) - - {:ok, nil} - end) - |> Repo.transaction() - end - - defp union_all_queries([query]), - do: query - - defp union_all_queries([query | rest]), - do: query |> union_all(^union_all_queries(rest)) - - defp flatten_images(images) do - Enum.flat_map(images, fn - nil -> [] - %{id: id} -> [id] - {%{id: id}, _hit} -> [id] - enum -> flatten_images(enum) - end) + } + end + ) end end diff --git a/lib/philomena/loader.ex b/lib/philomena/loader.ex new file mode 100644 index 000000000..b1730ff1f --- /dev/null +++ b/lib/philomena/loader.ex @@ -0,0 +1,185 @@ +defmodule Philomena.Loader do + @moduledoc """ + Shared helpers for turning an id into a loaded record. + + Context functions repeatedly parse an id, load the record, and authorize the + actor against it. These helpers capture that shape so it lives in one place. + + An id that no `integer` column could hold is `{:error, :not_found}` (via + `Philomena.IntegerId.parse/1`). + """ + + import Ecto.Query, only: [preload: 2] + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Repo + alias Philomena.IntegerId + alias Philomena.Authorization + + @typedoc "Type of acceptable actor inputs." + @type actor :: Authorization.actor() + + @typedoc "Type of acceptable integer ID inputs." + @type integer_id :: IntegerId.integer_id() + + @typedoc "Generic type of fetch_and_authorize return value." + @type fetch_and_authorize_result(t) :: {:ok, t} | {:error, :unauthorized | :not_found} + + @typedoc "Generic type of a load that does not perform authorization." + @type fetch_result(t) :: {:ok, t} | {:error, :not_found} + + @typedoc "Errors shared by all authorized member loaders." + @type load_error :: :unauthorized | :not_found + + @doc """ + Parses an integer ID, normalizing malformed or out-of-range values to + `{:error, :not_found}`. + + ## Examples + + iex> parse_id("1234") + {:ok, 1234} + + iex> parse_id("NaN") + {:error, :not_found} + + """ + @spec parse_id(integer_id()) :: {:ok, integer()} | {:error, :not_found} + def parse_id(id) do + case IntegerId.parse(id) do + {:ok, id} -> + {:ok, id} + + :error -> + {:error, :not_found} + end + end + + @doc """ + Loads the `queryable` record named by `id`, applying `preloads`, and authorizes + `actor` for `action` on it. + + Parsing and loading happen before authorization. A malformed ID or absent row + is therefore always `{:error, :not_found}`, independent of the actor. Only a + real record can produce `{:error, :unauthorized}`. + + Returns `{:ok, record}`, `{:error, :unauthorized}`, or `{:error, :not_found}`. + + ## Examples + + iex> fetch_and_authorize(Channel, actor, :edit, "1") + {:ok, %Channel{}} + + iex> fetch_and_authorize(Channel, actor, :edit, "not-a-number") + {:error, :not_found} + + """ + @spec fetch_and_authorize( + queryable :: Ecto.Queryable.t(), + actor :: actor(), + action :: atom(), + id :: integer_id(), + preloads :: list() + ) :: fetch_and_authorize_result(struct()) + def fetch_and_authorize(queryable, actor, action, id, preloads \\ []) do + with {:ok, record} <- fetch(queryable, id, preloads), + :ok <- authorize(actor, action, record) do + {:ok, record} + end + end + + @doc """ + Loads the `queryable` record named by `id`, applying `preloads`, with no + authorization. + + A missing row, or an id that no `integer` column could hold, is + `{:error, :not_found}`. + + Returns `{:ok, record}` or `{:error, :not_found}`. + + ## Examples + + iex> fetch(SubnetBan, "1") + {:ok, %SubnetBan{}} + + iex> fetch(SubnetBan, "999999999") + {:error, :not_found} + + """ + @spec fetch( + queryable :: Ecto.Queryable.t(), + id :: integer_id(), + preloads :: list() + ) :: + fetch_result(struct()) + def fetch(queryable, id, preloads \\ []) do + with {:ok, id} <- parse_id(id) do + queryable + |> preload(^preloads) + |> Repo.get(id) + |> case do + nil -> + {:error, :not_found} + + record -> + {:ok, record} + end + end + end + + @doc """ + Loads one record from `query` without authorization. + + This is the query-based counterpart to `fetch/3` for slugs, positions, + composite keys, and parent-scoped resources. An empty result is + `{:error, :not_found}`. If the query returns more than one row, + `Ecto.MultipleResultsError` is raised because that violates the caller's + one-record invariant. + + ## Examples + + iex> one(from rule in Rule, where: rule.position == 1) + {:ok, %Rule{}} + + iex> one(from rule in Rule, where: rule.position == 999_999) + {:error, :not_found} + + """ + @spec one(query :: Ecto.Queryable.t()) :: fetch_result(struct()) + def one(query) do + case Repo.one(query) do + nil -> + {:error, :not_found} + + record -> + {:ok, record} + end + end + + @doc """ + Loads one record from `query`, then authorizes `actor` for `action` on it. + + An empty query result is always `{:error, :not_found}`. A loaded record the + actor cannot access is `{:error, :unauthorized}`. + + ## Examples + + iex> one_and_authorize(from(rule in Rule, where: rule.position == 1), actor, :show) + {:ok, %Rule{}} + + iex> one_and_authorize(from(rule in Rule, where: rule.position == 999_999), actor, :show) + {:error, :not_found} + + """ + @spec one_and_authorize( + query :: Ecto.Queryable.t(), + actor :: actor(), + action :: atom() + ) :: fetch_and_authorize_result(struct()) + def one_and_authorize(query, actor, action) do + with {:ok, record} <- one(query), + :ok <- authorize(actor, action, record) do + {:ok, record} + end + end +end diff --git a/lib/philomena/mod_notes.ex b/lib/philomena/mod_notes.ex index 1b5ca58ae..4dcdeb311 100644 --- a/lib/philomena/mod_notes.ex +++ b/lib/philomena/mod_notes.ex @@ -1,190 +1,302 @@ defmodule Philomena.ModNotes do @moduledoc """ - The ModNotes context. + Staff notes attached to users, reports, and DNP entries. + + Target selection is represented by a typed Target descriptor and every + target is loaded and separately authorized. Writes are attributed + to the acting staff member and transactionally coupled to their + moderation audit record. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.IntegerId + alias Philomena.Multi + alias Philomena.Attribution.Actor + alias Philomena.Loader + alias Philomena.ModerationLogs alias Philomena.ModNotes.ModNote + alias Philomena.ModNotes.Target + alias Philomena.Repo - @doc """ - Returns a list of 2-tuples of mod notes and rendered output for the target - named by `target`, a one-entry keyword list of the target foreign key column - and its id (e.g. `user_id: 1`). - - See `list_mod_notes/3` for more information about collection rendering. + @embedded_page_size 250 - ## Examples + defp fetch_and_authorize_target(actor, %Target{} = target, action) do + Loader.fetch_and_authorize(target.schema, actor, action, target.value) + end - iex> list_all_mod_notes_for_target(& &1.body, user_id: 1) - [ - {%ModNote{body: "hello *world*"}, "hello *world*"} - ] + defp target_query(%Target{} = target) do + from note in ModNote, + where: field(note, ^target.column) == ^target.value + end - """ - def list_all_mod_notes_for_target(collection_renderer, [{column, id}]) do - ModNote - |> where([m], field(m, ^column) == ^id) + defp ordered_notes(queryable) do + queryable |> preload(:moderator) |> order_by(desc: :id) - |> Repo.all() - |> preload_and_render(collection_renderer) end - @doc """ - Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output - for the query string and current pagination. - - All mod notes containing the substring `query_string` are matched and returned - case-insensitively. - - See `list_mod_notes/3` for more information. - - ## Examples + defp render_notes(notes, collection_renderer) do + preloaded = Repo.preload(notes, ModNote.target_preloads()) + rendered = collection_renderer.(preloaded) + Enum.zip(preloaded, rendered) + end - iex> list_mod_notes_by_query_string("quack", & &1.body, page_size: 15) - %Scrivener.Page{} + defp paginate_notes(queryable, collection_renderer, pagination) do + page = + queryable + |> ordered_notes() + |> Repo.paginate(pagination) - """ - def list_mod_notes_by_query_string(query_string, collection_renderer, pagination) do - ModNote - |> where([m], ilike(m.body, ^"%#{query_string}%")) - |> list_mod_notes(collection_renderer, pagination) + %{page | entries: render_notes(page.entries, collection_renderer)} end - @doc """ - Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output - for the target named by `target`, a one-entry keyword list of the target - foreign key column and its id (e.g. `user_id: 1`), and current pagination. - - See `list_mod_notes/3` for more information. - """ - def list_mod_notes_for_target(collection_renderer, pagination, [{column, id}]) do - ModNote - |> where([m], field(m, ^column) == ^id) - |> list_mod_notes(collection_renderer, pagination) + defp load_mod_note(actor, id, action) do + Loader.fetch_and_authorize(ModNote, actor, action, id, ModNote.target_preloads()) end @doc """ - Returns a `m:Scrivener.Page` of 2-tuples of mod notes and rendered output - for the current pagination. - - When coerced to a list and rendered as Markdown, the result may look like: + Returns up to 250 newest notes for `target`, rendered for an embedded page. - [ - {%ModNote{body: "hello *world*"}, "hello world"} - ] + The actor must be allowed to index notes and to view notes for the safely + loaded target. Malformed and missing target IDs are `{:error, :not_found}`. + History is retained indefinitely, but the returned list is bounded to 250. ## Examples - iex> list_mod_notes(& &1.body, page_size: 15) - %Scrivener.Page{} + iex> list_for_target(moderator, {:user, "12"}, renderer) + {:ok, [{%ModNote{}, "rendered body"}]} - """ - def list_mod_notes(queryable \\ ModNote, collection_renderer, pagination) do - mod_notes = - queryable - |> preload(:moderator) - |> order_by(desc: :id) - |> Repo.paginate(pagination) + iex> list_for_target(user, {:user, "12"}, renderer) + {:error, :unauthorized} - put_in(mod_notes.entries, preload_and_render(mod_notes, collection_renderer)) + """ + @spec list_for_target(Actor.t(), {atom(), IntegerId.integer_id()}, (list(ModNote.t()) -> + list(term()))) :: + {:ok, [{ModNote.t(), term()}]} | {:error, :not_found | :unauthorized} + def list_for_target(%Actor{} = actor, {type, id}, collection_renderer) + when type in [:user, :report, :dnp_entry] do + with :ok <- authorize(actor, :index, ModNote), + {:ok, target} <- Target.from_type_and_id(type, id), + {:ok, _record} <- + fetch_and_authorize_target(actor, target, :show_mod_notes) do + notes = + target + |> target_query() + |> ordered_notes() + |> limit(@embedded_page_size) + |> Repo.all() + + {:ok, render_notes(notes, collection_renderer)} + end end - defp preload_and_render(mod_notes, collection_renderer) do - bodies = collection_renderer.(mod_notes) - preloaded = preload_targets(mod_notes) + @doc """ + Loads the paginated staff note index, optionally scoped to one target. - Enum.zip(preloaded, bodies) - end + A target filter is one of `user_id`, `report_id`, or `dnp_entry_id`, and is + authorized for `:show_mod_notes`. Multiple, malformed, or missing targets + act as if no filter was provided. With no filter, all notes are returned + newest first. - defp preload_targets(mod_notes) do - mod_notes - |> Enum.to_list() - |> Repo.preload(ModNote.target_preloads()) + ## Examples + + iex> list_mod_notes(moderator, %{"user_id" => "12"}, renderer, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_mod_notes(user, %{}, renderer, pagination) + {:error, :unauthorized} + + """ + @spec list_mod_notes( + Actor.t(), + map(), + (list(ModNote.t()) -> list(term())), + Repo.pagination_params() + ) :: {:ok, Scrivener.Page.t()} | {:error, :not_found | :unauthorized} + def list_mod_notes(%Actor{} = actor, params, collection_renderer, pagination) do + with :ok <- authorize(actor, :index, ModNote) do + with {:ok, target} <- Target.from_params(params), + {:ok, _record} <- fetch_and_authorize_target(actor, target, :show_mod_notes) do + {:ok, paginate_notes(target_query(target), collection_renderer, pagination)} + else + _ -> + {:ok, paginate_notes(ModNote, collection_renderer, pagination)} + end + end end @doc """ - Gets a single mod_note. + Builds a new-note changeset for the target selected in `params`. - Raises `Ecto.NoResultsError` if the Mod note does not exist. + The target is loaded and authorized with `:annotate`. ## Examples - iex> get_mod_note!(123) - %ModNote{} + iex> new_mod_note(moderator, %{"report_id" => "7"}) + {:ok, %Ecto.Changeset{}} - iex> get_mod_note!(456) - ** (Ecto.NoResultsError) + iex> new_mod_note(user, %{}) + {:error, :unauthorized} """ - def get_mod_note!(id), do: Repo.get!(ModNote, id) + @spec new_mod_note(Actor.t(), map()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :not_found | :unauthorized} + def new_mod_note(%Actor{user: moderator} = actor, params) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, ModNote), + {:ok, target} <- Target.from_params(params), + {:ok, _} <- fetch_and_authorize_target(actor, target, :annotate) do + {:ok, + %ModNote{moderator_id: moderator.id} + |> ModNote.creation_changeset(%{}, Target.to_changes(target))} + end + end @doc """ - Creates a mod_note authored by `creator` against the target named by - `target`, a one-entry keyword list of the target foreign key column and its - id (e.g. `user_id: 1`). + Creates an attributed note and its moderation log in one transaction. + + The target is loaded and authorized with `:annotate`. ## Examples - iex> create_mod_note(user, %{"body" => "..."}, user_id: 1) + iex> create_mod_note(moderator, %{"user_id" => "12", "body" => "Watching"}) {:ok, %ModNote{}} - iex> create_mod_note(user, %{"body" => ""}, user_id: 1) - {:error, %Ecto.Changeset{}} + iex> create_mod_note(user, attrs) + {:error, :unauthorized} """ - def create_mod_note(creator, attrs, target) do - %ModNote{moderator_id: creator.id} - |> ModNote.creation_changeset(attrs, target) - |> Repo.insert() + @spec create_mod_note(Actor.t(), map()) :: + {:ok, ModNote.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def create_mod_note(%Actor{user: moderator} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, ModNote), + {:ok, target} <- Target.from_params(attrs), + {:ok, _} <- fetch_and_authorize_target(actor, target, :annotate) do + mod_note_changeset = + %ModNote{moderator_id: moderator.id} + |> ModNote.creation_changeset(attrs, Target.to_changes(target)) + + Multi.new() + |> Multi.insert(:mod_note, mod_note_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "ModNote:create", + "/admin/mod_notes", + "Created mod note for #{Target.label(target)}" + ) + |> Multi.transact() + |> case do + {:ok, %{mod_note: %ModNote{} = mod_note}} -> + {:ok, mod_note} + + {:error, :mod_note, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Updates a mod_note. + Loads an authorized note and edit changeset for `actor`. ## Examples - iex> update_mod_note(mod_note, %{field: new_value}) - {:ok, %ModNote{}} + iex> edit_mod_note(moderator, "1") + {:ok, {%ModNote{}, %Ecto.Changeset{}}} - iex> update_mod_note(mod_note, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_mod_note(user, "1") + {:error, :unauthorized} """ - def update_mod_note(%ModNote{} = mod_note, attrs) do - mod_note - |> ModNote.changeset(attrs) - |> Repo.update() + @spec edit_mod_note(Actor.t(), Loader.integer_id()) :: + {:ok, {ModNote.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_mod_note(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, mod_note} <- load_mod_note(actor, id, :edit) do + {:ok, {mod_note, ModNote.changeset(mod_note)}} + end end @doc """ - Deletes a ModNote. + Updates a note and appends its audit record in the same transaction. ## Examples - iex> delete_mod_note(mod_note) + iex> update_mod_note(moderator, "1", %{"body" => "Updated"}) {:ok, %ModNote{}} - iex> delete_mod_note(mod_note) - {:error, %Ecto.Changeset{}} + iex> update_mod_note(user, "1", attrs) + {:error, :unauthorized} """ - def delete_mod_note(%ModNote{} = mod_note) do - Repo.delete(mod_note) + @spec update_mod_note(Actor.t(), Loader.integer_id(), map()) :: + {:ok, ModNote.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_mod_note(%Actor{} = actor, id, attrs) do + with :ok <- verify_write_access(actor), + {:ok, mod_note} <- load_mod_note(actor, id, :update) do + mod_note_changeset = ModNote.changeset(mod_note, attrs) + + Multi.new() + |> Multi.update(:mod_note, mod_note_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "ModNote:update", + "/admin/mod_notes/#{mod_note.id}", + "Updated mod note #{mod_note.id}" + ) + |> Multi.transact() + |> case do + {:ok, %{mod_note: %ModNote{} = mod_note}} -> + {:ok, mod_note} + + {:error, :mod_note, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking mod_note changes. + Deletes a note and appends its audit record in the same transaction. ## Examples - iex> change_mod_note(mod_note) - %Ecto.Changeset{source: %ModNote{}} + iex> delete_mod_note(moderator, "1") + {:ok, %ModNote{}} + + iex> delete_mod_note(user, "1") + {:error, :unauthorized} """ - def change_mod_note(%ModNote{} = mod_note) do - ModNote.changeset(mod_note, %{}) + @spec delete_mod_note(Actor.t(), Loader.integer_id()) :: + {:ok, ModNote.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_mod_note(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, mod_note} <- load_mod_note(actor, id, :delete) do + Multi.new() + |> Multi.delete(:mod_note, mod_note) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "ModNote:delete", + "/admin/mod_notes/#{mod_note.id}", + "Deleted mod note #{mod_note.id}" + ) + |> Multi.transact() + |> case do + {:ok, %{mod_note: %ModNote{} = mod_note}} -> + {:ok, mod_note} + + {:error, :mod_note, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end end diff --git a/lib/philomena/mod_notes/mod_note.ex b/lib/philomena/mod_notes/mod_note.ex index 63ef1bc6a..081f2ce34 100644 --- a/lib/philomena/mod_notes/mod_note.ex +++ b/lib/philomena/mod_notes/mod_note.ex @@ -10,6 +10,8 @@ defmodule Philomena.ModNotes.ModNote do # note; all are NULL on an orphaned note whose target was deleted. @target_columns [:user_id, :report_id, :dnp_entry_id] + @type t :: %__MODULE__{} + schema "mod_notes" do belongs_to :moderator, User @@ -36,7 +38,7 @@ defmodule Philomena.ModNotes.ModNote do end @doc false - def changeset(mod_note, attrs) do + def changeset(mod_note, attrs \\ %{}) do mod_note |> cast(attrs, [:body]) |> validate_required([:body]) diff --git a/lib/philomena/mod_notes/target.ex b/lib/philomena/mod_notes/target.ex new file mode 100644 index 000000000..4e09b4586 --- /dev/null +++ b/lib/philomena/mod_notes/target.ex @@ -0,0 +1,106 @@ +defmodule Philomena.ModNotes.Target do + @moduledoc """ + Staff note target helper type, parser, and utilities. + """ + alias Philomena.IntegerId + alias Philomena.Loader + alias Philomena.DnpEntries.DnpEntry + alias Philomena.Reports.Report + alias Philomena.Users.User + + @target_definitions [ + user: {User, :user_id}, + report: {Report, :report_id}, + dnp_entry: {DnpEntry, :dnp_entry_id} + ] + + @enforce_keys [:type, :value, :schema, :column] + defstruct [:type, :value, :schema, :column] + + @typedoc "A supported note target type and database ID." + @type t :: %__MODULE__{ + type: :user | :report | :dnp_entry, + value: IntegerId.integer_id(), + schema: User | Report | DnpEntry, + column: :user_id | :report_id | :dnp_entry_id + } + + defp get_param(params, key) do + Map.get(params, to_string(key)) || Map.get(params, key) + end + + defp target_params(params) do + @target_definitions + |> Enum.map(fn {type, {_schema, column}} -> {type, get_param(params, column)} end) + |> Enum.reject(fn {_type, value} -> value in [nil, ""] end) + end + + defp parse_targets(params) do + parsed = + params + |> target_params() + |> Enum.map(fn {type, value} -> from_type_and_id(type, value) end) + |> Enum.map(fn + {:ok, target} -> target + {:error, _} -> :error + end) + + if :error in parsed do + {:error, :not_found} + else + {:ok, parsed} + end + end + + @doc """ + Generates a target from the combination of type and ID. + + Returns `{:error, :not_found}` if the input does not specify a target. + """ + @spec from_type_and_id(atom(), IntegerId.integer_id()) :: {:ok, t()} | {:error, :not_found} + def from_type_and_id(type, id) do + with {:ok, {schema, column}} <- Keyword.fetch(@target_definitions, type), + {:ok, id} <- Loader.parse_id(id) do + {:ok, %__MODULE__{type: type, value: id, schema: schema, column: column}} + else + _ -> + {:error, :not_found} + end + end + + @doc """ + Retrieves a single target from params. + + Returns `{:error, :not_found}` if no targets or multiple targets are found. + """ + @spec from_params(map()) :: {:ok, t()} | {:error, :not_found} + def from_params(params) do + case parse_targets(params) do + {:ok, [%__MODULE__{} = target]} -> + {:ok, target} + + _ -> + {:error, :not_found} + end + end + + @doc """ + Returns a user-facing label for the given target. + """ + @spec label(t()) :: String.t() + def label(%__MODULE__{} = target) do + target.type + |> Atom.to_string() + |> String.replace("_", " ") + |> then(&"#{&1} #{target.value}") + end + + @doc """ + Converts a target to a keyword of changes suitable for passing as the second + argument to `Ecto.Changeset.change/2`. + """ + @spec to_changes(t()) :: Keyword.t() + def to_changes(%__MODULE__{} = target) do + [{target.column, target.value}] + end +end diff --git a/lib/philomena/moderation_logs.ex b/lib/philomena/moderation_logs.ex index 19c7458d0..6ed8bf43c 100644 --- a/lib/philomena/moderation_logs.ex +++ b/lib/philomena/moderation_logs.ex @@ -1,55 +1,147 @@ defmodule Philomena.ModerationLogs do @moduledoc """ - The ModerationLogs context. + Append-only audit records for staff actions. + + Moderated database changes should compose `put_log/6` into their owning + `Ecto.Multi`, so failure to persist the audit record rolls the action back. + The actor-scoped listing exposes only the retained two-week window. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3] + alias Philomena.Multi + alias Philomena.Attribution.Actor alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + alias Philomena.Users.User + + defp log_changeset(%User{} = user, type, subject_path, body) do + %ModerationLog{user_id: user.id} + |> ModerationLog.changeset(%{type: type, subject_path: subject_path, body: body}) + end + + defp list_moderation_logs(pagination) do + ModerationLog + |> where([ml], ml.created_at >= ago(2, "week")) + |> preload(:user) + |> order_by(desc: :created_at, desc: :id) + |> Repo.paginate(pagination) + end @doc """ - Returns a paginated list of moderation logs as a `m:Scrivener.Page`. + Returns the retained, paginated moderation logs visible to `actor`. + + Only records from the last two weeks are returned, newest first. Access is + authorized with `:index` on `ModerationLog`. ## Examples - iex> list_moderation_logs(page_size: 15) - [%ModerationLog{}, ...] + iex> list_moderation_logs(admin, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_moderation_logs(user, pagination) + {:error, :unauthorized} """ - def list_moderation_logs(pagination) do - ModerationLog - |> where([ml], ml.created_at >= ago(2, "week")) - |> preload(:user) - |> order_by(desc: :created_at) - |> Repo.paginate(pagination) + @spec list_moderation_logs(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(ModerationLog.t())} | {:error, :unauthorized} + def list_moderation_logs(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :index, ModerationLog) do + {:ok, list_moderation_logs(pagination)} + end end @doc """ - Creates a moderation log. + Adds an attributed audit-log insert to `multi` under `step`. - This is called from within the context function that performs the logged - action, after that action succeeds - after the transaction commits, not - inside it. `subject_path` is built with `Philomena.ModerationLogs.Paths` so the - context does not depend on `PhilomenaWeb`. + The returned `Ecto.Multi` does no work until its owner transacts it. A failed + log changeset therefore rolls back every preceding database step. Build + `subject_path` with `Philomena.ModerationLogs.Paths` when a canonical helper + exists. ## Examples - iex> create_moderation_log(%{field: value}) - {:ok, %ModerationLog{}} + iex> put_log(multi, :log, actor, "User:update", "/profiles/name", "Updated user") + %Ecto.Multi{} - iex> create_moderation_log(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + """ + @spec put_log( + multi :: Multi.t(), + step :: Multi.name(), + actor :: Actor.t(), + type :: String.t(), + subject_path :: String.t(), + body :: String.t() + ) :: + Multi.t() + def put_log(%Multi{} = multi, step, %Actor{user: %User{} = user}, type, subject_path, body) + when is_binary(body) do + Multi.insert(multi, step, log_changeset(user, type, subject_path, body)) + end + + @doc ~S""" + Adds an audit-log insert whose attributes are derived from prior Multi changes. + + `callback` receives the completed changes and must return + `{type, subject_path, body}`. Use this form when the audited subject is + created by an earlier Multi step. + + ## Examples + + iex> put_log(multi, :log, actor, fn %{user: user} -> + ...> {"User:create", "/profiles/#{user.name}", "Created user"} + ...> end) + %Ecto.Multi{} """ - def create_moderation_log(user, type, subject_path, body) do - %ModerationLog{user_id: user.id} - |> ModerationLog.changeset(%{type: type, subject_path: subject_path, body: body}) + @spec put_log( + multi :: Multi.t(), + step :: Multi.name(), + actor :: Actor.t(), + callback :: (Ecto.Multi.changes() -> {String.t(), String.t(), String.t()}) + ) :: + Multi.t() + def put_log(%Multi{} = multi, step, %Actor{user: user}, callback) + when is_function(callback, 1) do + Multi.run(multi, step, fn repo, changes -> + {type, subject_path, body} = callback.(changes) + + user + |> log_changeset(type, subject_path, body) + |> repo.insert() + end) + end + + @doc """ + Inserts an audit record within the caller's ambient Repo transaction. + + Transactional mutations should prefer `put_log/6`. + + ## Examples + + iex> create_moderation_log(actor, "User:update", "/profiles/name", "Updated user") + {:ok, %ModerationLog{}} + + """ + @spec create_moderation_log(Actor.t() | User.t(), String.t(), String.t(), String.t()) :: + {:ok, ModerationLog.t()} | {:error, Ecto.Changeset.t()} + def create_moderation_log(actor, type, subject_path, body) + + def create_moderation_log(%Actor{} = actor, type, subject_path, body) do + create_moderation_log(actor.user, type, subject_path, body) + end + + def create_moderation_log(%User{} = user, type, subject_path, body) do + user + |> log_changeset(type, subject_path, body) |> Repo.insert() end @doc """ - Removes moderation logs created more than 2 weeks ago. + Removes moderation logs older than the two-week retention window. + + The release task raises on database failure. ## Examples @@ -57,6 +149,7 @@ defmodule Philomena.ModerationLogs do {31, nil} """ + @spec cleanup!() :: {non_neg_integer(), nil} def cleanup! do ModerationLog |> where([ml], ml.created_at < ago(2, "week")) diff --git a/lib/philomena/moderation_logs/moderation_log.ex b/lib/philomena/moderation_logs/moderation_log.ex index e85ac6f0d..598aadf07 100644 --- a/lib/philomena/moderation_logs/moderation_log.ex +++ b/lib/philomena/moderation_logs/moderation_log.ex @@ -4,6 +4,8 @@ defmodule Philomena.ModerationLogs.ModerationLog do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "moderation_logs" do belongs_to :user, User @@ -19,5 +21,6 @@ defmodule Philomena.ModerationLogs.ModerationLog do moderation_log |> cast(attrs, [:body, :type, :subject_path]) |> validate_required([:body, :type, :subject_path]) + |> foreign_key_constraint(:user_id) end end diff --git a/lib/philomena/moderation_logs/paths.ex b/lib/philomena/moderation_logs/paths.ex index 484fdb455..ffc676180 100644 --- a/lib/philomena/moderation_logs/paths.ex +++ b/lib/philomena/moderation_logs/paths.ex @@ -2,21 +2,19 @@ defmodule Philomena.ModerationLogs.Paths do @moduledoc """ Builders for moderation-log `subject_path` strings. - The values are data, not verified routes: `subject_path` is stored in the - `moderation_logs` table and rendered by the mod-log UI as an opaque `href`. - We deliberately give up compile-time route verification for them; they are - simple, stable paths. + The values are plain data: each `subject_path` is stored as an opaque string + in the `moderation_logs` table. Nothing verifies that they resolve to + anything; they are simple, stable paths. ## Encoding - `~p` runs each interpolated dynamic segment through `Phoenix.Param.to_param/1` - and then `URI.encode(segment, &URI.char_unreserved?/1)`. - Slugs (`Philomena.Slug.slug/1`) can contain characters that are *not* - URI-unreserved - notably `+` (from spaces) and other escaped punctuation runs - like `-dot-`, `-fwslash-` - so those bytes must be percent-encoded to stay - identical to `~p`. `encode_segment/1` below matches Phoenix exactly. - Integer ids and forum short names (`~r/\\A[a-z]+\\z/`) pass through unchanged, - but are encoded the same way for uniformity. + Each dynamic segment runs through `Phoenix.Param.to_param/1` and then + `URI.encode(segment, &URI.char_unreserved?/1)`. Slugs + (`Philomena.Slug.slug/1`) can contain characters that are *not* URI-unreserved + - notably `+` (from spaces) and escaped punctuation runs like `-dot-`, + `-fwslash-` - so those bytes are percent-encoded. Integer ids and forum short + names (`~r/\\A[a-z]+\\z/`) pass through unchanged, but are encoded the same way + for uniformity. """ alias Philomena.Images.Image @@ -25,6 +23,7 @@ defmodule Philomena.ModerationLogs.Paths do alias Philomena.Forums.Forum alias Philomena.Topics.Topic alias Philomena.Posts.Post + alias Philomena.Reports.Report alias Philomena.DnpEntries.DnpEntry alias Philomena.ArtistLinks.ArtistLink @@ -82,6 +81,14 @@ defmodule Philomena.ModerationLogs.Paths do """ def dnp_entry_path(%DnpEntry{id: id}), do: "/dnp/" <> encode_segment(id) + @doc """ + Path to a report in the staff review interface, e.g. `/admin/reports/123`. + + Accepts a report or an already parsed report ID. + """ + def admin_report_path(%Report{id: id}), do: admin_report_path(id) + def admin_report_path(id) when is_integer(id), do: "/admin/reports/" <> encode_segment(id) + @doc """ Path to an artist link on a user's profile, e.g. `/profiles/somebody/artist_links/123`. @@ -111,9 +118,9 @@ defmodule Philomena.ModerationLogs.Paths do "/fingerprint_profiles/" <> encode_segment(fingerprint) end - # Mirrors Phoenix.VerifiedRoutes segment encoding: `Phoenix.Param.to_param/1` - # followed by `URI.encode(&URI.char_unreserved?/1)`. `to_string/1` is - # equivalent to `to_param` for the integer ids and binary slugs used here. + # Percent-encodes a path segment: `to_string/1` (equivalent to + # `Phoenix.Param.to_param/1` for the integer ids and binary slugs used here) + # followed by `URI.encode(&URI.char_unreserved?/1)`. @spec encode_segment(term()) :: String.t() defp encode_segment(segment) do segment diff --git a/lib/philomena/multi.ex b/lib/philomena/multi.ex new file mode 100644 index 000000000..da02f91e2 --- /dev/null +++ b/lib/philomena/multi.ex @@ -0,0 +1,819 @@ +defmodule Philomena.Multi do + @moduledoc """ + `m:Ecto.Multi` wrapper with locking utilities and deferred action semantics. + """ + + import Ecto.Query, only: [lock: 2] + + @enforce_keys [:multi] + defstruct [:multi] + + @type t :: %__MODULE__{ + multi: Ecto.Multi.t() + } + + @type name :: Ecto.Multi.name() + @type changes :: Ecto.Multi.changes() + @type failure :: Ecto.Multi.failure() + + @doc """ + Runs a query and stores all results in the Multi. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.all/2`. + + ## Example + + Multi.new() + |> Multi.all(:all, Post) + |> Multi.transact() + + Multi.new() + |> Multi.all(:all, fn _changes -> Post end) + |> Multi.transact() + + """ + @spec all( + t(), + Ecto.Multi.name(), + queryable :: Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()), + opts :: Keyword.t() + ) :: t() + def all(%__MODULE__{} = multi, name, queryable_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.all(&1, name, queryable_or_fun, opts)) + end + + @doc """ + Appends the second Multi to the first. + + All names must be unique within both structures. + + ## Example + + iex> lhs = Multi.new() |> Multi.run(:left, fn _, changes -> {:ok, changes} end) + iex> rhs = Multi.new() |> Multi.run(:right, fn _, changes -> {:error, changes} end) + iex> Multi.append(lhs, rhs) |> Multi.to_list |> Keyword.keys + [:left, :right] + + """ + @spec append(t(), t()) :: t() + def append(%__MODULE__{} = lhs, %__MODULE__{} = rhs) do + update_in(lhs.multi, &Ecto.Multi.append(&1, rhs.multi)) + end + + @doc """ + Adds a delete operation to the Multi. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.delete/2`. + + ## Example + + post = MyApp.Repo.get!(Post, 1) + Multi.new() + |> Multi.delete(:delete, post) + |> Multi.transact() + + Multi.new() + |> Multi.run(:post, fn repo, _changes -> + case repo.get(Post, 1) do + nil -> {:error, :not_found} + post -> {:ok, post} + end + end) + |> Multi.delete(:delete, fn %{post: post} -> + # Others validations + post + end) + |> Multi.transact() + + """ + @spec delete( + t(), + Ecto.Multi.name(), + Ecto.Changeset.t() + | Ecto.Schema.t() + | (Ecto.Multi.changes() -> Ecto.Changeset.t() | Ecto.Schema.t()), + Keyword.t() + ) :: t + def delete(%__MODULE__{} = multi, name, changeset_or_struct_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.delete(&1, name, changeset_or_struct_fun, opts)) + end + + @doc """ + Adds a `delete_all` operation to the Multi. + + Accepts the same arguments and options as `c:Ecto.Repo.delete_all/2`. + + ## Example + + queryable = from(p in Post, where: p.id < 5) + Multi.new() + |> Multi.delete_all(:delete_all, queryable) + |> Multi.transact() + + Multi.new() + |> Multi.run(:post, fn repo, _changes -> + case repo.get(Post, 1) do + nil -> {:error, :not_found} + post -> {:ok, post} + end + end) + |> Multi.delete_all(:delete_all, fn %{post: post} -> + # Others validations + from(c in Comment, where: c.post_id == ^post.id) + end) + |> Multi.transact() + + """ + @spec delete_all( + t(), + Ecto.Multi.name(), + Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()), + Keyword.t() + ) :: t() + def delete_all(%__MODULE__{} = multi, name, queryable_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.delete_all(&1, name, queryable_or_fun, opts)) + end + + @doc """ + Causes the Multi to fail with the given value. + + Running the Multi in a transaction will execute + no previous steps and return the value of the first + error added. + """ + @spec error(t(), Ecto.Multi.name(), error :: term()) :: t() + def error(%__MODULE__{} = multi, name, value) do + update_in(multi.multi, &Ecto.Multi.error(&1, name, value)) + end + + @doc """ + Checks if an entry matching the given query exists and stores a boolean in the Multi. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.exists?/2`. + + ## Example + + Multi.new() + |> Multi.exists?(:post, Post) + |> Multi.transact() + + Multi.new() + |> Multi.exists?(:post, fn _changes -> Post end) + |> Multi.transact() + + """ + @spec exists?( + t(), + Ecto.Multi.name(), + queryable :: Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()), + opts :: Keyword.t() + ) :: t() + def exists?(%__MODULE__{} = multi, name, queryable_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.exists?(&1, name, queryable_or_fun, opts)) + end + + @doc """ + Adds an insert operation to the Multi. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.insert/2`. + + ## Example + + Multi.new() + |> Multi.insert(:insert, %Post{title: "first"}) + |> Multi.transact() + + Multi.new() + |> Multi.insert(:post, %Post{title: "first"}) + |> Multi.insert(:comment, fn %{post: post} -> + Ecto.build_assoc(post, :comments) + end) + |> Multi.transact() + + """ + @spec insert( + t(), + Ecto.Multi.name(), + Ecto.Changeset.t() + | Ecto.Schema.t() + | (Ecto.Multi.changes() -> Ecto.Changeset.t() | Ecto.Schema.t()), + Keyword.t() + ) :: t + def insert(%__MODULE__{} = multi, name, changeset_or_struct_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.insert(&1, name, changeset_or_struct_or_fun, opts)) + end + + @doc """ + Adds an `insert_all` operation to the Multi. + + Accepts the same arguments and options as `c:Ecto.Repo.insert_all/3`. + + ## Example + + posts = [%{title: "My first post"}, %{title: "My second post"}] + Multi.new() + |> Multi.insert_all(:insert_all, Post, posts) + |> Multi.transact() + + Multi.new() + |> Multi.run(:post, fn repo, _changes -> + case repo.get(Post, 1) do + nil -> {:error, :not_found} + post -> {:ok, post} + end + end) + |> Multi.insert_all(:insert_all, Comment, fn %{post: post} -> + # Others validations + + entries + |> Enum.map(fn comment -> + Map.put(comment, :post_id, post.id) + end) + end) + |> Multi.transact() + + """ + @spec insert_all( + t(), + Ecto.Multi.name(), + term(), + entries_or_query_or_fun :: + [map() | Keyword.t()] + | (Ecto.Multi.changes() -> [map() | Keyword.t()]) + | Ecto.Query.t(), + Keyword.t() + ) :: t + def insert_all( + %__MODULE__{} = multi, + name, + schema_or_source, + entries_or_query_or_fun, + opts \\ [] + ) do + update_in( + multi.multi, + &Ecto.Multi.insert_all(&1, name, schema_or_source, entries_or_query_or_fun, opts) + ) + end + + @doc """ + Inserts or updates a changeset depending on whether or not the changeset was persisted. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.insert_or_update/2`. + + ## Example + + changeset = Post.changeset(%Post{}, %{title: "New title"}) + Multi.new() + |> Multi.insert_or_update(:insert_or_update, changeset) + |> Multi.transact() + + Multi.new() + |> Multi.run(:post, fn repo, _changes -> + {:ok, repo.get(Post, 1) || %Post{}} + end) + |> Multi.insert_or_update(:update, fn %{post: post} -> + Ecto.Changeset.change(post, title: "New title") + end) + |> Multi.transact() + + """ + @spec insert_or_update( + t(), + Ecto.Multi.name(), + Ecto.Changeset.t() | (Ecto.Multi.changes() -> Ecto.Changeset.t()), + Keyword.t() + ) :: t() + def insert_or_update(%__MODULE__{} = multi, name, changeset_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.insert_or_update(&1, name, changeset_or_fun, opts)) + end + + @doc """ + Merges a Multi returned dynamically by an anonymous function. + + This function is useful when the Multi to be merged requires information + from the original Multi. The second argument is an anonymous function + that receives the Multi changes so far. The anonymous function must return + another Multi. + + If you would prefer to simply merge two Multis together, see `append/2` or + `prepend/2`. + + Duplicated operations are not allowed. + + ## Example + + multi = + Multi.new() + |> Multi.insert(:post, %Post{title: "first"}) + + multi + |> Multi.merge(fn %{post: post} -> + Multi.new() + |> Multi.insert(:comment, Ecto.build_assoc(post, :comments)) + end) + |> Multi.transact() + + """ + @spec merge(t(), (Ecto.Multi.changes() -> t())) :: t() + def merge(%__MODULE__{} = multi, merge) when is_function(merge, 1) do + update_in(multi.multi, &Ecto.Multi.merge(&1, fn changes -> merge.(changes).multi end)) + end + + @doc """ + Inspects results from a Multi. + + By default, the name is shown as a label to the inspect. Custom labels are + supported through the `IO.inspect/2` `label` option. + + ## Options + + All options for IO.inspect/2 are supported, as well as: + + * `:only` - A field or a list of fields to inspect, will print the entire + map by default. + + ## Examples + + Multi.new() + |> Multi.insert(:person_a, changeset) + |> Multi.insert(:person_b, changeset) + |> Multi.inspect() + |> Multi.transact() + + Prints: + %{person_a: %Person{...}, person_b: %Person{...}} + + We can use the `:only` option to limit which fields will be printed: + + Multi.new() + |> Multi.insert(:person_a, changeset) + |> Multi.insert(:person_b, changeset) + |> Multi.inspect(only: :person_a) + |> Multi.transact() + + Prints: + %{person_a: %Person{...}} + + """ + @spec inspect(t(), Keyword.t()) :: t() + def inspect(%__MODULE__{} = multi, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.inspect(&1, opts)) + end + + @doc """ + Returns an empty `Multi` struct. + + ## Example + + iex> Multi.new() |> Multi.to_list() + [] + + """ + @spec new() :: t() + def new do + %__MODULE__{multi: Ecto.Multi.new()} + end + + @doc """ + Runs a query expecting one result and stores the result in the Multi. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.one/2`. + + ## Example + + Multi.new() + |> Multi.one(:post, Post) + |> Multi.one(:author, fn %{post: post} -> + from(a in Author, where: a.id == ^post.author_id) + end) + |> Multi.transact() + + """ + @spec one( + t(), + Ecto.Multi.name(), + queryable :: Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()), + opts :: Keyword.t() + ) :: t() + def one(%__MODULE__{} = multi, name, queryable_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.one(&1, name, queryable_or_fun, opts)) + end + + @doc """ + Adds a value to the changes so far under the given name. + + The given `value` is added to the Multi before the transaction starts. + If you would like to run arbitrary functions as part of your transaction, + see `run/3` or `run/5`. + + ## Example + + Imagine there is an existing company schema that you retrieved from + the database. You can insert it as a change in the Multi using `put/3`: + + Multi.new() + |> Multi.put(:company, company) + |> Multi.insert(:user, fn changes -> User.changeset(changes.company) end) + |> Multi.insert(:person, fn changes -> Person.changeset(changes.user, changes.company) end) + |> Multi.transact() + + In the example above, there isn't a significant benefit in putting + the `company` in the Multi because you could also access the + `company` variable directly inside the anonymous function. + + However, the benefit of `put/3` is seen when composing `Ecto.Multi`s. + If the insert operations above were defined in another module, + you could use `put(:company, company)` to inject changes that + will be accessed by other functions down the chain, removing + the need to pass both `multi` and `company` values around. + """ + @spec put(t(), Ecto.Multi.name(), any()) :: t() + def put(%__MODULE__{} = multi, name, value) do + update_in(multi.multi, &Ecto.Multi.put(&1, name, value)) + end + + @doc """ + Adds a function to run as part of the Multi. + + The function should return either `{:ok, value}` or `{:error, value}`, + and receives the repo as the first argument and the changes so far + as the second argument. + + ## Example + + Multi.run(multi, :write, fn _repo, %{image: image} -> + with :ok <- File.write(image.name, image.contents) do + {:ok, nil} + end + end) + + """ + @spec run(t(), Ecto.Multi.name(), Ecto.Multi.run()) :: t() + def run(%__MODULE__{} = multi, name, run) when is_function(run, 2) do + update_in(multi.multi, &Ecto.Multi.run(&1, name, run)) + end + + @doc """ + Returns the list of operations stored in the Multi. + + Always use this function when you need to access the operations you + have defined in `Multi`. Inspecting the `Multi` struct internals + directly is discouraged. + """ + @spec to_list(t()) :: [{Ecto.Multi.name(), term()}] + def to_list(%__MODULE__{} = multi) do + Ecto.Multi.to_list(multi.multi) + end + + @doc """ + Adds an update operation to the Multi. + + The `name` must be unique within the Multi. + + The remaining arguments and options are the same as in `c:Ecto.Repo.update/2`. + + ## Example + + post = MyApp.Repo.get!(Post, 1) + changeset = Ecto.Changeset.change(post, title: "New title") + Multi.new() + |> Multi.update(:update, changeset) + |> Multi.transact() + + Multi.new() + |> Multi.insert(:post, %Post{title: "first"}) + |> Multi.update(:fun, fn %{post: post} -> + Ecto.Changeset.change(post, title: "New title") + end) + |> Multi.transact() + + """ + @spec update( + t(), + Ecto.Multi.name(), + Ecto.Changeset.t() | (Ecto.Multi.changes() -> Ecto.Changeset.t()), + Keyword.t() + ) :: t() + def update(%__MODULE__{} = multi, name, changeset_or_fun, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.update(&1, name, changeset_or_fun, opts)) + end + + @doc """ + Adds an `update_all` operation to the Multi. + + Accepts the same arguments and options as `c:Ecto.Repo.update_all/3`. + + ## Example + + Multi.new() + |> Multi.update_all(:update_all, Post, set: [title: "New title"]) + |> Multi.transact() + + Multi.new() + |> Multi.run(:post, fn repo, _changes -> + case repo.get(Post, 1) do + nil -> {:error, :not_found} + post -> {:ok, post} + end + end) + |> Multi.update_all(:update_all, fn %{post: post} -> + # Others validations + from(c in Comment, where: c.post_id == ^post.id, update: [set: [title: "New title"]]) + end, []) + |> Multi.transact() + + """ + @spec update_all( + t(), + Ecto.Multi.name(), + Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()), + Keyword.t(), + Keyword.t() + ) :: t + def update_all(%__MODULE__{} = multi, name, queryable_or_fun, updates, opts \\ []) do + update_in(multi.multi, &Ecto.Multi.update_all(&1, name, queryable_or_fun, updates, opts)) + end + + @doc ~S""" + Acquires a transaction-scoped advisory lock for the application-defined key. + + This is useful when there would otherwise be nothing to lock. + + ## Example + + Multi.new() + |> Multi.lock_advisory(:lock_user_ip, "ip:#{ip}") + |> Multi.transact() + + """ + @spec lock_advisory(t(), Ecto.Multi.name(), binary()) :: t() + def lock_advisory(%__MODULE__{} = multi, name, key) do + lock_fn = + fn repo, _changes -> + repo.query("SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", [key]) + end + + update_in(multi.multi, &Ecto.Multi.run(&1, name, lock_fn)) + end + + @doc """ + Locks all of the rows returned by the query for update. + + The locked result is available under `name` to later Multi steps. This is + useful before making a change that depends on the returned rows' current + state. The query may be a function of earlier Multi changes and is evaluated + inside the transaction. + + > #### Warning {: .warning} + > + > In PostgreSQL, rows are locked in the order of the `ORDER BY` clause as rows + > were when the table was scanned. To avoid deadlocks when using `lock_all/3`, + > you must provide a query with a fully deterministic ordering, on a column + > that never changes for a given row, such as a surrogate `id` key. + + ## Example + + query = Image |> where([i], i.id in ^image_ids) |> order_by(asc: :id) + + Multi.new() + |> Multi.lock_all(:user, query) + |> Multi.transact() + + """ + @spec lock_all( + t(), + Ecto.Multi.name(), + Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()) + ) :: t() + def lock_all(%__MODULE__{} = multi, name, queryable_or_fun) do + lock_fn = + fn repo, changes -> + queryable = + if is_function(queryable_or_fun, 1) do + queryable_or_fun.(changes) + else + queryable_or_fun + end + + {:ok, + queryable + |> lock("FOR UPDATE") + |> repo.all()} + end + + update_in(multi.multi, &Ecto.Multi.run(&1, name, lock_fn)) + end + + @doc """ + Locks a query result for update, or aborts the transaction if it was not found. + + The locked result is available under `name` to later Multi steps. This is + useful before making a change that depends on the row's current state. The + query may be a function of earlier Multi changes and is evaluated inside the + transaction. + + ## Example + + Multi.new() + |> Multi.lock_one(:user, from(u in User, where: u.id == ^user_id)) + |> Multi.transact() + + Multi.new() + |> Multi.lock_one(:topic, topic_query) + |> Multi.lock_one(:forum, fn %{topic: topic} -> + from(forum in Forum, where: forum.id == ^topic.forum_id) + end) + |> Multi.transact() + + """ + @spec lock_one( + t(), + Ecto.Multi.name(), + Ecto.Queryable.t() | (Ecto.Multi.changes() -> Ecto.Queryable.t()) + ) :: t() + def lock_one(%__MODULE__{} = multi, name, queryable_or_fun) do + lock_fn = + fn repo, changes -> + queryable = + if is_function(queryable_or_fun, 1) do + queryable_or_fun.(changes) + else + queryable_or_fun + end + + queryable + |> lock("FOR UPDATE") + |> repo.one() + |> case do + nil -> {:error, :not_found} + result -> {:ok, result} + end + end + + update_in(multi.multi, &Ecto.Multi.run(&1, name, lock_fn)) + end + + @doc """ + Run the Multi steps inside a transaction. + + Allows setting the transaction isolation level to SERIALIZABLE if + `isolation: :serializable` is provided in `opts`. + + See `c:Ecto.Repo.transact/2` for more information about options. + """ + @spec transact(t(), Keyword.t()) :: {:ok, Ecto.Multi.changes()} | Ecto.Multi.failure() + def transact(%__MODULE__{} = multi, opts \\ []) do + {isolation, opts} = Keyword.pop(opts, :isolation) + + multi = + case isolation do + nil -> + multi + + :serializable -> + new() + |> run(:set_isolation, fn repo, _ -> + repo.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + end) + |> append(multi) + end + + multi.multi + |> Philomena.Repo.transact(opts) + |> case do + {:ok, changes} -> + :ok = + changes + |> Enum.each(fn + {{:on_commit, _ref}, callback} -> + callback.(changes) + + _ -> + :ok + end) + + {:ok, changes} + + {:error, _step, _reason, changes} = error -> + changes + |> Enum.each(fn + {{:on_rollback, _ref}, callback} -> + callback.(changes) + + _ -> + :ok + end) + + error + + error -> + error + end + end + + @doc """ + Run the Multi steps inside a transaction, restarting the transaction while + any step returns `{:error, :conflict}`, or if the database reports a + serialization failure. + + See `transact/2` for more information. + """ + @spec transact_with_automatic_retry(t(), Keyword.t()) :: + {:ok, Ecto.Multi.changes()} | Ecto.Multi.failure() + def transact_with_automatic_retry(%__MODULE__{} = multi, opts \\ []) do + try do + multi + |> transact(opts) + |> case do + {:ok, changes} -> + {:ok, changes} + + {:error, _step, :conflict, _changes} -> + transact_with_automatic_retry(multi, opts) + + error -> + error + end + rescue + error in Postgrex.Error -> + case error.postgres do + %{code: :serialization_failure} -> + transact_with_automatic_retry(multi, opts) + + _postgres -> + reraise error, __STACKTRACE__ + end + end + end + + @doc """ + Registers a callback to occur after the Multi commits. + + The callback receives the transaction changes and runs only after a + successful transaction. There is no ordering guarantee of post-commit + callback execution. Use this for side effects that must occur after + transaction completion, like object storage or indexing. + + ## Example + + Multi.new() + |> Multi.run(:user, fn _repo, _changes -> {:ok, user} end) + |> Multi.on_commit(fn %{user: user} -> Users.reindex_user(user) end) + |> Multi.transact() + + """ + @spec on_commit(t(), (Ecto.Multi.changes() -> any())) :: t() + def on_commit(%__MODULE__{} = multi, callback) when is_function(callback, 1) do + update_in(multi.multi, &Ecto.Multi.put(&1, {:on_commit, make_ref()}, callback)) + end + + @doc """ + Registers a callback to occur when the Multi transaction rolls back. + + The callback receives the changes from the failed transaction. This is useful + for compensating external reservations made by a `Multi.run/3` step. + """ + @spec on_rollback(t(), (Ecto.Multi.changes() -> any())) :: t() + def on_rollback(%__MODULE__{} = multi, callback) when is_function(callback, 1) do + update_in(multi.multi, &Ecto.Multi.put(&1, {:on_rollback, make_ref()}, callback)) + end + + @doc """ + Reserves an external action for the transaction and releases it if the + transaction rolls back. + + `record_action` must return `:ok` or an error tuple. The reservation is + stored as `:action_reservation` in the Multi changes. + """ + @spec reserve_action(t(), (-> :ok | {:error, term()}), (-> :ok)) :: t() + def reserve_action(%__MODULE__{} = multi, record_action, rollback_action) + when is_function(record_action, 0) and is_function(rollback_action, 0) do + multi + |> run(:action_reservation, fn _repo, _changes -> + case record_action.() do + :ok -> {:ok, nil} + error -> error + end + end) + |> on_rollback(fn changes -> + if Map.has_key?(changes, :action_reservation) do + rollback_action.() + end + + :ok + end) + end +end diff --git a/lib/philomena/notifications.ex b/lib/philomena/notifications.ex index 421e970e1..dfe5657cd 100644 --- a/lib/philomena/notifications.ex +++ b/lib/philomena/notifications.ex @@ -1,85 +1,293 @@ defmodule Philomena.Notifications do @moduledoc """ - The Notifications context. + Notification reads and internal event delivery. + + At most one unread row exists per recipient and event subject, and a repeated + broadcast refreshes it without changing its original creation time. + + Event owners call these services from `Philomena.Multi.run/3`, so their + database changes and notifications commit or roll back together. """ import Ecto.Query, warn: false - alias Philomena.Repo + alias Philomena.Attribution.Actor alias Philomena.Channels + alias Philomena.Channels.Channel + alias Philomena.Comments.Comment alias Philomena.Forums alias Philomena.Galleries + alias Philomena.Galleries.Gallery alias Philomena.Images - alias Philomena.Topics - + alias Philomena.Images.Image + alias Philomena.Multi alias Philomena.Notifications.ChannelLiveNotification alias Philomena.Notifications.ForumPostNotification alias Philomena.Notifications.ForumTopicNotification alias Philomena.Notifications.GalleryImageNotification alias Philomena.Notifications.ImageCommentNotification alias Philomena.Notifications.ImageMergeNotification + alias Philomena.Posts.Post + alias Philomena.Repo + alias Philomena.Topics + alias Philomena.Topics.Topic + alias Philomena.Users.User + + @categories [ + :channel_live, + :forum_post, + :forum_topic, + :gallery_image, + :image_comment, + :image_merge + ] + + @category_params Map.new(@categories, &{Atom.to_string(&1), &1}) + + @typedoc "A category of unread notifications." + @type category :: + :channel_live + | :forum_post + | :forum_topic + | :gallery_image + | :image_comment + | :image_merge + + @typedoc "The number of notification rows affected by a broadcast or clear." + @type count_result :: {:ok, non_neg_integer()} + + defp category_query(category) do + case category do + :channel_live -> + from(n in ChannelLiveNotification, preload: :channel) + + :gallery_image -> + from(n in GalleryImageNotification, preload: [gallery: :user]) + + :forum_post -> + from(n in ForumPostNotification, preload: [topic: :forum, post: :user]) + + :forum_topic -> + from(n in ForumTopicNotification, preload: [topic: [:forum, :user]]) + + :image_comment -> + from(n in ImageCommentNotification, + preload: [image: [:sources, tags: :aliases], comment: :user] + ) + + :image_merge -> + from(n in ImageMergeNotification, + preload: [:source, target: [:sources, tags: :aliases]] + ) + end + end + + defp user_category_query(category, %User{} = user) do + category + |> category_query() + |> where(user_id: ^user.id) + end + + defp unread_page(category, %User{} = user, pagination) do + category + |> user_category_query(user) + |> order_by(desc: :updated_at) + |> Repo.paginate(pagination) + end + + defp subscription_query(subscription, author) do + case author do + %User{id: user_id} -> + # Avoid sending notifications to the user which performed the action + from(row in subscription, where: row.user_id != ^user_id) + + _anonymous_author -> + # When not created by a user, send notifications to all subscribers + subscription + end + end + + defp convert_to_notification(subscription, extra) do + now = dynamic([_row], type(^DateTime.utc_now(:second), :utc_datetime)) - alias Philomena.Notifications.Category - alias Philomena.Notifications.Creator + base = %{ + user_id: dynamic([subscription], subscription.user_id), + created_at: now, + updated_at: now, + read: false + } + + extra = + Map.new(extra, fn {field, value} -> + {field, dynamic([_row], type(^value, :integer))} + end) + + from(subscription, select: ^Map.merge(base, extra)) + end + + defp insert_notifications(query, notification, unique_key) do + {count, nil} = + Repo.insert_all( + notification, + query, + on_conflict: {:replace_all_except, [:created_at]}, + conflict_target: [unique_key, :user_id] + ) + + {:ok, count} + end + + defp broadcast_notification(opts) do + opts = Keyword.validate!(opts, [:notification_author, :from, :into, :select, :unique_key]) + + notification_author = Keyword.get(opts, :notification_author) + {subscription_schema, filters} = Keyword.fetch!(opts, :from) + notification_schema = Keyword.fetch!(opts, :into) + select_keywords = Keyword.fetch!(opts, :select) + unique_key = Keyword.fetch!(opts, :unique_key) + + subscription_schema + |> subscription_query(notification_author) + |> where(^filters) + |> convert_to_notification(select_keywords) + |> insert_notifications(notification_schema, unique_key) + end + + defp clear_for_user(_query, nil), do: {:ok, 0} + + defp clear_for_user(query, %User{} = user) do + {count, nil} = + query + |> where(user_id: ^user.id) + |> Repo.delete_all() + + {:ok, count} + end @doc """ - Return the count of all currently unread notifications for the user in all categories. + Parses a notification category route parameter. + + Unknown categories are `{:error, :not_found}`. ## Examples - iex> total_unread_notification_count(user) + iex> parse_category("image_comment") + {:ok, :image_comment} + + iex> parse_category("unknown") + {:error, :not_found} + + """ + @spec parse_category(any()) :: {:ok, category()} | {:error, :not_found} + def parse_category(param) when is_binary(param) do + case Map.fetch(@category_params, param) do + {:ok, category} -> {:ok, category} + :error -> {:error, :not_found} + end + end + + def parse_category(_param), do: {:error, :not_found} + + @doc """ + Counts unread notifications belonging to `actor`'s user. + + Anonymous actors receive zero. + + ## Examples + + iex> total_unread_count(actor) 15 + iex> total_unread_count(anonymous_actor) + 0 + """ - def total_unread_notification_count(user) do - Category.total_unread_notification_count(user) + @spec total_unread_count(Actor.t()) :: non_neg_integer() + def total_unread_count(%Actor{user: nil}), do: 0 + + def total_unread_count(%Actor{user: %User{} = user}) do + queries = + Enum.map(@categories, fn category -> + category + |> user_category_query(user) + |> exclude(:preload) + |> select([_notification], %{one: 1}) + end) + + queries + |> Enum.reduce(&union_all(&2, ^&1)) + |> Repo.aggregate(:count) end @doc """ - Gather up and return the top N notifications for the user, for each category of - unread notification currently existing. + Loads paginated notifications for every unread category belonging to `actor`. + + Anonymous actors are considered unauthorized. ## Examples - iex> unread_notifications_for_user(user, page_size: 10) - [ - channel_live: [], - forum_post: [%ForumPostNotification{...}, ...], - forum_topic: [%ForumTopicNotification{...}, ...], - gallery_image: [], - image_comment: [%ImageCommentNotification{...}, ...], - image_merge: [] - ] + iex> list_unread_notifications(actor, page_size: 10) + {:ok, [channel_live: %Scrivener.Page{}, ...]} + + iex> list_unread_notifications(anonymous_actor, page_size: 10) + {:error, :unauthorized} """ - def unread_notifications_for_user(user, pagination) do - Category.unread_notifications_for_user(user, pagination) + @spec list_unread_notifications(Actor.t(), Repo.pagination_params()) :: + {:ok, [{category(), Scrivener.Page.t()}]} | {:error, :unauthorized} + def list_unread_notifications(%Actor{user: nil}, _pagination), do: {:error, :unauthorized} + + def list_unread_notifications(%Actor{user: %User{} = user}, pagination) do + unread = + Enum.map(@categories, fn category -> + {category, unread_page(category, user, pagination)} + end) + + {:ok, unread} end @doc """ - Returns paginated unread notifications for the user, given the category. + Loads one parsed unread category belonging to `actor`. + + Returns the parsed category with its page. Unknown categories are + `{:error, :not_found}`. ## Examples - iex> unread_notifications_for_user_and_category(user, :image_comment) - [%ImageCommentNotification{...}] + iex> show_unread_notification_category(actor, "forum_post", pagination) + {:ok, {:forum_post, %Scrivener.Page{}}} + + iex> show_unread_notification_category(actor, "unknown", pagination) + {:error, :not_found} """ - def unread_notifications_for_user_and_category(user, category, pagination) do - Category.unread_notifications_for_user_and_category(user, category, pagination) + @spec show_unread_notification_category(Actor.t(), any(), Repo.pagination_params()) :: + {:ok, {category(), Scrivener.Page.t()}} + | {:error, :not_found | :unauthorized} + def show_unread_notification_category(%Actor{user: nil}, _param, _pagination), + do: {:error, :unauthorized} + + def show_unread_notification_category(%Actor{user: %User{} = user}, param, pagination) do + with {:ok, category} <- parse_category(param) do + {:ok, {category, unread_page(category, user, pagination)}} + end end @doc """ - Creates a channel live notification, returning the number of affected users. + Broadcasts a channel go-live event to the channel's subscribers. + + This write participates in the caller's ambient Repo transaction. + The owning context is responsible for deciding that the event is authorized. ## Examples - iex> create_channel_live_notification(channel) + iex> broadcast_channel_live(channel) {:ok, 2} """ - def create_channel_live_notification(channel) do - Creator.broadcast_notification( + @spec broadcast_channel_live(Channel.t()) :: count_result() + def broadcast_channel_live(%Channel{} = channel) do + broadcast_notification( from: {Channels.Subscription, channel_id: channel.id}, into: ChannelLiveNotification, select: [channel_id: channel.id], @@ -88,17 +296,21 @@ defmodule Philomena.Notifications do end @doc """ - Creates a forum post notification, returning the number of affected users. + Broadcasts a new post event to topic subscribers other than the author. + + This write participates in the caller's ambient Repo transaction. + The owning context is responsible for authorizing the post. ## Examples - iex> create_forum_post_notification(user, topic, post) + iex> broadcast_forum_post(author, topic, post) {:ok, 2} """ - def create_forum_post_notification(user, topic, post) do - Creator.broadcast_notification( - notification_author: user, + @spec broadcast_forum_post(User.t() | nil, Topic.t(), Post.t()) :: count_result() + def broadcast_forum_post(author, %Topic{} = topic, %Post{} = post) do + broadcast_notification( + notification_author: author, from: {Topics.Subscription, topic_id: topic.id}, into: ForumPostNotification, select: [topic_id: topic.id, post_id: post.id], @@ -107,17 +319,21 @@ defmodule Philomena.Notifications do end @doc """ - Creates a forum topic notification, returning the number of affected users. + Broadcasts a new topic event to forum subscribers other than the author. + + This write participates in the caller's ambient Repo transaction. + The owning context is responsible for authorizing the topic. ## Examples - iex> create_forum_topic_notification(user, topic) + iex> broadcast_forum_topic(author, topic) {:ok, 2} """ - def create_forum_topic_notification(user, topic) do - Creator.broadcast_notification( - notification_author: user, + @spec broadcast_forum_topic(User.t() | nil, Topic.t()) :: count_result() + def broadcast_forum_topic(author, %Topic{} = topic) do + broadcast_notification( + notification_author: author, from: {Forums.Subscription, forum_id: topic.forum_id}, into: ForumTopicNotification, select: [topic_id: topic.id], @@ -126,16 +342,20 @@ defmodule Philomena.Notifications do end @doc """ - Creates a gallery image notification, returning the number of affected users. + Broadcasts an images added event to gallery subscribers. + + This write participates in the caller's ambient Repo transaction. + The owning context is responsible for authorizing the change. ## Examples - iex> create_gallery_image_notification(gallery) + iex> broadcast_gallery_image(gallery) {:ok, 2} """ - def create_gallery_image_notification(gallery) do - Creator.broadcast_notification( + @spec broadcast_gallery_image(Gallery.t()) :: count_result() + def broadcast_gallery_image(%Gallery{} = gallery) do + broadcast_notification( from: {Galleries.Subscription, gallery_id: gallery.id}, into: GalleryImageNotification, select: [gallery_id: gallery.id], @@ -144,17 +364,21 @@ defmodule Philomena.Notifications do end @doc """ - Creates an image comment notification, returning the number of affected users. + Broadcasts an image comment event to image subscribers other than the author. + + This write participates in the caller's ambient Repo transaction. + The owning context is responsible for authorizing the comment. ## Examples - iex> create_image_comment_notification(user, image, comment) + iex> broadcast_image_comment(author, image, comment) {:ok, 2} """ - def create_image_comment_notification(user, image, comment) do - Creator.broadcast_notification( - notification_author: user, + @spec broadcast_image_comment(User.t() | nil, Image.t(), Comment.t()) :: count_result() + def broadcast_image_comment(author, %Image{} = image, %Comment{} = comment) do + broadcast_notification( + notification_author: author, from: {Images.Subscription, image_id: image.id}, into: ImageCommentNotification, select: [image_id: image.id, comment_id: comment.id], @@ -163,16 +387,20 @@ defmodule Philomena.Notifications do end @doc """ - Creates an image merge notification, returning the number of affected users. + Broadcasts an image merge event to subscribers of the target image. + + This write participates in the caller's ambient Repo transaction. + The owning context is responsible for authorizing the merge. ## Examples - iex> create_image_merge_notification(target, source) + iex> broadcast_image_merge(target, source) {:ok, 2} """ - def create_image_merge_notification(target, source) do - Creator.broadcast_notification( + @spec broadcast_image_merge(Image.t(), Image.t()) :: count_result() + def broadcast_image_merge(%Image{} = target, %Image{} = source) do + broadcast_notification( from: {Images.Subscription, image_id: target.id}, into: ImageMergeNotification, select: [target_id: target.id, source_id: source.id], @@ -181,116 +409,162 @@ defmodule Philomena.Notifications do end @doc """ - Removes the channel live notification for a given channel and user, returning - the number of affected users. + Clears `user`'s live notification for `channel`. + + The clear participates in the caller's ambient Repo transaction. + Passing a `nil` user removes zero rows. ## Examples - iex> clear_channel_live_notification(channel, user) - {:ok, 2} + iex> clear_channel_live(channel, user) + {:ok, 1} + + iex> clear_channel_live(channel, nil) + {:ok, 0} """ - def clear_channel_live_notification(channel, user) do + @spec clear_channel_live(Channel.t(), User.t() | nil) :: count_result() + def clear_channel_live(%Channel{} = channel, user) do ChannelLiveNotification |> where(channel_id: ^channel.id) - |> delete_all_for_user(user) + |> clear_for_user(user) end @doc """ - Removes the forum post notification for a given topic and user, returning - the number of affected notifications. + Clears `user`'s new-post notification for `topic`. + + The clear participates in the caller's ambient Repo transaction. + Passing a `nil` user removes zero rows. ## Examples - iex> clear_forum_post_notification(topic, user) - {:ok, 2} + iex> clear_forum_post(topic, user) + {:ok, 1} """ - def clear_forum_post_notification(topic, user) do + @spec clear_forum_post(Topic.t(), User.t() | nil) :: count_result() + def clear_forum_post(%Topic{} = topic, user) do ForumPostNotification |> where(topic_id: ^topic.id) - |> delete_all_for_user(user) + |> clear_for_user(user) end @doc """ - Removes the forum topic notification for a given topic and user, returning - the number of affected notifications. + Clears `user`'s new-topic notification for `topic`. + + The clear participates in the caller's ambient Repo transaction. + Passing a `nil` user removes zero rows. ## Examples - iex> clear_forum_topic_notification(topic, user) - {:ok, 2} + iex> clear_forum_topic(topic, user) + {:ok, 1} """ - def clear_forum_topic_notification(topic, user) do + @spec clear_forum_topic(Topic.t(), User.t() | nil) :: count_result() + def clear_forum_topic(%Topic{} = topic, user) do ForumTopicNotification |> where(topic_id: ^topic.id) - |> delete_all_for_user(user) + |> clear_for_user(user) end @doc """ - Removes the gallery image notification for a given gallery and user, returning - the number of affected notifications. + Clears `user`'s image notification for `gallery`. + + The clear participates in the caller's ambient Repo transaction. + Passing a `nil` user removes zero rows. ## Examples - iex> clear_gallery_image_notification(topic, user) - {:ok, 2} + iex> clear_gallery_image(gallery, user) + {:ok, 1} """ - def clear_gallery_image_notification(gallery, user) do + @spec clear_gallery_image(Gallery.t(), User.t() | nil) :: count_result() + def clear_gallery_image(%Gallery{} = gallery, user) do GalleryImageNotification |> where(gallery_id: ^gallery.id) - |> delete_all_for_user(user) + |> clear_for_user(user) end @doc """ - Removes the image comment notification for a given image and user, returning - the number of affected notifications. + Clears `user`'s comment notification for `image`. + + The clear participates in the caller's ambient Repo transaction. + Passing a `nil` user removes zero rows. ## Examples - iex> clear_gallery_image_notification(topic, user) - {:ok, 2} + iex> clear_image_comment(image, user) + {:ok, 1} """ - def clear_image_comment_notification(image, user) do + @spec clear_image_comment(Image.t(), User.t() | nil) :: count_result() + def clear_image_comment(%Image{} = image, user) do ImageCommentNotification |> where(image_id: ^image.id) - |> delete_all_for_user(user) + |> clear_for_user(user) end @doc """ - Removes the image merge notification for a given image and user, returning - the number of affected notifications. + Clears `user`'s merge notification for `image`. + + The clear participates in the caller's ambient Repo transaction. + Passing a `nil` user removes zero rows. ## Examples - iex> clear_image_merge_notification(topic, user) - {:ok, 2} + iex> clear_image_merge(image, user) + {:ok, 1} """ - def clear_image_merge_notification(image, user) do + @spec clear_image_merge(Image.t(), User.t() | nil) :: count_result() + def clear_image_merge(%Image{} = image, user) do ImageMergeNotification |> where(target_id: ^image.id) - |> delete_all_for_user(user) + |> clear_for_user(user) end - # - # Clear all unread notifications using the given query. - # - # Returns `{:ok, count}`, where `count` is the number of affected rows. - # - defp delete_all_for_user(query, user) do - if user do - {count, nil} = - query - |> where(user_id: ^user.id) - |> Repo.delete_all() - - {:ok, count} - else - {:ok, 0} - end + @doc """ + Migrates image comment and image merge notifications to the target image. + """ + @spec put_migrate_image_notifications(Multi.t(), Image.t(), Image.t()) :: Multi.t() + def put_migrate_image_notifications(%Multi{} = multi, %Image{} = source, %Image{} = target) do + Multi.run(multi, :migrate_image_notifications, fn repo, _changes -> + comment_notifications = + from notification in ImageCommentNotification, + where: notification.image_id == ^source.id, + select: %{ + user_id: notification.user_id, + image_id: ^target.id, + comment_id: notification.comment_id, + read: notification.read, + created_at: notification.created_at, + updated_at: notification.updated_at + } + + merge_notifications = + from notification in ImageMergeNotification, + where: notification.target_id == ^source.id, + select: %{ + user_id: notification.user_id, + target_id: ^target.id, + source_id: notification.source_id, + read: notification.read, + created_at: notification.created_at, + updated_at: notification.updated_at + } + + {comment_count, nil} = + repo.insert_all(ImageCommentNotification, comment_notifications, on_conflict: :nothing) + + {merge_count, nil} = + repo.insert_all(ImageMergeNotification, merge_notifications, on_conflict: :nothing) + + repo.delete_all(exclude(comment_notifications, :select)) + repo.delete_all(exclude(merge_notifications, :select)) + + {:ok, {comment_count, merge_count}} + end) end end diff --git a/lib/philomena/notifications/category.ex b/lib/philomena/notifications/category.ex deleted file mode 100644 index bc185e42a..000000000 --- a/lib/philomena/notifications/category.ex +++ /dev/null @@ -1,166 +0,0 @@ -defmodule Philomena.Notifications.Category do - @moduledoc """ - Notification category querying. - """ - - import Ecto.Query, warn: false - alias Philomena.Repo - - alias Philomena.Notifications.ChannelLiveNotification - alias Philomena.Notifications.ForumPostNotification - alias Philomena.Notifications.ForumTopicNotification - alias Philomena.Notifications.GalleryImageNotification - alias Philomena.Notifications.ImageCommentNotification - alias Philomena.Notifications.ImageMergeNotification - - @type t :: - :channel_live - | :forum_post - | :forum_topic - | :gallery_image - | :image_comment - | :image_merge - - @doc """ - Return a list of all supported categories. - """ - def categories do - [ - :channel_live, - :forum_post, - :forum_topic, - :gallery_image, - :image_comment, - :image_merge - ] - end - - @doc """ - Return the count of all currently unread notifications for the user in all categories. - - ## Examples - - iex> total_unread_notification_count(user) - 15 - - """ - def total_unread_notification_count(user) do - categories() - |> Enum.map(fn category -> - category - |> query_for_category_and_user(user) - |> exclude(:preload) - |> select([_], %{one: 1}) - end) - |> union_all_queries() - |> Repo.aggregate(:count) - end - - defp union_all_queries([query | rest]) do - Enum.reduce(rest, query, fn q, acc -> union_all(acc, ^q) end) - end - - @doc """ - Gather up and return the top N notifications for the user, for each category of - unread notification currently existing. - - ## Examples - - iex> unread_notifications_for_user(user, page_size: 10) - [ - channel_live: [], - forum_post: [%ForumPostNotification{...}, ...], - forum_topic: [%ForumTopicNotification{...}, ...], - gallery_image: [], - image_comment: [%ImageCommentNotification{...}, ...], - image_merge: [] - ] - - """ - def unread_notifications_for_user(user, pagination) do - Enum.map(categories(), fn category -> - results = - category - |> query_for_category_and_user(user) - |> order_by(desc: :updated_at) - |> Repo.paginate(pagination) - - {category, results} - end) - end - - @doc """ - Returns paginated unread notifications for the user, given the category. - - ## Examples - - iex> unread_notifications_for_user_and_category(user, :image_comment) - [%ImageCommentNotification{...}] - - """ - def unread_notifications_for_user_and_category(user, category, pagination) do - category - |> query_for_category_and_user(user) - |> order_by(desc: :updated_at) - |> Repo.paginate(pagination) - end - - @doc """ - Determine the category of a notification. - - ## Examples - - iex> notification_category(%ImageCommentNotification{}) - :image_comment - - """ - def notification_category(n) do - case n.__struct__ do - ChannelLiveNotification -> :channel_live - GalleryImageNotification -> :gallery_image - ImageCommentNotification -> :image_comment - ImageMergeNotification -> :image_merge - ForumPostNotification -> :forum_post - ForumTopicNotification -> :forum_topic - end - end - - @doc """ - Returns an `m:Ecto.Query` that finds unread notifications for the given category, - for the given user, with preloads applied. - - ## Examples - - iex> query_for_category_and_user(:channel_live, user) - #Ecto.Query - - """ - def query_for_category_and_user(category, user) do - query = - case category do - :channel_live -> - from(n in ChannelLiveNotification, preload: :channel) - - :gallery_image -> - from(n in GalleryImageNotification, preload: [gallery: :user]) - - :image_comment -> - from(n in ImageCommentNotification, - preload: [image: [:sources, tags: :aliases], comment: :user] - ) - - :image_merge -> - from(n in ImageMergeNotification, - preload: [:source, target: [:sources, tags: :aliases]] - ) - - :forum_topic -> - from(n in ForumTopicNotification, preload: [topic: [:forum, :user]]) - - :forum_post -> - from(n in ForumPostNotification, preload: [topic: :forum, post: :user]) - end - - where(query, user_id: ^user.id) - end -end diff --git a/lib/philomena/notifications/creator.ex b/lib/philomena/notifications/creator.ex deleted file mode 100644 index 808a6d3d6..000000000 --- a/lib/philomena/notifications/creator.ex +++ /dev/null @@ -1,92 +0,0 @@ -defmodule Philomena.Notifications.Creator do - @moduledoc """ - Internal notifications creation logic. - """ - - import Ecto.Query, warn: false - alias Philomena.Repo - - @doc """ - Propagate notifications for a notification table type. - - Returns `{:ok, count}`, where `count` is the number of affected rows. - - ## Examples - - iex> broadcast_notification( - ...> from: {GallerySubscription, gallery_id: gallery.id}, - ...> into: GalleryImageNotification, - ...> select: [gallery_id: gallery.id], - ...> unique_key: :gallery_id - ...> ) - {:ok, 2} - - iex> broadcast_notification( - ...> notification_author: user, - ...> from: {ImageSubscription, image_id: image.id}, - ...> into: ImageCommentNotification, - ...> select: [image_id: image.id, comment_id: comment.id], - ...> unique_key: :image_id - ...> ) - {:ok, 2} - - """ - def broadcast_notification(opts) do - opts = Keyword.validate!(opts, [:notification_author, :from, :into, :select, :unique_key]) - - notification_author = Keyword.get(opts, :notification_author, nil) - {subscription_schema, filters} = Keyword.fetch!(opts, :from) - notification_schema = Keyword.fetch!(opts, :into) - select_keywords = Keyword.fetch!(opts, :select) - unique_key = Keyword.fetch!(opts, :unique_key) - - subscription_schema - |> subscription_query(notification_author) - |> where(^filters) - |> convert_to_notification(select_keywords) - |> insert_notifications(notification_schema, unique_key) - end - - defp convert_to_notification(subscription, extra) do - now = dynamic([_], type(^DateTime.utc_now(:second), :utc_datetime)) - - base = %{ - user_id: dynamic([s], s.user_id), - created_at: now, - updated_at: now, - read: false - } - - extra = - Map.new(extra, fn {field, value} -> - {field, dynamic([_], type(^value, :integer))} - end) - - from(subscription, select: ^Map.merge(base, extra)) - end - - defp subscription_query(subscription, notification_author) do - case notification_author do - %{id: user_id} -> - # Avoid sending notifications to the user which performed the action. - from s in subscription, - where: s.user_id != ^user_id - - _ -> - # When not created by a user, send notifications to all subscribers. - subscription - end - end - - defp insert_notifications(query, notification, unique_key) do - {count, nil} = - Repo.insert_all( - notification, - query, - on_conflict: {:replace_all_except, [:created_at]}, - conflict_target: [unique_key, :user_id] - ) - - {:ok, count} - end -end diff --git a/lib/philomena/poll_options.ex b/lib/philomena/poll_options.ex index f9b28d88b..3ff6d3040 100644 --- a/lib/philomena/poll_options.ex +++ b/lib/philomena/poll_options.ex @@ -1,104 +1,56 @@ defmodule Philomena.PollOptions do @moduledoc """ - The PollOptions context. + Poll option loading for the PollVotes aggregate. + + Poll options are not independent resources. Persistence is owned by the + poll changeset and vote transactions. """ import Ecto.Query, warn: false - alias Philomena.Repo + alias Philomena.Multi alias Philomena.PollOptions.PollOption + alias Philomena.Polls.Poll + alias Philomena.Repo @doc """ - Returns the list of poll_options. - - ## Examples - - iex> list_poll_options() - [%PollOption{}, ...] - - """ - def list_poll_options do - Repo.all(PollOption) - end - - @doc """ - Gets a single poll_option. - - Raises `Ecto.NoResultsError` if the Poll option does not exist. - - ## Examples - - iex> get_poll_option!(123) - %PollOption{} - - iex> get_poll_option!(456) - ** (Ecto.NoResultsError) - - """ - def get_poll_option!(id), do: Repo.get!(PollOption, id) - - @doc """ - Creates a poll_option. - - ## Examples - - iex> create_poll_option(%{field: value}) - {:ok, %PollOption{}} - - iex> create_poll_option(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_poll_option(attrs \\ %{}) do - %PollOption{} - |> PollOption.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a poll_option. - - ## Examples - - iex> update_poll_option(poll_option, %{field: new_value}) - {:ok, %PollOption{}} - - iex> update_poll_option(poll_option, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_poll_option(%PollOption{} = poll_option, attrs) do - poll_option - |> PollOption.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a PollOption. + Loads every option belonging to `poll`. ## Examples - iex> delete_poll_option(poll_option) - {:ok, %PollOption{}} - - iex> delete_poll_option(poll_option) - {:error, %Ecto.Changeset{}} + iex> load_options(poll) + [%PollOption{}, %PollOption{}] """ - def delete_poll_option(%PollOption{} = poll_option) do - Repo.delete(poll_option) + @spec load_options(Poll.t()) :: [PollOption.t()] + def load_options(%Poll{} = poll) do + poll + |> Repo.preload(:options) + |> Map.fetch!(:options) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking poll_option changes. - - ## Examples - - iex> change_poll_option(poll_option) - %Ecto.Changeset{source: %PollOption{}} + Adds a vote count adjustment for options belonging to `poll_id` to `multi`. + The caller is responsible for locking the parent poll. """ - def change_poll_option(%PollOption{} = poll_option) do - PollOption.changeset(poll_option, %{}) + @spec put_vote_count_delta( + Multi.t(), + Multi.name(), + integer(), + (Multi.changes() -> [integer()]), + integer() + ) :: Multi.t() + def put_vote_count_delta(%Multi{} = multi, step, poll_id, option_ids_callback, amount) + when is_integer(poll_id) and is_integer(amount) do + Multi.run(multi, step, fn repo, changes -> + option_ids = option_ids_callback.(changes) + + query = + PollOption + |> where([option], option.id in ^option_ids and option.poll_id == ^poll_id) + + {:ok, repo.update_all(query, inc: [vote_count: amount])} + end) end end diff --git a/lib/philomena/poll_options/poll_option.ex b/lib/philomena/poll_options/poll_option.ex index 7034ed4db..97c1525b7 100644 --- a/lib/philomena/poll_options/poll_option.ex +++ b/lib/philomena/poll_options/poll_option.ex @@ -5,6 +5,8 @@ defmodule Philomena.PollOptions.PollOption do alias Philomena.PollVotes.PollVote alias Philomena.Polls.Poll + @type t :: %__MODULE__{} + schema "poll_options" do belongs_to :poll, Poll has_many :poll_votes, PollVote diff --git a/lib/philomena/poll_votes.ex b/lib/philomena/poll_votes.ex index 6989d4346..01c602ef1 100644 --- a/lib/philomena/poll_votes.ex +++ b/lib/philomena/poll_votes.ex @@ -1,226 +1,179 @@ defmodule Philomena.PollVotes do @moduledoc """ - The PollVotes context. + Poll voting, staff result inspection, and vote removal. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Repo + import Philomena.Authorization, only: [verify_write_access: 1] + alias Philomena.Attribution.Actor + alias Philomena.Loader + alias Philomena.PollOptions + alias Philomena.PollOptions.PollOption alias Philomena.Polls alias Philomena.Polls.Poll - alias Philomena.PollVotes.PollVote - alias Philomena.PollOptions.PollOption - - @doc """ - Gets a single poll_vote. - - Raises `Ecto.NoResultsError` if the Poll vote does not exist. - - ## Examples - - iex> get_poll_vote!(123) - %PollVote{} + alias Philomena.PollVotes.{Ballot, PollVote} + alias Philomena.Forums + alias Philomena.Topics + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.Users.User - iex> get_poll_vote!(456) - ** (Ecto.NoResultsError) + defp user_voted?(%Poll{id: poll_id}, %User{id: user_id}) do + PollVote + |> join(:inner, [vote], option in assoc(vote, :poll_option)) + |> where([vote, option], option.poll_id == ^poll_id and vote.user_id == ^user_id) + |> Repo.exists?() + end - """ - def get_poll_vote!(id), do: Repo.get!(PollVote, id) + defp load_poll_vote(poll, vote_id) do + PollVote + |> join(:inner, [vote], option in assoc(vote, :poll_option)) + |> where([_vote, option], option.poll_id == ^poll.id) + |> Loader.fetch(vote_id) + end @doc """ - Gets a single poll_vote, or nil when no vote has the given id. + Lists voter identities for the parent-scoped poll. - ## Examples - - iex> get_poll_vote(123) - %PollVote{} - - iex> get_poll_vote(456) - nil - - """ - def get_poll_vote(id), do: Repo.get(PollVote, id) - - @doc """ - Creates a poll_vote. + This is a separate staff-only ability from viewing aggregate poll results. ## Examples - iex> create_poll_vote(%{field: value}) - {:ok, %PollVote{}} - - iex> create_poll_vote(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> list_votes(moderator_actor, "dis", "favorite-pony") + {:ok, [%PollOption{}]} """ - def create_poll_votes(user, poll, attrs) do - now = DateTime.utc_now(:second) - poll_votes = filter_options(user, poll, now, attrs) - - Multi.new() - |> Multi.run(:lock, fn repo, _ -> - poll = - Poll - |> where(id: ^poll.id) - |> lock("FOR UPDATE") - |> repo.one() - - {:ok, poll} - end) - |> Multi.run(:ended, fn _repo, _changes -> - # Bail if poll is no longer active - if Polls.active?(poll) do - {:ok, []} - else - {:error, []} - end - end) - |> Multi.run(:existing_votes, fn _repo, _changes -> - # Don't proceed if any votes exist - if voted?(poll, user) do - {:error, []} - else - {:ok, []} - end - end) - |> Multi.run(:new_votes, fn repo, _changes -> - {_count, votes} = repo.insert_all(PollVote, poll_votes, returning: true) - - {:ok, votes} - end) - |> Multi.run(:update_option_counts, fn repo, %{new_votes: new_votes} -> - option_ids = Enum.map(new_votes, & &1.poll_option_id) - - {count, nil} = - PollOption - |> where([po], po.id in ^option_ids) - |> repo.update_all(inc: [vote_count: 1]) - - {:ok, count} - end) - |> Multi.run(:update_poll_votes_count, fn repo, %{new_votes: new_votes} -> - length = length(new_votes) - - {count, nil} = - Poll - |> where(id: ^poll.id) - |> repo.update_all(inc: [total_votes: length]) - - {:ok, count} - end) - |> Repo.transaction() - end - - defp filter_options(user, poll, now, %{"option_ids" => options}) when is_list(options) do - valid_option_ids = poll_option_ids(poll) - - votes = - options - |> Enum.map(&parse_option_id/1) - |> Enum.filter(&MapSet.member?(valid_option_ids, &1)) - |> Enum.uniq() - |> Enum.map(&%{poll_option_id: &1, user_id: user.id, created_at: now}) - - case poll.vote_method do - "single" -> Enum.take(votes, 1) - _other -> votes + @spec list_votes(Actor.t(), String.t(), String.t()) :: + {:ok, [PollOption.t()]} | {:error, :not_found | :unauthorized} + def list_votes(%Actor{} = actor, forum_slug, topic_slug) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :list_poll_votes), + {:ok, poll} <- Polls.load_topic_poll(topic) do + {:ok, + PollOption + |> where(poll_id: ^poll.id) + |> where([option], option.vote_count > 0) + |> preload(poll_votes: :user) + |> Repo.all()} end end - defp filter_options(_user, _poll, _now, _attrs), do: [] - - defp poll_option_ids(poll) do - PollOption - |> where(poll_id: ^poll.id) - |> select([po], po.id) - |> Repo.all() - |> MapSet.new() - end - - defp parse_option_id(option_id) when is_binary(option_id) do - case Integer.parse(option_id) do - {id, ""} -> id - _ -> nil - end - end - - defp parse_option_id(option_id) when is_integer(option_id), do: option_id - defp parse_option_id(_option_id), do: nil - - def voted?(nil, _user), do: false - def voted?(_poll, nil), do: false - - def voted?(%{id: poll_id}, %{id: user_id}) do - PollVote - |> join(:inner, [pv], _ in assoc(pv, :poll_option)) - |> where([pv, po], po.poll_id == ^poll_id and pv.user_id == ^user_id) - |> Repo.exists?() - end - @doc """ - Updates a poll_vote. + Records a complete, validated vote selection for the parent-scoped poll. + + Every option must exist under that poll, IDs must be unique, single-choice + polls accept exactly one option, and multiple-choice polls accept at least + one. The poll is locked before its active and prior-vote invariants are + checked. Any failure rejects the entire selection with a changeset. ## Examples - iex> update_poll_vote(poll_vote, %{field: new_value}) - {:ok, %PollVote{}} + iex> create_votes(actor, "dis", "favorite-pony", %{"option_ids" => ["1"]}) + {:ok, %Ballot{}} - iex> update_poll_vote(poll_vote, %{field: bad_value}) + iex> create_votes(actor, "dis", "favorite-pony", %{"option_ids" => ["bad"]}) {:error, %Ecto.Changeset{}} """ - def update_poll_vote(%PollVote{} = poll_vote, attrs) do - poll_vote - |> PollVote.changeset(attrs) - |> Repo.update() + @spec create_votes(Actor.t(), String.t(), String.t(), map() | nil) :: + {:ok, Ballot.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def create_votes(%Actor{user: user} = actor, forum_slug, topic_slug, params) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :vote), + {:ok, poll} <- Polls.load_topic_poll(topic) do + options = PollOptions.load_options(poll) + poll_query = where(Poll, id: ^poll.id) + + Multi.new() + |> Multi.lock_one(:poll, preload(poll_query, topic: :forum)) + |> Multi.run(:ballot, fn _repo, %{poll: poll} -> + %Ballot{poll: poll} + |> Ballot.changeset(params, poll, Enum.map(options, & &1.id)) + |> Ballot.validate_active(Polls.active?(poll)) + |> Ballot.validate_not_voted(user_voted?(poll, user)) + |> Ecto.Changeset.apply_action(:create) + end) + |> Multi.insert_all(:poll_votes, PollVote, fn %{ballot: ballot} -> + now = DateTime.utc_now(:second) + + Enum.map(ballot.option_ids, &%{poll_option_id: &1, user_id: user.id, created_at: now}) + end) + |> PollOptions.put_vote_count_delta( + :update_options, + poll.id, + fn %{ballot: ballot} -> ballot.option_ids end, + 1 + ) + |> Polls.put_total_votes_delta(:update_poll, poll.id, fn %{poll_votes: {count, _}} -> + count + end) + |> Multi.transact() + |> case do + {:ok, %{ballot: %Ballot{} = ballot}} -> + {:ok, ballot} + + {:error, :ballot, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Deletes a PollVote. + Removes one vote belonging to the parent-scoped poll. - ## Examples + Cached option and poll totals are decremented in the same transaction. - iex> delete_poll_vote(poll_vote) - {:ok, %PollVote{}} + ## Examples - iex> delete_poll_vote(poll_vote) - {:error, %Ecto.Changeset{}} + iex> delete_vote(moderator_actor, "dis", "favorite-pony", "1") + {:ok, %Poll{}} """ - def delete_poll_vote(%PollVote{} = poll_vote) do - Multi.new() - |> Multi.delete(:poll_vote, poll_vote) - |> Multi.run(:update_option_count, fn repo, _changes -> - {_count, [poll_id]} = - PollOption - |> where(id: ^poll_vote.poll_option_id) - |> select([po], po.poll_id) - |> repo.update_all(inc: [vote_count: -1]) - - {:ok, poll_id} - end) - |> Multi.run(:update_poll_votes_count, fn repo, %{update_option_count: poll_id} -> - {count, nil} = - Poll - |> where(id: ^poll_id) - |> repo.update_all(inc: [total_votes: -1]) - - {:ok, count} - end) - |> Repo.transaction() + @spec delete_vote(Actor.t(), String.t(), String.t(), Loader.integer_id()) :: + {:ok, Poll.t()} | {:error, :ban | :not_found | :unauthorized} + def delete_vote(%Actor{} = actor, forum_slug, topic_slug, vote_id) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :delete_poll_vote), + {:ok, poll} <- Polls.load_topic_poll(topic), + {:ok, poll_vote} <- load_poll_vote(poll, vote_id) do + poll_query = where(Poll, id: ^poll.id) + + try do + {:ok, _changes} = + Multi.new() + |> Multi.lock_one(:poll, poll_query) + |> Multi.delete(:poll_vote, poll_vote) + |> PollOptions.put_vote_count_delta( + :update_options, + poll.id, + fn _changes -> [poll_vote.poll_option_id] end, + -1 + ) + |> Polls.put_total_votes_delta(:update_poll, poll.id, fn _changes -> -1 end) + |> Multi.transact() + + {:ok, poll} + rescue + Ecto.StaleEntryError -> {:error, :not_found} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking poll_vote changes. + Returns whether `actor`'s signed-in user has voted in a loaded poll. ## Examples - iex> change_poll_vote(poll_vote) - %Ecto.Changeset{source: %PollVote{}} + iex> voted?(actor, poll) + false """ - def change_poll_vote(%PollVote{} = poll_vote) do - PollVote.changeset(poll_vote, %{}) - end + @spec voted?(Actor.t(), Poll.t() | nil) :: boolean() + def voted?(%Actor{user: %User{} = user}, %Poll{} = poll), do: user_voted?(poll, user) + def voted?(%Actor{}, _poll), do: false end diff --git a/lib/philomena/poll_votes/ballot.ex b/lib/philomena/poll_votes/ballot.ex new file mode 100644 index 000000000..ced13e2bd --- /dev/null +++ b/lib/philomena/poll_votes/ballot.ex @@ -0,0 +1,61 @@ +defmodule Philomena.PollVotes.Ballot do + use Ecto.Schema + import Ecto.Changeset + + alias Philomena.Polls.Poll + + @type t :: %__MODULE__{} + + embedded_schema do + belongs_to :poll, Poll + field :option_ids, {:array, :integer} + end + + @doc false + def changeset(ballot, attrs, poll, valid_option_ids) do + ballot + |> cast(attrs, [:option_ids]) + |> validate_required(:option_ids, message: "must select a choice") + |> validate_change(:option_ids, &validate_selection(poll, valid_option_ids, &1, &2)) + end + + @doc false + def validate_active(changeset, active?) do + if active? do + changeset + else + add_error(changeset, :option_ids, "poll is closed") + end + end + + @doc false + def validate_not_voted(changeset, voted?) do + if voted? do + add_error(changeset, :option_ids, "has already voted") + else + changeset + end + end + + defp validate_selection(poll, valid_option_ids, name, option_ids) do + cond do + option_ids == [] -> + [] + + not Enum.all?(option_ids, &(&1 in valid_option_ids)) -> + [{name, "contains an invalid choice"}] + + Enum.uniq(option_ids) != option_ids -> + [{name, "contains duplicate choices"}] + + poll.vote_method == "single" and Enum.count_until(option_ids, 2) != 1 -> + [{name, "must select exactly one choice"}] + + poll.vote_method == "multiple" and Enum.count_until(option_ids, 1) != 1 -> + [{name, "must select at least one choice"}] + + true -> + [] + end + end +end diff --git a/lib/philomena/poll_votes/poll_vote.ex b/lib/philomena/poll_votes/poll_vote.ex index 70531378d..db5795511 100644 --- a/lib/philomena/poll_votes/poll_vote.ex +++ b/lib/philomena/poll_votes/poll_vote.ex @@ -5,6 +5,8 @@ defmodule Philomena.PollVotes.PollVote do alias Philomena.PollOptions.PollOption alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "poll_votes" do belongs_to :poll_option, PollOption belongs_to :user, User diff --git a/lib/philomena/polls.ex b/lib/philomena/polls.ex index 61cfaaa73..81d76bd6b 100644 --- a/lib/philomena/polls.ex +++ b/lib/philomena/polls.ex @@ -1,114 +1,145 @@ defmodule Philomena.Polls do @moduledoc """ - The Polls context. + Poll forms, updates, and shared services for voting. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [verify_write_access: 1] + alias Philomena.Attribution.Actor + alias Philomena.Loader alias Philomena.Polls.Poll + alias Philomena.Multi + alias Philomena.Forums + alias Philomena.Topics + alias Philomena.Topics.Topic @doc """ - Returns the list of polls. + Loads a poll through its topic. + + This service is shared with `Philomena.PollVotes`; callers cannot load + a poll independently of an authorized parent chain. ## Examples - iex> list_polls() - [%Poll{}, ...] + iex> load_topic_poll(topic_with_poll) + {:ok, %Poll{}} + + iex> load_topic_poll(topic_without_poll) + {:error, :not_found} """ - def list_polls do - Repo.all(Poll) + @spec load_topic_poll(Topic.t()) :: + {:ok, Poll.t()} | {:error, :not_found} + def load_topic_poll(%Topic{} = topic) do + Poll + |> where([poll], poll.topic_id == ^topic.id) + |> preload([:options, topic: :forum]) + |> Loader.one() end @doc """ - Gets a single poll. + Loads an authorized poll edit form. - Raises `Ecto.NoResultsError` if the Poll does not exist. + Existing options are included in the form. ## Examples - iex> get_poll!(123) - %Poll{} - - iex> get_poll!(456) - ** (Ecto.NoResultsError) + iex> edit_poll(moderator_actor, "dis", "favorite-pony") + {:ok, %Ecto.Changeset{}} """ - def get_poll!(id), do: Repo.get!(Poll, id) - - @doc """ - Creates a poll. - - ## Examples - - iex> create_poll(%{field: value}) - {:ok, %Poll{}} - - iex> create_poll(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_poll(attrs \\ %{}) do - %Poll{} - |> Poll.changeset(attrs) - |> Repo.insert() + @spec edit_poll(Actor.t(), String.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :not_found | :unauthorized} + def edit_poll(%Actor{} = actor, forum_slug, topic_slug) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :edit_poll), + {:ok, poll} <- load_topic_poll(topic) do + {:ok, Poll.changeset(poll)} + end end @doc """ - Updates a poll. + Updates the poll beneath the authorized route topic. + + The poll is row-locked while it and its options update transactionally. Invalid + close times, vote methods, titles, or option sets return a form carrying the + rejected changeset. Once voting has started, the vote method and options are + immutable so existing votes retain their meaning; the title and end time may + still be changed. ## Examples - iex> update_poll(poll, %{field: new_value}) + iex> update_poll(moderator_actor, "dis", "favorite-pony", attrs) {:ok, %Poll{}} - iex> update_poll(poll, %{field: bad_value}) + iex> update_poll(moderator_actor, "dis", "favorite-pony", invalid_attrs) {:error, %Ecto.Changeset{}} """ - def update_poll(%Poll{} = poll, attrs) do - poll - |> Poll.changeset(attrs) - |> Repo.update() + @spec update_poll(Actor.t(), String.t(), String.t(), map()) :: + {:ok, Poll.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized} + def update_poll(%Actor{} = actor, forum_slug, topic_slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :update_poll), + {:ok, poll} <- load_topic_poll(topic) do + poll_query = + Poll + |> where(id: ^poll.id) + |> preload([:options, topic: :forum]) + + Multi.new() + |> Multi.lock_one(:locked_poll, poll_query) + |> Multi.update(:poll, fn %{locked_poll: poll} -> Poll.changeset(poll, attrs) end) + |> Multi.transact() + |> case do + {:ok, %{poll: %Poll{} = poll}} -> + {:ok, poll} + + {:error, :poll, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Deletes a Poll. - - ## Examples - - iex> delete_poll(poll) - {:ok, %Poll{}} - - iex> delete_poll(poll) - {:error, %Ecto.Changeset{}} + Adds a total vote adjustment for `poll_id` to `multi`. + PollVotes uses this transaction step after inserting or deleting votes. The + poll row is expected to have been locked by the caller before this step is + composed. """ - def delete_poll(%Poll{} = poll) do - Repo.delete(poll) + @spec put_total_votes_delta(Multi.t(), Multi.name(), integer(), (Multi.changes() -> integer())) :: + Multi.t() + def put_total_votes_delta(%Multi{} = multi, step, poll_id, amount_callback) + when is_integer(poll_id) do + Multi.run(multi, step, fn repo, changes -> + amount = amount_callback.(changes) + + {:ok, repo.update_all(where(Poll, id: ^poll_id), inc: [total_votes: amount])} + end) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking poll changes. + Returns whether a loaded poll is accepting votes at `now`. + + The close instant itself is inactive. ## Examples - iex> change_poll(poll) - %Ecto.Changeset{source: %Poll{}} + iex> active?(poll, DateTime.utc_now()) + true """ - def change_poll(%Poll{} = poll) do - Poll.changeset(poll, %{}) - end + @spec active?(Poll.t() | nil, DateTime.t()) :: boolean() + def active?(poll, now \\ DateTime.utc_now()) - def active?(%{id: poll_id}) do - now = DateTime.utc_now() - - Poll - |> where([p], p.id == ^poll_id and p.active_until > ^now) - |> Repo.exists?() - end + def active?(%Poll{active_until: %DateTime{} = active_until}, %DateTime{} = now), + do: DateTime.compare(active_until, now) == :gt - def active?(_poll), do: false + def active?(_poll, _now), do: false end diff --git a/lib/philomena/polls/poll.ex b/lib/philomena/polls/poll.ex index eb998265b..d8dd57bdd 100644 --- a/lib/philomena/polls/poll.ex +++ b/lib/philomena/polls/poll.ex @@ -5,9 +5,11 @@ defmodule Philomena.Polls.Poll do alias Philomena.Topics.Topic alias Philomena.PollOptions.PollOption + @type t :: %__MODULE__{} + schema "polls" do belongs_to :topic, Topic - has_many :options, PollOption + has_many :options, PollOption, on_replace: :delete field :title, :string field :vote_method, :string @@ -18,7 +20,7 @@ defmodule Philomena.Polls.Poll do end @doc false - def changeset(poll, attrs) do + def changeset(poll, attrs \\ %{}) do poll |> cast(attrs, [:title, :active_until, :vote_method]) |> validate_required([:title, :active_until, :vote_method]) @@ -26,9 +28,42 @@ defmodule Philomena.Polls.Poll do |> validate_inclusion(:vote_method, ["single", "multiple"]) |> cast_assoc(:options, required: true, with: &PollOption.creation_changeset/2) |> validate_length(:options, min: 2, max: 20) + |> preserve_recorded_vote_meaning(poll) |> ignore_if_blank() end + defp preserve_recorded_vote_meaning(changeset, %__MODULE__{total_votes: total_votes}) + when total_votes > 0 do + changeset + |> reject_vote_method_change() + |> reject_option_changes() + end + + defp preserve_recorded_vote_meaning(changeset, _poll), do: changeset + + defp reject_vote_method_change(changeset) do + if get_change(changeset, :vote_method) do + add_error(changeset, :vote_method, "cannot be changed after voting has started") + else + changeset + end + end + + defp reject_option_changes(changeset) do + changed? = + changeset + |> get_change(:options, []) + |> Enum.any?(fn option_changeset -> + option_changeset.action in [:insert, :delete, :replace] or option_changeset.changes != %{} + end) + + if changed? do + add_error(changeset, :options, "cannot be changed after voting has started") + else + changeset + end + end + defp ignore_if_blank(%{valid?: false, changes: changes} = changeset) when changes == %{}, do: %{changeset | action: :ignore} diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 45f9fd284..4aefbad78 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -1,342 +1,816 @@ defmodule Philomena.Posts do @moduledoc """ - The Posts context. + Forum post reads, writes, moderation, and search indexing. """ import Ecto.Query, warn: false - alias Ecto.Multi + + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + + import Philomena.Forums.TransactionWorkflow + + alias Philomena.Multi alias Philomena.Repo alias PhilomenaQuery.Search alias Philomena.Topics.Topic alias Philomena.Topics - alias Philomena.Forums + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths alias Philomena.UserStatistics alias Philomena.Users.User - alias Philomena.Posts.Post + alias Philomena.Posts.{Post, PostVersion} alias Philomena.Posts alias Philomena.IndexWorker + alias Philomena.Forums alias Philomena.Forums.Forum + alias Philomena.Forums.Visibility alias Philomena.Notifications - alias Philomena.Versions alias Philomena.Reports + alias Philomena.Versions + alias Philomena.RateLimiter + alias Philomena.Attribution.Actor + alias PhilomenaQuery.Batch + + @post_create_window 15 + + defp load_post_in_topic(%Actor{} = actor, topic, post_id, action) do + with {:ok, post_id} <- Loader.parse_id(post_id) do + Post + |> where(topic_id: ^topic.id, id: ^post_id) + |> Visibility.available_posts(actor) + |> preload(topic: :forum, user: [awards: :badge]) + |> Loader.one_and_authorize(actor, action) + end + end + + defp notify_post(_repo, %{post: post, locked_topic: topic}) do + Notifications.broadcast_forum_post(post.user, topic, post) + end + + defp broadcast_post_creation(%{forum: %{access_level: "normal"}} = result) do + PhilomenaWeb.Endpoint.broadcast!( + "firehose", + "post:create", + PhilomenaWeb.Api.Json.Forum.Topic.PostView.render("firehose.json", result) + ) + + result + end + + defp broadcast_post_creation(result), do: result + + defp put_reindex_post(%Multi{} = multi, step \\ :post) do + Multi.on_commit(multi, fn %{^step => post} -> reindex_post(post) end) + end @doc """ - Gets a single post. + Adds an `on_commit` step that reindexes all posts in a topic. - Raises `Ecto.NoResultsError` if the Post does not exist. + `step` names the transaction result containing the topic and defaults to + `:topic`. Indexing runs only after the transaction commits. ## Examples - iex> get_post!(123) - %Post{} - - iex> get_post!(456) - ** (Ecto.NoResultsError) + iex> Multi.new() |> put_reindex_posts_in_topic() + %Multi{} """ - def get_post!(id), do: Repo.get!(Post, id) + @spec put_reindex_posts_in_topic(Multi.t(), Multi.name()) :: Multi.t() + def put_reindex_posts_in_topic(%Multi{} = multi, step \\ :topic) do + Multi.on_commit(multi, fn %{^step => topic} -> reindex_posts_in_topic(topic) end) + end @doc """ - Creates a post. + Adds a system approval report when a post becomes unapproved. - ## Examples + The callback receives the transaction changes and returns the post to + inspect. If `became_unapproved?` is true, the post author's approved-post + count is decremented and an Approval report is created; otherwise no steps + are added. - iex> create_post(%{field: value}) - {:ok, %Post{}} + ## Examples - iex> create_post(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> Multi.new() |> put_approval_report(fn changes -> changes.post end) + %Multi{} """ - def create_post(topic, attributes, params \\ %{}) do - now = DateTime.utc_now(:second) + @spec put_approval_report(Multi.t(), (Multi.changes() -> Post.t())) :: Multi.t() + def put_approval_report(%Multi{} = multi, post_callback) + when is_function(post_callback, 1) do + Multi.merge(multi, fn changes -> + post = post_callback.(changes) + + if post.became_unapproved? do + Multi.new() + |> UserStatistics.put_increment(post.user_id, :posts_count, -1) + |> Reports.put_create_system_report( + "Approval", + "Post contains external links", + :post_id, + post.id + ) + else + Multi.new() + end + end) + end - topic_query = - Topic - |> where(id: ^topic.id) + @doc """ + Returns an `%Ecto.Changeset{}` for tracking post changes. - topic_lock_query = - topic_query - |> lock("FOR UPDATE") + ## Examples - forum_query = - Forum - |> where(id: ^topic.forum_id) + iex> change_post(post) + %Ecto.Changeset{source: %Post{}} - Multi.new() - |> Multi.one(:topic, topic_lock_query) - |> Multi.run(:post, fn repo, _ -> - last_position = - Post - |> where(topic_id: ^topic.id) - |> order_by(desc: :topic_position) - |> select([p], p.topic_position) - |> limit(1) - |> repo.one() - - Ecto.build_assoc(topic, :posts, [topic_position: (last_position || -1) + 1] ++ attributes) - |> Post.creation_changeset(params, attributes) - |> repo.insert() - end) - |> Multi.run(:update_topic, fn repo, %{post: %{id: post_id}} -> - {count, nil} = - repo.update_all(topic_query, - inc: [post_count: 1], - set: [last_post_id: post_id, last_replied_to_at: now] - ) + """ + @spec change_post(Post.t()) :: Ecto.Changeset.t() + def change_post(%Post{} = post) do + Post.changeset(post, %{}) + end - {:ok, count} - end) - |> Multi.run(:update_forum, fn repo, %{post: %{id: post_id}} -> - {count, nil} = - repo.update_all(forum_query, inc: [post_count: 1], set: [last_post_id: post_id]) + @doc """ + Loads one post visible to `actor` beneath its route forum and topic. - {:ok, count} - end) - |> Multi.run(:notification, ¬ify_post/2) - |> Topics.maybe_subscribe_on(:topic, attributes[:user], :watch_on_reply) - |> Repo.transaction() - |> case do - {:ok, %{post: post}} = result -> - reindex_post(post) + The locator is safely parsed, and the post query is constrained by the loaded + topic before authorization. Destroyed posts are not-found. Existing route + members forbidden to the actor are unauthorized. + + ## Examples + + iex> show_topic_post(actor, "dis", "some-topic", "1") + {:ok, %Post{}} - result + iex> show_topic_post(actor, "dis", "some-topic", "not-a-number") + {:error, :not_found} - error -> - error + """ + @spec show_topic_post( + Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + post_id :: Loader.integer_id() + ) :: + {:ok, Post.t()} | {:error, :not_found | :unauthorized} + def show_topic_post(%Actor{} = actor, forum_slug, topic_slug, post_id) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :show) do + load_post_in_topic(actor, topic, post_id, :show) end end - defp notify_post(_repo, %{post: post, topic: topic}) do - Notifications.create_forum_post_notification(post.user, topic, post) + @doc """ + Loads one post visible to `actor` by its global ID. + + The ID is safely parsed, the parent topic and forum are preloaded, and the + forum, topic, and post are authorized in hierarchy order. Destroyed, + malformed, or missing posts are not-found. + + ## Examples + + iex> show_post(actor, "1") + {:ok, %Post{}} + + iex> show_post(actor, "999999999") + {:error, :not_found} + + """ + @spec show_post(Actor.t(), Loader.integer_id()) :: + {:ok, Post.t()} | {:error, :not_found | :unauthorized} + def show_post(%Actor{} = actor, post_id) do + with {:ok, post_id} <- Loader.parse_id(post_id), + {:ok, post} <- + Post + |> where(id: ^post_id) + |> Visibility.available_posts(actor) + |> preload([:user, topic: :forum]) + |> Loader.one(), + :ok <- authorize(actor, :show, post.topic.forum), + :ok <- authorize(actor, :show, post.topic), + :ok <- authorize(actor, :show, post) do + {:ok, post} + end end @doc """ - Creates a system report for non-approved posts containing external images. - Returns false for already approved posts. + Searches posts visible to `actor`, applying the compiled query string and + pagination and sorting newest first. + + Moderators and administrators search every forum and visibility state. + Assistants search normal and assistant forums. Other actors search visible + posts in normal forums. Results carry the associations needed by both HTML + and JSON renderers. An empty query compiles to an empty result. - ## Returns - - `false`: If the post is already approved - - `{:ok, %Report{}}`: If a system report was created + Returns `{:ok, results}`, or `{:error, msg}` when `query_string` fails to + compile. ## Examples - iex> report_non_approved(approved_post) - false + iex> query_posts(actor, "chartreuse", pagination) + {:ok, %Scrivener.Page{}} - iex> report_non_approved(unapproved_post) - {:ok, %Report{}} + iex> query_posts(actor, ")", pagination) + {:error, "Imbalanced parentheses."} """ - def report_non_approved(%Post{approved: true}), do: false + @spec query_posts(Actor.t(), String.t() | nil, Search.pagination_params()) :: + {:ok, Scrivener.Page.t()} | {:error, String.t()} + def query_posts(%Actor{user: user} = actor, query_string, pagination) do + with {:ok, query} <- Posts.Query.compile(query_string, user: user) do + results = + Post + |> Search.search_definition( + %{ + query: %{ + bool: %{ + must: query, + filter: Visibility.search_filters(actor) + } + }, + sort: %{created_at: :desc} + }, + pagination + ) + |> Search.search_records( + preload(Post, [:deleted_by, topic: :forum, user: [awards: :badge]]) + ) - def report_non_approved(post) do - Reports.create_system_report( - "Approval", - "Post contains external links", - post_id: post.id - ) + {:ok, results} + end end @doc """ - Updates a post. + Creates a reply on behalf of `actor` in the topic named by `topic_slug` + within the forum named by `forum_slug`, from `params`. + + `actor`'s write access is verified first. Then the forum is + loaded by short name and authorized for `:show`, the topic is loaded by slug + with hidden topics kept invisible unless the actor may `:show` them, and the + actor is authorized for `:create_post` on the topic (which no rule permits on a + locked or hidden topic). The reply is then inserted. + + On a successful insert, an approved post increments its author's forum post + count and an unapproved one is reported for containing external links. The + returned map carries the post, topic, and forum needed for the firehose + broadcast and for the caller to reuse. + + ## Return shapes + + - `{:ok, post}` on success (the post carries its topic and forum) + - `{:error, changeset}` when the insert is rejected + - `{:error, :ban}` or `{:error, :unauthorized}` from the write-access check + - `{:error, :unauthorized}` when the forum or topic is not visible or the topic may not be posted in + - `{:error, :not_found}` when the topic does not exist + - `{:error, :rate_limited}` when a non-exempt actor has posted within the last 15 seconds ## Examples - iex> update_post(post, %{field: new_value}) + iex> create_post(actor, "dis", "some-topic", %{"body" => "Hi"}) {:ok, %Post{}} - iex> update_post(post, %{field: bad_value}) + iex> create_post(actor, "dis", "some-topic", %{"body" => ""}) {:error, %Ecto.Changeset{}} """ - def update_post(%Post{} = post, editor, attrs) do - now = DateTime.utc_now(:second) - post_changes = Post.changeset(post, attrs, now) - - Multi.new() - |> Multi.update(:post, post_changes) - |> Multi.run(:version, fn repo, %{post: updated} -> - Versions.record_edit(repo, post, updated, editor) - end) - |> Repo.transaction() - |> case do - {:ok, %{post: post}} = result -> - reindex_post(post) + @spec create_post( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + params :: map() + ) :: + {:ok, Post.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found | :rate_limited} + def create_post(%Actor{user: creator} = actor, forum_slug, topic_slug, params) do + with :ok <- verify_write_access(actor) do + Multi.new() + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :post_create, @post_create_window) end, + fn -> RateLimiter.rollback_action(actor, :post_create) end + ) + |> put_forum_and_topic_locks(actor, forum_slug, :show, topic_slug, :create_post) + |> put_max_topic_position() + |> Multi.insert(:post, fn %{max_topic_position: max_topic_position, locked_topic: topic} -> + topic + |> Ecto.build_assoc(:posts, topic_position: (max_topic_position || -1) + 1) + |> Map.put(:topic, topic) + |> Post.creation_changeset(params, actor) + end) + |> Topics.put_post_visibility_counters(visible?: true) + |> Topics.put_refresh_last_post() + |> Forums.put_post_visibility_counters(visible?: true) + |> Forums.put_refresh_last_post() + |> Topics.maybe_subscribe_on(:locked_topic, creator, :watch_on_reply) + |> Multi.run(:notification, ¬ify_post/2) + |> UserStatistics.put_increment(creator, :posts_count) + |> put_approval_report(fn %{post: post} -> post end) + |> put_reindex_post() + |> Multi.transact() + |> case do + {:ok, %{locked_forum: %Forum{} = forum, post: %Post{} = post}} -> + # The firehose representation includes the topic author. + broadcast_post_creation(%{ + post: post, + topic: Repo.preload(post.topic, :user), + forum: forum + }) + + {:ok, post} + + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} + + {:error, :post, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end + end - result + @doc """ + Loads the post named by `post_id` within the topic named by + `topic_slug` in the forum named by `forum_slug` for editing, on behalf of + `actor`. + + The same full write-access check used by update runs first. The forum, topic, + and post are then parent-scoped and authorized for `:edit`. + + Returns `{:ok, changeset}` - the changeset's data is the post with its topic, + forum, and author preloaded - + `{:error, :ban}` for a banned actor, `{:error, :unauthorized}` when the forum, + topic, or post is not visible or may not be edited, or `{:error, :not_found}` + when the topic or post does not exist. + + ## Examples + + iex> edit_post(actor, "dis", "some-topic", "1") + {:ok, %Ecto.Changeset{}} - error -> - error + """ + @spec edit_post( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + Loader.integer_id() + ) :: + {:ok, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def edit_post(actor, forum_slug, topic_slug, post_id) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :create_post), + {:ok, post} <- load_post_in_topic(actor, topic, post_id, :edit) do + {:ok, change_post(post)} end end @doc """ - Deletes a Post. + Updates the post named by `post_id` within the topic named by + `topic_slug` in the forum named by `forum_slug` from `params`, on behalf + of `actor`. + + `actor`'s write access is verified first, before the same load-and-authorize chain + `load_post_for_edit/4` uses (see `load_editable_post/4`). The edit is then applied + by `update_post/5`, recording a version attributed to `actor`'s user; an unapproved + result is reported for containing external links (an approved result is a no-op). + + ## Return shapes + + - `{:ok, post}` on success (the post carries its topic and forum for the caller to reuse) + - `{:error, changeset}` when the edit is rejected; `changeset.data` is the loaded post + - `{:error, :ban}` or `{:error, :unauthorized}` from the write-access check + - `{:error, :unauthorized}` when the forum, topic, or post is not visible or may not be edited + - `{:error, :not_found}` when the topic or post does not exist ## Examples - iex> delete_post(post) + iex> update_post(actor, "dis", "some-topic", "1", %{"body" => "Edited"}) {:ok, %Post{}} - iex> delete_post(post) + iex> update_post(actor, "dis", "some-topic", "1", %{"body" => ""}) {:error, %Ecto.Changeset{}} """ - def delete_post(%Post{} = post) do - Repo.delete(post) + @spec update_post( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + post_id :: Loader.integer_id(), + params :: map() + ) :: + {:ok, Post.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :ban | :unauthorized | :not_found} + def update_post(%Actor{} = actor, forum_slug, topic_slug, post_id, params) do + with :ok <- verify_write_access(actor), + {:ok, post_id} <- Loader.parse_id(post_id) do + Multi.new() + |> put_forum_and_topic_and_post_locks( + actor, + forum_slug, + :show, + topic_slug, + :create_post, + post_id, + :update + ) + |> Multi.update(:post, fn %{locked_post: post} -> + Post.changeset(post, params, DateTime.utc_now(:second)) + end) + |> Versions.record_edit(:version, :locked_post, :post, actor) + |> put_approval_report(fn %{post: post} -> post end) + |> put_reindex_post() + |> Multi.transact() + |> case do + {:ok, %{post: %Post{} = post}} -> + {:ok, post} + + {:error, :post, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Hides a post and handles associated reports. + Hides the post named by `post_id`, recording the + `deletion_reason` carried in `params`, on behalf of `actor`. - ## Parameters - - post: The post to hide - - attrs: Attributes for the hide operation - - user: The user performing the hide action + The post is authorized for `:hide`. On success, the post's + associated reports are closed, the topic's and forum's last-post pointers are + refreshed, the post is reindexed, and a moderation log is written attributing + the deletion to `actor`. + + The post is loaded (and returned) with its `:topic` and the topic's `:forum` + preloaded so the caller can reuse them for either outcome. + A rejected hide changeset (e.g. a blank deletion reason) returns + `{:error, changeset}` carrying the loaded post in `changeset.data`. ## Examples - iex> hide_post(post, %{staff_note: "Rule violation"}, user) + iex> create_post_hide(moderator_actor, "dis", "some-topic", "1", %{"deletion_reason" => "Spam"}) {:ok, %Post{}} - iex> hide_post(post, %{deletion_reason: ""}, user) + iex> create_post_hide(moderator_actor, "dis", "some-topic", "1", %{"deletion_reason" => ""}) {:error, %Ecto.Changeset{}} + iex> create_post_hide(user_actor, "dis", "some-topic", "1", %{"deletion_reason" => "Spam"}) + {:error, :unauthorized} + """ - def hide_post(%Post{} = post, attrs, user) do - post = post |> Repo.preload(:topic) - - Multi.new() - |> Multi.update(:post, Post.hide_changeset(post, attrs, user)) - |> Multi.update_all(:reports, Reports.close_report_query(user, post_id: post.id), []) - |> Multi.update_all(:topic, Topics.update_topic_last_post_query(post.topic_id), []) - |> Multi.update_all(:forum, Forums.update_forum_last_post_query(post.topic.forum_id), []) - |> Repo.transaction() - |> case do - {:ok, %{post: post, reports: {_count, reports}}} -> - Reports.reindex_reports(reports) - reindex_post(post) - - {:ok, post} - - error -> - normalize_multi_error(error) + @spec create_post_hide(Actor.t(), String.t(), String.t(), Loader.integer_id(), map()) :: + {:ok, Post.t()} + | {:error, :ban | :unauthorized | :not_found} + | {:error, Ecto.Changeset.t()} + def create_post_hide(%Actor{user: user} = actor, forum_slug, topic_slug, post_id, params) do + with :ok <- verify_write_access(actor), + {:ok, post_id} <- Loader.parse_id(post_id) do + Multi.new() + |> put_forum_and_topic_and_post_locks( + actor, + forum_slug, + :show, + topic_slug, + :show, + post_id, + :hide + ) + |> Multi.update(:post, fn %{locked_post: post} -> + Post.hide_changeset(post, params, user) + end) + |> Reports.put_close_reports(:reports, user, post_id: post_id) + |> Topics.put_refresh_last_post() + |> Forums.put_refresh_last_post() + |> put_reindex_post() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_topic: topic, post: post} -> + { + "Topic.Post.Hide:create", + Paths.forum_post_path(post), + "Deleted forum post ##{post.id} in topic '#{topic.title}' (#{post.deletion_reason})" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{post: %Post{} = post}} -> + {:ok, post} + + {:error, :post, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end end end @doc """ - Unhides a previously hidden post. + Restores the post named by `post_id`, on behalf of `actor`. + + Loading and authorization mirror `hide_post/5`. On success the topic's and + forum's last-post pointers are refreshed, the post is reindexed, and a + moderation log is written attributing the restore to `actor`. + + The post is loaded (and returned) with its `:topic` and the topic's `:forum` + preloaded so the caller can reuse them. A rejected restore + returns `{:error, changeset}` carrying the loaded post in `changeset.data`. ## Examples - iex> unhide_post(post) + iex> delete_post_hide(moderator_actor, "dis", "some-topic", "1") {:ok, %Post{}} + iex> delete_post_hide(user_actor, "dis", "some-topic", "1") + {:error, :unauthorized} + """ - def unhide_post(%Post{} = post) do - post = post |> Repo.preload(:topic) - - Multi.new() - |> Multi.update(:post, Post.unhide_changeset(post)) - |> Multi.update_all(:topic, Topics.update_topic_last_post_query(post.topic_id), []) - |> Multi.update_all(:forum, Forums.update_forum_last_post_query(post.topic.forum_id), []) - |> Repo.transaction() - |> case do - {:ok, %{post: post}} -> - reindex_post(post) - - {:ok, post} - - error -> - error + @spec delete_post_hide(Actor.t(), String.t(), String.t(), Loader.integer_id()) :: + {:ok, Post.t()} + | {:error, :ban | :unauthorized | :not_found} + | {:error, Ecto.Changeset.t()} + def delete_post_hide(%Actor{} = actor, forum_slug, topic_slug, post_id) do + with :ok <- verify_write_access(actor), + {:ok, post_id} <- Loader.parse_id(post_id) do + Multi.new() + |> put_forum_and_topic_and_post_locks( + actor, + forum_slug, + :show, + topic_slug, + :show, + post_id, + :unhide + ) + |> Multi.update(:post, fn %{locked_post: post} -> Post.unhide_changeset(post) end) + |> Topics.put_refresh_last_post() + |> Forums.put_refresh_last_post() + |> put_reindex_post() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_topic: topic, post: post} -> + { + "Topic.Post.Hide:delete", + Paths.forum_post_path(post), + "Restored forum post ##{post.id} in topic '#{topic.title}'" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{post: %Post{} = post}} -> + {:ok, post} + + {:error, :post, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end end end @doc """ - Marks a post as destroyed and removes its text (hard deletion). + Destroys (permanently wipes the text of) the post named by + `post_id`, on behalf of `actor`. + + The post is authorized for `:delete`. On success, the post's + text is blanked, the topic's and forum's post counts and the author's forum + post count are decremented, the post is reindexed, and a moderation log is + written attributing the destruction to `actor`. + + The post is loaded (and returned) with its `:topic` and the topic's `:forum` + preloaded so the caller can reuse them for either outcome. + A failed destroy returns `{:error, changeset}` carrying the loaded post in + `changeset.data`. ## Examples - iex> destroy_post(post) + iex> create_post_delete(moderator_actor, "dis", "some-topic", "1") {:ok, %Post{}} - """ - def destroy_post(%Post{} = post) do - post = post |> Repo.preload([:topic, :user]) - - Multi.new() - |> Multi.update(:post, Post.destroy_changeset(post)) - |> Multi.update_all( - :topic, - Topic |> where(id: ^post.topic_id), - inc: [post_count: -1] - ) - |> Multi.update_all( - :forum, - Forum |> where(id: ^post.topic.forum_id), - inc: [post_count: -1] - ) - |> Repo.transaction() - |> case do - {:ok, %{post: post}} -> - UserStatistics.inc_stat(post.user_id, :posts_count, -1) - reindex_post(post) + iex> create_post_delete(user_actor, "dis", "some-topic", "1") + {:error, :unauthorized} - {:ok, post} + iex> create_post_delete(moderator_actor, "dis", "some-topic", "not-an-integer") + {:error, :not_found} - error -> - error + """ + @spec create_post_delete(Actor.t(), String.t(), String.t(), Loader.integer_id()) :: + {:ok, Post.t()} + | {:error, :ban | :unauthorized | :not_found} + | {:error, Ecto.Changeset.t()} + def create_post_delete(%Actor{} = actor, forum_slug, topic_slug, post_id) do + with :ok <- verify_write_access(actor), + {:ok, post_id} <- Loader.parse_id(post_id) do + Multi.new() + |> put_forum_and_topic_and_post_locks( + actor, + forum_slug, + :show, + topic_slug, + :show, + post_id, + :delete + ) + |> Multi.update(:post, fn %{locked_post: post} -> Post.destroy_changeset(post) end) + |> Topics.put_post_visibility_counters(visible?: false) + |> Forums.put_post_visibility_counters(visible?: false) + |> UserStatistics.put_increment( + fn %{post: post} -> + if post.approved, do: post.user_id + end, + :posts_count, + -1 + ) + |> put_reindex_post() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_topic: topic, post: post} -> + { + "Topic.Post.Delete:create", + Paths.forum_post_path(post), + "Destroyed forum post ##{post.id} in topic '#{topic.title}'" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{post: %Post{} = post}} -> + {:ok, post} + + {:error, :post, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end end end @doc """ - Approves a post, closes associated reports, and increments the user forum - posts count. + Approves the post named by `post_id`, on behalf of `actor`. - ## Parameters - - post: The post to approve - - user: The user performing the approval + The post is authorized for `:approve`. On success, the post's associated + reports are closed, the author's forum post count is incremented, the post + is reindexed, and a moderation log is written attributing the approval to `actor`. + + The post is loaded (and returned) with its `:topic` and the topic's `:forum` + preloaded so the caller can reuse them for either outcome. A failed approval + changeset returns `{:error, changeset}` carrying the loaded post in + `changeset.data`. ## Examples - iex> approve_comment(post, user) + iex> create_post_approve(moderator_actor, "dis", "some-topic", "1") {:ok, %Post{}} + iex> create_post_approve(user_actor, "dis", "some-topic", "1") + {:error, :unauthorized} + + iex> create_post_approve(moderator_actor, "dis", "some-topic", "not-an-integer") + {:error, :not_found} + """ - def approve_post(%Post{} = post, user) do - report_query = Reports.close_report_query(user, post_id: post.id) - post = Post.approve_changeset(post) - - Multi.new() - |> Multi.update(:post, post) - |> Multi.update_all(:reports, report_query, []) - |> Repo.transaction() - |> case do - {:ok, %{post: post, reports: {_count, reports}}} -> - UserStatistics.inc_stat(post.user_id, :posts_count) - Reports.reindex_reports(reports) - reindex_post(post) - - {:ok, post} - - error -> - error + @spec create_post_approve(Actor.t(), String.t(), String.t(), Loader.integer_id()) :: + {:ok, Post.t()} + | {:error, :ban | :unauthorized | :not_found} + | {:error, Ecto.Changeset.t()} + def create_post_approve(%Actor{user: user} = actor, forum_slug, topic_slug, post_id) do + with :ok <- verify_write_access(actor), + {:ok, post_id} <- Loader.parse_id(post_id) do + Multi.new() + |> put_forum_and_topic_and_post_locks( + actor, + forum_slug, + :show, + topic_slug, + :show, + post_id, + :approve + ) + |> Multi.update(:post, fn %{locked_post: post} -> Post.approve_changeset(post) end) + |> Reports.put_close_reports(:reports, user, post_id: post_id) + |> put_reindex_post() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_topic: topic, post: post} -> + { + "Topic.Post.Approve:create", + Paths.forum_post_path(post), + "Approved forum post ##{post.id} in topic '#{topic.title}'" + } + end + ) + |> UserStatistics.put_increment(fn %{post: post} -> post.user_id end, :posts_count) + |> Multi.transact() + |> case do + {:ok, %{post: %Post{} = post}} -> + {:ok, post} + + {:error, :post, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking post changes. + Loads the edit history of the post named by `post_id` within + the topic named by `topic_slug` in the forum named by `forum_slug`, on behalf + of `actor`. + + The forum is loaded by short name and authorized for `:show`, and the topic is + loaded by slug with hidden topics visible only to actors who may `:show` them. + The post is then loaded by id within that topic: malformed, missing, and + wrong-topic IDs return `{:error, :not_found}`. A post hidden from users is + visible only when `actor` may `:show` it, otherwise + `{:error, :unauthorized}`. + + On success the loaded post (with its topic, forum, and author associations + preloaded) is returned alongside the topic and the last 25 versions of the + post, newest first, with diffs and version authors resolved. + + Returns `{:ok, {topic, post, versions}}`, `{:error, :unauthorized}` when the + forum, topic, or hidden post is not visible to `actor`, or + `{:error, :not_found}` when the topic or post does not exist. ## Examples - iex> change_post(post) - %Ecto.Changeset{source: %Post{}} + iex> list_post_history(user, "dis", "some-topic", "1") + {:ok, {%Topic{}, %Post{}, [%Version{}, ...]}} + + iex> list_post_history(user, "dis", "some-topic", "999999999") + {:error, :not_found} """ - def change_post(%Post{} = post) do - Post.changeset(post, %{}) + @spec list_post_history( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + post_id :: Loader.integer_id() + ) :: + {:ok, {Topic.t(), Post.t(), [PostVersion.t()]}} + | {:error, :unauthorized | :not_found} + def list_post_history(%Actor{} = actor, forum_slug, topic_slug, post_id) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :show), + {:ok, post} <- load_post_in_topic(actor, topic, post_id, :show) do + {:ok, {topic, post, Versions.for_post(post)}} + end + end + + @doc """ + Loads a post as a report target within its route forum and topic. + + The forum and topic visibility checks run before the post is loaded through a + parent-scoped query. Malformed, missing, and mismatched post IDs are always + not-found. The Reports context owns write access and form construction. + + ## Examples + + iex> load_report_target(actor, "dis", "some-topic", "1") + {:ok, %Post{}} + """ + @spec load_report_target( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + post_id :: Loader.integer_id() + ) :: + {:ok, Post.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, forum_slug, topic_slug, post_id) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- Topics.show_forum_topic(actor, forum, topic_slug, :show) do + load_post_in_topic(actor, topic, post_id, :show) + end + end + + @doc """ + Replaces attribution data on a user's posts in batches. + """ + @spec wipe_user_attribution!(integer(), term(), String.t()) :: :ok + def wipe_user_attribution!(user_id, ip, fingerprint) do + Post + |> where(user_id: ^user_id) + |> Batch.query_batches() + |> Enum.each(&Repo.update_all(&1, set: [ip: ip, fingerprint: fingerprint])) + + :ok end @doc """ @@ -348,6 +822,7 @@ defmodule Philomena.Posts do :ok """ + @spec user_name_reindex(String.t(), String.t()) :: term() def user_name_reindex(old_name, new_name) do data = Posts.SearchIndex.user_name_update_by_query(old_name, new_name) @@ -360,10 +835,11 @@ defmodule Philomena.Posts do ## Examples - iex> reindex_comment(post) + iex> reindex_post(post) %Post{} """ + @spec reindex_post(Post.t()) :: Post.t() def reindex_post(%Post{} = post) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Posts", "id", [post.id]]) @@ -383,8 +859,9 @@ defmodule Philomena.Posts do :ok """ - def reindex_posts_in_topic(topic_id) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Posts", "topic_id", [topic_id]]) + @spec reindex_posts_in_topic(Topic.t()) :: :ok + def reindex_posts_in_topic(%Topic{} = topic) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Posts", "topic_id", [topic.id]]) :ok end @@ -398,6 +875,7 @@ defmodule Philomena.Posts do [user: user_query, topic: topic_query] """ + @spec indexing_preloads() :: list() def indexing_preloads do user_query = select(User, [u], map(u, [:id, :name])) @@ -429,18 +907,11 @@ defmodule Philomena.Posts do :ok """ + @spec perform_reindex(atom(), [term()]) :: term() def perform_reindex(column, condition) do Post |> preload(^indexing_preloads()) |> where([p], field(p, ^column) in ^condition) |> Search.reindex(Post) end - - # `Repo.transaction/1` reports a failed step as `{:error, name, value, changes}`. - # Callers only ever want the changeset that failed, in the shape every other - # context function returns it. - defp normalize_multi_error({:error, _name, %Ecto.Changeset{} = changeset, _changes}), - do: {:error, changeset} - - defp normalize_multi_error(result), do: result end diff --git a/lib/philomena/posts/post.ex b/lib/philomena/posts/post.ex index b99634573..4b06e38ef 100644 --- a/lib/philomena/posts/post.ex +++ b/lib/philomena/posts/post.ex @@ -2,14 +2,19 @@ defmodule Philomena.Posts.Post do use Ecto.Schema import Ecto.Changeset + alias Philomena.Attribution.Actor alias Philomena.Users.User alias Philomena.Topics.Topic + alias Philomena.Reports.Report alias Philomena.Schema.Approval + @type t :: %__MODULE__{} + schema "posts" do belongs_to :user, User belongs_to :topic, Topic belongs_to :deleted_by, User + has_many :reports, Report field :body, :string field :edit_reason, :string @@ -21,7 +26,8 @@ defmodule Philomena.Posts.Post do field :edited_at, :utc_datetime field :deletion_reason, :string, default: "" field :destroyed_content, :boolean, default: false - field :approved, :boolean, default: false + field :approved, :boolean, default: true + field :became_unapproved?, :boolean, virtual: true, default: false timestamps(inserted_at: :created_at, type: :utc_datetime) end @@ -38,25 +44,25 @@ defmodule Philomena.Posts.Post do end @doc false - def creation_changeset(post, attrs, attribution) do + def creation_changeset(post, attrs, %Actor{} = actor) do post |> cast(attrs, [:body, :anonymous]) |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) - |> change(attribution) - |> Approval.maybe_put_approval(attribution[:user], :external_links) + |> change(Actor.to_changes(actor)) + |> Approval.maybe_put_approval(actor.user, :external_links) end @doc false - def topic_creation_changeset(post, attrs, attribution, anonymous?) do + def topic_creation_changeset(post, attrs, %Actor{} = actor, anonymous?) do post |> change(anonymous: anonymous?) |> cast(attrs, [:body]) |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) - |> change(attribution) + |> change(Actor.to_changes(actor)) |> change(topic_position: 0) - |> Approval.maybe_put_approval(attribution[:user], :external_links) + |> Approval.maybe_put_approval(actor.user, :external_links) end def hide_changeset(post, attrs, user) do @@ -69,19 +75,42 @@ defmodule Philomena.Posts.Post do def unhide_changeset(post) do change(post) + |> validate_undestroyed() |> put_change(:hidden_from_users, false) |> put_change(:deleted_by_id, nil) |> put_change(:deletion_reason, "") end def destroy_changeset(post) do - change(post) + post + |> change() + |> validate_hidden() + |> validate_undestroyed() |> put_change(:destroyed_content, true) |> put_change(:body, "") end + @doc false def approve_changeset(post) do - change(post) - |> put_change(:approved, true) + post + |> change() + |> validate_undestroyed() + |> Approval.approve_changeset() + end + + defp validate_hidden(changeset) do + if not get_field(changeset, :hidden_from_users) do + add_error(changeset, :destroyed_content, "cannot be set while post is visible") + else + changeset + end + end + + defp validate_undestroyed(changeset) do + if get_field(changeset, :destroyed_content) do + add_error(changeset, :destroyed_content, "has already been destroyed") + else + changeset + end end end diff --git a/lib/philomena/posts/post_version.ex b/lib/philomena/posts/post_version.ex index fdb3022de..7b252b272 100644 --- a/lib/philomena/posts/post_version.ex +++ b/lib/philomena/posts/post_version.ex @@ -4,6 +4,8 @@ defmodule Philomena.Posts.PostVersion do alias Philomena.Posts.Post alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "post_versions" do belongs_to :post, Post belongs_to :user, User diff --git a/lib/philomena/profiles.ex b/lib/philomena/profiles.ex new file mode 100644 index 000000000..8ecbda626 --- /dev/null +++ b/lib/philomena/profiles.ex @@ -0,0 +1,384 @@ +defmodule Philomena.Profiles do + @moduledoc """ + Public profile pages and sensitive staff-only account metadata, IP histories, + and fingerprint histories. + """ + + import Ecto.Query, warn: false + + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Attribution.Actor + alias Philomena.Bans + alias Philomena.Comments + alias Philomena.Comments.Comment + alias Philomena.Filters.Filter + alias Philomena.Galleries.Gallery + alias Philomena.Images.Image + alias Philomena.Images.Search, as: ImageSearch + alias Philomena.Images.Search.Scope + alias Philomena.Interactions + alias Philomena.ModNotes + alias Philomena.Posts.Post + alias Philomena.Profiles.AdminMetadata + alias Philomena.Profiles.FingerprintHistory + alias Philomena.Profiles.IpHistory + alias Philomena.Profiles.ProfilePage + alias Philomena.Repo + alias Philomena.Tags.Tag + alias Philomena.UserFingerprints + alias Philomena.UserIps + alias Philomena.UserNameChanges + alias Philomena.Users + alias Philomena.Users.User + alias Philomena.UserStatistics.UserStatistic + alias PhilomenaQuery.Search + + @name_history_pagination %{page: 1, page_size: 250} + + @profile_preloads [ + :forced_filter, + awards: [:badge, :awarded_by], + public_links: :tag, + verified_links: :tag, + commission: [ + sheet_image: [:sources, tags: :aliases], + items: [example_image: [:sources, tags: :aliases]] + ] + ] + + defp assemble_profile_page(actor, scope, current_filter, user) do + {:ok, {recent_uploads_def, _tags}} = + ImageSearch.search_string(actor, scope, "uploader_id:#{user.id}", + pagination: %{page_number: 1, page_size: 4} + ) + + {:ok, {recent_faves_def, _tags}} = + ImageSearch.search_string(actor, scope, "faved_by_id:#{user.id}", + pagination: %{page_number: 1, page_size: 4} + ) + + tags = link_tags(user.public_links) + + verified_tag_ids = + user.verified_links + |> link_tags() + |> Enum.map(& &1.id) + + recent_artwork_def = recent_artwork_definition(actor, scope, tags) + + recent_comments_def = + Comments.comment_search_definition( + actor, + current_filter, + [ + %{term: %{author_id: user.id}}, + %{term: %{hidden_from_users: false}} + ], + pagination: %{page_size: 3}, + show_hidden: false + ) + + recent_posts_def = + Search.search_definition( + Post, + %{ + query: %{ + bool: %{ + must: [ + %{term: %{author_id: user.id}}, + %{term: %{hidden_from_users: false}}, + %{term: %{access_level: "normal"}} + ] + } + }, + sort: %{created_at: :desc} + }, + %{page_size: 6} + ) + + %{ + recent_uploads: recent_uploads, + recent_faves: recent_faves, + recent_artwork: recent_artwork, + recent_comments: recent_comments, + recent_posts: recent_posts + } = + Search.msearch_records( + recent_uploads: {recent_uploads_def, preload(Image, [:sources, tags: :aliases])}, + recent_faves: {recent_faves_def, preload(Image, [:sources, tags: :aliases])}, + recent_artwork: {recent_artwork_def, preload(Image, [:sources, tags: :aliases])}, + recent_comments: + {recent_comments_def, + preload(Comment, [ + :deleted_by, + user: [awards: :badge], + image: [:sources, tags: :aliases] + ])}, + recent_posts: + {recent_posts_def, preload(Post, [:deleted_by, user: [awards: :badge], topic: :forum])} + ) + + recent_posts = Enum.filter(recent_posts, &(authorize(actor, :show, &1.topic) == :ok)) + recent_comments = Enum.filter(recent_comments, &(authorize(actor, :show, &1.image) == :ok)) + + recent_galleries = + Gallery + |> where(user_id: ^user.id, anonymous: false) + |> preload(thumbnail: [:sources, tags: :aliases]) + |> limit(4) + |> Repo.all() + + interactions = + Interactions.user_interactions(actor, [recent_uploads, recent_faves, recent_artwork]) + + %ProfilePage{ + user: user, + recent_uploads: recent_uploads, + recent_faves: recent_faves, + recent_artwork: recent_artwork, + recent_comments: recent_comments, + recent_posts: recent_posts, + recent_galleries: recent_galleries, + statistics: calculate_statistics(user), + watcher_counts: watcher_counts(verified_tag_ids), + tags: tags, + interactions: interactions, + bans: user_bans(user) + } + end + + defp recent_artwork_definition(_actor, _scope, []) do + Search.search_definition(Image, %{query: %{match_none: %{}}}) + end + + defp recent_artwork_definition(actor, scope, tags) do + {definition, _tags} = + ImageSearch.query(actor, scope, %{terms: %{tag_ids: Enum.map(tags, & &1.id)}}, + pagination: %{page_number: 1, page_size: 4} + ) + + definition + end + + defp link_tags([]), do: [] + defp link_tags(links), do: links |> Enum.map(& &1.tag) |> Enum.reject(&is_nil/1) + + defp watcher_counts(tag_ids) do + Tag + |> where([t], t.id in ^tag_ids) + |> join( + :inner_lateral, + [t], + _ in fragment("SELECT count(*) FROM users WHERE watched_tag_ids @> ARRAY[?]", t.id), + on: true + ) + |> select([t, c], {t.id, c.count}) + |> Repo.all() + |> Map.new() + end + + defp user_bans(user) do + Bans.User + |> where(user_id: ^user.id) + |> order_by(desc: :created_at) + |> Repo.all() + end + + defp calculate_statistics(user) do + today = Date.utc_today() + + last_90 = + UserStatistic + |> where(user_id: ^user.id) + |> where([us], us.day >= ^Date.add(today, -89)) + |> Repo.all() + |> Map.new(&{Date.diff(today, &1.day), &1}) + + %{ + images_count: individual_stat(last_90, :images_count), + image_faves_count: individual_stat(last_90, :image_faves_count), + comments_count: individual_stat(last_90, :comments_count), + image_votes_count: individual_stat(last_90, :image_votes_count), + metadata_updates_count: individual_stat(last_90, :metadata_updates_count), + posts_count: individual_stat(last_90, :posts_count) + } + end + + defp individual_stat(mapping, stat_name) do + Enum.map(89..0//-1, &(map_fetch(mapping[&1], stat_name) || 0)) + end + + defp map_fetch(nil, _field_name), do: nil + defp map_fetch(map, field_name), do: Map.get(map, field_name) + + defp load_detailed_profile(actor, slug) do + with {:ok, user} <- Users.load_profile(actor, slug), + :ok <- authorize(actor, :show_details, user), + :ok <- authorize(actor, :show, :identity_metadata) do + {:ok, user} + end + end + + @doc """ + Assembles the public profile page for the active user named by `slug`. + + The actor is carried separately from the image-search scope and the loaded + profile is authorized with `:show`. Missing and deactivated profiles are + always not found. `current_filter` scopes the recent comments strip. Posts + and comments whose parents the actor cannot show are removed after search. + The loaded user includes its forced filter for the caller's owner/staff-only + presentation gate. + + Returns `{:ok, %ProfilePage{}}`. + + ## Examples + + iex> show_profile(actor, scope, filter, "somebody") + {:ok, %ProfilePage{}} + + iex> show_profile(actor, scope, filter, "missing") + {:error, :not_found} + + """ + @spec show_profile(Actor.t(), Scope.t(), Filter.t(), String.t()) :: + {:ok, ProfilePage.t()} | {:error, :unauthorized | :not_found} + def show_profile( + %Actor{} = actor, + %Scope{} = scope, + %Filter{} = current_filter, + slug + ) do + with {:ok, user} <- Users.load_profile(actor, slug) do + user = Repo.preload(user, @profile_preloads) + {:ok, assemble_profile_page(actor, scope, current_filter, user)} + end + end + + @doc """ + Loads sensitive account metadata about `user` for `actor`. + + The actor must be authorized for `:show_details` on the user and to show + `:identity_metadata` before the current filter or latest IP and fingerprint + rows are queried. + + ## Examples + + iex> load_admin_metadata(moderator, user) + {:ok, %AdminMetadata{}} + + iex> load_admin_metadata(ordinary_user, user) + {:error, :unauthorized} + + """ + @spec load_admin_metadata(Actor.t(), User.t()) :: + {:ok, AdminMetadata.t()} | {:error, :unauthorized} + def load_admin_metadata(%Actor{} = actor, %User{} = user) do + with :ok <- authorize(actor, :show_details, user), + :ok <- authorize(actor, :show, :identity_metadata), + {:ok, last_ip} <- UserIps.latest_for_user(actor, user), + {:ok, last_fingerprint} <- UserFingerprints.latest_for_user(actor, user) do + user = Repo.preload(user, [:current_filter]) + + {:ok, + %AdminMetadata{ + filter: user.current_filter, + last_ip: last_ip, + last_fingerprint: last_fingerprint + }} + end + end + + @doc """ + Loads up to 250 newest moderation notes on `user` for `actor`, processed + through `collection_renderer`. + + The profile is loaded and authorized for `:show_details`, then any additional + ModNotes permissions are checked. + + ## Examples + + iex> load_mod_notes(moderator, user, renderer) + {:ok, [{%ModNote{}, "rendered"}]} + + """ + @spec load_mod_notes(Actor.t(), User.t(), (list() -> list())) :: + {:ok, list()} | {:error, :not_found | :unauthorized} + def load_mod_notes(%Actor{} = actor, %User{} = user, collection_renderer) do + with :ok <- authorize(actor, :show_details, user) do + ModNotes.list_for_target(actor, {:user, user.id}, collection_renderer) + end + end + + @doc """ + Loads up to 250 newest name changes of `user` for `actor`. + + The profile is loaded and authorized for `:show_details`, then any additional + UserNameChanges permissions are checked. + + ## Examples + + iex> load_name_changes(moderator, user) + {:ok, [%UserNameChange{}]} + + """ + @spec load_name_changes(Actor.t(), User.t()) :: + {:ok, [UserNameChanges.UserNameChange.t()]} | {:error, :unauthorized} + def load_name_changes(%Actor{} = actor, %User{} = user) do + with :ok <- authorize(actor, :show_details, user), + {:ok, page} <- UserNameChanges.load_history(actor, user, @name_history_pagination) do + {:ok, page.entries} + end + end + + @doc """ + Loads a page of IP history for the active profile named by `slug`, plus other + users seen on the IPs in that page. + + The profile is loaded and authorized for `:show_details`, then the actor is + authorized to show `:identity_metadata`. + + ## Examples + + iex> list_profile_ip_history(moderator, slug, page: 1, page_size: 25) + {:ok, %IpHistory{}} + + """ + @spec list_profile_ip_history(Actor.t(), String.t(), Repo.pagination_params()) :: + {:ok, IpHistory.t()} | {:error, :unauthorized | :not_found} + def list_profile_ip_history(%Actor{} = actor, slug, pagination) do + with {:ok, user} <- load_detailed_profile(actor, slug), + {:ok, {user_ips, other_users}} <- + UserIps.load_user_history(actor, user, pagination) do + {:ok, %IpHistory{user: user, user_ips: user_ips, other_users: other_users}} + end + end + + @doc """ + Loads a page of fingerprint history for the active profile named by `slug`, + plus other users seen with the fingerprints in that page. + + The profile is loaded and authorized for `:show_details`, then the actor is + authorized to show `:identity_metadata`. + + ## Examples + + iex> list_profile_fingerprint_history(moderator, slug, page: 1, page_size: 25) + {:ok, %FingerprintHistory{}} + + """ + @spec list_profile_fingerprint_history(Actor.t(), String.t(), Repo.pagination_params()) :: + {:ok, FingerprintHistory.t()} | {:error, :unauthorized | :not_found} + def list_profile_fingerprint_history(%Actor{} = actor, slug, pagination) do + with {:ok, user} <- load_detailed_profile(actor, slug), + {:ok, {user_fingerprints, other_users}} <- + UserFingerprints.load_user_history(actor, user, pagination) do + {:ok, + %FingerprintHistory{ + user: user, + user_fingerprints: user_fingerprints, + other_users: other_users + }} + end + end +end diff --git a/lib/philomena/profiles/admin_metadata.ex b/lib/philomena/profiles/admin_metadata.ex new file mode 100644 index 000000000..43302a011 --- /dev/null +++ b/lib/philomena/profiles/admin_metadata.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Profiles.AdminMetadata do + @moduledoc """ + Sensitive account metadata displayed alongside a user's profile for an + authorized staff viewer. + """ + + alias Philomena.Filters.Filter + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.UserIps.UserIp + + @enforce_keys [:filter, :last_ip, :last_fingerprint] + defstruct [:filter, :last_ip, :last_fingerprint] + + @type t :: %__MODULE__{ + filter: Filter.t() | nil, + last_ip: UserIp.t() | nil, + last_fingerprint: UserFingerprint.t() | nil + } +end diff --git a/lib/philomena/profiles/fingerprint_history.ex b/lib/philomena/profiles/fingerprint_history.ex new file mode 100644 index 000000000..2a943fcc8 --- /dev/null +++ b/lib/philomena/profiles/fingerprint_history.ex @@ -0,0 +1,18 @@ +defmodule Philomena.Profiles.FingerprintHistory do + @moduledoc """ + A paginated user's browser-fingerprint history and the bounded + cross-references for fingerprints on the current page. + """ + + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.Users.User + + @enforce_keys [:user, :user_fingerprints, :other_users] + defstruct [:user, :user_fingerprints, :other_users] + + @type t :: %__MODULE__{ + user: User.t(), + user_fingerprints: Scrivener.Page.t(UserFingerprint.t()), + other_users: %{optional(String.t()) => [UserFingerprint.t()]} + } +end diff --git a/lib/philomena/profiles/ip_history.ex b/lib/philomena/profiles/ip_history.ex new file mode 100644 index 000000000..5eeaf2ca8 --- /dev/null +++ b/lib/philomena/profiles/ip_history.ex @@ -0,0 +1,18 @@ +defmodule Philomena.Profiles.IpHistory do + @moduledoc """ + A paginated user's IP history and the bounded cross-references for the IPs on + the current page. + """ + + alias Philomena.UserIps.UserIp + alias Philomena.Users.User + + @enforce_keys [:user, :user_ips, :other_users] + defstruct [:user, :user_ips, :other_users] + + @type t :: %__MODULE__{ + user: User.t(), + user_ips: Scrivener.Page.t(UserIp.t()), + other_users: %{optional(Postgrex.INET.t()) => [UserIp.t()]} + } +end diff --git a/lib/philomena/profiles/profile_page.ex b/lib/philomena/profiles/profile_page.ex new file mode 100644 index 000000000..117c458f7 --- /dev/null +++ b/lib/philomena/profiles/profile_page.ex @@ -0,0 +1,61 @@ +defmodule Philomena.Profiles.ProfilePage do + @moduledoc """ + Everything a user's public profile holds for one viewer: the user with its + profile associations, the recent uploads/faves/artwork image strips, the + viewer's interactions across them, the recent comments and posts, recent + galleries, the 90-day statistics series, watcher counts for the user's + verified-link tags, the user's public-link tags, and the user's bans. + + `recent_comments` holds only the comments whose images the viewer may see. + The loaded user carries its forced filter for the existing owner/staff-only + presentation gate. + Descriptions, comment bodies, and commission text are carried raw; + processing them is the caller's concern. + """ + + alias Philomena.Users.User + + @enforce_keys [ + :user, + :recent_uploads, + :recent_faves, + :recent_artwork, + :recent_comments, + :recent_posts, + :recent_galleries, + :statistics, + :watcher_counts, + :tags, + :interactions, + :bans + ] + defstruct [ + :user, + :recent_uploads, + :recent_faves, + :recent_artwork, + :recent_comments, + :recent_posts, + :recent_galleries, + :statistics, + :watcher_counts, + :tags, + :interactions, + :bans + ] + + @type t :: %__MODULE__{ + user: User.t(), + recent_uploads: Scrivener.Page.t(), + recent_faves: Scrivener.Page.t(), + recent_artwork: Scrivener.Page.t(), + recent_comments: list(), + recent_posts: list(), + recent_galleries: list(), + statistics: map(), + watcher_counts: map(), + tags: list(), + interactions: list(), + bans: list() + } +end diff --git a/lib/philomena/rate_limiter.ex b/lib/philomena/rate_limiter.ex new file mode 100644 index 000000000..02173092a --- /dev/null +++ b/lib/philomena/rate_limiter.ex @@ -0,0 +1,124 @@ +defmodule Philomena.RateLimiter do + @moduledoc """ + Per-identity rate limiting for controller-facing write operations. + + Contexts reserve a rate-limited write by calling `record_action/3` before + its transaction and call `rollback_action/2` if that transaction rolls back. + Counters live in Valkey under a per-operation key scoped to the acting + identity - the actor's user when signed in, otherwise its IP - and expire + `window` seconds after each successful reservation. + + `record_action/3` uses Valkey's atomic increment reply to reserve a slot. A + reservation over the inclusive limit is refused, so concurrent requests + cannot all pass a separate check before any of them are recorded. With the + inclusive limit of 1, two writes are allowed in a window and the third is + refused. A reservation is decremented only when its transaction rolls back. + Staff (admins, moderators, assistants) and users with `bypass_rate_limits` + set are never limited and record no counters; see `considered_for_limit?/1`. + """ + + alias Philomena.Attribution.Actor + alias Philomena.Users.User + + @key_prefix "rl:" + @limit 1 + + @doc """ + Reserve a rate-limited `operation` for `actor`. + + Increments the actor's counter for `operation` and starts its expiry + `window`, in seconds. Exempt actors reserve nothing. A reservation over the + limit returns `{:error, :rate_limited}` immediately. + + ## Examples + + iex> record_action(actor, :post_create, 15) + :ok + + """ + @spec record_action(Actor.t(), atom(), pos_integer()) :: + :ok | {:error, :rate_limited} + def record_action(%Actor{} = actor, operation, window) do + if considered_for_limit?(actor.user) do + key = key(actor, operation) + count = Redix.command!(redix_connection(), ["INCR", key]) + + if count <= @limit + 1 do + Redix.command!(redix_connection(), ["EXPIRE", key, window]) + :ok + else + {:error, :rate_limited} + end + else + :ok + end + end + + @doc """ + Roll back a reservation made by `record_action/3`. + + This is intended to be called only when the transaction which owns the + reservation rolls back. Exempt actors have no counter to decrement. + + ## Examples + + iex> rollback_action(actor, :post_create) + :ok + + """ + @spec rollback_action(Actor.t(), atom()) :: :ok + def rollback_action(%Actor{} = actor, operation) do + if considered_for_limit?(actor.user) do + rollback_counter(key(actor, operation), 1) + end + + :ok + end + + @doc """ + Deletes every rate limit counter. + + This maintenance service is used by development seeds. It is + deliberately global and must not be called from request paths. + + ## Examples + + iex> reset_limits_globally!() + :ok + + """ + @spec reset_limits_globally!() :: :ok + def reset_limits_globally! do + case Redix.command!(redix_connection(), ["KEYS", "#{@key_prefix}*"]) do + [] -> + :ok + + keys -> + Redix.command!(redix_connection(), ["DEL" | keys]) + end + + :ok + end + + # Staff and rate-limit-bypassing users are never limited; everyone else, + # anonymous or signed in, is. + defp considered_for_limit?(nil), do: true + + defp considered_for_limit?(%User{role: role}) when role in ~w(admin moderator assistant), + do: false + + defp considered_for_limit?(%User{bypass_rate_limits: true}), do: false + defp considered_for_limit?(%User{}), do: true + + defp key(%Actor{} = actor, operation), do: "#{@key_prefix}#{operation}:#{scope(actor)}" + + defp scope(%Actor{user: nil, ip: ip}), do: "i:#{ip}" + defp scope(%Actor{user: user}), do: "u:#{user.id}" + + defp rollback_counter(key, amount) do + Redix.command!(redix_connection(), ["DECRBY", key, amount]) + :ok + end + + defp redix_connection, do: :redix +end diff --git a/lib/philomena/release.ex b/lib/philomena/release.ex index 865a9a4b2..3a7743d68 100644 --- a/lib/philomena/release.ex +++ b/lib/philomena/release.ex @@ -51,7 +51,7 @@ defmodule Philomena.Release do def verify_artist_links do start_app() - Philomena.ArtistLinks.automatic_verify!() + Philomena.ArtistLinks.run_automatic_verification!() end def update_stats do @@ -74,9 +74,14 @@ defmodule Philomena.Release do Philomena.Tags.cleanup!() end + def replace_aliases_in_implied_tags do + start_app() + Philomena.Tags.replace_aliases_in_implied_tags!() + end + def convert_reports do start_app() - Philomena.Reports.convert_reports!() + Philomena.Reports.LegacyConverter.convert_reports!() end def backfill_versions do diff --git a/lib/philomena/repo.ex b/lib/philomena/repo.ex index cd5da3a53..beb6a7d3a 100644 --- a/lib/philomena/repo.ex +++ b/lib/philomena/repo.ex @@ -4,4 +4,8 @@ defmodule Philomena.Repo do adapter: Ecto.Adapters.Postgres use Scrivener, page_size: 250 + + # Database pagination accepts Scrivener's map or keyword inputs. Search owns + # a narrower typed map because it applies its own defaults and page limits. + @type pagination_params :: map() | keyword() end diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 25fa91b8c..85d814a74 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -1,403 +1,732 @@ defmodule Philomena.Reports do @moduledoc """ - The Reports context. + Report forms, submission limits, staff review, and report search indexing. """ import Ecto.Query, warn: false - alias Philomena.Repo - - alias PhilomenaQuery.Batch - alias PhilomenaQuery.Search - alias Philomena.Reports.Report - alias Philomena.Reports - alias Philomena.IndexWorker - alias Philomena.Rules + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] - alias Philomena.Images.Image + alias Philomena.Multi + alias Philomena.Attribution.Actor + alias Philomena.Comments alias Philomena.Comments.Comment - alias Philomena.Posts.Post + alias Philomena.Commissions alias Philomena.Commissions.Commission + alias Philomena.Conversations alias Philomena.Conversations.Conversation + alias Philomena.Galleries alias Philomena.Galleries.Gallery + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.IndexWorker + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.ModNotes + alias Philomena.Posts + alias Philomena.Posts.Post + alias Philomena.Repo + alias Philomena.Reports + alias Philomena.Reports.QueryBuilder + alias Philomena.Reports.QueryForm + alias Philomena.Reports.Report + alias Philomena.Reports.ReportForm + alias Philomena.Reports.ReportPage + alias Philomena.Rules + alias Philomena.Rules.Rule + alias Philomena.Users + alias Philomena.Users.User + alias PhilomenaQuery.Batch - @reason_regex ~r/^(Rule|Other|Takedown|Verification|Approval|Review|System)([^:]*): (.*)$/ + alias PhilomenaQuery.Search - @doc """ - Returns the current number of open reports. + @max_open_reports 5 + @default_preloads [:admin, :rule, user: :linked_tags] - If the user is allowed to view reports, returns the current count. - If the user is not allowed to view reports, returns `nil`. + @typedoc "Locator for a reportable item." + @type target_locator :: + {:image, Loader.integer_id()} + | {:comment, Loader.integer_id(), Loader.integer_id()} + | {:post, String.t(), String.t(), Loader.integer_id()} + | {:user, String.t()} + | {:commission, String.t()} + | {:conversation, String.t()} + | {:gallery, Loader.integer_id()} - ## Examples + defp report_query(preloads) do + Report + |> preload(^preloads) + |> preload(^Report.target_preloads()) + end - iex> count_reports(%User{}) - nil + defp load_report_target(%Actor{} = actor, locator) do + case locator do + {:image, image_id} -> + Images.load_report_target(actor, image_id) - iex> count_reports(%User{role: "admin"}) - 4 + {:comment, image_id, comment_id} -> + Comments.load_report_target(actor, image_id, comment_id) - """ - def count_open_reports(user) do - if Canada.Can.can?(user, :index, Report) do - Report - |> where(open: true) - |> Repo.aggregate(:count) - else - nil - end - end + {:post, forum_slug, topic_slug, post_id} -> + Posts.load_report_target(actor, forum_slug, topic_slug, post_id) - @doc """ - Returns the list of reports. + {:user, slug} -> + Users.load_report_target(actor, slug) - ## Examples + {:commission, slug} -> + Commissions.load_report_target(actor, slug) - iex> list_reports() - [%Report{}, ...] + {:conversation, slug} -> + Conversations.load_report_target(actor, slug) - """ - def list_reports do - Repo.all(Report) + {:gallery, gallery_id} -> + Galleries.load_report_target(actor, gallery_id) + end end - @doc """ - Gets a single report. + defp open_report_count(repo, query) do + query + |> where([report], report.state in ["open", "in_progress"]) + |> repo.aggregate(:count) + end - Raises `Ecto.NoResultsError` if the Report does not exist. + defp ensure_report_limit(repo, %Actor{user: user, ip: ip} = actor) do + cond do + authorize(actor, :bypass_submission_limit, Report) == :ok -> + :ok - ## Examples + not is_nil(user) and + open_report_count(repo, where(Report, user_id: ^user.id)) >= @max_open_reports -> + {:error, :too_many_reports} - iex> get_report!(123) - %Report{} + open_report_count(repo, where(Report, ip: ^ip)) >= @max_open_reports -> + {:error, :too_many_reports} - iex> get_report!(456) - ** (Ecto.NoResultsError) + true -> + :ok + end + end - """ - def get_report!(id), do: Repo.get!(Report, id) + defp put_lock_report(%Multi{} = multi, %Actor{} = actor, action, report_id) do + multi + |> Multi.lock_one(:locked_report, where(Report, id: ^report_id)) + |> Multi.run(:authorize, fn _repo, %{locked_report: report} -> + with :ok <- authorize(actor, action, report) do + {:ok, nil} + end + end) + end - @doc """ - Creates a report against the target named by `target`, a one-entry keyword - list of the target foreign key column and its id (e.g. `image_id: image.id`). + defp map_lock_errors(result) do + case result do + {:error, _step, :unauthorized, _changes} -> + {:error, :unauthorized} - ## Examples + {:error, _step, :not_found, _changes} -> + {:error, :not_found} + end + end - iex> create_report(attribution, %{"reason" => "..."}, image_id: image.id) - {:ok, %Report{}} + defp close_report_query(%User{id: user_id}, [{column, id}]) + when column in [ + :image_id, + :comment_id, + :post_id, + :reported_user_id, + :commission_id, + :conversation_id, + :gallery_id + ] do + now = DateTime.utc_now(:second) - iex> create_report(attribution, %{"reason" => ""}, image_id: image.id) - {:error, %Ecto.Changeset{}} + from report in Report, + where: field(report, ^column) == ^id and report.open == true, + select: report.id, + update: [ + set: [open: false, state: "closed", admin_id: ^user_id, updated_at: ^now] + ] + end - """ - def create_report(attribution, attrs, target) do - rule = Rules.find_rule(attrs["rule_id"]) + defp reindex_closed_reports(report_ids) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", report_ids]) + end - struct(Report, target) - |> Report.user_creation_changeset(attrs, attribution, rule) - |> Repo.insert() - |> reindex_after_update() + defp put_reindex_report(%Multi{} = multi, report_step \\ :report) do + Multi.on_commit(multi, fn %{^report_step => report} -> + Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", [report.id]]) + end) end @doc """ - Returns an `m:Ecto.Query` which updates all open reports against the target - named by `target`, a one-entry keyword list of the target foreign key column - and its id (e.g. `image_id: image.id`), to close them. + Returns the maximum number of open reports allowed for a regular submitter. - Because this is only a query due to the limitations of `m:Ecto.Multi`, this must be - coupled with an associated call to `reindex_reports/1` to operate correctly, e.g.: - - report_query = Reports.close_report_query(user, image_id: image.id) + ## Examples - Multi.new() - |> Multi.update_all(:reports, report_query, []) - |> Repo.transaction() - |> case do - {:ok, %{reports: {_count, reports}} = result} -> - Reports.reindex_reports(reports) + iex> max_open_reports() + 5 - {:ok, result} + """ + @spec max_open_reports() :: pos_integer() + def max_open_reports, do: @max_open_reports - error -> - error - end + @doc """ + Returns the number of open reports visible in the staff counter. - Use `close_reports/2` to close and reindex reports in one step outside an `m:Ecto.Multi`. + The count is authorized with `:index` on `Report`. Unauthorized users + receive `nil`. ## Examples - iex> close_report_query(%User{}, image_id: 1) - #Ecto.Query<...> + iex> count_open_reports(moderator) + 4 - """ - def close_report_query(closing_user, [{column, id}]) do - now = DateTime.utc_now(:second) + iex> count_open_reports(user) + nil - from r in Report, - where: field(r, ^column) == ^id and r.open == true, - select: r.id, - update: [ - set: [ - open: false, - state: "closed", - admin_id: ^closing_user.id, - updated_at: ^now - ] - ] + """ + @spec count_open_reports(Actor.t()) :: non_neg_integer() | nil + def count_open_reports(%Actor{} = actor) do + case authorize(actor, :index, Report) do + :ok -> + Report + |> where(open: true) + |> Repo.aggregate(:count) + + {:error, :unauthorized} -> + nil + end end @doc """ - Closes all open reports against the target named by `target` (see - `close_report_query/2`), marking them as closed by the specified user. - Also reindexes the affected reports. + Loads the signed-in actor's reports, newest first. - Returns `{:ok, {count, reports}}`. - """ - def close_reports(closing_user, target) do - {_count, reports} = - result = Repo.update_all(close_report_query(closing_user, target), []) + Results are scoped to `actor.user`. Anonymous actors are unauthorized. - reindex_reports(reports) - {:ok, result} + ## Examples + + iex> list_user_reports(actor, pagination) + {:ok, %Scrivener.Page{}} + + iex> list_user_reports(anonymous, pagination) + {:error, :unauthorized} + + """ + @spec list_user_reports(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(Report.t())} | {:error, :unauthorized} + def list_user_reports(%Actor{user: user} = actor, pagination) do + with :ok <- authorize(actor, :index_own, Report) do + reports = + Report + |> where(user_id: ^user.id) + |> order_by(desc: :created_at) + |> preload(:rule) + |> preload(^Report.target_preloads()) + |> Repo.paginate(pagination) + + {:ok, reports} + end end @doc """ - Automatically create a report with the given rule and reason against the - target named by `target`, a one-entry keyword list of the target foreign key - column and its id (e.g. `comment_id: comment.id`). + Loads the staff report index described by `params` and `pagination`. - ## Examples + Access is authorized with `:index` before any report query runs. A `query` + parameter selects the report search language. Malformed search text returns + `{:error, changeset}` with the error rendered in the query form. - iex> create_system_report("Rule #0", "Custom report reason", comment_id: 1) - {:ok, %Report{}} + ## Examples - """ - def create_system_report(rule_name, reason, target) do - rule = Rules.get_by_name!(rule_name) + iex> list_reports(admin, %{"query" => "open:true"}, pagination) + {:ok, %ReportPage{}, %Ecto.Changeset{}} - attrs = %{ - reason: reason, - user_agent: "system" - } + iex> list_reports(user, %{}, pagination) + {:error, :unauthorized} - attribution = %{ - system: true, - ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, - fingerprint: "ffff" - } - - struct(Report, target) - |> Report.creation_changeset(attrs, attribution, rule) - |> Repo.insert() - |> reindex_after_update() + """ + @spec list_reports(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, ReportPage.t(), Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :unauthorized} + def list_reports(%Actor{user: user} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Report), + {:ok, query, query_form} <- QueryBuilder.build_query(params, user) do + reports = + Report + |> Search.search_definition(query, pagination) + |> Search.search_records(report_query(@default_preloads)) + + {my_reports, system_reports} = + if not is_nil(query_form.query) do + {[], []} + else + open_report_query = + Report + |> where(open: true) + |> preload(^@default_preloads) + |> preload(^Report.target_preloads()) + |> order_by(desc: :created_at) + + my_reports = where(open_report_query, admin_id: ^user.id) + system_reports = where(open_report_query, system: true) + + {Repo.all(my_reports), Repo.all(system_reports)} + end + + page = + %ReportPage{ + reports: reports, + my_reports: my_reports, + system_reports: system_reports + } + + {:ok, page, QueryForm.changeset(query_form, user.id)} + end end @doc """ - Updates a report. + Loads a report for the staff show page with its target associations resolved. + + Malformed and missing IDs are always not-found. A real report the actor may + not show is unauthorized. ## Examples - iex> update_report(report, %{field: new_value}) + iex> show_report(moderator, "1") {:ok, %Report{}} - iex> update_report(report, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> show_report(moderator, "999999999") + {:error, :not_found} """ - def update_report(%Report{} = report, attrs) do - report - |> Report.changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec show_report(Actor.t(), Loader.integer_id()) :: + {:ok, Report.t()} | {:error, :unauthorized | :not_found} + def show_report(%Actor{} = actor, id) do + Loader.fetch_and_authorize(report_query(@default_preloads), actor, :show, id) end @doc """ - Deletes a Report. + Returns rendered moderator notes attached to `report`, or `nil` when the actor + may not read them. + + The note context separately authorizes the report with `:show_mod_notes`, so + sensitive note queries do not run before that gate. ## Examples - iex> delete_report(report) - {:ok, %Report{}} + iex> mod_notes(moderator, report, renderer) + [{%ModNote{}, "rendered"}] - iex> delete_report(report) - {:error, %Ecto.Changeset{}} + iex> mod_notes(user, report, renderer) + nil """ - def delete_report(%Report{} = report) do - Repo.delete(report) + @spec mod_notes(Actor.t(), Report.t(), (list() -> list())) :: list() | nil + def mod_notes(%Actor{} = actor, %Report{} = report, collection_renderer) do + case ModNotes.list_for_target(actor, {:report, report.id}, collection_renderer) do + {:ok, notes} -> notes + {:error, _reason} -> nil + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking report changes. + Builds a report form for the target described by `locator`. + + Write access is verified before the owning context safely loads and authorizes + the target. The returned `ReportForm` retains both the target and its empty + report changeset. Malformed and missing locators are not-found for every + actor. Hidden or otherwise forbidden real targets are unauthorized. ## Examples - iex> change_report(report) - %Ecto.Changeset{source: %Report{}} + iex> new_report(actor, {:image, "1"}) + {:ok, %ReportForm{target: %Image{}}} + + iex> new_report(banned_actor, {:image, "1"}) + {:error, :ban} """ - def change_report(%Report{} = report) do - Report.changeset(report, %{}) + @spec new_report(Actor.t(), target_locator()) :: + {:ok, ReportForm.t()} | {:error, :ban | :unauthorized | :not_found} + def new_report(%Actor{} = actor, locator) do + with :ok <- verify_write_access(actor), + {:ok, target} <- load_report_target(actor, locator) do + changeset = + target + |> Ecto.build_assoc(:reports) + |> Report.changeset() + + {:ok, + %ReportForm{ + target: target, + changeset: changeset, + rules: Rules.list_reportable_rules() + }} + end end @doc """ - Marks the report as claimed by the given user. + Creates a report for the safely loaded target described by `locator`. + + The same write-access, loading, and visibility checks as `new_report/2` run. + Normal and anonymous users are subject to an open report limit; staff are + exempt. A rejected insert returns a `ReportForm` carrying the loaded target + and rejected changeset, while a successful insert queues the report for + search indexing. - ## Example + ## Examples - iex> claim_report(%Report{}, %User{}) + iex> create_report(actor, {:image, "1"}, %{"reason" => "Spam"}) {:ok, %Report{}} + iex> create_report(actor, {:image, "1"}, %{"reason" => ""}) + {:error, %ReportForm{changeset: %Ecto.Changeset{}}} + + iex> create_report(actor, {:image, "1"}, attrs) + {:error, :too_many_reports} + """ - def claim_report(%Report{} = report, user) do - report - |> Report.claim_changeset(user) - |> Repo.update() - |> reindex_after_update() + @spec create_report(Actor.t(), target_locator(), map() | nil) :: + {:ok, Report.t()} + | {:error, :too_many_reports | :ban | :unauthorized | :not_found} + | {:error, ReportForm.t()} + def create_report(%Actor{user: user} = actor, locator, params) do + with :ok <- verify_write_access(actor), + {:ok, target} <- load_report_target(actor, locator), + {:ok, rule_id} <- Report.fetch_rule_id(params), + {:ok, rule} <- Rules.fetch_rule(rule_id) do + report_changeset = + target + |> Ecto.build_assoc(:reports) + |> Report.user_creation_changeset(params, actor, rule) + + Multi.new() + |> Multi.lock_advisory(:report_limit_ip, "reports:ip:#{actor.ip}") + |> then(fn multi -> + if user do + Multi.lock_one(multi, :report_limit_user, where(User, id: ^user.id)) + else + multi + end + end) + |> Multi.run(:report_limit, fn repo, _changes -> + case ensure_report_limit(repo, actor) do + :ok -> {:ok, nil} + error -> error + end + end) + |> Multi.insert(:report, report_changeset) + |> put_reindex_report() + |> Multi.transact() + |> case do + {:ok, %{report: %Report{} = report}} -> + {:ok, report} + + {:error, :report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, + %ReportForm{ + target: target, + changeset: changeset, + rules: Rules.list_reportable_rules() + }} + + {:error, :report_limit, :too_many_reports, _changes} -> + {:error, :too_many_reports} + end + end end @doc """ - Marks the report as unclaimed. + Claims an open, unclaimed report for the acting staff member. - ## Example + The report is loaded under a row lock and authorized with `:claim`. A raced + or repeated claim returns a changeset error rather than reassigning the + report. - iex> unclaim_report(%Report{}) - {:ok, %Report{}} + ## Examples + + iex> create_report_claim(moderator, "1") + {:ok, %Report{state: "in_progress"}} + + iex> create_report_claim(user, "1") + {:error, :unauthorized} """ - def unclaim_report(%Report{} = report) do - report - |> Report.unclaim_changeset() - |> Repo.update() - |> reindex_after_update() + @spec create_report_claim(Actor.t(), Loader.integer_id()) :: + {:ok, Report.t()} | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def create_report_claim(%Actor{user: user} = actor, report_id) do + with {:ok, report_id} <- Loader.parse_id(report_id) do + Multi.new() + |> put_lock_report(actor, :claim, report_id) + |> Multi.update(:report, fn %{locked_report: report} -> + Report.claim_changeset(report, user) + end) + |> put_reindex_report() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{report: report} -> + { + "Report.Claim:create", + Paths.admin_report_path(report.id), + "Claimed report" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{report: %Report{} = report}} -> + {:ok, report} + + {:error, :report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Marks the report as closed by the given user. + Releases the claim on an open report. - ## Example + The report is locked and authorized with `:unclaim`. - iex> close_report(%Report{}, %User{}) - {:ok, %Report{}} + ## Examples + + iex> delete_report_claim(moderator, "1") + {:ok, %Report{state: "open"}} """ - def close_report(%Report{} = report, user) do - report - |> Report.close_changeset(user) - |> Repo.update() - |> reindex_after_update() + @spec delete_report_claim(Actor.t(), Loader.integer_id()) :: + {:ok, Report.t()} | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_report_claim(%Actor{user: user} = actor, report_id) do + with {:ok, report_id} <- Loader.parse_id(report_id) do + Multi.new() + |> put_lock_report(actor, :unclaim, report_id) + |> Multi.update(:report, fn %{locked_report: report} -> + Report.unclaim_changeset(report, user) + end) + |> put_reindex_report() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{report: report} -> + { + "Report.Claim:delete", + Paths.admin_report_path(report.id), + "Released report" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{report: %Report{} = report}} -> + {:ok, report} + + {:error, :report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Reindex all reports where the user or admin has `old_name`. + Closes a report on behalf of the acting staff member. - ## Example + The report is locked and authorized with `:close`. - iex> user_name_reindex("Administrator", "Administrator2") - {:ok, %Req.Response{}} + ## Examples + + iex> create_report_close(moderator, "1") + {:ok, %Report{state: "closed", open: false}} """ - def user_name_reindex(old_name, new_name) do - data = Reports.SearchIndex.user_name_update_by_query(old_name, new_name) + @spec create_report_close(Actor.t(), Loader.integer_id()) :: + {:ok, Report.t()} | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def create_report_close(%Actor{user: user} = actor, report_id) do + with {:ok, report_id} <- Loader.parse_id(report_id) do + Multi.new() + |> put_lock_report(actor, :close, report_id) + |> Multi.update(:report, fn %{locked_report: report} -> + Report.close_changeset(report, user) + end) + |> put_reindex_report() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{report: report} -> + { + "Report.Close:create", + Paths.admin_report_path(report.id), + "Closed report" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{report: %Report{} = report}} -> + {:ok, report} - Search.update_by_query(Report, data.query, data.set_replacements, data.replacements) + {:error, :report, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + error -> + map_lock_errors(error) + end + end end - defp reindex_after_update({:ok, report}) do - reindex_report(report) + @doc """ + Adds a bulk close of reports for one already loaded target to `multi`. + + This is an internal composition API for owning contexts that delete or + approve a reportable target. - {:ok, report} - end + ## Examples - defp reindex_after_update(result) do - result + iex> put_close_reports(multi, :reports, moderator, image_id: image.id) + %Ecto.Multi{} + + """ + @spec put_close_reports(Multi.t(), Multi.name(), User.t(), keyword()) :: Multi.t() + def put_close_reports(%Multi{} = multi, step, closing_user, target) do + multi + |> Multi.update_all(step, fn _ -> close_report_query(closing_user, target) end, []) + |> Multi.on_commit(fn %{^step => {_count, report_ids}} -> + reindex_closed_reports(report_ids) + end) end @doc """ - Callback for post-transaction update. + Creates an internal system report within the transaction described by `multi`. + + The rule name must identify a reportable rule. This trusted service is used by + owning contexts to add a report when their target has been created or moderated. + + ## Examples + + iex> put_create_system_report( + ...> multi, + ...> "Approval", + ...> "Needs review", + ...> :comment_id, + ...> comment.id + ...> ) + %Multi{} - See `close_report_query/2` for more information and example. """ - def reindex_reports(report_ids) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", report_ids]) + @spec put_create_system_report( + multi :: Multi.t(), + rule_name :: String.t(), + reason :: String.t(), + target_column :: atom(), + target_id :: integer() + ) :: + Multi.t() + def put_create_system_report(multi, rule_name, reason, target_column, target_id) do + {:ok, rule} = Rules.fetch_rule_by_name(rule_name) + + attrs = %{reason: reason, user_agent: "system"} + + actor = %Actor{ + ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, + fingerprint: "ffff" + } - report_ids - end + report_changeset = + Report + |> struct([{target_column, target_id}]) + |> Report.system_creation_changeset(attrs, actor, rule) - @doc false - def reindex_report(%Report{} = report) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Reports", "id", [report.id]]) + multi + |> Multi.insert(:report, report_changeset) + |> put_reindex_report() + end + @doc """ + Converts one legacy report reason and persists the structured fields. + """ + @spec convert_legacy_report!(Report.t(), String.t(), Rule.t()) :: Report.t() + def convert_legacy_report!(%Report{} = report, reason, rule) do report + |> Report.conversion_changeset(%{reason: String.trim(reason)}, rule) + |> Repo.update!() end - @doc false - def perform_reindex(column, condition) do + @doc """ + Replaces attribution data on a user's reports in batches. + """ + @spec wipe_user_attribution!(integer(), term(), String.t()) :: :ok + def wipe_user_attribution!(user_id, ip, fingerprint) do Report - |> where([r], field(r, ^column) in ^condition) - |> preload([:user, :admin]) - |> Repo.all() - |> preload_targets() - |> Enum.map(&Search.index_document(&1, Report)) + |> where(user_id: ^user_id) + |> Batch.query_batches() + |> Enum.each(&Repo.update_all(&1, set: [ip: ip, fingerprint: fingerprint])) + + :ok end @doc """ - Preloads the target associations onto the given report(s). + Updates indexed user name fields. + + This maintenance callback is invoked after a committed user rename. + + ## Examples + + iex> user_name_reindex("Old Name", "New Name") + [{:ok, %Req.Response{}}] + """ - def preload_targets(%Report{} = report) do - Repo.preload(report, Report.target_preloads()) + @spec user_name_reindex(String.t(), String.t()) :: [term()] + def user_name_reindex(old_name, new_name) do + data = Reports.SearchIndex.user_name_update_by_query(old_name, new_name) + Search.update_by_query(Report, data.query, data.set_replacements, data.replacements) end - def preload_targets(reports) do - reports - |> Enum.to_list() - |> Repo.preload(Report.target_preloads()) - end + @doc """ + Returns the associations required to serialize reports into OpenSearch. + + This is the batch-indexer behaviour used by `Philomena.SearchIndexer`. + + ## Examples + + iex> indexing_preloads() + [:user, :admin, ...] + """ + @spec indexing_preloads() :: list() def indexing_preloads do [ :user, :admin, :reported_user, - image: from(i in Image, preload: :user), - comment: from(c in Comment, preload: :user), - post: from(p in Post, preload: :user), - commission: from(x in Commission, preload: :user), - conversation: from(c in Conversation, preload: [:from, :to]), - gallery: from(g in Gallery, preload: :user) + image: from(image in Image, preload: :user), + comment: from(comment in Comment, preload: :user), + post: from(post in Post, preload: :user), + commission: from(commission in Commission, preload: :user), + conversation: from(conversation in Conversation, preload: [:from, :to]), + gallery: from(gallery in Gallery, preload: :user) ] end - def convert_reports!() do - rules = - Rules.list_reportable_rules() - |> Enum.map(&{&1.name, &1}) - |> Map.new() - - Report - |> preload([:rule]) - |> Batch.records(batch_size: 128) - |> Enum.each(&convert_report(&1, rules)) - end + @doc """ + Reindexes reports matching `column` and `condition` for the index worker. - defp convert_report(%Report{rule_id: 1, reason: report_reason} = report, rules) do - match = Regex.run(@reason_regex, report_reason) + `column` is supplied by the trusted worker registry, not request input. - case match do - [_, prefix, suffix, reason] -> - rule = - case Map.get(rules, "#{prefix}#{suffix}") do - nil -> %{id: 1} - rule -> rule - end + ## Examples - report - |> Report.conversion_changeset(%{reason: String.trim(reason)}, rule) - |> Repo.update!() + iex> perform_reindex(:id, [1, 2]) + :ok - _ -> - {:error, report} - end + """ + @spec perform_reindex(atom(), list()) :: :ok + def perform_reindex(column, condition) do + Report + |> where([report], field(report, ^column) in ^condition) + |> preload(^indexing_preloads()) + |> Search.reindex(Report) end - - defp convert_report(report, _rules), do: {:ok, report} end diff --git a/lib/philomena/reports/legacy_converter.ex b/lib/philomena/reports/legacy_converter.ex new file mode 100644 index 000000000..a94e67b47 --- /dev/null +++ b/lib/philomena/reports/legacy_converter.ex @@ -0,0 +1,49 @@ +defmodule Philomena.Reports.LegacyConverter do + @moduledoc """ + Converts legacy report reasons to their structured rule and reason fields. + """ + + import Ecto.Query + + alias PhilomenaQuery.Batch + alias Philomena.Reports.Report + alias Philomena.Rules + alias Philomena.Reports + + @reason_regex ~r/^(Rule|Other|Takedown|Verification|Approval|Review|System)([^:]*): (.*)$/ + + defp convert_report(%Report{rule_id: 1, reason: report_reason} = report, rules) do + case Regex.run(@reason_regex, report_reason) do + [_, prefix, suffix, reason] -> + rule = Map.get(rules, "#{prefix}#{suffix}", %{id: 1}) + + Reports.convert_legacy_report!(report, reason, rule) + + _other -> + {:error, report} + end + end + + defp convert_report(report, _rules), do: {:ok, report} + + @doc """ + Converts legacy report reasons to their structured rule and reason fields. + + ## Examples + + iex> convert_reports!() + :ok + + """ + @spec convert_reports!() :: :ok + def convert_reports! do + rules = + Rules.list_reportable_rules() + |> Map.new(&{&1.name, &1}) + + Report + |> preload(:rule) + |> Batch.records(batch_size: 128) + |> Enum.each(&convert_report(&1, rules)) + end +end diff --git a/lib/philomena/reports/query_builder.ex b/lib/philomena/reports/query_builder.ex new file mode 100644 index 000000000..65c781e9b --- /dev/null +++ b/lib/philomena/reports/query_builder.ex @@ -0,0 +1,39 @@ +defmodule Philomena.Reports.QueryBuilder do + @moduledoc false + + alias Philomena.Reports.QueryForm + alias Philomena.Users.User + + @doc """ + Builds a report search query based on the given parameters. + + ## Parameters + + * `params` - Map of optional search parameters: + * `query` - Search query + + Returns `{:ok, query, query_form}` with an OpenSearch query body for `Reports` + that can be used with `PhilomenaQuery.Search`, or `{:error, changeset}` if the + provided parameters are invalid. + """ + @spec build_query(map(), User.t()) :: {:ok, map(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}, %User{} = user) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(user.id, params) + |> Ecto.Changeset.apply_action(:create) do + {:ok, apply_sort(query_form.compiled_query), query_form} + end + end + + defp apply_sort(query) do + %{ + query: query, + sort: [ + %{open: :desc}, + %{state: :desc}, + %{created_at: :desc} + ] + } + end +end diff --git a/lib/philomena/reports/query_form.ex b/lib/philomena/reports/query_form.ex new file mode 100644 index 000000000..ea4dffa1a --- /dev/null +++ b/lib/philomena/reports/query_form.ex @@ -0,0 +1,28 @@ +defmodule Philomena.Reports.QueryForm do + use Ecto.Schema + + import Ecto.Changeset + import PhilomenaQuery.Ecto.QueryValidator + + @type t :: %__MODULE__{} + + alias Philomena.Reports.Query + + embedded_schema do + field :query, :string + + field :compiled_query, :map, virtual: true + end + + @doc false + def changeset(query_form, admin_id, attrs \\ %{}) do + query_form + |> cast(attrs, [:query]) + |> validate_query( + :query, + with: &Query.compile/1, + default: "open:true AND NOT (admin_id:#{admin_id} OR system:true)", + into: :compiled_query + ) + end +end diff --git a/lib/philomena/reports/report.ex b/lib/philomena/reports/report.ex index dafdf93ee..0c242ce12 100644 --- a/lib/philomena/reports/report.ex +++ b/lib/philomena/reports/report.ex @@ -2,6 +2,7 @@ defmodule Philomena.Reports.Report do use Ecto.Schema import Ecto.Changeset + alias Philomena.Attribution.Actor alias Philomena.Users.User alias Philomena.Rules.Rule alias Philomena.Images.Image @@ -23,6 +24,8 @@ defmodule Philomena.Reports.Report do :gallery_id ] + @type t :: %__MODULE__{} + schema "reports" do belongs_to :user, User belongs_to :admin, User @@ -69,7 +72,7 @@ defmodule Philomena.Reports.Report do end @doc false - def changeset(report, attrs) do + def changeset(report, attrs \\ %{}) do report |> cast(attrs, []) |> validate_required([]) @@ -82,18 +85,19 @@ defmodule Philomena.Reports.Report do |> validate_required([:reason]) end - # Ensure that the report is not currently claimed before - # attempting to claim def claim_changeset(report, user) do change(report) - |> validate_inclusion(:admin_id, []) + |> validate_open() + |> validate_unclaimed() |> put_change(:admin_id, user.id) |> put_change(:open, true) |> put_change(:state, "in_progress") end - def unclaim_changeset(report) do + def unclaim_changeset(report, _user) do change(report) + |> validate_open() + |> validate_claimed() |> put_change(:admin_id, nil) |> put_change(:open, true) |> put_change(:state, "open") @@ -101,19 +105,44 @@ defmodule Philomena.Reports.Report do def close_changeset(report, user) do change(report) + |> validate_open() |> put_change(:admin_id, user.id) |> put_change(:open, false) |> put_change(:state, "closed") end + defp validate_open(changeset) do + if get_field(changeset, :open) do + changeset + else + add_error(changeset, :state, "must be open") + end + end + + defp validate_claimed(changeset) do + if is_nil(get_field(changeset, :admin_id)) do + add_error(changeset, :admin_id, "was not claimed") + else + changeset + end + end + + defp validate_unclaimed(changeset) do + if is_nil(get_field(changeset, :admin_id)) do + changeset + else + add_error(changeset, :admin_id, "has already been claimed") + end + end + @doc false - def creation_changeset(report, attrs, attribution, rule) do + def creation_changeset(report, attrs, %Actor{} = actor, rule) do report |> cast(attrs, [:reason, :user_agent]) |> put_assoc(:rule, rule) |> validate_length(:reason, max: 10_000, count: :bytes) |> validate_length(:user_agent, max: 1000, count: :bytes) - |> change(attribution) + |> change(Actor.to_changes(actor)) |> validate_required([ :reason, :ip, @@ -123,12 +152,33 @@ defmodule Philomena.Reports.Report do |> validate_target() end - def user_creation_changeset(report, attrs, attribution, rule) do + def system_creation_changeset(report, attrs, %Actor{} = actor, %Rule{} = rule) do report - |> creation_changeset(attrs, attribution, rule) + |> creation_changeset(attrs, actor, rule) + |> change(system: true) + end + + def user_creation_changeset(report, attrs, %Actor{} = actor, %Rule{} = rule) do + report + |> creation_changeset(attrs, actor, rule) |> validate_rule() end + @doc false + def fetch_rule_id(attrs) do + %__MODULE__{} + |> cast(attrs, [:rule_id]) + |> validate_required(:rule_id) + |> apply_action(:create) + |> case do + {:ok, %{rule_id: rule_id}} -> + {:ok, rule_id} + + _ -> + {:error, :not_found} + end + end + # A report must reference exactly one target on creation. defp validate_target(changeset) do set = Enum.count(@target_columns, &(not is_nil(get_field(changeset, &1)))) diff --git a/lib/philomena/reports/report_form.ex b/lib/philomena/reports/report_form.ex new file mode 100644 index 000000000..587c6f4e8 --- /dev/null +++ b/lib/philomena/reports/report_form.ex @@ -0,0 +1,37 @@ +defmodule Philomena.Reports.ReportForm do + @moduledoc """ + A report form paired with the safely loaded target it reports and the + reportable rules available for selection. + + The target is retained when validation fails so the controller can render the + same form without loading or authorizing the resource a second time. Rules + are loaded before presentation so views never query while rendering. + """ + + alias Philomena.Comments.Comment + alias Philomena.Commissions.Commission + alias Philomena.Conversations.Conversation + alias Philomena.Galleries.Gallery + alias Philomena.Images.Image + alias Philomena.Posts.Post + alias Philomena.Rules.Rule + alias Philomena.Users.User + + @type target :: + Image.t() + | Comment.t() + | Post.t() + | User.t() + | Commission.t() + | Conversation.t() + | Gallery.t() + + @enforce_keys [:target, :changeset, :rules] + defstruct [:target, :changeset, :rules] + + @type t :: %__MODULE__{ + target: target(), + changeset: Ecto.Changeset.t(), + rules: [Rule.t()] + } +end diff --git a/lib/philomena/reports/report_page.ex b/lib/philomena/reports/report_page.ex new file mode 100644 index 000000000..4708ed5f5 --- /dev/null +++ b/lib/philomena/reports/report_page.ex @@ -0,0 +1,17 @@ +defmodule Philomena.Reports.ReportPage do + @moduledoc """ + The assembled admin report listing: the searched reports, plus the viewing + admin's own open reports and the open system reports. + """ + + alias Philomena.Reports.Report + + @enforce_keys [:reports, :my_reports, :system_reports] + defstruct [:reports, :my_reports, :system_reports] + + @type t :: %__MODULE__{ + reports: Scrivener.Page.t(), + my_reports: [Report.t()], + system_reports: [Report.t()] + } +end diff --git a/lib/philomena/roles.ex b/lib/philomena/roles.ex deleted file mode 100644 index 395ce80f9..000000000 --- a/lib/philomena/roles.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule Philomena.Roles do - @moduledoc """ - The Roles context. - """ - - import Ecto.Query, warn: false - alias Philomena.Repo - - alias Philomena.Roles.Role - - @doc """ - Returns the list of roles. - - ## Examples - - iex> list_roles() - [%Role{}, ...] - - """ - def list_roles do - Repo.all(Role) - end - - @doc """ - Gets a single role. - - Raises `Ecto.NoResultsError` if the Role does not exist. - - ## Examples - - iex> get_role!(123) - %Role{} - - iex> get_role!(456) - ** (Ecto.NoResultsError) - - """ - def get_role!(id), do: Repo.get!(Role, id) - - @doc """ - Creates a role. - - ## Examples - - iex> create_role(%{field: value}) - {:ok, %Role{}} - - iex> create_role(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_role(attrs \\ %{}) do - %Role{} - |> Role.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a role. - - ## Examples - - iex> update_role(role, %{field: new_value}) - {:ok, %Role{}} - - iex> update_role(role, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_role(%Role{} = role, attrs) do - role - |> Role.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Role. - - ## Examples - - iex> delete_role(role) - {:ok, %Role{}} - - iex> delete_role(role) - {:error, %Ecto.Changeset{}} - - """ - def delete_role(%Role{} = role) do - Repo.delete(role) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking role changes. - - ## Examples - - iex> change_role(role) - %Ecto.Changeset{source: %Role{}} - - """ - def change_role(%Role{} = role) do - Role.changeset(role, %{}) - end -end diff --git a/lib/philomena/roles/role.ex b/lib/philomena/roles/role.ex index 27ccb73a9..64c67960e 100644 --- a/lib/philomena/roles/role.ex +++ b/lib/philomena/roles/role.ex @@ -2,6 +2,8 @@ defmodule Philomena.Roles.Role do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + schema "roles" do field :name, :string field :resource_type, :string diff --git a/lib/philomena/rules.ex b/lib/philomena/rules.ex index 3e04f664a..69e65a59b 100644 --- a/lib/philomena/rules.ex +++ b/lib/philomena/rules.ex @@ -1,37 +1,30 @@ defmodule Philomena.Rules do @moduledoc """ - The Rules context. + Rule publication, version history, and authorized rule administration. + + Public rule routes use the stable position as their locator. Locator parsing, + lookup, and authorization share the same missing-before-forbidden contract as + ID-based contexts. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.IntegerId + alias Philomena.Authorization + alias Philomena.Loader alias Philomena.Repo + alias Philomena.Attribution.Actor alias Philomena.Rules.Rule alias Philomena.Rules.RuleVersion alias Philomena.Users.User - @doc """ - Returns the list of rules. - - ## Examples - - iex> list_rules() - [%Rule{}, ...] - - """ - def list_rules do + defp list_rules do Repo.all(from r in Rule, order_by: [asc: r.position]) end - @doc """ - Returns the list of visible rules. - - ## Examples - - iex> list_visible_rules() - [%Rule{}, ...] - """ - def list_visible_rules do + defp list_visible_rules do Repo.all( from r in Rule, where: r.hidden == false and r.internal == false, @@ -48,6 +41,7 @@ defmodule Philomena.Rules do [%Rule{name: "Rule #0", ...}, ...] """ + @spec list_reportable_rules() :: [Rule.t()] def list_reportable_rules do Repo.all( from r in Rule, @@ -61,46 +55,36 @@ defmodule Philomena.Rules do ## Examples - iex> find_rule(123) - %Rule{} + iex> fetch_rule(123) + {:ok, %Rule{}} - iex> find_rule(456) - nil + iex> fetch_rule(456) + {:error, not_found} """ - def find_rule(id), do: Repo.get(Rule, id) + @spec fetch_rule(Loader.integer_id()) :: {:ok, Rule.t()} | {:error, :not_found} + def fetch_rule(id) do + Loader.fetch(Rule, id) + end @doc """ - Gets a single rule by its position. - - Raises `Ecto.NoResultsError` if the Rule does not exist. + Loads a rule by its name. ## Examples - iex> get_by_position!(0) - %Rule{name: "Rule #0", position: 0, ...} + iex> fetch_rule_by_name("Rule #0") + {:ok, %Rule{name: "Rule #0", ...}} - iex> get_by_position!(99999) - ** (Ecto.NoResultsError) + iex> fetch_rule_by_name("Nonexistent Rule") + {:error, :not_found} """ - def get_by_position!(position), do: Repo.get_by!(Rule, position: position) - - @doc """ - Gets a single rule by its name. - - Raises `Ecto.NoResultsError` if the Rule does not exist. - - ## Examples - - iex> get_by_name!("Rule #0") - %Rule{name: "Rule #0", ...} - - iex> get_by_name!("Nonexistent Rule") - ** (Ecto.NoResultsError) - - """ - def get_by_name!(name), do: Repo.get_by!(Rule, name: name) + @spec fetch_rule_by_name(String.t()) :: Loader.fetch_result(Rule.t()) + def fetch_rule_by_name(name) do + Rule + |> where(name: ^name) + |> Loader.one() + end @doc """ Returns a list of all rule versions for a given rule. @@ -111,6 +95,7 @@ defmodule Philomena.Rules do [%RuleVersion{...}, ...] """ + @spec list_rule_versions(Rule.t()) :: [RuleVersion.t()] def list_rule_versions(%Rule{} = rule) do Repo.all( from rv in RuleVersion, @@ -138,74 +123,184 @@ defmodule Philomena.Rules do create_rule_version(rule, %User{id: nil}) end - defp create_rule(attrs) do + defp insert_rule(attrs) do %Rule{} |> Rule.changeset(attrs) |> Repo.insert() end - @doc """ - Creates a rule and stores the initial version attributed to a user. - - If the user is nil, then it is assumed to be a system action. - - ## Examples - - iex> create_rule_with_version(%{name: "Rule #0", ...}, user) - {:ok, [%Rule{}, %RuleVersion{}]} - - iex> create_rule_with_version(%{bad_field: bad_value, ...}, user) - {:error, %Ecto.Changeset{}} - - """ - def create_rule_with_version(attrs, user) do + defp create_rule_with_version(attrs, user) do Repo.transact(fn -> - with {:ok, rule} <- create_rule(attrs), + with {:ok, rule} <- insert_rule(attrs), {:ok, rule_version} <- create_rule_version(rule, user) do {:ok, [rule, rule_version]} end end) end - defp update_rule(%Rule{} = rule, attrs) do + defp save_rule(%Rule{} = rule, attrs) do rule |> Rule.changeset(attrs) |> Repo.update() end + defp update_rule_with_version(%Rule{} = rule, user, attrs) do + Repo.transact(fn -> + with {:ok, updated_rule} <- save_rule(rule, attrs), + {:ok, rule_version} <- create_rule_version(updated_rule, user) do + {:ok, [updated_rule, rule_version]} + end + end) + end + + # Returns an `%Ecto.Changeset{}` for tracking rule changes. + defp change_rule(%Rule{} = rule, attrs \\ %{}) do + Rule.changeset(rule, attrs) + end + + defp load_authorized_rule(actor, position, action) do + with {:ok, position} <- Loader.parse_id(position) do + Rule + |> where(position: ^position) + |> Loader.one_and_authorize(actor, action) + end + end + + @doc """ + Returns the rules `actor` may see, ordered by position. + + A viewer who may edit rules sees every rule; everyone else sees only the + visible (non-hidden, non-internal) rules. + """ + @spec list_rules_for(Actor.t()) :: [Rule.t()] + def list_rules_for(%Actor{} = actor) do + case authorize(actor, :edit, Rule) do + :ok -> list_rules() + {:error, :unauthorized} -> list_visible_rules() + end + end + + @doc """ + Loads the rule at `position` for `actor` to be shown. + + A malformed or absent position is not found for every actor. A real hidden or + internal rule is unauthorized unless the actor has rule-edit permission. + + ## Examples + + iex> show_rule(actor, "1") + {:ok, %Rule{}} + + iex> show_rule(actor, "not-a-position") + {:error, :not_found} + """ + @spec show_rule(Actor.t(), IntegerId.integer_id()) :: + {:ok, Rule.t()} | {:error, :not_found | :unauthorized} + def show_rule(%Actor{} = actor, position) do + load_authorized_rule(actor, position, :show) + end + + @doc """ + Prepares a new rule on behalf of `actor`. + + Verifies write access and authorizes `:new` before returning the changeset. + + ## Examples + + iex> new_rule(admin_actor) + {:ok, %Ecto.Changeset{}} + + iex> new_rule(user_actor) + {:error, :unauthorized} + """ + @spec new_rule(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | Authorization.write_error() + def new_rule(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, Rule) do + {:ok, change_rule(%Rule{})} + end + end + @doc """ - Updates a rule and stores the new version attributed to a user. + Creates a rule (with its initial version) on behalf of `actor` from `attrs`. - If the user is nil, then it is assumed to be a system edit. + Verifies write access and authorizes `:create`. The rule and its initial + version are inserted in one transaction. ## Examples - iex> update_rule_with_version(rule, user, %{field: new_value}) + iex> create_rule(admin_actor, %{name: "Rule #1", position: 1}) {:ok, [%Rule{}, %RuleVersion{}]} - iex> update_rule_with_version(rule, user, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> create_rule(user_actor, %{name: "Rule #1", position: 1}) + {:error, :unauthorized} """ - def update_rule_with_version(%Rule{} = rule, user, attrs) do - Repo.transact(fn -> - with {:ok, updated_rule} <- update_rule(rule, attrs), - {:ok, rule_version} <- create_rule_version(updated_rule, user) do - {:ok, [updated_rule, rule_version]} - end - end) + @spec create_rule(Actor.t(), map()) :: + {:ok, [Rule.t() | RuleVersion.t()]} + | {:error, Ecto.Changeset.t()} + | Authorization.write_error() + def create_rule(%Actor{} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, Rule) do + create_rule_with_version(attrs, actor.user) + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking rule changes. + Loads the rule at `position` for `actor` to edit. + + Verifies write access before safely loading the position and authorizing + `:edit` on the real rule. ## Examples - iex> change_rule(rule) - %Ecto.Changeset{data: %Rule{}} + iex> edit_rule(admin_actor, "1") + {:ok, {%Rule{}, %Ecto.Changeset{}}} + + iex> edit_rule(admin_actor, "missing") + {:error, :not_found} """ - def change_rule(%Rule{} = rule, attrs \\ %{}) do - Rule.changeset(rule, attrs) + @spec edit_rule(Actor.t(), IntegerId.integer_id()) :: + {:ok, {Rule.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_rule(%Actor{} = actor, position) do + with :ok <- verify_write_access(actor), + {:ok, rule} <- load_authorized_rule(actor, position, :edit) do + {:ok, {rule, change_rule(rule)}} + end + end + + @doc """ + Updates the rule at `position` (with a new version), on behalf of `actor`, from + `attrs`. + + Verifies write access before safely loading the position and authorizing + `:update`. The update and its version are stored in one transaction. A + validation failure carries the original rule for form rendering. + + ## Examples + + iex> update_rule(admin_actor, "1", %{title: "Be excellent"}) + {:ok, [%Rule{}, %RuleVersion{}]} + + iex> update_rule(admin_actor, "1", %{name: ""}) + {:error, {%Rule{}, %Ecto.Changeset{}}} + + """ + @spec update_rule(Actor.t(), IntegerId.integer_id(), map()) :: + {:ok, [Rule.t() | RuleVersion.t()]} + | {:error, {Rule.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def update_rule(%Actor{} = actor, position, attrs) do + with :ok <- verify_write_access(actor), + {:ok, rule} <- load_authorized_rule(actor, position, :update) do + case update_rule_with_version(rule, actor.user, attrs) do + {:ok, [updated_rule, rule_version]} -> {:ok, [updated_rule, rule_version]} + {:error, changeset} -> {:error, {rule, changeset}} + end + end end end diff --git a/lib/philomena/rules/rule.ex b/lib/philomena/rules/rule.ex index 79dad9490..2ba6b225d 100644 --- a/lib/philomena/rules/rule.ex +++ b/lib/philomena/rules/rule.ex @@ -2,6 +2,8 @@ defmodule Philomena.Rules.Rule do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + @derive {Phoenix.Param, key: :position} schema "rules" do field :name, :string, default: "" diff --git a/lib/philomena/rules/rule_version.ex b/lib/philomena/rules/rule_version.ex index 2a9c9c32d..abf717b78 100644 --- a/lib/philomena/rules/rule_version.ex +++ b/lib/philomena/rules/rule_version.ex @@ -2,6 +2,8 @@ defmodule Philomena.Rules.RuleVersion do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + alias Philomena.Rules.Rule alias Philomena.Users.User diff --git a/lib/philomena/schema/approval.ex b/lib/philomena/schema/approval.ex index 135113948..cd415aa85 100644 --- a/lib/philomena/schema/approval.ex +++ b/lib/philomena/schema/approval.ex @@ -36,8 +36,23 @@ defmodule Philomena.Schema.Approval do user, check ) do - change(changeset, approved: approved?(user, body, check)) + was_approved? = fetch_field!(changeset, :approved) + approved? = approved?(user, body, check) + + change( + changeset, + approved: approved?, + became_unapproved?: was_approved? and not approved? + ) end def maybe_put_approval(changeset, _user, _check), do: changeset + + def approve_changeset(changeset) do + if get_field(changeset, :approved) do + add_error(changeset, :approved, "is already approved") + else + change(changeset, approved: true) + end + end end diff --git a/lib/philomena/schema/tag_list.ex b/lib/philomena/schema/tag_list.ex index 9b1db6c36..f4da92758 100644 --- a/lib/philomena/schema/tag_list.ex +++ b/lib/philomena/schema/tag_list.ex @@ -1,8 +1,7 @@ defmodule Philomena.Schema.TagList do - # TODO: remove this in favor of normalized relations + # Remove when tag lists are converted to normalized relations. alias Philomena.Tags.Tag alias Philomena.Repo - import Ecto.Changeset import Ecto.Query def assign_tag_list(model, field, target_field) do @@ -24,30 +23,4 @@ defmodule Philomena.Schema.TagList do %{model | target_field => tag_list} end - - def propagate_tag_list(changeset, field, target_field) do - tag_list = changeset |> get_field(field) |> parse_tag_list() - - lookup = - Tag - |> where([t], t.name in ^tag_list) - |> Repo.all() - |> Map.new(fn t -> {t.name, t.aliased_tag_id || t.id} end) - - tag_ids = - tag_list - |> Enum.map(&lookup[&1]) - |> Enum.reject(&is_nil/1) - |> Enum.uniq() - - changeset - |> put_change(target_field, tag_ids) - end - - defp parse_tag_list(list) do - (list || "") - |> String.split(",") - |> Enum.map(&String.trim(&1)) - |> Enum.filter(&(&1 != "")) - end end diff --git a/lib/philomena/site_notices.ex b/lib/philomena/site_notices.ex index a9042614f..7562558af 100644 --- a/lib/philomena/site_notices.ex +++ b/lib/philomena/site_notices.ex @@ -1,22 +1,39 @@ defmodule Philomena.SiteNotices do @moduledoc """ - The SiteNotices context. + Public notice scheduling and authorized administrative management. + + Public readers can fetch notices in their active UTC window without an + actor. Administrative functions are actor-first, enforce the global write + prerequisite for form and mutation paths, and distinguish missing records + from forbidden records. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.Attribution.Actor + alias Philomena.Authorization + alias Philomena.Loader + alias Philomena.Repo alias Philomena.SiteNotices.SiteNotice + defp load_site_notice(actor, id, action) do + Loader.fetch_and_authorize(SiteNotice, actor, action, id) + end + @doc """ - Returns the list of site_notices. + Returns currently active public notices, newest first. + + A notice is active only when it is live and the current UTC time is strictly + between its start and finish times. ## Examples - iex> list_site_notices() + iex> active_site_notices() [%SiteNotice{}, ...] """ + @spec active_site_notices() :: [SiteNotice.t()] def active_site_notices do now = DateTime.utc_now() @@ -28,83 +45,160 @@ defmodule Philomena.SiteNotices do end @doc """ - Gets a single site_notice. + Returns the paginated site notices for the admin listing, on behalf of + `actor`, newest start date first. - Raises `Ecto.NoResultsError` if the Site notice does not exist. + Authorizes `:index` against the site-notice model. Returns + `{:ok, site_notices}` or `{:error, :unauthorized}`. ## Examples - iex> get_site_notice!(123) - %SiteNotice{} + iex> list_site_notices(actor, %{page_number: 1, page_size: 25}) + {:ok, %Scrivener.Page{}} - iex> get_site_notice!(456) - ** (Ecto.NoResultsError) + iex> list_site_notices(regular_user_actor, %{page_number: 1, page_size: 25}) + {:error, :unauthorized} """ - def get_site_notice!(id), do: Repo.get!(SiteNotice, id) + @spec list_site_notices(Actor.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t()} | {:error, :unauthorized} + def list_site_notices(%Actor{} = actor, pagination) do + with :ok <- authorize(actor, :index, SiteNotice) do + site_notices = + SiteNotice + |> order_by(desc: :start_date) + |> Repo.paginate(pagination) + + {:ok, site_notices} + end + end @doc """ - Creates a site_notice. + Builds the changeset for a new site notice, on behalf of `actor`. + + Verifies write access, then authorizes `:new` against the site-notice model. ## Examples - iex> create_site_notice(%{field: value}) - {:ok, %SiteNotice{}} + iex> new_site_notice(admin_actor) + {:ok, %Ecto.Changeset{}} - iex> create_site_notice(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> new_site_notice(banned_admin_actor) + {:error, :ban} """ - def create_site_notice(creator, attrs \\ %{}) do - %SiteNotice{user_id: creator.id} - |> SiteNotice.changeset(attrs) - |> Repo.insert() + @spec new_site_notice(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | Authorization.write_error() + def new_site_notice(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, SiteNotice) do + {:ok, SiteNotice.changeset(%SiteNotice{})} + end end @doc """ - Updates a site_notice. + Creates a site notice on behalf of `actor`, whose user becomes its author. + + Verifies write access, authorizes `:create` against the site-notice model, and + attributes the inserted notice to the actor's user. ## Examples - iex> update_site_notice(site_notice, %{field: new_value}) + iex> create_site_notice(admin, %{field: value}) {:ok, %SiteNotice{}} - iex> update_site_notice(site_notice, %{field: bad_value}) + iex> create_site_notice(admin, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ - def update_site_notice(%SiteNotice{} = site_notice, attrs) do - site_notice - |> SiteNotice.changeset(attrs) - |> Repo.update() + @spec create_site_notice(Actor.t(), map()) :: + {:ok, SiteNotice.t()} + | Authorization.write_error() + | {:error, Ecto.Changeset.t()} + def create_site_notice(%Actor{} = actor, attrs \\ %{}) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, SiteNotice) do + %SiteNotice{user_id: actor.user.id} + |> SiteNotice.changeset(attrs) + |> Repo.insert() + end end @doc """ - Deletes a SiteNotice. + Loads the site notice named by the `id` for editing, on behalf of + `actor`, pairing it with a change-tracking changeset. + + Verifies write access, then loads the notice and authorizes `:edit` against + the real record. Malformed and absent IDs are always not found. ## Examples - iex> delete_site_notice(site_notice) + iex> edit_site_notice(admin_actor, "12") + {:ok, {%SiteNotice{}, %Ecto.Changeset{}}} + + iex> edit_site_notice(admin_actor, "missing") + {:error, :not_found} + + """ + @spec edit_site_notice(Actor.t(), Loader.integer_id()) :: + {:ok, {SiteNotice.t(), Ecto.Changeset.t()}} + | {:error, Authorization.write_error_reason() | :not_found} + def edit_site_notice(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, site_notice} <- load_site_notice(actor, id, :edit) do + {:ok, {site_notice, SiteNotice.changeset(site_notice)}} + end + end + + @doc """ + Updates the site notice named by the `id`, on behalf of `actor`. + + Verifies write access before loading the real record and authorizing + `:update`. A validation failure returns its changeset. + + ## Examples + + iex> update_site_notice(admin_actor, "12", %{title: "Maintenance"}) {:ok, %SiteNotice{}} - iex> delete_site_notice(site_notice) + iex> update_site_notice(admin_actor, "12", %{title: ""}) {:error, %Ecto.Changeset{}} """ - def delete_site_notice(%SiteNotice{} = site_notice) do - Repo.delete(site_notice) + @spec update_site_notice(Actor.t(), Loader.integer_id(), map()) :: + {:ok, SiteNotice.t()} + | {:error, Authorization.write_error_reason() | :not_found | Ecto.Changeset.t()} + def update_site_notice(%Actor{} = actor, id, params) do + with :ok <- verify_write_access(actor), + {:ok, site_notice} <- load_site_notice(actor, id, :update) do + site_notice + |> SiteNotice.changeset(params) + |> Repo.update() + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking site_notice changes. + Deletes the site notice named by the `id`, on behalf of `actor`. + + Verifies write access before loading the real record and authorizing + `:delete`. ## Examples - iex> change_site_notice(site_notice) - %Ecto.Changeset{source: %SiteNotice{}} + iex> delete_site_notice(admin_actor, "12") + {:ok, %SiteNotice{}} + + iex> delete_site_notice(admin_actor, "missing") + {:error, :not_found} """ - def change_site_notice(%SiteNotice{} = site_notice) do - SiteNotice.changeset(site_notice, %{}) + @spec delete_site_notice(Actor.t(), Loader.integer_id()) :: + {:ok, SiteNotice.t()} + | {:error, Authorization.write_error_reason() | :not_found} + def delete_site_notice(%Actor{} = actor, id) do + with :ok <- verify_write_access(actor), + {:ok, site_notice} <- load_site_notice(actor, id, :delete) do + Repo.delete(site_notice) + end end end diff --git a/lib/philomena/site_notices/site_notice.ex b/lib/philomena/site_notices/site_notice.ex index fa76558ab..66e8b4e43 100644 --- a/lib/philomena/site_notices/site_notice.ex +++ b/lib/philomena/site_notices/site_notice.ex @@ -4,6 +4,8 @@ defmodule Philomena.SiteNotices.SiteNotice do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "site_notices" do belongs_to :user, User @@ -19,7 +21,7 @@ defmodule Philomena.SiteNotices.SiteNotice do end @doc false - def changeset(site_notice, attrs) do + def changeset(site_notice, attrs \\ %{}) do site_notice |> cast(attrs, [:title, :text, :link, :link_text, :live, :start_date, :finish_date]) |> validate_required([:title, :text, :live, :start_date, :finish_date]) diff --git a/lib/philomena/site_statistics.ex b/lib/philomena/site_statistics.ex new file mode 100644 index 000000000..1d854c997 --- /dev/null +++ b/lib/philomena/site_statistics.ex @@ -0,0 +1,209 @@ +defmodule Philomena.SiteStatistics do + @moduledoc """ + Computes aggregate, sitewide statistics from PostgreSQL and OpenSearch. + + The returned struct is presentation-neutral. Callers decide how and where to + render it. + """ + + import Ecto.Query, warn: false + + alias Philomena.Comments.Comment + alias Philomena.Commissions.Commission + alias Philomena.Commissions.Item + alias Philomena.Forums.Forum + alias Philomena.Galleries.Gallery + alias Philomena.Galleries.Interaction + alias Philomena.Images.Image + alias Philomena.Posts.Post + alias Philomena.Repo + alias Philomena.Reports.Report + alias Philomena.Topics.Topic + alias Philomena.Users.User + alias PhilomenaQuery.Search + + @image_aggregation %{ + aggs: %{ + deleted: %{filter: %{term: %{hidden_from_users: true}}}, + non_deleted: %{ + aggs: %{ + all_time: %{date_histogram: %{field: "created_at", calendar_interval: "day"}}, + avg_comments: %{avg: %{field: "comment_count"}}, + faves_gt_0: %{filter: %{range: %{faves: %{gt: 0}}}}, + last_24h: %{filter: %{range: %{created_at: %{gt: "now-24h"}}}}, + score_gt_0: %{filter: %{range: %{score: %{gt: 0}}}}, + score_lt_0: %{filter: %{range: %{score: %{lt: 0}}}} + }, + filter: %{term: %{hidden_from_users: false}} + } + } + } + + @comment_aggregation %{ + aggs: %{ + deleted: %{filter: %{term: %{hidden_from_users: true}}}, + last_24h: %{filter: %{range: %{created_at: %{gt: "now-24h"}}}} + }, + track_total_hits: true + } + + @enforce_keys [ + :image_aggs, + :comment_aggs, + :forums_count, + :topics_count, + :posts_count, + :users_count, + :users_24h, + :open_commissions, + :commission_items, + :open_reports, + :report_stat_count, + :response_time, + :gallery_count, + :gallery_size, + :distinct_creators, + :images_in_galleries + ] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + image_aggs: map(), + comment_aggs: map(), + forums_count: non_neg_integer(), + topics_count: non_neg_integer(), + posts_count: non_neg_integer(), + users_count: non_neg_integer(), + users_24h: non_neg_integer(), + open_commissions: non_neg_integer(), + commission_items: non_neg_integer(), + open_reports: non_neg_integer(), + report_stat_count: non_neg_integer(), + response_time: non_neg_integer(), + gallery_count: non_neg_integer(), + gallery_size: non_neg_integer(), + distinct_creators: non_neg_integer(), + images_in_galleries: non_neg_integer() + } + + @doc """ + Computes a current snapshot of aggregate site statistics. + + ## Examples + + iex> snapshot = calculate() + + """ + @spec calculate() :: t() + def calculate do + {gallery_count, gallery_size, distinct_creators, images_in_galleries} = galleries() + {open_reports, report_count, response_time} = moderation() + {open_commissions, commission_items} = commissions() + {image_aggs, comment_aggs} = aggregations() + {forums, topics, posts} = forums() + {users, users_24h} = users() + + %__MODULE__{ + image_aggs: image_aggs, + comment_aggs: comment_aggs, + forums_count: forums, + topics_count: topics, + posts_count: posts, + users_count: users, + users_24h: users_24h, + open_commissions: open_commissions, + commission_items: commission_items, + open_reports: open_reports, + report_stat_count: report_count, + response_time: response_time, + gallery_count: gallery_count, + gallery_size: gallery_size, + distinct_creators: distinct_creators, + images_in_galleries: images_in_galleries + } + end + + defp aggregations do + { + Search.search(Image, @image_aggregation), + Search.search(Comment, @comment_aggregation) + } + end + + defp forums do + forums = + Forum + |> where(access_level: "normal") + |> Repo.aggregate(:count, :id) + + first_topic = Repo.one(first(Topic)) + last_topic = Repo.one(last(Topic)) + first_post = Repo.one(first(Post)) + last_post = Repo.one(last(Post)) + + {forums, id_span(last_topic, first_topic), id_span(last_post, first_post)} + end + + defp users do + total = Repo.aggregate(User, :count, :id) + + last_24h = + User + |> where([user], user.created_at > ago(1, "day")) + |> Repo.aggregate(:count, :id) + + {total, last_24h} + end + + defp galleries do + gallery_count = Repo.aggregate(Gallery, :count, :id) + + gallery_size = + Gallery + |> Repo.aggregate(:avg, :image_count) + |> Kernel.||(Decimal.new(0)) + |> Decimal.to_float() + |> trunc() + + distinct_creators = + Gallery + |> distinct(:user_id) + |> Repo.aggregate(:count, :id) + + first_interaction = Repo.one(first(Interaction)) + last_interaction = Repo.one(last(Interaction)) + + {gallery_count, gallery_size, distinct_creators, id_span(last_interaction, first_interaction)} + end + + defp commissions do + open_commissions = Repo.aggregate(where(Commission, open: true), :count, :id) + commission_items = Repo.aggregate(Item, :count, :id) + + {open_commissions, commission_items} + end + + defp moderation do + open_reports = Repo.aggregate(where(Report, open: true), :count, :id) + first_report = Repo.one(first(Report)) + last_report = Repo.one(last(Report)) + + closed_reports = + Report + |> where(open: false) + |> order_by(desc: :created_at) + |> limit(250) + |> Repo.all() + + response_time = + closed_reports + |> Enum.reduce(0, &(&2 + DateTime.diff(&1.updated_at, &1.created_at, :second))) + |> Kernel./(max(length(closed_reports), 1) * 3600) + |> trunc() + + {open_reports, id_span(last_report, first_report), response_time} + end + + defp id_span(nil, nil), do: 0 + defp id_span(%{id: last_id}, %{id: first_id}), do: last_id - first_id +end diff --git a/lib/philomena/source_changes.ex b/lib/philomena/source_changes.ex index ebbb6e5a3..379b0bb25 100644 --- a/lib/philomena/source_changes.ex +++ b/lib/philomena/source_changes.ex @@ -1,91 +1,349 @@ defmodule Philomena.SourceChanges do @moduledoc """ - The SourceChanges context. + Source URL edit history for images and attributed identities. + + This context resolves and authorizes image, user, IP, and fingerprint targets + before querying history. Image histories follow image visibility; user + histories require detailed-profile access; IP and fingerprint histories + require the shared identity-metadata permission. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + alias Philomena.Repo + alias Philomena.Attribution.Actor + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Multi alias Philomena.SourceChanges.SourceChange + alias Philomena.SourceChanges.QueryBuilder + alias Philomena.SourceChanges.QueryForm + alias Philomena.SourceChanges.SourceChangePage + alias Philomena.UserFingerprints + alias Philomena.Users + alias Philomena.Users.User + alias Philomena.Loader + alias PhilomenaQuery.Batch + alias PhilomenaQuery.IpMask + + @preloads [:user, image: [:user, :sources, tags: :aliases]] + + defp cast_ip(ip) do + case EctoNetwork.INET.cast(ip) do + {:ok, ip} -> + {:ok, ip} + + _error -> + {:error, :not_found} + end + end + + defp cast_fingerprint(fingerprint) when is_binary(fingerprint) do + fingerprint = + fingerprint + |> String.trim() + |> String.downcase() + + if UserFingerprints.valid_format?(fingerprint) do + {:ok, fingerprint} + else + {:error, :not_found} + end + end + + defp cast_fingerprint(_fingerprint), do: {:error, :not_found} @doc """ - Gets a single source_change. + Counts the history rows for an already-loaded image. - Raises `Ecto.NoResultsError` if the Source change does not exist. + This composition service is used after Images has authorized and + updated the image, so it does not resolve or authorize a raw locator itself. ## Examples - iex> get_source_change!(123) - %SourceChange{} + iex> count_for_image(image) + 3 + + """ + @spec count_for_image(Image.t()) :: non_neg_integer() + def count_for_image(%Image{id: image_id}) do + SourceChange + |> where(image_id: ^image_id) + |> Repo.aggregate(:count) + end + + @doc """ + Builds a lateral query that counts source changes for the image in the + parent query. + + The returned query expects an `:image` parent binding and is intended for + use with `Ecto.Query.subquery/1`. + + ## Examples - iex> get_source_change!(456) - ** (Ecto.NoResultsError) + iex> SourceChanges.count_query() + #Ecto.Query<...> """ - def get_source_change!(id), do: Repo.get!(SourceChange, id) + @spec count_query() :: Ecto.Query.t() + def count_query do + SourceChange + |> where(image_id: parent_as(:image).id) + |> select(%{count: count()}) + end @doc """ - Creates a source_change. + Loads a page of source changes for the image named by `image_id`. + + Images owns target loading and `:show` authorization. Malformed and absent + IDs are not found; an existing image hidden from the actor is unauthorized. + Entries are newest first with their users and images preloaded. ## Examples - iex> create_source_change(%{field: value}) - {:ok, %SourceChange{}} + iex> list_image_source_changes(actor, "42", %{}, page: 1, page_size: 25) + {:ok, %SourceChangePage{target: %Image{}, source_changes: %Scrivener.Page{}}, changeset} - iex> create_source_change(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> list_image_source_changes(actor, "missing", %{}, page: 1, page_size: 25) + {:error, :not_found} """ - def create_source_change(attrs \\ %{}) do - %SourceChange{} - |> SourceChange.changeset(attrs) - |> Repo.insert() + @spec list_image_source_changes( + Actor.t(), + Philomena.IntegerId.integer_id(), + map(), + Repo.pagination_params() + ) :: + {:ok, SourceChangePage.t(), Ecto.Changeset.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def list_image_source_changes(%Actor{} = actor, image_id, params, pagination) do + with {:ok, image} <- Images.load_visible_image(actor, image_id), + {:ok, query, query_form} <- QueryBuilder.build_query(params) do + source_changes = + query + |> where(image_id: ^image.id) + |> preload(^@preloads) + |> Repo.paginate(pagination) + + page = %SourceChangePage{target: image, source_changes: source_changes} + + {:ok, page, QueryForm.changeset(query_form)} + end end @doc """ - Updates a source_change. + Loads a page of source changes attributed to the active user named by `slug`. + + Missing and deactivated profiles are not found. No history or count query + runs for a forbidden target. Changes to the user's own anonymous uploads are + excluded. `params` may include an `added` filter (`true`/`"1"` for additions, + `false`/`"0"` for removals). `image_count` counts the distinct images + represented by the same filtered history query. The successful result includes + the normalized query changeset. ## Examples - iex> update_source_change(source_change, %{field: new_value}) - {:ok, %SourceChange{}} + iex> list_user_source_changes(moderator, "artist", %{}, page: 1, page_size: 25) + {:ok, %SourceChangePage{target: %User{}, image_count: 3}, changeset} + + iex> list_user_source_changes(moderator, "missing", %{}, page: 1, page_size: 25) + {:error, :not_found} + + """ + @spec list_user_source_changes(Actor.t(), String.t(), map(), Repo.pagination_params()) :: + {:ok, SourceChangePage.t(), Ecto.Changeset.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def list_user_source_changes(%Actor{} = actor, slug, params, pagination) do + with {:ok, user} <- Users.load_profile(actor, slug), + :ok <- authorize(actor, :show, user), + {:ok, query, query_form} <- QueryBuilder.build_query(params) do + query = + query + |> join(:inner, [source_change], _ in assoc(source_change, :image)) + |> where( + [source_change, image], + source_change.user_id == ^user.id and + not (image.user_id == ^user.id and image.anonymous == true) + ) + + source_changes = + query + |> preload(^@preloads) + |> Repo.paginate(pagination) + + image_count = + query + |> exclude(:order_by) + |> select([_source_change, image], count(image.id, :distinct)) + |> Repo.one() + + page = %SourceChangePage{ + target: user, + source_changes: source_changes, + image_count: image_count + } - iex> update_source_change(source_change, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + {:ok, page, QueryForm.changeset(query_form)} + end + end + + @doc """ + Loads a page of source changes attributed to `ip` or a requested subnet. + + The address is parsed before the identity-metadata permission is checked, so + malformed addresses are always not found. Valid addresses with no history + return an empty page. `params["mask"]` selects the queried subnet. `params` + may include an `added` filter (`true`/`"1"` for additions, `false`/`"0"` for + removals). The result target is the canonical address and `range` is the + actual masked range. + + The successful result includes the normalized query changeset. + + ## Examples + + iex> list_ip_source_changes(moderator, "203.0.113.5", %{}, page: 1, page_size: 25) + {:ok, %SourceChangePage{target: %Postgrex.INET{}, range: %Postgrex.INET{}}, changeset} + + iex> list_ip_source_changes(moderator, "not-an-ip", %{}, page: 1, page_size: 25) + {:error, :not_found} """ - def update_source_change(%SourceChange{} = source_change, attrs) do - source_change - |> SourceChange.changeset(attrs) - |> Repo.update() + @spec list_ip_source_changes(Actor.t(), String.t(), map(), Repo.pagination_params()) :: + {:ok, SourceChangePage.t(), Ecto.Changeset.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def list_ip_source_changes(%Actor{} = actor, ip, params, pagination) do + with {:ok, ip} <- cast_ip(ip), + :ok <- authorize(actor, :show, :identity_metadata), + {:ok, query, query_form} <- QueryBuilder.build_query(params) do + range = IpMask.parse_mask(ip, params) + + source_changes = + query + |> where(fragment("? >>= ip", ^range)) + |> preload(^@preloads) + |> Repo.paginate(pagination) + + page = %SourceChangePage{target: ip, range: range, source_changes: source_changes} + + {:ok, page, QueryForm.changeset(query_form)} + end end @doc """ - Deletes a SourceChange. + Loads a page of source changes attributed to a browser `fingerprint`. + + The value is trimmed, lowercased, and validated with UserFingerprints before + the identity-metadata permission is checked. Malformed values are always not + found. A valid fingerprint with no history returns an empty page. + + `params` may include an `added` filter (`true`/`"1"` for additions, `false`/ + `"0"` for removals). The successful result includes the normalized query + changeset. ## Examples - iex> delete_source_change(source_change) - {:ok, %SourceChange{}} + iex> list_fingerprint_source_changes(moderator, "c123", %{}, page: 1, page_size: 25) + {:ok, %SourceChangePage{target: "c123"}, changeset} + + iex> list_fingerprint_source_changes(moderator, "invalid", %{}, page: 1, page_size: 25) + {:error, :not_found} - iex> delete_source_change(source_change) - {:error, %Ecto.Changeset{}} + """ + @spec list_fingerprint_source_changes(Actor.t(), String.t(), map(), Repo.pagination_params()) :: + {:ok, SourceChangePage.t(), Ecto.Changeset.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def list_fingerprint_source_changes(%Actor{} = actor, fingerprint, params, pagination) do + with {:ok, fingerprint} <- cast_fingerprint(fingerprint), + :ok <- authorize(actor, :show, :identity_metadata), + {:ok, query, query_form} <- QueryBuilder.build_query(params) do + source_changes = + query + |> where(fingerprint: ^fingerprint) + |> preload(^@preloads) + |> Repo.paginate(pagination) + + page = %SourceChangePage{target: fingerprint, source_changes: source_changes} + + {:ok, page, QueryForm.changeset(query_form)} + end + end + @doc """ + Replaces attribution data on a user's source change history in batches. """ - def delete_source_change(%SourceChange{} = source_change) do - Repo.delete(source_change) + @spec wipe_user_attribution!(integer(), term(), String.t()) :: :ok + def wipe_user_attribution!(user_id, ip, fingerprint) do + SourceChange + |> where(user_id: ^user_id) + |> Batch.query_batches() + |> Enum.each(&Repo.update_all(&1, set: [ip: ip, fingerprint: fingerprint])) + + :ok end @doc """ - Returns an `%Ecto.Changeset{}` for tracking source_change changes. + Deletes one source change while undoing the change to its image. + + This action removes the history entirely and does not create a new source + change row, so erased source URLs will not be visible in history. ## Examples - iex> change_source_change(source_change) - %Ecto.Changeset{source: %SourceChange{}} + iex> erase_source_change(actor, source_change_id) + {:ok, %SourceChange{}} + + """ + @spec erase_source_change(Actor.t(), Loader.integer_id()) :: + {:ok, SourceChange.t()} | {:error, :ban | :unauthorized | :not_found} + def erase_source_change(%Actor{} = actor, source_change_id) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :erase, %User{}), + {:ok, source_change} <- Loader.fetch(SourceChange, source_change_id) do + Multi.new() + |> Images.put_revert_source_change(source_change) + |> Multi.delete(:source_change, source_change) + |> Multi.transact() + |> case do + {:ok, %{source_change: %SourceChange{} = source_change}} -> + {:ok, source_change} + end + end + end + + @doc """ + Records added and removed source URLs from an image mutation in `multi`. + The image owner can attach any image reindex needed by the surrounding + workflow after composing this operation. """ - def change_source_change(%SourceChange{} = source_change) do - SourceChange.changeset(source_change, %{}) + @spec put_record_image_changes(Multi.t(), Actor.t(), Multi.name()) :: Multi.t() + def put_record_image_changes(%Multi{} = multi, %Actor{} = actor, image_step \\ :image) do + multi + |> Multi.run(:added_source_changes, fn repo, %{^image_step => image} -> + rows = Enum.map(image.added_sources, &source_change_attributes(actor, image, &1, true)) + {count, nil} = repo.insert_all(SourceChange, rows) + {:ok, count} + end) + |> Multi.run(:removed_source_changes, fn repo, %{^image_step => image} -> + rows = Enum.map(image.removed_sources, &source_change_attributes(actor, image, &1, false)) + {count, nil} = repo.insert_all(SourceChange, rows) + {:ok, count} + end) + end + + defp source_change_attributes(%Actor{user: user} = actor, image, source, added) do + now = DateTime.utc_now(:second) + + %{ + image_id: image.id, + source_url: source, + user_id: if(user, do: user.id), + created_at: now, + updated_at: now, + ip: actor.ip, + fingerprint: actor.fingerprint, + added: added + } end end diff --git a/lib/philomena/source_changes/query_builder.ex b/lib/philomena/source_changes/query_builder.ex new file mode 100644 index 000000000..97351383e --- /dev/null +++ b/lib/philomena/source_changes/query_builder.ex @@ -0,0 +1,48 @@ +defmodule Philomena.SourceChanges.QueryBuilder do + @moduledoc false + + import Ecto.Query, warn: false + + alias Philomena.SourceChanges.SourceChange + alias Philomena.SourceChanges.QueryForm + + @doc """ + Builds a source change query for the given parameters. + + ## Parameters + + * `added` - Optional filter by state. When omitted, filters nothing. + + Returns `{:ok, query, query_form}` with a queryable that can be used with + `Repo.paginate/2`, or `{:error, changeset}` if the provided parameters are + invalid. + """ + @spec build_query(map()) :: + {:ok, Ecto.Query.t(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + query = + SourceChange + |> maybe_filter_added(query_form) + |> order_by(desc: :created_at, desc: :id) + + {:ok, query, query_form} + end + end + + defp maybe_filter_added(query, %QueryForm{added: added}) do + case added do + true -> + where(query, added: true) + + false -> + where(query, added: false) + + nil -> + query + end + end +end diff --git a/lib/philomena/source_changes/query_form.ex b/lib/philomena/source_changes/query_form.ex new file mode 100644 index 000000000..74207c055 --- /dev/null +++ b/lib/philomena/source_changes/query_form.ex @@ -0,0 +1,15 @@ +defmodule Philomena.SourceChanges.QueryForm do + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :added, :boolean + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + cast(query_form, attrs, [:added]) + end +end diff --git a/lib/philomena/source_changes/source_change.ex b/lib/philomena/source_changes/source_change.ex index d17e0d932..3028e14a0 100644 --- a/lib/philomena/source_changes/source_change.ex +++ b/lib/philomena/source_changes/source_change.ex @@ -2,6 +2,8 @@ defmodule Philomena.SourceChanges.SourceChange do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + schema "source_changes" do belongs_to :user, Philomena.Users.User belongs_to :image, Philomena.Images.Image diff --git a/lib/philomena/source_changes/source_change_page.ex b/lib/philomena/source_changes/source_change_page.ex new file mode 100644 index 000000000..af228d29f --- /dev/null +++ b/lib/philomena/source_changes/source_change_page.ex @@ -0,0 +1,23 @@ +defmodule Philomena.SourceChanges.SourceChangePage do + @moduledoc """ + A paginated source-change history together with its resolved target metadata. + + `range` is present for masked IP histories, and `image_count` is present for + user histories. + """ + + alias Philomena.Images.Image + alias Philomena.Users.User + + @enforce_keys [:target, :source_changes] + defstruct [:target, :source_changes, :range, :image_count] + + @type target :: Image.t() | User.t() | Postgrex.INET.t() | String.t() + + @type t :: %__MODULE__{ + target: target(), + source_changes: Scrivener.Page.t(), + range: Postgrex.INET.t() | nil, + image_count: non_neg_integer() | nil + } +end diff --git a/lib/philomena/static_pages.ex b/lib/philomena/static_pages.ex index ca6600f20..6df3a2b1b 100644 --- a/lib/philomena/static_pages.ex +++ b/lib/philomena/static_pages.ex @@ -1,122 +1,273 @@ defmodule Philomena.StaticPages do @moduledoc """ - The StaticPages context. + Public page presentation, staff-authored revisions, and generated site + statistics content. + + The generated statistics page deliberately bypasses revision history because + it is replaced by a periodic system service rather than a human editor. """ import Ecto.Query, warn: false - alias Ecto.Multi + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Loader + alias Philomena.Multi alias Philomena.Repo + alias Philomena.Attribution.Actor alias Philomena.StaticPages.StaticPage alias Philomena.StaticPages.Version + defp list_static_pages do + Repo.all(StaticPage) + end + + defp load_static_page(actor, action, slug) when is_binary(slug) do + StaticPage + |> where(slug: ^slug) + |> Loader.one_and_authorize(actor, action) + end + + defp load_static_page(_actor, _action, _slug), do: {:error, :not_found} + + defp create_static_page(user, attrs) do + static_page = StaticPage.changeset(%StaticPage{}, attrs) + + Multi.new() + |> Multi.insert(:static_page, static_page) + |> Multi.insert(:version, fn %{static_page: static_page} -> + %Version{static_page_id: static_page.id, user_id: user.id} + |> Version.changeset(attrs) + end) + |> Multi.transact() + end + + defp update_static_page(%StaticPage{} = static_page, user, attrs) do + version = + %Version{static_page_id: static_page.id, user_id: user.id} + |> Version.changeset(attrs) + + static_page = + static_page + |> StaticPage.changeset(attrs) + + Multi.new() + |> Multi.update(:static_page, static_page) + |> Multi.insert(:version, version) + |> Multi.transact() + end + + defp change_static_page(%StaticPage{} = static_page) do + StaticPage.changeset(static_page, %{}) + end + @doc """ - Returns the list of static_pages. + Returns the static pages listing on behalf of `actor`. + + The listing is staff-only. Returns `{:error, :unauthorized}` when the viewer + may not manage static pages, otherwise `{:ok, static_pages}`. ## Examples - iex> list_static_pages() - [%StaticPage{}, ...] + iex> list_pages(admin_actor) + {:ok, [%StaticPage{}]} """ - def list_static_pages do - Repo.all(StaticPage) + @spec list_pages(Actor.t()) :: {:ok, [StaticPage.t()]} | {:error, :unauthorized} + def list_pages(%Actor{} = actor) do + with :ok <- authorize(actor, :index, StaticPage) do + {:ok, list_static_pages()} + end end @doc """ - Gets a single static_page. + Loads the static page named by `slug`, on behalf of `actor`. - Raises `Ecto.NoResultsError` if the Static page does not exist. + Missing pages are always not-found, while an existing forbidden page is + unauthorized. Individual pages are public. ## Examples - iex> get_static_page!(123) - %StaticPage{} + iex> show_page(actor, "about") + {:ok, %StaticPage{}} - iex> get_static_page!(456) - ** (Ecto.NoResultsError) + iex> show_page(actor, "missing") + {:error, :not_found} """ - def get_static_page!(id), do: Repo.get!(StaticPage, id) + @spec show_page(Actor.t(), String.t()) :: + {:ok, StaticPage.t()} | {:error, :not_found | :unauthorized} + def show_page(%Actor{} = actor, slug) do + load_static_page(actor, :show, slug) + end @doc """ - Creates a static_page. + Loads the revision history for the static page named by `slug`, on behalf of + `actor`. + + The page is loaded and authorized before its history query runs. On success, + versions are newest first (ties broken by id) with their editors preloaded. ## Examples - iex> create_static_page(%{field: value}) - {:ok, %StaticPage{}} - - iex> create_static_page(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> list_page_history(actor, "about") + {:ok, {%StaticPage{}, [%Version{}]}} """ - def create_static_page(user, attrs \\ %{}) do - static_page = StaticPage.changeset(%StaticPage{}, attrs) + @spec list_page_history(Actor.t(), String.t()) :: + {:ok, {StaticPage.t(), [Version.t()]}} | {:error, :not_found | :unauthorized} + def list_page_history(%Actor{} = actor, slug) do + with {:ok, static_page} <- load_static_page(actor, :show, slug) do + versions = + Version + |> where(static_page_id: ^static_page.id) + |> preload(:user) + |> order_by(desc: :created_at, desc: :id) + |> Repo.all() + + {:ok, {static_page, versions}} + end + end - Multi.new() - |> Multi.insert(:static_page, static_page) - |> Multi.run(:version, fn repo, %{static_page: static_page} -> - %Version{static_page_id: static_page.id, user_id: user.id} - |> Version.changeset(attrs) - |> repo.insert() - end) - |> Repo.transaction() + @doc """ + Prepares a new static page, on behalf of `actor`. + + The form enforces the same write-access and `:new` authorization checks as + creation. + + ## Examples + + iex> new_page(admin_actor) + {:ok, %Ecto.Changeset{}} + + iex> new_page(banned_actor) + {:error, :ban} + + """ + @spec new_page(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_page(%Actor{} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :new, StaticPage) do + {:ok, change_static_page(%StaticPage{})} + end end @doc """ - Updates a static_page. + Creates a static page (with its initial version), on behalf of `actor`. + + The page and its initial revision commit atomically. Validation failures + return the page changeset; successful calls return the created page. ## Examples - iex> update_static_page(static_page, %{field: new_value}) + iex> create_page(admin_actor, %{title: "About", slug: "about", body: "..."}) {:ok, %StaticPage{}} - iex> update_static_page(static_page, %{field: bad_value}) + iex> create_page(admin_actor, %{title: ""}) {:error, %Ecto.Changeset{}} """ - def update_static_page(%StaticPage{} = static_page, user, attrs) do - version = - %Version{static_page_id: static_page.id, user_id: user.id} - |> Version.changeset(attrs) + @spec create_page(Actor.t(), map()) :: + {:ok, StaticPage.t()} + | {:error, Ecto.Changeset.t() | :ban | :unauthorized} + def create_page(%Actor{} = actor, attrs) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :create, StaticPage) do + actor.user + |> create_static_page(attrs) + |> case do + {:ok, %{static_page: %StaticPage{} = static_page}} -> + {:ok, static_page} + + {:error, :static_page, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end - static_page = - static_page - |> StaticPage.changeset(attrs) + @doc """ + Loads the static page named by `slug` for edit, on behalf of `actor`. - Multi.new() - |> Multi.update(:static_page, static_page) - |> Multi.insert(:version, version) - |> Repo.transaction() + The form enforces the same write-access and `:edit` authorization checks as + update. Missing pages are always not-found. + + ## Examples + + iex> edit_page(admin_actor, "about") + {:ok, {%StaticPage{}, %Ecto.Changeset{}}} + + """ + @spec edit_page(Actor.t(), String.t()) :: + {:ok, {StaticPage.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_page(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, static_page} <- load_static_page(actor, :edit, slug) do + {:ok, {static_page, change_static_page(static_page)}} + end end @doc """ - Deletes a StaticPage. + Updates the static page named by `slug` (with a new version), on behalf of + `actor`. + + The page update and revision insert commit atomically. Missing pages are + always not-found; validation failures return the page changeset. ## Examples - iex> delete_static_page(static_page) + iex> update_page(admin_actor, "about", %{body: "Updated"}) {:ok, %StaticPage{}} - iex> delete_static_page(static_page) - {:error, %Ecto.Changeset{}} - """ - def delete_static_page(%StaticPage{} = static_page) do - Repo.delete(static_page) + @spec update_page(Actor.t(), String.t(), map()) :: + {:ok, StaticPage.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_page(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, static_page} <- load_static_page(actor, :update, slug) do + static_page + |> update_static_page(actor.user, attrs) + |> case do + {:ok, %{static_page: %StaticPage{} = static_page}} -> + {:ok, static_page} + + {:error, :static_page, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking static_page changes. + Creates or replaces the generated statistics page body. + + This service is used by the site statistics renderer. As there is no + relevance to auditing its changes, it does not create any edit history. ## Examples - iex> change_static_page(static_page) - %Ecto.Changeset{source: %StaticPage{}} + iex> upsert_statistics_page("There are 42 images.") + {1, nil} """ - def change_static_page(%StaticPage{} = static_page) do - StaticPage.changeset(static_page, %{}) + @spec upsert_statistics_page(String.t()) :: {non_neg_integer(), nil | [term()]} + def upsert_statistics_page(body) when is_binary(body) do + now = DateTime.utc_now(:second) + + Repo.insert_all( + StaticPage, + [ + %{ + title: "Statistics", + slug: "stats", + body: body, + created_at: now, + updated_at: now + } + ], + on_conflict: {:replace, [:body, :updated_at]}, + conflict_target: :slug + ) end end diff --git a/lib/philomena/static_pages/static_page.ex b/lib/philomena/static_pages/static_page.ex index bf883076e..b4c8fe544 100644 --- a/lib/philomena/static_pages/static_page.ex +++ b/lib/philomena/static_pages/static_page.ex @@ -2,6 +2,8 @@ defmodule Philomena.StaticPages.StaticPage do use Ecto.Schema import Ecto.Changeset + @type t :: %__MODULE__{} + @derive {Phoenix.Param, key: :slug} schema "static_pages" do diff --git a/lib/philomena/static_pages/version.ex b/lib/philomena/static_pages/version.ex index 49ed3860c..0965ac86c 100644 --- a/lib/philomena/static_pages/version.ex +++ b/lib/philomena/static_pages/version.ex @@ -5,6 +5,8 @@ defmodule Philomena.StaticPages.Version do alias Philomena.StaticPages.StaticPage alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "static_page_versions" do belongs_to :static_page, StaticPage belongs_to :user, User diff --git a/lib/philomena/subscriptions.ex b/lib/philomena/subscriptions.ex index f636ace84..03694eabf 100644 --- a/lib/philomena/subscriptions.ex +++ b/lib/philomena/subscriptions.ex @@ -8,7 +8,8 @@ defmodule Philomena.Subscriptions do This is the name of the object field in the subscription table. For `m:Philomena.Images`, this would be `:image_id`. - The following functions and documentation are produced in the calling module: + The following internal persistence functions are produced in the calling + module: - `subscribed?/2` - `subscriptions/2` - `create_subscription/2` @@ -17,8 +18,8 @@ defmodule Philomena.Subscriptions do """ import Ecto.Query, warn: false - alias Ecto.Multi + alias Philomena.Multi alias Philomena.Repo defmacro __using__(opts) do @@ -41,15 +42,8 @@ defmodule Philomena.Subscriptions do subscription_module = Module.concat(__CALLER__.module, Subscription) quote do - @doc """ - Returns whether the user is currently subscribed to this object. - - ## Examples - - iex> subscribed?(object, user) - false - - """ + @doc false + @spec subscribed?(struct(), Philomena.Users.User.t() | nil) :: boolean() def subscribed?(object, user) do Philomena.Subscriptions.subscribed?( unquote(subscription_module), @@ -59,16 +53,10 @@ defmodule Philomena.Subscriptions do ) end - @doc """ - Returns a map containing whether the user is currently subscribed to any of - the provided objects. - - ## Examples - - iex> subscriptions([%{id: 1}, %{id: 2}], user) - %{2 => true} - - """ + @doc false + @spec subscriptions(Enumerable.t(), Philomena.Users.User.t() | nil) :: %{ + optional(term()) => true + } def subscriptions(objects, user) do Philomena.Subscriptions.subscriptions( unquote(subscription_module), @@ -78,18 +66,9 @@ defmodule Philomena.Subscriptions do ) end - @doc """ - Creates a subscription. - - ## Examples - - iex> create_subscription(object, user) - {:ok, %Subscription{}} - - iex> create_subscription(object, user) - {:error, %Ecto.Changeset{}} - - """ + @doc false + @spec create_subscription(struct(), Philomena.Users.User.t()) :: + {:ok, struct()} | {:error, Ecto.Changeset.t()} def create_subscription(object, user) do Philomena.Subscriptions.create_subscription( unquote(subscription_module), @@ -99,15 +78,8 @@ defmodule Philomena.Subscriptions do ) end - @doc """ - Deletes a subscription and removes notifications for it. - - ## Examples - - iex> delete_subscription(object, user) - {:ok, %Subscription{}} - - """ + @doc false + @spec delete_subscription(struct(), Philomena.Users.User.t()) :: {:ok, struct()} def delete_subscription(object, user) do unquote(on_delete) @@ -119,21 +91,13 @@ defmodule Philomena.Subscriptions do ) end - @doc """ - Creates a subscription inside the `m:Ecto.Multi` flow if `user` is not nil - and `field` in `user` is `true`. - - Valid values for field are `:watch_on_reply`, `:watch_on_upload`, `:watch_on_new_topic`. - - ## Examples - - iex> maybe_subscribe_on(multi, :image, user, :watch_on_reply) - %Ecto.Multi{} - - iex> maybe_subscribe_on(multi, :topic, nil, :watch_on_reply) - %Ecto.Multi{} - - """ + @doc false + @spec maybe_subscribe_on( + Philomena.Multi.t(), + Philomena.Multi.name(), + Philomena.Users.User.t() | nil, + :watch_on_reply | :watch_on_upload | :watch_on_new_topic + ) :: Philomena.Multi.t() def maybe_subscribe_on(multi, change_name, user, field) do Philomena.Subscriptions.maybe_subscribe_on(multi, __MODULE__, change_name, user, field) end @@ -141,6 +105,7 @@ defmodule Philomena.Subscriptions do end @doc false + @spec subscribed?(module(), atom(), struct(), Philomena.Users.User.t() | nil) :: boolean() def subscribed?(subscription_module, field_name, object, user) do case user do nil -> @@ -154,6 +119,9 @@ defmodule Philomena.Subscriptions do end @doc false + @spec subscriptions(module(), atom(), Enumerable.t(), Philomena.Users.User.t() | nil) :: %{ + optional(term()) => true + } def subscriptions(subscription_module, field_name, objects, user) do case user do nil -> @@ -170,6 +138,8 @@ defmodule Philomena.Subscriptions do end @doc false + @spec create_subscription(module(), atom(), struct(), Philomena.Users.User.t()) :: + {:ok, struct()} | {:error, Ecto.Changeset.t()} def create_subscription(subscription_module, field_name, object, user) do struct!(subscription_module, [{field_name, object.id}, {:user_id, user.id}]) |> subscription_module.changeset(%{}) @@ -177,6 +147,8 @@ defmodule Philomena.Subscriptions do end @doc false + @spec delete_subscription(module(), atom(), struct(), Philomena.Users.User.t()) :: + {:ok, struct()} def delete_subscription(subscription_module, field_name, object, user) do subscription = struct!(subscription_module, [{field_name, object.id}, {:user_id, user.id}]) @@ -188,6 +160,13 @@ defmodule Philomena.Subscriptions do end @doc false + @spec maybe_subscribe_on( + Multi.t(), + module(), + Multi.name(), + Philomena.Users.User.t() | nil, + :watch_on_reply | :watch_on_upload | :watch_on_new_topic + ) :: Multi.t() def maybe_subscribe_on(multi, module, change_name, user, field) when field in [:watch_on_reply, :watch_on_upload, :watch_on_new_topic] do case user do diff --git a/lib/philomena/tag_changes.ex b/lib/philomena/tag_changes.ex index 2ca86818c..dc1925405 100644 --- a/lib/philomena/tag_changes.ex +++ b/lib/philomena/tag_changes.ex @@ -1,345 +1,861 @@ defmodule Philomena.TagChanges do @moduledoc """ - The TagChanges context. + Searchable tag edit history and moderation workflows. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3] + + alias Philomena.Attribution.Actor + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.IndexWorker + alias Philomena.IntegerId + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.Multi alias Philomena.Repo - alias PhilomenaQuery.Parse.IpParser - alias PhilomenaQuery.Search alias Philomena.TagChangeRevertWorker - alias Philomena.TagChanges - alias Philomena.TagChanges.TagChange - alias Philomena.TagChanges.Query + alias Philomena.TagChanges.QueryBuilder + alias Philomena.TagChanges.QueryForm + alias Philomena.TagChanges.RevertForm alias Philomena.TagChanges.SearchIndex - alias Philomena.IndexWorker - alias Philomena.Images - alias Philomena.Images.Image + alias Philomena.TagChanges.TagChangeTag + alias Philomena.TagChanges.TagChange + alias Philomena.TagChanges.TagChangePage + alias Philomena.Tags alias Philomena.Tags.Tag + alias Philomena.UserFingerprints + alias Philomena.Users alias Philomena.Users.User + alias PhilomenaQuery.Batch + alias PhilomenaQuery.Search - # Accepts a list of TagChanges.TagChange IDs. - def mass_revert(ids, attributes) do - tag_changes = - Repo.all( - from tc in TagChange, - inner_join: i in assoc(tc, :image), - where: tc.id in ^ids and i.hidden_from_users == false, - order_by: [desc: :created_at], - preload: [tags: [:tag, :tag_change]] - ) + @history_preloads [ + :user, + image: [:user, :sources, tags: :aliases], + tag_change_tags: [:tag] + ] + + defp tag_change_tag_rows(_tag_change, nil, _added), do: [] - case mass_revert_tags(Enum.flat_map(tag_changes, & &1.tags), attributes) do - {:ok, _result} -> - {:ok, tag_changes} + defp tag_change_tag_rows(tag_change, tags, added) do + Enum.map(tags, &%{tag_change_id: tag_change.id, tag_id: &1.id, added: added}) + end + + defp cast_ip(ip) do + case EctoNetwork.INET.cast(ip) do + {:ok, ip} -> + {:ok, ip} - error -> - error + _error -> + {:error, :not_found} end end - # Accepts a list of TagChanges.Tag objects with tag_change and tag relations preloaded. - def mass_revert_tags(tags, attributes) do - # Reverting a set of changes means restoring the state from before the - # earliest of them, so collapse each (image, tag) history to its earliest - # record and invert that. `created_at` has second precision, - # so ties are broken by tag change id. - changes_per_image = - tags - |> Enum.group_by(& &1.tag_change.image_id) - |> Enum.map(fn {image_id, instances} -> - changed_tags = - instances - |> Enum.sort_by(&{DateTime.to_unix(&1.tag_change.created_at), &1.tag_change_id}) - |> Enum.uniq_by(& &1.tag_id) - - {added_tags, removed_tags} = Enum.split_with(changed_tags, & &1.added) - - # We send removed tags to be added, and added to be removed. That's how reverting works! - %{ - image_id: image_id, - added_tags: Enum.map(removed_tags, & &1.tag), - removed_tags: Enum.map(added_tags, & &1.tag) - } - end) + defp cast_fingerprint(fingerprint) when is_binary(fingerprint) do + fingerprint = + fingerprint + |> String.trim() + |> String.downcase() - Images.batch_update(changes_per_image, attributes) + if UserFingerprints.valid_format?(fingerprint) do + {:ok, fingerprint} + else + {:error, :not_found} + end end - def full_revert(%{user_id: _user_id, attributes: _attributes} = params), - do: Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [params]) + defp cast_fingerprint(_fingerprint), do: {:error, :not_found} + + defp search_tag_changes( + %Actor{user: user}, + target, + resource_filter, + params, + pagination + ) do + with {:ok, body, query_form} <- QueryBuilder.build_query(user, params) do + filters = List.wrap(resource_filter) + body = %{body | query: %{bool: %{must: [body.query | filters]}}} + + tag_changes = + TagChange + |> Search.search_definition(body, pagination) + |> Search.search_records(preload(TagChange, ^@history_preloads)) + + page = %TagChangePage{ + target: target, + tag_changes: tag_changes + } - def full_revert(%{ip: _ip, attributes: _attributes} = params), - do: Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [params]) + {:ok, page, QueryForm.changeset(query_form, user)} + end + end - def full_revert(%{fingerprint: _fingerprint, attributes: _attributes} = params), - do: Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [params]) + defp revert_tag_change_ids(ids, attributes) do + tag_change_query = + from tag_change in TagChange, + inner_join: image in assoc(tag_change, :image), + where: tag_change.id in ^ids and image.hidden_from_users == false, + order_by: [desc: tag_change.created_at, desc: tag_change.id], + preload: [tag_change_tags: [:tag, :tag_change]] + + tag_changes = Repo.all(tag_change_query) + + with {:ok, _result} <- + tag_changes + |> Enum.flat_map(& &1.tag_change_tags) + |> revert_tag_change_tags(attributes) do + {:ok, tag_changes} + end + end + + defp revert_tag_change_tags(tag_change_tags, attributes) do + tag_change_tags + |> Enum.group_by(& &1.tag_change.image_id) + |> Enum.map(fn {image_id, instances} -> + changed_tags = + instances + |> Enum.sort_by(&{DateTime.to_unix(&1.tag_change.created_at), &1.tag_change_id}) + |> Enum.map(fn %{tag_id: tag_id, added: added} -> + %{tag_id: tag_id, added: added} + end) + + %{ + image_id: image_id, + tag_changes: changed_tags + } + end) + |> Images.batch_revert(attributes) + end + + defp put_enqueue_full_revert(%Multi{} = multi, actor, target) do + attributes = %{ + ip: to_string(actor.ip), + fingerprint: actor.fingerprint, + user_id: actor.user.id, + batch_size: 100 + } + + Multi.on_commit(multi, fn _changes -> + Exq.enqueue(Exq, "indexing", TagChangeRevertWorker, [ + Map.put(target, :attributes, attributes) + ]) + end) + end @doc """ - Updates tag change search indices when a user's name changes. + Enqueues a reversion of all tag changes attributed to a user profile. + + Missing profiles return `{:error, :not_found}`. ## Examples - iex> user_name_reindex("old_username", "new_username") - :ok + iex> create_user_tag_change_revert(moderator, "some-user") + {:ok, %User{}} """ - def user_name_reindex(old_name, new_name) do - data = SearchIndex.user_name_update_by_query(old_name, new_name) - - Search.update_by_query(TagChange, data.query, data.set_replacements, data.replacements) + @spec create_user_tag_change_revert(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_user_tag_change_revert(%Actor{} = actor, slug) do + with :ok <- authorize(actor, :revert, TagChange), + {:ok, user} <- Users.load_profile(actor, slug) do + Multi.new() + |> put_enqueue_full_revert(actor, %{user_id: user.id}) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "TagChange.FullRevert:create", + Paths.profile_path(user), + "Reverted all tag changes for user #{user.name}" + ) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, user} + + error -> + error + end + end end @doc """ - Queues a tag change for reindexing. + Enqueues a reversion of all tag changes attributed to an IP address. - Adds the tag change to the indexing queue to update its search index. + Invalid IP addresses return `{:error, :not_found}`. ## Examples - iex> reindex_tag_change(tag_change) - %TagChange{} + iex> create_ip_tag_change_revert(moderator, "203.0.113.5") + {:ok, "203.0.113.5"} """ - def reindex_tag_change(%TagChange{} = tag_change) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["TagChanges", "id", [tag_change.id]]) - - tag_change + @spec create_ip_tag_change_revert(Actor.t(), term()) :: + {:ok, String.t()} | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_ip_tag_change_revert(%Actor{} = actor, ip) do + with :ok <- authorize(actor, :revert, TagChange), + {:ok, ip} <- cast_ip(ip) do + ip = to_string(ip) + + Multi.new() + |> put_enqueue_full_revert(actor, %{ip: ip}) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "TagChange.FullRevert:create", + Paths.ip_profile_path(ip), + "Reverted all tag changes for ip #{ip}" + ) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, ip} + + error -> + error + end + end end @doc """ - Queues all listed tag change IDs for search index updates. - Returns the list unchanged, for use in a pipeline. + Enqueues a reversion of all tag changes attributed to a fingerprint. + + Invalid fingerprints return `{:error, :not_found}`. ## Examples - iex> reindex_tag_changes([1, 2, 3]) - [1, 2, 3] + iex> create_fingerprint_tag_change_revert(moderator, "C123") + {:ok, "c123"} """ - def reindex_tag_changes(tag_change_ids) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["TagChanges", "id", tag_change_ids]) - - tag_change_ids + @spec create_fingerprint_tag_change_revert(Actor.t(), term()) :: + {:ok, String.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_fingerprint_tag_change_revert(%Actor{} = actor, fingerprint) do + with :ok <- authorize(actor, :revert, TagChange), + {:ok, fingerprint} <- cast_fingerprint(fingerprint) do + Multi.new() + |> put_enqueue_full_revert(actor, %{fingerprint: fingerprint}) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "TagChange.FullRevert:create", + Paths.fingerprint_profile_path(fingerprint), + "Reverted all tag changes for fingerprint #{fingerprint}" + ) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, fingerprint} + + error -> + error + end + end end @doc """ - Queues all tag changes associated with a list of image IDs for search index updates. - Returns the list unchanged, for use in a pipeline. + Searches all tag changes visible to `actor`. + + Hidden-image changes are excluded unless the actor may show their images. + `params` accepts `tcq`, `sf`, and `sd`; invalid search or sort input returns + a rejected query changeset. The successful result includes the normalized + query changeset. ## Examples - iex> reindex_tag_changes_on_images([1, 2, 3]) - [1, 2, 3] + iex> list_tag_changes(actor, %{"tcq" => "safe"}, page: 1, page_size: 25) + {:ok, %TagChangePage{target: nil}, changeset} """ - def reindex_tag_changes_on_images(image_ids) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["TagChanges", "image_id", image_ids]) + @spec list_tag_changes(Actor.t(), map(), Search.pagination_params()) :: + {:ok, TagChangePage.t(), Ecto.Changeset.t()} + | {:error, :unauthorized | Ecto.Changeset.t()} + def list_tag_changes(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, TagChange) do + search_tag_changes(actor, nil, [], params, pagination) + end + end + + @doc """ + Searches tag changes on the image named by `image_id`. + + Images owns target loading and visibility authorization. Malformed and absent + IDs are not found; a real hidden image forbidden to the actor is unauthorized. + + ## Examples + + iex> list_image_tag_changes(actor, "42", %{}, page: 1, page_size: 25) + {:ok, %TagChangePage{target: image}, changeset} - image_ids + iex> list_image_tag_changes(actor, "missing", %{}, page: 1, page_size: 25) + {:error, :not_found} + + """ + @spec list_image_tag_changes( + Actor.t(), + IntegerId.integer_id(), + map(), + Search.pagination_params() + ) :: + {:ok, TagChangePage.t(), Ecto.Changeset.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def list_image_tag_changes(%Actor{} = actor, image_id, params, pagination) do + with {:ok, image} <- Images.load_visible_image(actor, image_id) do + search_tag_changes(actor, image, %{term: %{image_id: image.id}}, params, pagination) + end end @doc """ - Returns a list of associations to preload when indexing tag changes. + Searches tag changes involving the tag named by `slug`. + + Missing tags are not found before OpenSearch is queried. ## Examples - iex> indexing_preloads() - [:image, :tags, :user] + iex> list_tag_tag_changes(actor, "safe", %{}, page: 1, page_size: 25) + {:ok, %TagChangePage{target: tag}, changeset} """ - def indexing_preloads do - alias_tags_query = select(Tag, [:aliased_tag_id, :name]) + @spec list_tag_tag_changes(Actor.t(), String.t(), map(), Search.pagination_params()) :: + {:ok, TagChangePage.t(), Ecto.Changeset.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def list_tag_tag_changes(%Actor{} = actor, slug, params, pagination) when is_binary(slug) do + with {:ok, tag} <- Tags.load_canonical_tag(actor, slug) do + search_tag_changes(actor, tag, %{term: %{tag_id: tag.id}}, params, pagination) + end + end - base_tags_query = - Tag - |> select([:id, :name]) - |> preload(aliases: ^alias_tags_query) + def list_tag_tag_changes(%Actor{}, _slug, _params, _pagination), do: {:error, :not_found} - image_query = - Image - |> select([:anonymous, :user_id]) + @doc """ + Searches tag changes attributed to the active user named by their profile slug. - [ - image: image_query, - tags: [ - tag: base_tags_query - ], - user: select(User, [:name]) - ] + Users owns target loading. Ordinary viewers receive only publicly attributed + changes; actors with identity-metadata access receive the user's true + attribution, including changes hidden by anonymous upload attribution. + + ## Examples + + iex> list_user_tag_changes(actor, "Somebody", %{}, page: 1, page_size: 25) + {:ok, %TagChangePage{target: user}, changeset} + + """ + @spec list_user_tag_changes(Actor.t(), String.t(), map(), Search.pagination_params()) :: + {:ok, TagChangePage.t(), Ecto.Changeset.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def list_user_tag_changes(%Actor{} = actor, slug, params, pagination) do + with {:ok, user} <- Users.load_profile(actor, slug) do + user_resource_filter = + if authorize(actor, :show, :identity_metadata) == :ok do + %{term: %{true_user_id: user.id}} + else + %{term: %{user_id: user.id}} + end + + search_tag_changes( + actor, + user, + user_resource_filter, + params, + pagination + ) + end end @doc """ - Reindexes tag changes based on a column condition. + Searches tag changes attributed to the canonical IP address `ip`. - Updates the search index for all tag changes matching the given column condition. - Used for batch reindexing of tag changes. + Malformed addresses are not found before the shared identity-metadata gate; + valid addresses with no changes return an empty page. ## Examples - iex> perform_reindex(:id, [1, 2, 3]) - {:ok, [%TagChange{}, ...]} + iex> list_ip_tag_changes(moderator, "203.0.113.5", %{}, page: 1, page_size: 25) + {:ok, %TagChangePage{target: ip}, changeset} + + iex> list_ip_tag_changes(moderator, "not-an-ip", %{}, page: 1, page_size: 25) + {:error, :not_found} """ - def perform_reindex(column, condition) do - TagChange - |> preload(^indexing_preloads()) - |> where([tc], field(tc, ^column) in ^condition) - |> Search.reindex(TagChange) + @spec list_ip_tag_changes(Actor.t(), String.t(), map(), Search.pagination_params()) :: + {:ok, TagChangePage.t(), Ecto.Changeset.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def list_ip_tag_changes(%Actor{} = actor, ip, params, pagination) do + with {:ok, ip} <- cast_ip(ip), + :ok <- authorize(actor, :show, :identity_metadata) do + search_tag_changes(actor, ip, %{term: %{ip: to_string(ip)}}, params, pagination) + end end - defp tags_to_tag_change(_, nil, _), do: [] + @doc """ + Searches tag changes attributed to a canonical browser `fingerprint`. - defp tags_to_tag_change(tag_change, tags, added) do - tags - |> Enum.map( - &%{ - tag_change_id: tag_change.id, - tag_id: &1.id, - added: added - } - ) + The value is normalized and validated through UserFingerprints before the + shared identity-metadata gate. Valid fingerprints with no changes return an + empty page. + + ## Examples + + iex> list_fingerprint_tag_changes(moderator, "C123", %{}, page: 1, page_size: 25) + {:ok, %TagChangePage{target: fingerprint}, changeset} + + """ + @spec list_fingerprint_tag_changes(Actor.t(), String.t(), map(), Search.pagination_params()) :: + {:ok, TagChangePage.t(), Ecto.Changeset.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def list_fingerprint_tag_changes(%Actor{} = actor, fingerprint, params, pagination) do + with {:ok, fingerprint} <- cast_fingerprint(fingerprint), + :ok <- authorize(actor, :show, :identity_metadata) do + search_tag_changes( + actor, + fingerprint, + %{term: %{fingerprint: fingerprint}}, + params, + pagination + ) + end end @doc """ - Creates a tag_change. + Adds tag-change creation to an Images update transaction. + + `image_step` must resolve to an `%Image{}` with added and removed tag lists. + This helper adds `:tag_change` and `:tag_changes` steps; the latter preserves + the existing `{added_count, removed_count}` result. Search indexing is + deferred until the owning transaction commits. + + ## Examples + + iex> put_tag_change(multi, actor) + %Philomena.Multi{} + """ - def create_tag_change(image, attrs, added_tags, removed_tags) do - user = attrs[:user] - user_id = if user, do: user.id, else: nil + @spec put_tag_change(Multi.t(), Actor.t(), Multi.name()) :: Multi.t() + def put_tag_change(%Multi{} = multi, %Actor{} = actor, image_step \\ :image) do + user_id = if actor.user, do: actor.user.id - {:ok, tc} = + multi + |> Multi.insert(:tag_change, fn %{^image_step => image} -> %TagChange{ image_id: image.id, user_id: user_id, - ip: attrs[:ip], - fingerprint: attrs[:fingerprint] + ip: actor.ip, + fingerprint: actor.fingerprint } - |> Repo.insert() + end) + |> Multi.run(:tag_changes, fn repo, %{^image_step => image, tag_change: tag_change} -> + {added_count, nil} = + repo.insert_all(TagChangeTag, tag_change_tag_rows(tag_change, image.added_tags, true)) + + {removed_count, nil} = + repo.insert_all(TagChangeTag, tag_change_tag_rows(tag_change, image.removed_tags, false)) + + {:ok, {added_count, removed_count}} + end) + |> Multi.on_commit(fn %{tag_change: tag_change} -> + Exq.enqueue(Exq, "indexing", IndexWorker, ["TagChanges", "id", [tag_change.id]]) + end) + end + + @doc """ + Reverts the valid tag-change IDs in `ids` on behalf of `actor`. - {added_count, nil} = - Repo.insert_all(TagChanges.Tag, tags_to_tag_change(tc, added_tags, true)) + Malformed ID lists return `{:error, changeset}`. Missing IDs and changes on + hidden images are skipped, making stale or repeated batch submissions safe. + The selected edits are inverted as one net edit per affected image, so a + successful reversion creates at most one new tag-change row per image. The + successful moderation log records the number of loaded changes reverted. - {removed_count, nil} = - Repo.insert_all(TagChanges.Tag, tags_to_tag_change(tc, removed_tags, false)) + ## Examples + + iex> create_tag_change_revert(moderator, %{"ids" => ["12", "13"]}) + {:ok, [%TagChange{}, %TagChange{}]} + + """ + @spec create_tag_change_revert(Actor.t(), map()) :: + {:ok, [TagChange.t()]} | {:error, :unauthorized | Ecto.Changeset.t()} + def create_tag_change_revert(%Actor{} = actor, params) do + with :ok <- authorize(actor, :revert, TagChange), + {:ok, revert_form} <- + %RevertForm{} + |> RevertForm.changeset(params) + |> Ecto.Changeset.apply_action(:create), + {:ok, tag_changes} <- + revert_tag_change_ids(revert_form.ids, %{ + ip: actor.ip, + fingerprint: actor.fingerprint, + user_id: actor.user.id + }) do + ModerationLogs.create_moderation_log( + actor, + "TagChange.Revert:create", + Paths.profile_path(actor.user), + "Reverted #{length(tag_changes)} tag changes" + ) + + {:ok, tag_changes} + end + end + + @doc """ + Reverts a validated worker batch without request authorization or logging. + + The worker owns batching by image ID. All selected edits for an image are + inverted as one net edit; self-canceling edits produce no new tag-change row. + Missing IDs and hidden-image changes are skipped; database failures are + returned to the worker. + + ## Examples - reindex_tag_change(tc) + iex> revert_for_worker([12, 13], attributes) + {:ok, [%TagChange{}, %TagChange{}]} - {:ok, {added_count, removed_count}} + """ + @spec revert_for_worker([IntegerId.integer_id()], map()) :: + {:ok, [TagChange.t()]} | {:error, Ecto.Changeset.t()} + def revert_for_worker(ids, attributes) do + with {:ok, revert_form} <- + %RevertForm{} + |> RevertForm.changeset(%{ids: ids}) + |> Ecto.Changeset.apply_action(:create) do + revert_tag_change_ids(revert_form.ids, attributes) + end end @doc """ - Deletes a TagChange. + Deletes the tag change named by `id` and its moderation audit atomically. + + Loading precedes `:delete` authorization, so malformed and absent IDs are + always not found while a real forbidden row is unauthorized. Anonymous + changes use an explicit author label in the log. The search document is + deleted only after the database transaction commits. ## Examples - iex> delete_tag_change(tag_change) + iex> delete_tag_change(moderator, "42") {:ok, %TagChange{}} - iex> delete_tag_change(tag_change) - {:error, %Ecto.Changeset{}} + iex> delete_tag_change(actor, "missing") + {:error, :not_found} """ - def delete_tag_change(%TagChange{} = tag_change) do - case Repo.delete(tag_change) do - {:ok, %TagChange{} = tc} = result -> - Search.delete_document(tc.id, TagChange) - result - - result -> - result + @spec delete_tag_change(Actor.t(), IntegerId.integer_id()) :: + {:ok, TagChange.t()} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def delete_tag_change(%Actor{} = actor, id) do + with {:ok, tag_change} <- + TagChange + |> preload([:user, :image, tag_change_tags: [:tag]]) + |> Loader.fetch_and_authorize(actor, :delete, id) do + author = if tag_change.user, do: tag_change.user.name, else: "an anonymous user" + + Multi.new() + |> Multi.delete(:tag_change, tag_change) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "TagChange:delete", + Paths.image_path(tag_change.image), + "Deleted tag change by #{author} containing #{length(tag_change.tag_change_tags)} tags on image #{tag_change.image_id} from history" + ) + |> Multi.on_commit(fn %{tag_change: tag_change} -> + Search.delete_document(tag_change.id, TagChange) + end) + |> Multi.transact() + |> case do + {:ok, %{tag_change: %TagChange{} = tag_change}} -> + {:ok, tag_change} + + {:error, :tag_change, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end @doc """ - Deletes tag changes that have no associated tags. + Deletes tag-change rows left empty by tag deletion and removes their search + documents. + + Returns `{count, ids}` using the database's explicit `RETURNING id` result. + ## Examples - iex> delete_empty_tag_changes() - {number_of_deleted_records, [%TagChange{}, ...]} + + iex> cleanup_empty_for_tag_deletion() + {2, [12, 13]} + """ - def delete_empty_tag_changes do - {count, tag_changes} = + @spec cleanup_empty_for_tag_deletion() :: {non_neg_integer(), [integer()]} + def cleanup_empty_for_tag_deletion do + empty_changes = TagChange |> from(as: :tag_change) |> where( - not exists(where(TagChanges.Tag, [t], t.tag_change_id == parent_as(:tag_change).id)) + not exists( + where( + TagChangeTag, + [tag_change_tag], + tag_change_tag.tag_change_id == parent_as(:tag_change).id + ) + ) ) - |> select([tc], tc) - |> Repo.delete_all() + |> select([tag_change], tag_change.id) - Enum.each(tag_changes, &Search.delete_document(&1.id, TagChange)) + {count, tag_change_ids} = Repo.delete_all(empty_changes) + Enum.each(tag_change_ids, &Search.delete_document(&1, TagChange)) - {count, tag_changes} + {count, tag_change_ids} end - def count_tag_changes(field_name, value) do + @doc """ + Counts tag-change batches and changed tags for an already-loaded image. + + ## Examples + + iex> count_for_image(image) + {3, 7} + + """ + @spec count_for_image(Image.t()) :: {non_neg_integer(), non_neg_integer()} + def count_for_image(%Image{id: image_id}) do TagChange - |> where([c], field(c, ^field_name) == ^value) - |> join(:left, [c], t in assoc(c, :tags)) - |> select([c, t], {count(c, :distinct), count(t)}) + |> where(image_id: ^image_id) + |> join(:left, [tag_change], tag_change_tag in assoc(tag_change, :tag_change_tags)) + |> select([tag_change, tag], {count(tag_change, :distinct), count(tag)}) |> Repo.one() end - def load(user, params, pagination) do - {:ok, query} = Query.compile(get_query(params), user: user) + @doc """ + Builds a lateral query that counts tag-change batches and changed tags for + the image in the parent query. + + The returned query expects an `:image` parent binding and is intended for + use with `Ecto.Query.subquery/1`. + + ## Examples + + iex> TagChanges.count_query() + #Ecto.Query<...> + """ + @spec count_query() :: Ecto.Query.t() + def count_query do TagChange - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: [query | resource_filters(user, params)] - } - }, - sort: parse_sort(params) - }, - pagination - ) - |> Search.search_records( - preload(TagChange, [:user, image: [:user, :sources, tags: :aliases], tags: [:tag]]) + |> where(image_id: parent_as(:image).id) + |> join(:left, [tag_change], tag_change_tag in assoc(tag_change, :tag_change_tags)) + |> select([tag_change, tag], %{ + change_count: count(tag_change, :distinct), + tag_count: count(tag) + }) + end + + @doc """ + Worker service that reverts every tag change selected by `queryable`, batching + only on image IDs so one image's history cannot straddle a batch boundary. + + ## Examples + + iex> revert_all_for_worker(query, %{batch_size: 100}) + :ok + + """ + @spec revert_all_for_worker(Ecto.Queryable.t(), map()) :: :ok | {:error, term()} + def revert_all_for_worker(queryable, attributes) do + batch_size = attributes[:batch_size] || 100 + attributes = Map.delete(attributes, :batch_size) + + queryable + |> Batch.query_batches(batch_size: batch_size, id_field: :image_id) + |> Enum.reduce_while(:ok, fn queryable, :ok -> + queryable + |> select([tag_change], tag_change.id) + |> Repo.all() + |> revert_for_worker(attributes) + |> case do + {:ok, _tag_changes} -> + {:cont, :ok} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end) + end + + @doc """ + Replaces attribution data on a user's tag change history in batches. + """ + @spec wipe_user_attribution!(integer(), term(), String.t()) :: :ok + def wipe_user_attribution!(user_id, ip, fingerprint) do + TagChange + |> where(user_id: ^user_id) + |> Batch.query_batches() + |> Enum.each(&Repo.update_all(&1, set: [ip: ip, fingerprint: fingerprint])) + + :ok + end + + @doc """ + Adds deletion of tag change tag join table rows represented by `query` to + `multi` and queues the affected tag changes after commit. + """ + @spec put_delete_tag_change_tags(Multi.t(), Multi.name(), Ecto.Query.t()) :: Multi.t() + def put_delete_tag_change_tags(%Multi{} = multi, step, %Ecto.Query{} = query) do + tag_change_ids_step = {:tag_change_ids, step} + + multi + |> Multi.all( + tag_change_ids_step, + select(query, [tag_change_tag], tag_change_tag.tag_change_id) ) + |> Multi.delete_all(step, query) + |> Multi.on_commit(fn %{^tag_change_ids_step => tag_change_ids} -> + reindex_tag_changes(tag_change_ids) + end) end - defp resource_filters(user, %{"resource_type" => type, "resource_id" => id}) - when is_binary(type) and is_binary(id) and id != "" do - [resource_filter(user, type, id)] + @doc """ + Records tag changes from inserted and deleted image tagging rows in `multi`. + + Images supplies the two prior Multi steps. This function generates the tag + changes and queues the affected rows after commit. + """ + @spec put_batch_tag_changes(Multi.t(), Multi.name(), Multi.name(), map()) :: Multi.t() + def put_batch_tag_changes(%Multi{} = multi, inserted_step, deleted_step, attributes) do + multi + |> Multi.run(:batch_tag_changes, fn + repo, + %{ + ^inserted_step => {_inserted_count, inserted_taggings}, + ^deleted_step => {_deleted_count, deleted_taggings} + } -> + inserted = + Enum.map(inserted_taggings, fn %{image_id: image_id, tag_id: tag_id} -> + %{image_id: image_id, tag_id: tag_id, added: true} + end) + + deleted = + Enum.map(deleted_taggings, fn [image_id, tag_id] -> + %{image_id: image_id, tag_id: tag_id, added: false} + end) + + changes = Enum.concat(inserted, deleted) + now = DateTime.utc_now(:second) + + # Create tag change batches for every image ID. + tag_change_rows = + changes + |> Enum.uniq_by(& &1.image_id) + |> Enum.map(fn %{image_id: image_id} -> + %{ + image_id: image_id, + user_id: attributes[:user_id], + ip: attributes[:ip], + fingerprint: attributes[:fingerprint], + created_at: now + } + end) + + {_count, tag_changes} = + repo.insert_all(TagChange, tag_change_rows, returning: [:image_id, :id]) + + tag_change_ids = Enum.map(tag_changes, & &1.id) + tag_change_ids_by_image_id = Map.new(tag_changes, &{&1.image_id, &1.id}) + + # Create tags belonging to tag changes. + tag_change_tag_rows = + Enum.map(changes, fn %{image_id: image_id, tag_id: tag_id, added: added} -> + %{ + tag_change_id: tag_change_ids_by_image_id[image_id], + tag_id: tag_id, + added: added + } + end) + + {_count, nil} = repo.insert_all(TagChangeTag, tag_change_tag_rows) + + {:ok, tag_change_ids} + end) + |> Multi.on_commit(fn %{batch_tag_changes: tag_change_ids} -> + reindex_tag_changes(tag_change_ids) + end) end - defp resource_filters(_user, _params), do: [] + @doc """ + Updates tag-change search documents after a user rename. + + ## Examples - # Term filters mirroring the fields each role may query through `tcq` - # (see Philomena.TagChanges.Query): ip and fingerprint are moderator-only. - # A recognized resource the requester may not filter by, or an invalid - # value, matches nothing rather than silently listing everything. - defp resource_filter(_user, "image", id), do: %{term: %{image_id: id}} - defp resource_filter(_user, "tag", name), do: %{term: %{tag: String.downcase(name)}} - defp resource_filter(_user, "user", name), do: %{term: %{user: String.downcase(name)}} + iex> user_name_reindex("old name", "new name") + :ok - defp resource_filter(%{role: role}, "ip", ip) when role in ~W(moderator admin) do - case IpParser.parse(ip) do - {:ok, _tokens, "", _, _, _} -> %{term: %{ip: ip}} - _ -> %{match_none: %{}} - end + """ + @spec user_name_reindex(String.t(), String.t()) :: term() + def user_name_reindex(old_name, new_name) do + data = SearchIndex.user_name_update_by_query(old_name, new_name) + Search.update_by_query(TagChange, data.query, data.set_replacements, data.replacements) end - defp resource_filter(%{role: role}, "fingerprint", fp) when role in ~W(moderator admin), - do: %{term: %{fingerprint: fp}} + @doc """ + Queues every tag change for worker reindexing. + + ## Examples - defp resource_filter(_user, _type, _id), do: %{match_none: %{}} + iex> reindex_tag_changes([12, 13]) + [12, 13] - defp parse_sort(%{"sf" => sf, "sd" => sd}) - when sf in ["created_at", "tag_count", "added_tag_count", "removed_tag_count"] and - sd in ["desc", "asc"] do - [%{sf => sd}, %{"id" => sd}] + """ + @spec reindex_tag_changes([integer()]) :: [integer()] + def reindex_tag_changes(ids) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["TagChanges", "id", ids]) + ids end - defp parse_sort(_params) do - [%{created_at: :desc}, %{id: :desc}] + @doc """ + Returns the association projection required to serialize tag-change search + documents. + + ## Examples + + iex> indexing_preloads() + [image: image_query, tag_change_tags: [tag: tag_query], user: user_query] + + """ + @spec indexing_preloads() :: list() + def indexing_preloads do + alias_tags_query = select(Tag, [:aliased_tag_id, :name]) + + base_tags_query = + Tag + |> select([:id, :name]) + |> preload(aliases: ^alias_tags_query) + + image_query = select(Image, [:anonymous, :hidden_from_users, :user_id]) + + [ + image: image_query, + tag_change_tags: [tag: base_tags_query], + user: select(User, [:name]) + ] end - defp get_query(%{"tcq" => ""}), do: "*" + @doc """ + Worker entry point for reindexing tag changes matching `column` and + `condition`. + + ## Examples - defp get_query(%{"tcq" => q}), do: q + iex> perform_reindex(:id, [12, 13]) + :ok - defp get_query(_), do: "*" + """ + @spec perform_reindex(atom(), [term()]) :: term() + def perform_reindex(column, condition) do + TagChange + |> preload(^indexing_preloads()) + |> where([tag_change], field(tag_change, ^column) in ^condition) + |> Search.reindex(TagChange) + end end diff --git a/lib/philomena/tag_changes/limits.ex b/lib/philomena/tag_changes/limits.ex index 1d5aeede9..6199e7486 100644 --- a/lib/philomena/tag_changes/limits.ex +++ b/lib/philomena/tag_changes/limits.ex @@ -7,102 +7,93 @@ defmodule Philomena.TagChanges.Limits do `considered_for_limit?/1`. """ + alias Philomena.Users.User + @tag_changes_per_ten_minutes 50 @rating_changes_per_ten_minutes 1 @ten_minutes_in_seconds 10 * 60 @doc """ - Determine if the current user and IP can make any tag changes at all. - - The user may be limited due to making more than 50 tag changes in the past 10 minutes. - Should be used in tandem with `update_tag_count_after_update/3`. - - ## Examples - - iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}) - false + Reserve `tag_amount` tag changes and `rating_amount` rating changes for a + transaction. - iex> limited_for_tag_count?(%User{}, %Postgrex.INET{}, 72) - true + The user may be limited due to making more than 50 tag changes or one rating + change in the past 10 minutes. A reservation over either limit is undone + before returning `{:error, :rate_limited}`. """ - def limited_for_tag_count?(user, ip, additional \\ 0) do - check_limit(user, tag_count_key(user, ip), @tag_changes_per_ten_minutes, additional) + @spec record_action(User.t() | nil, Postgrex.INET.t(), non_neg_integer(), non_neg_integer()) :: + :ok | {:error, :rate_limited} + def record_action(user, ip, tag_amount, rating_amount) do + case increment_counter( + user, + tag_count_key(user, ip), + tag_amount, + @tag_changes_per_ten_minutes + ) do + {:error, :rate_limited} = error -> + error + + {:ok, nil} -> + case increment_counter( + user, + rating_count_key(user, ip), + rating_amount, + @rating_changes_per_ten_minutes + ) do + {:ok, nil} -> + :ok + + {:error, :rate_limited} = error -> + decrement_counter(tag_count_key(user, ip), tag_amount) + error + end + end end @doc """ - Determine if the current user and IP can make rating tag changes. - - The user may be limited due to making more than one rating tag change in the past 10 minutes. - Should be used in tandem with `update_rating_count_after_update/3`. - - ## Examples - - iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}) - false - - iex> limited_for_rating_count?(%User{}, %Postgrex.INET{}, 2) - true - + Roll back the tag and rating reservations owned by a transaction. """ - def limited_for_rating_count?(user, ip) do - # A rating change is a single change; pass it as the pending amount so the - # `> limit` comparison in check_limit still permits exactly one per window. - check_limit(user, rating_count_key(user, ip), @rating_changes_per_ten_minutes, 1) - end - - @doc """ - Post-transaction update for successful tag changes. - - Should be used in tandem with `limited_for_tag_count?/2`. - - ## Examples - - iex> update_tag_count_after_update(%User{}, %Postgrex.INET{}, 25) + @spec rollback_action(User.t() | nil, Postgrex.INET.t(), non_neg_integer(), non_neg_integer()) :: + :ok + def rollback_action(user, ip, tag_amount, rating_amount) do + if considered_for_limit?(user) do + decrement_counter(tag_count_key(user, ip), tag_amount) + decrement_counter(rating_count_key(user, ip), rating_amount) + else :ok + end - """ - def update_tag_count_after_update(user, ip, amount) do - increment_counter(user, tag_count_key(user, ip), amount, @ten_minutes_in_seconds) + :ok end - @doc """ - Post-transaction update for successful rating tag changes. - - Should be used in tandem with `limited_for_rating_count?/2`. - - ## Examples + defp increment_counter(_user, _key, 0, _limit), do: {:ok, nil} - iex> update_rating_count_after_update(%User{}, %Postgrex.INET{}, 1) - :ok - - """ - def update_rating_count_after_update(user, ip, amount) do - increment_counter(user, rating_count_key(user, ip), amount, @ten_minutes_in_seconds) - end - - defp check_limit(user, key, limit, additional) do + defp increment_counter(user, key, amount, limit) do if considered_for_limit?(user) do - amt = String.to_integer(Redix.command!(:redix, ["GET", key]) || "0") - amt + additional > limit + count = Redix.command!(:redix, ["INCRBY", key, amount]) + + if count <= limit do + Redix.command!(:redix, ["EXPIRE", key, @ten_minutes_in_seconds]) + {:ok, nil} + else + decrement_counter(key, amount) + {:error, :rate_limited} + end else - false + {:ok, nil} end end - defp increment_counter(user, key, amount, expiration) do - if considered_for_limit?(user) do - Redix.pipeline!(:redix, [ - ["INCRBY", key, amount], - ["EXPIRE", key, expiration] - ]) - end + defp decrement_counter(_key, 0), do: :ok + defp decrement_counter(key, amount) do + Redix.command!(:redix, ["DECRBY", key, amount]) :ok end - # Staff and rate-limit-bypassing users are never limited (matching - # PhilomenaWeb.LimitPlug); anonymous and unverified users are. + # Staff and rate-limit-bypassing users are never limited; anonymous and + # unverified users are. defp considered_for_limit?(nil), do: true defp considered_for_limit?(%{role: role}) when role in ~W(admin moderator assistant), do: false defp considered_for_limit?(%{bypass_rate_limits: true}), do: false diff --git a/lib/philomena/tag_changes/query.ex b/lib/philomena/tag_changes/query.ex index b2325839f..fa51725f4 100644 --- a/lib/philomena/tag_changes/query.ex +++ b/lib/philomena/tag_changes/query.ex @@ -1,4 +1,8 @@ defmodule Philomena.TagChanges.Query do + @moduledoc false + + import Philomena.Authorization, only: [authorize: 3] + alias PhilomenaQuery.Parse.Parser defp user_my_transform(%{user: %{id: id}}, "changes"), @@ -43,14 +47,18 @@ defmodule Philomena.TagChanges.Query do |> Parser.parse(query_string, context) end - defp fields_for(nil), do: anonymous_fields() - defp fields_for(%{role: role}) when role in ~W(user assistant), do: user_fields() - defp fields_for(%{role: role}) when role in ~W(moderator admin), do: moderator_fields() - defp fields_for(_), do: raise(ArgumentError, "Unknown user role.") - def compile(query_string, opts \\ []) do user = Keyword.get(opts, :user) - parse(fields_for(user), %{user: user}, query_string) + cond do + not is_nil(user) and authorize(user, :show, :identity_metadata) == :ok -> + parse(moderator_fields(), %{user: user}, query_string) + + not is_nil(user) -> + parse(user_fields(), %{user: user}, query_string) + + true -> + parse(anonymous_fields(), %{}, query_string) + end end end diff --git a/lib/philomena/tag_changes/query_builder.ex b/lib/philomena/tag_changes/query_builder.ex new file mode 100644 index 000000000..45f21dbd6 --- /dev/null +++ b/lib/philomena/tag_changes/query_builder.ex @@ -0,0 +1,45 @@ +defmodule Philomena.TagChanges.QueryBuilder do + @moduledoc false + + alias Philomena.Users.User + alias Philomena.TagChanges.QueryForm + + @doc """ + Builds a tag change search query based on the given parameters. + + ## Parameters + + * `params` - Map of optional search parameters: + * `tcq` - Search query + * `sf` - Sort field: + * `created_at` - Creation timestamp + * `tag_count` - Number of tags added and removed + * `added_tag_count` - Number of tags added + * `removed_tag_count` - Number of tags removed + * `sd` - Sort direction: + * `asc` - Results ascending by `sf` + * `desc` - Results descending by `sf` + + Returns `{:ok, query, query_form}` with an OpenSearch query body for `TagChanges + that can be used with `PhilomenaQuery.Search`, or `{:error, changeset}` if the + provided parameters are invalid. + """ + @spec build_query(User.t() | nil, map()) :: + {:ok, map(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(user, params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(user, params) + |> Ecto.Changeset.apply_action(:create) do + body = %{ + query: query_form.compiled_query, + sort: [ + %{query_form.sf => query_form.sd}, + %{"id" => query_form.sd} + ] + } + + {:ok, body, query_form} + end + end +end diff --git a/lib/philomena/tag_changes/query_form.ex b/lib/philomena/tag_changes/query_form.ex new file mode 100644 index 000000000..f25eda91c --- /dev/null +++ b/lib/philomena/tag_changes/query_form.ex @@ -0,0 +1,32 @@ +defmodule Philomena.TagChanges.QueryForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + import PhilomenaQuery.Ecto.QueryValidator + + alias Philomena.TagChanges.Query + + @type t :: %__MODULE__{} + + embedded_schema do + field :tcq, :string + field :sf, :string, default: "created_at" + field :sd, :string, default: "desc" + + field :compiled_query, :map, virtual: true + end + + @doc false + def changeset(%__MODULE__{} = query_form, user, attrs \\ %{}) do + query_form + |> cast(attrs, [:tcq, :sf, :sd]) + |> validate_inclusion(:sf, ~w(created_at tag_count added_tag_count removed_tag_count)) + |> validate_inclusion(:sd, ~w(asc desc)) + |> validate_query(:tcq, + with: &Query.compile(&1, user: user), + default: "*", + into: :compiled_query + ) + end +end diff --git a/lib/philomena/tag_changes/revert_form.ex b/lib/philomena/tag_changes/revert_form.ex new file mode 100644 index 000000000..4c92fdd59 --- /dev/null +++ b/lib/philomena/tag_changes/revert_form.ex @@ -0,0 +1,20 @@ +defmodule Philomena.TagChanges.RevertForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :ids, {:array, :integer} + end + + @doc false + def changeset(%__MODULE__{} = revert_form, attrs \\ %{}) do + revert_form + |> cast(attrs, [:ids]) + |> validate_required(:ids) + |> update_change(:ids, &Enum.uniq/1) + end +end diff --git a/lib/philomena/tag_changes/search_index.ex b/lib/philomena/tag_changes/search_index.ex index 4e2f8cf30..88dd61231 100644 --- a/lib/philomena/tag_changes/search_index.ex +++ b/lib/philomena/tag_changes/search_index.ex @@ -1,4 +1,6 @@ defmodule Philomena.TagChanges.SearchIndex do + @moduledoc false + @behaviour PhilomenaQuery.Search.Index @impl true @@ -57,7 +59,7 @@ defmodule Philomena.TagChanges.SearchIndex do tag_change.image.user_id == tag_change.user_id and tag_change.image.anonymous) - {added_tags, removed_tags} = Enum.split_with(tag_change.tags, & &1.added) + {added_tags, removed_tags} = Enum.split_with(tag_change.tag_change_tags, & &1.added) %{ id: tag_change.id, @@ -73,13 +75,13 @@ defmodule Philomena.TagChanges.SearchIndex do ip: to_string(tag_change.ip), fingerprint: tag_change.fingerprint, created_at: tag_change.created_at, - tag: tags_to_name_list(tag_change.tags), + tag: tags_to_name_list(tag_change.tag_change_tags), added_tag: tags_to_name_list(added_tags), removed_tag: tags_to_name_list(removed_tags), - tag_id: tags_to_id_list(tag_change.tags), + tag_id: tags_to_id_list(tag_change.tag_change_tags), added_tag_id: tags_to_id_list(added_tags), removed_tag_id: tags_to_id_list(removed_tags), - tag_count: length(tag_change.tags), + tag_count: length(tag_change.tag_change_tags), added_tag_count: length(added_tags), removed_tag_count: length(removed_tags) } diff --git a/lib/philomena/tag_changes/tag.ex b/lib/philomena/tag_changes/tag.ex deleted file mode 100644 index 978cea814..000000000 --- a/lib/philomena/tag_changes/tag.ex +++ /dev/null @@ -1,11 +0,0 @@ -defmodule Philomena.TagChanges.Tag do - use Ecto.Schema - - @primary_key false - schema "tag_change_tags" do - belongs_to :tag_change, Philomena.TagChanges.TagChange - belongs_to :tag, Philomena.Tags.Tag - - field :added, :boolean - end -end diff --git a/lib/philomena/tag_changes/tag_change.ex b/lib/philomena/tag_changes/tag_change.ex index 4bb0de04c..3a34b64a7 100644 --- a/lib/philomena/tag_changes/tag_change.ex +++ b/lib/philomena/tag_changes/tag_change.ex @@ -1,10 +1,12 @@ defmodule Philomena.TagChanges.TagChange do use Ecto.Schema + @type t :: %__MODULE__{} + schema "tag_changes" do belongs_to :user, Philomena.Users.User belongs_to :image, Philomena.Images.Image - has_many :tags, Philomena.TagChanges.Tag + has_many :tag_change_tags, Philomena.TagChanges.TagChangeTag field :ip, EctoNetwork.INET field :fingerprint, :string diff --git a/lib/philomena/tag_changes/tag_change_page.ex b/lib/philomena/tag_changes/tag_change_page.ex new file mode 100644 index 000000000..f8084ee03 --- /dev/null +++ b/lib/philomena/tag_changes/tag_change_page.ex @@ -0,0 +1,22 @@ +defmodule Philomena.TagChanges.TagChangePage do + @moduledoc """ + A paginated tag-change listing and its independently resolved target. + + `target` is `nil` for the global listing. Resource-specific listings carry the + loaded image, tag, user, canonical IP address, or canonical fingerprint. + """ + + alias Philomena.Images.Image + alias Philomena.Tags.Tag + alias Philomena.Users.User + + @type target :: Image.t() | Tag.t() | User.t() | Postgrex.INET.t() | String.t() | nil + + @enforce_keys [:target, :tag_changes] + defstruct [:target, :tag_changes] + + @type t :: %__MODULE__{ + target: target(), + tag_changes: Scrivener.Page.t() + } +end diff --git a/lib/philomena/tag_changes/tag_change_tag.ex b/lib/philomena/tag_changes/tag_change_tag.ex new file mode 100644 index 000000000..0b4c7cdd8 --- /dev/null +++ b/lib/philomena/tag_changes/tag_change_tag.ex @@ -0,0 +1,18 @@ +defmodule Philomena.TagChanges.TagChangeTag do + @moduledoc """ + A tag assignment recorded by a tag change. + + This is the join row between `TagChanges.TagChange` and `Tags.Tag`, not a + tag record itself. + """ + + use Ecto.Schema + + @primary_key false + schema "tag_change_tags" do + belongs_to :tag_change, Philomena.TagChanges.TagChange + belongs_to :tag, Philomena.Tags.Tag + + field :added, :boolean + end +end diff --git a/lib/philomena/tags.ex b/lib/philomena/tags.ex index 26719cece..724feb005 100644 --- a/lib/philomena/tags.ex +++ b/lib/philomena/tags.ex @@ -1,649 +1,1244 @@ defmodule Philomena.Tags do @moduledoc """ - The Tags context. + Tag discovery, metadata moderation, aliasing, and indexing services. + + Controller-facing APIs resolve a real slug before authorization and name + whether aliases remain distinct or resolve to their canonical target. Bulk + counter, alias, deletion, and reindex operations are explicit composition or + worker services. """ import Ecto.Query, warn: false - alias Ecto.Multi - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3, verify_write_access: 1] - alias PhilomenaQuery.Search + alias Philomena.ArtistLinks + alias Philomena.ArtistLinks.ArtistLink + alias Philomena.Attribution.Actor + alias Philomena.Channels + alias Philomena.DnpEntries + alias Philomena.DnpEntries.DnpEntry + alias Philomena.Filters + alias Philomena.Filters.Filter + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Search, as: ImageSearch + alias Philomena.Images.Search.Scope + alias Philomena.Images.Tagging alias Philomena.IndexWorker + alias Philomena.Interactions + alias Philomena.Loader + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.Multi + alias Philomena.Repo alias Philomena.TagAliasWorker - alias Philomena.TagUnaliasWorker - alias Philomena.TagReindexWorker alias Philomena.TagDeleteWorker + alias Philomena.TagReindexWorker + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChange + alias Philomena.TagChanges.TagChangeTag alias Philomena.Tags.Implication + alias Philomena.Tags.QueryBuilder + alias Philomena.Tags.QueryForm + alias Philomena.Tags.QuickTagTable alias Philomena.Tags.Tag + alias Philomena.Tags.TagDetail + alias Philomena.Tags.TagPage + alias Philomena.Tags.TagSuggestion alias Philomena.Tags.Uploader - alias Philomena.Images - alias Philomena.Images.Image + alias Philomena.Users alias Philomena.Users.User - alias Philomena.Filters - alias Philomena.Filters.Filter - alias Philomena.Images.Tagging - alias Philomena.ArtistLinks.ArtistLink - alias Philomena.DnpEntries.DnpEntry - alias Philomena.Channels.Channel - alias Philomena.TagChanges + alias PhilomenaQuery.Batch + alias PhilomenaQuery.Search - # There is a really delicate nuance that must be known to avoid deadlocks in - # vectorized mutation queries such as `INSERT ON CONFLICT UPDATE`, `UPDATE`, - # `DELETE`, `SELECT FOR [NO KEY] UPDATE` that touch multiple records. Note that - # `INSERT ON CONFLICT DO NOTHING` doesn't lock the conflicting records, so this - # nuance doesn't apply in that case (https://dba.stackexchange.com/questions/322912/will-insert-on-conflict-do-nothing-lock-the-row-in-case-of-conflict) - # - # If a vectorized mutation is run without a consistent locking order of the records, - # it can end up with a deadlock where one transaction locks a set of records - # that overlap with the other transaction while the other transaction locks - # the other set that overlaps with the first transaction. Thus, both transactions - # wait for each other to release the locks on records they locked resulting in - # a deadlock. - # - # For raw `UPDATE/DELETE ... WHERE ... IN (...)` queries, the items inside `IN (...)` - # don't influence the order of locking. These queries also don't have an `ORDER BY` - # clause. Thus, this function returns a `SELECT [lock_type]` query that establishes a - # consistent order of records by primary keys that must be used with all vectorized - # mutation queries to avoid deadlocks. This query can be used as a subquery in - # the `WHERE` clause for the vectorized mutation. - # - # If no locking order is set, the deadlock can appear randomly and its probability - # increases with the amount of items in the vectorized mutation query and with - # the number of overlapping records in concurrent transactions. - # - # This phenomena was discovered when @MareStare was trying to parallelize - # the image creation process for seeding the images during development, where - # tons of image uploads are issued in parallel with many overlapping tags - # (https://github.com/philomena-dev/philomena/pull/481). - # - # Big thanks to this StackOverflow post for explanations: - # https://stackoverflow.com/questions/27262900/postgres-update-and-lock-ordering/27263824#27263824 - defmacro vectorized_mutation_lock(lock_type, tag_ids) do - quote do - Tag - |> select([t], t.id) - |> lock(unquote(lock_type)) - |> where([t], t.id in ^unquote(tag_ids)) - |> order_by([t], t.id) - end + @show_preloads [ + :aliases, + :aliased_tag, + :implied_tags, + :implied_by_tags, + :dnp_entries, + :channels, + public_links: :user, + hidden_links: :user + ] + + @api_preloads [:aliased_tag, :aliases, :implied_tags, :implied_by_tags, :dnp_entries] + @alias_preloads [:implied_tags, :aliased_tag] + @image_preloads [:implied_tags] + + defp locked_tag_ids(tag_ids) do + Tag + |> where([tag], tag.id in ^tag_ids) + |> order_by([tag], tag.id) + |> select([tag], tag.id) + |> lock("FOR NO KEY UPDATE") end - @doc """ - Gets existing tags or creates new ones from a tag list string. + defp load_tag_for_action(actor, action, slug, preloads) when is_binary(slug) do + Tag + |> where(slug: ^slug) + |> preload(^preloads) + |> Loader.one_and_authorize(actor, action) + end - Takes a string of comma-separated tag names, parses it into individual tags, - and either retrieves existing tags or creates new ones for tags that don't exist. - Also handles tag aliases by returning the aliased tag instead of the alias. + defp load_tag_for_action(_actor, _action, _slug, _preloads), do: {:error, :not_found} - ## Examples + defp reindex_tag_images(%Tag{} = tag) do + Exq.enqueue(Exq, "indexing", TagReindexWorker, [tag.id]) + tag + end - iex> get_or_create_tags("safe, cute, pony") - [%Tag{name: "safe"}, %Tag{name: "cute"}, %Tag{name: "pony"}] + defp reindex_tag_ids([]), do: [] - """ - @spec get_or_create_tags(String.t()) :: list() - def get_or_create_tags(tag_list) do - case Tag.parse_tag_list(tag_list) do - [] -> [] - tag_names -> get_or_create_non_empty_tags_list(tag_names) + defp reindex_tag_ids(tag_ids) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", tag_ids]) + tag_ids + end + + # Computes the search query that lists the tag's images. A tag whose name + # compiles back to itself is used verbatim. Anything else is escaped so the + # search parser does not reinterpret it. + defp escape_name(name) do + if String.contains?(name, "(") or String.contains?(name, ")") do + # \ * ? " should be escaped, wrap in quotes so parser doesn't + # choke on parens. + name = + name + |> String.replace("\\", "\\\\") + |> String.replace("*", "\\*") + |> String.replace("?", "\\?") + |> String.replace("\"", "\\\"") + + "\"#{name}\"" + else + # \ * ? - ! " all must be escaped. + name + |> String.replace(~r/\A-/, "\\-") + |> String.replace(~r/\A!/, "\\!") + |> String.replace("\\", "\\\\") + |> String.replace("*", "\\*") + |> String.replace("?", "\\?") + |> String.replace("\"", "\\\"") end end - @spec get_or_create_non_empty_tags_list(list(String.t())) :: list() - defp get_or_create_non_empty_tags_list(tag_names) do - tags = - tag_names - |> Enum.map(fn tag_name -> + defp maybe_escape_name(%{name: name}) do + name = + name + |> String.replace(~r/\s+/, " ") + |> String.trim() + |> String.downcase() + + case Images.Query.compile(name) do + {:ok, %{term: %{"tags" => ^name}}} -> + name + + _error -> + escape_name(name) + end + end + + defp filtered_taggings_for_query(batch_query, [{:hidden_from_users, hidden_from_users}]) do + from tagging in batch_query, + as: :tagging, + where: + tagging.image_id in subquery( + from image in Image, + where: image.id == parent_as(:tagging).image_id, + where: image.hidden_from_users == ^hidden_from_users, + select: image.id + ) + end + + defp insert_all_for_alias(filtered_batch_query, target_tag) do + select( + filtered_batch_query, + [tagging], + %{image_id: tagging.image_id, tag_id: type(^target_tag.id, :integer)} + ) + end + + defp image_count_deltas(%Image{hidden_from_users: true}), + do: [] + + defp image_count_deltas(%Image{} = image) do + deltas = %{} + + deltas = + Enum.reduce(image.added_tags, deltas, fn tag, deltas -> + Map.update(deltas, tag.id, 1, &(&1 + 1)) + end) + + deltas = + Enum.reduce(image.removed_tags, deltas, fn tag, deltas -> + Map.update(deltas, tag.id, -1, &(&1 - 1)) + end) + + deltas + |> Enum.reject(fn {_tag_id, delta} -> delta == 0 end) + |> Enum.sort_by(fn {tag_id, _delta} -> tag_id end) + end + + defp update_image_count_changes(repo, image) do + image + |> image_count_deltas() + |> Enum.map(fn {tag_id, delta} -> + update_image_counts(repo, delta, [tag_id]) + + # Return the tag id for reindexing. + tag_id + end) + end + + defp maybe_insert_new_tags(%Multi{} = multi, []), + do: multi + + defp maybe_insert_new_tags(%Multi{} = multi, tag_names) do + insert_rows = + Enum.map(tag_names, fn tag_name -> %Tag{} |> Tag.creation_changeset(%{name: tag_name}) |> Ecto.Changeset.apply_changes() - |> Map.take([ - :slug, - :name, - :category, - :images_count, - :description, - :short_description, - :namespace, - :name_in_namespace, - :image, - :image_format, - :image_mime_type, - :mod_notes - ]) + |> Map.take(Tag.insert_fields()) |> Map.merge(%{ created_at: {:placeholder, :timestamp}, updated_at: {:placeholder, :timestamp} }) end) - %{new_tags: {_rows_affected, new_tags}, all_tags: all_tags} = - Multi.new() - |> Multi.insert_all( - :new_tags, - Tag, - tags, - placeholders: %{timestamp: DateTime.utc_now(:second)}, - on_conflict: :nothing, - returning: [:id] - ) - |> Multi.all( - :all_tags, - Tag - |> where([t], t.name in ^tag_names) - |> preload([:implied_tags, aliased_tag: :implied_tags]) - ) - |> Repo.transaction() - |> case do - {:ok, ok} -> - ok - - result -> - raise "get_or_create_tags failed: #{inspect(result)}\ntag_names: #{inspect(tag_names)}" + insert_options = [ + placeholders: %{timestamp: DateTime.utc_now(:second)}, + on_conflict: :nothing, + returning: [:id] + ] + + multi + |> Multi.insert_all(:new_tags, Tag, insert_rows, insert_options) + |> Multi.on_commit(fn %{new_tags: {_count, new_tags}} -> + if Enum.any?(new_tags) do + reindex_tags(new_tags) end + end) + end - new_tags - |> reindex_tags() + defp maybe_expand_implications(tag_list, repo, true) do + tag_list = Enum.flat_map(tag_list, &([&1] ++ &1.implied_tags)) + + with true <- Enum.any?(tag_list, &Tag.original_character_tag?/1), + %Tag{} = oc_tag <- + Tag + |> where(name: ^Tag.original_character_tag_name()) + |> repo.one() do + Enum.concat([oc_tag], tag_list) + else + _ -> tag_list + end + end - all_tags - |> Enum.map(&(&1.aliased_tag || &1)) - |> Enum.uniq_by(& &1.id) + defp maybe_expand_implications(tag_list, _repo, _expand_implications), + do: tag_list + + defp tag_alias_family(tag) do + canonical_tag = tag.aliased_tag || tag + alias_ids = Enum.map(canonical_tag.aliases, & &1.id) + family_ids = Enum.uniq([tag.id, canonical_tag.id] ++ alias_ids) + + %{ + canonical_id: canonical_tag.id, + tag_ids: family_ids + } + end + + defp put_delete_taggings_in_query(%Multi{} = multi, tag, query) do + # Lock all images in the batch first to prevent image operations from racing tag updates. + image_query = + from image in Image, + where: image.id in subquery(select(query, [tagging], tagging.image_id)), + order_by: [asc: :id], + select: image.id + + # The image counter represents only the count of visible images. + # To preserve this meaning, the operation must be split into deleting + # taggings of visible and non-visible images. + # + # The image counter is updated so the partial deletion state is resumable. + + visible_taggings = filtered_taggings_for_query(query, hidden_from_users: false) + hidden_taggings = filtered_taggings_for_query(query, hidden_from_users: true) + + multi + |> Multi.lock_all(:locked_image_ids, image_query) + |> Multi.lock_one(:locked_tag, where(Tag, id: ^tag.id)) + |> Images.put_delete_taggings(:old_visible, visible_taggings) + |> Images.put_delete_taggings(:old_hidden, hidden_taggings) + |> Multi.update_all( + :source_tag, + fn %{old_visible: {count, _}} -> + Tag + |> where(id: ^tag.id) + |> update(inc: [images_count: ^(-count)]) + end, + [] + ) + end + + defp put_delete_tag_change_tags_in_query(%Multi{} = multi, batch_query) do + tag_change_query = + from tag_change in TagChange, + where: tag_change.id in subquery(select(batch_query, [t], t.tag_change_id)), + order_by: [asc: :id], + select: tag_change.id + + multi + |> Multi.lock_all(:tag_change_ids, tag_change_query) + |> TagChanges.put_delete_tag_change_tags(:tag_change_tags, batch_query) end @doc """ - Gets a single tag. + Gets existing tags or creates new ones from multiple lists of tag names, + canonicalizes each list, and places the results in the `:canonical_tags` + Multi step. - Raises `Ecto.NoResultsError` if the Tag does not exist. + `name_sets` is a list of `{name, tag_names, options}` tuples. `tag_names` + is a list of strings. When `:allow_insert_new?` is true in a set's options, + missing tags from that set can be created. When `:expand_implications?` is + true, that set also includes all tags implied from its original tags. - ## Examples + The `:canonical_tags` result is a map from each set name to its list of + canonical tags. Tags shared by multiple sets are fetched and locked only + once. - iex> get_tag!(123) - %Tag{} + ## Examples - iex> get_tag!(456) - ** (Ecto.NoResultsError) + iex> (Multi.new() + ...> |> put_canonicalize_tag_name_sets([ + ...> {:tags, ~w(safe cute pony), allow_insert_new?: true} + ...> ]) + ...> |> Multi.transact()) + {:ok, %{canonical_tags: %{tags: [%Tag{name: "safe"}, %Tag{name: "cute"}, %Tag{name: "pony"}]}}} """ - def get_tag!(id), do: Repo.get!(Tag, id) + @spec put_canonicalize_tag_name_sets( + multi :: Multi.t(), + name_sets :: [{Multi.name(), [String.t()], Keyword.t()}] + ) :: + Multi.t() + def put_canonicalize_tag_name_sets(%Multi{} = multi, name_sets) do + tag_names = + name_sets + |> Enum.flat_map(fn {_set, names, _options} -> names end) + |> Enum.uniq() + + insertable_tag_names = + name_sets + |> Enum.filter(fn {_set, _names, options} -> Keyword.get(options, :allow_insert_new?) end) + |> Enum.flat_map(fn {_set, names, _options} -> names end) + |> Enum.uniq() + + tag_query = + Tag + |> where([tag], tag.name in ^tag_names) + |> preload([:implied_tags, aliased_tag: :implied_tags]) + + multi + |> maybe_insert_new_tags(insertable_tag_names) + |> Multi.all(:unlocked_tags, tag_query) + |> Multi.run(:canonical_tags, fn repo, %{unlocked_tags: tags} -> + tags_by_name = Map.new(tags, &{&1.name, &1}) + + buckets = + Map.new(name_sets, fn {set, names, options} -> + expand_implications? = Keyword.get(options, :expand_implications?) + + {set, + names + |> Enum.filter(&Map.has_key?(tags_by_name, &1)) + |> Enum.map(&tags_by_name[&1]) + |> Enum.map(&(&1.aliased_tag || &1)) + |> maybe_expand_implications(repo, expand_implications?) + |> Enum.uniq_by(& &1.id)} + end) + + {:ok, buckets} + end) + |> Multi.lock_all(:locked_tags, fn %{unlocked_tags: tags, canonical_tags: buckets} -> + tag_ids = + buckets + |> Enum.flat_map(fn {_set, tags} -> Enum.map(tags, & &1.id) end) + |> Enum.concat(Enum.map(tags, & &1.id)) + |> Enum.uniq() + + Tag + |> where([tag], tag.id in ^tag_ids) + |> order_by(asc: :id) + end) + |> Multi.run(:detect_conflict, fn + _repo, %{canonical_tags: buckets, unlocked_tags: unlocked_tags, locked_tags: locked_tags} -> + locked_tags_by_id = Map.new(locked_tags, &{&1.id, &1}) + + # Implication list addition is a permitted race. + # + # However, deletion and alias canonicalization absolutely must be + # quiescent because incorrect state during a transaction can corrupt tag + # image counters. + # + # This code checks that the tag's alias is the same as it was before the + # lock to handle legacy databases that might have aliases resident in + # implied tag lists. Eventually this should be migrated out. + conflict_fn = + fn tag -> + not Map.has_key?(locked_tags_by_id, tag.id) or + Map.fetch!(locked_tags_by_id, tag.id).aliased_tag_id != tag.aliased_tag_id + end + + buckets + |> Enum.flat_map(fn {_set, tags} -> tags end) + |> Enum.concat(unlocked_tags) + |> Enum.uniq_by(& &1.id) + |> Enum.any?(conflict_fn) + |> if do + {:error, :conflict} + else + {:ok, nil} + end + end) + end @doc """ - Gets a single tag. + Adds transaction steps to load and optimistically lock alias families + for the given `tag_ids`. - Returns nil if the Tag does not exist. + An "alias family" refers to the set of all tag IDs which have the same + canonical tag ID. - ## Examples + The initial query is expanded through each tag's current aliases. All + rows in those are then locked in ascending ID order. If an alias pointer + changes while the families are being locked, the Multi is rolled back + with `{:error, :conflict}` to allow automatic retry. - iex> get_tag_by_name("safe") - %Tag{} + The loaded tags are stored in `:tags` for conflict detection. The + `:tag_alias_families` result is keyed by each requested tag ID and contains + its canonical ID and the IDs in that canonical tag's family. - iex> get_tag_by_name("nonexistent") - nil + ## Examples + + iex> (Multi.new() + ...> |> put_lock_tag_alias_families([12, 13]) + ...> |> Multi.transact_with_automatic_retry()) + {:ok, + %{tags: [%Tag{id: 12}, %Tag{id: 13}], + tag_alias_families: %{ + 12 => %{canonical_id: 12, tag_ids: [12]}, + 13 => %{canonical_id: 13, tag_ids: [13]} + }}} """ - def get_tag_by_name(name), do: Repo.get_by(Tag, name: name) + @spec put_lock_tag_alias_families(Multi.t(), [integer()]) :: Multi.t() + def put_lock_tag_alias_families(%Multi{} = multi, tag_ids) do + tag_query = + Tag + |> where([tag], tag.id in ^tag_ids) + |> preload([:aliases, aliased_tag: :aliases]) + |> order_by(asc: :id) + + multi + |> Multi.all(:tags, tag_query) + |> Multi.lock_all(:locked_tags, fn %{tags: tags} -> + tag_ids = Enum.flat_map(tags, &tag_alias_family(&1).tag_ids) + + Tag + |> where([tag], tag.id in ^tag_ids) + |> order_by(asc: :id) + end) + |> Multi.run(:detect_conflict, fn + _repo, %{tags: unlocked_tags, locked_tags: locked_tags} -> + locked_aliases = Map.new(locked_tags, &{&1.id, &1.aliased_tag_id}) + + if Enum.any?(unlocked_tags, &(locked_aliases[&1.id] != &1.aliased_tag_id)) do + {:error, :conflict} + else + {:ok, nil} + end + end) + |> Multi.run(:tag_alias_families, fn _repo, %{tags: tags} -> + {:ok, Map.new(tags, fn tag -> {tag.id, tag_alias_family(tag)} end)} + end) + end @doc """ - Gets a single tag by its name, or the tag it is aliased to, if it is aliased. + Loads the visible tag named by `slug` without resolving aliases. - Returns nil if the tag does not exist. + The JSON representation uses this API so an alias remains independently + addressable. Missing and malformed slugs are not found before `:show` + authorization. ## Examples - iex> get_tag_or_alias_by_name("safe") - %Tag{} + iex> show_tag(actor, "safe") + {:ok, %Tag{}} - iex> get_tag_or_alias_by_name("nonexistent") - nil + iex> show_tag(actor, "nonexistent") + {:error, :not_found} """ - def get_tag_or_alias_by_name(name) do - Tag - |> where(name: ^name) - |> preload(:aliased_tag) - |> Repo.one() - |> case do - nil -> nil - tag -> tag.aliased_tag || tag - end + @spec show_tag(Actor.t(), String.t()) :: + {:ok, Tag.t()} | {:error, :not_found | :unauthorized} + def show_tag(%Actor{} = actor, slug) do + load_tag_for_action(actor, :show, slug, @api_preloads) end @doc """ - Creates a tag. + Loads the visible canonical tag named by `slug`. + + Aliases resolve to their target before `:show` authorization. TagChanges uses + this boundary for history links. Missing and malformed slugs are not found. ## Examples - iex> create_tag(%{field: value}) + iex> load_canonical_tag(actor, "safe") {:ok, %Tag{}} - iex> create_tag(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> load_canonical_tag(actor, "missing") + {:error, :not_found} """ - def create_tag(attrs \\ %{}) do - %Tag{} - |> Tag.creation_changeset(attrs) - |> Repo.insert() + @spec load_canonical_tag(Actor.t(), String.t()) :: + {:ok, Tag.t()} | {:error, :not_found | :unauthorized} + def load_canonical_tag(%Actor{} = actor, slug) do + with {:ok, tag} <- load_tag_for_action(actor, :show, slug, [:aliased_tag]), + tag = tag.aliased_tag || tag, + :ok <- authorize(actor, :show, tag) do + {:ok, tag} + end end @doc """ - Updates a tag. + Searches the tag index on behalf of `actor`. + + `params["query"]` owns the query language input. Invalid syntax returns its + rejected query-form changeset; missing input intentionally compiles to the + established match-none query. Results sort by image count, name, then ID and + carry the associations needed by the public tag representation. ## Examples - iex> update_tag(tag, %{field: new_value}) - {:ok, %Tag{}} + iex> query_tags(actor, %{"query" => "artist:*"}, pagination) + {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} - iex> update_tag(tag, %{field: bad_value}) + iex> query_tags(actor, %{"query" => ")"}, pagination) {:error, %Ecto.Changeset{}} """ - def update_tag(%Tag{} = tag, attrs) do - tag_input = Tag.parse_tag_list(attrs["implied_tag_list"]) - - implied_tags = - Tag - |> where([t], t.name in ^tag_input) - |> Repo.all() + @spec query_tags(Actor.t(), map(), Search.pagination_params()) :: + {:ok, Scrivener.Page.t(), Ecto.Changeset.t()} + | {:error, :unauthorized | Ecto.Changeset.t()} + def query_tags(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, Tag), + {:ok, body, query_form} <- QueryBuilder.build_query(params) do + tags = + Tag + |> Search.search_definition(body, pagination) + |> Search.search_records(preload(Tag, ^@api_preloads)) - tag - |> Tag.changeset(attrs, implied_tags) - |> Repo.update() - |> reindex_after_update(tag) + {:ok, tags, QueryForm.changeset(query_form)} + end end - defp reindex_after_update(result, old_tag) do - case result do - {:ok, tag} -> - if tag.category != old_tag.category do - reindex_tag_images(tag) - end + @doc """ + Loads the tags matching the supplied integer IDs. - reindex_tag(tag) - {:ok, tag} + Unknown IDs are omitted and results are not guaranteed to follow input + order. Request parsing and request-specific limits remain the caller's + responsibility. - error -> - error - end + ## Examples + + iex> list_tags_by_ids([42, 999_999_999]) + [%Tag{id: 42}] + + """ + @spec list_tags_by_ids([integer()]) :: [Tag.t()] + def list_tags_by_ids(ids) when is_list(ids) do + Tag + |> where([tag], tag.id in ^ids) + |> Repo.all() end @doc """ - Updates a tag's associated image. + Returns tag suggestions for a normalized search-as-you-type term. - Takes a tag and image upload attributes, analyzes the upload, - persists it, and removes the old tag image if successful. + Canonical names and aliases are prefix-matched in OpenSearch. PostgreSQL is + authoritative for image counts, so results are filtered and re-sorted after + loading to tolerate temporary index desynchronization. At most ten indexed + candidates are considered. ## Examples - iex> update_tag_image(tag, %{"image" => upload}) - {:ok, %Tag{}} + iex> autocomplete_tags("pon", 2) + [%TagSuggestion{alias: nil, canonical: "pony", images: 42}] """ - def update_tag_image(%Tag{} = tag, attrs) do - tag - |> Uploader.analyze_upload(attrs) - |> Repo.update() - |> case do - {:ok, tag} -> - Uploader.persist_upload(tag) - Uploader.unpersist_old_upload(tag) + @spec autocomplete_tags(String.t(), 1..10) :: [TagSuggestion.t()] + def autocomplete_tags(term, limit \\ 10) - {:ok, tag} - - error -> - error - end + def autocomplete_tags(term, limit) + when is_binary(term) and is_integer(limit) and limit > 0 and limit <= 10 do + Tag + |> Search.search_definition( + %{ + query: %{ + bool: %{ + should: [ + %{prefix: %{name: term}}, + %{prefix: %{name_in_namespace: term}} + ] + } + }, + sort: %{images: :desc} + }, + %{page_size: 10} + ) + |> Search.search_records(preload(Tag, :aliased_tag)) + |> Enum.map(fn tag -> + canonical = tag.aliased_tag || tag + + %TagSuggestion{ + alias: if(tag.aliased_tag, do: tag.name), + canonical: canonical.name, + images: canonical.images_count + } + end) + |> Enum.filter(&(&1.images > 0)) + |> Enum.sort_by(& &1.images, :desc) + |> Enum.take(limit) end @doc """ - Removes a tag's associated image. + Returns the cached data used to render the quick-tag table. - Removes the image from the tag and deletes the persisted file. + The cache is stored in `:persistent_term` because the table is read on many + requests and changes only when explicitly refreshed or the VM restarts. ## Examples - iex> remove_tag_image(tag) - {:ok, %Tag{}} + iex> table = quick_tag_table() + iex> is_map(table.tags) and is_map(table.shipping) + true """ - def remove_tag_image(%Tag{} = tag) do - tag - |> Tag.remove_image_changeset() - |> Repo.update() - |> case do - {:ok, tag} -> - Uploader.unpersist_old_upload(tag) + @spec quick_tag_table() :: QuickTagTable.t() + def quick_tag_table, do: QuickTagTable.get() - {:ok, tag} + @doc """ + Recomputes and replaces the cached quick-tag table data. - error -> - error - end - end + ## Examples + + iex> table = refresh_quick_tag_table() + iex> is_map(table.data) + true + + """ + @spec refresh_quick_tag_table() :: QuickTagTable.t() + def refresh_quick_tag_table, do: QuickTagTable.refresh() @doc """ - Deletes a Tag. + Assembles the `TagPage` for the viewer described by `scope`, loading the + tag named by `slug`. + + Loads the tag before `:show` authorization. Missing and malformed slugs are + always not found. A tag that is aliased into another is + `{:aliased_to, tag}`, its `:aliased_tag` association carrying the target. + Otherwise the page carries the tag, the executed page of + images tagged with it, the viewer's interactions, and the escaped search + query for the tag. + + Returns `{:ok, %TagPage{}}`, `{:aliased_to, tag}`, `{:error, :not_found}`, + or `{:error, :unauthorized}`. ## Examples - iex> delete_tag(tag) - {:ok, %Tag{}} + iex> show_tag_page(actor, scope, "safe") + {:ok, %TagPage{}} - iex> delete_tag(tag) - {:error, %Ecto.Changeset{}} + iex> show_tag_page(actor, scope, "artist-colon-somebody") + {:aliased_to, %Tag{}} """ - def delete_tag(%Tag{} = tag) do - Exq.enqueue(Exq, "indexing", TagDeleteWorker, [tag.id]) - - {:ok, tag} + @spec show_tag_page(Actor.t(), Scope.t(), String.t()) :: + {:ok, TagPage.t()} | {:aliased_to, Tag.t()} | {:error, :not_found | :unauthorized} + def show_tag_page(%Actor{} = actor, %Scope{} = scope, slug) do + with {:ok, tag} <- load_tag_for_action(actor, :show, slug, @show_preloads) do + case tag do + %{aliased_tag: %Tag{}} -> + {:aliased_to, tag} + + _tag -> + {images, _tags} = ImageSearch.query(actor, scope, %{term: %{"tags" => tag.name}}) + images = ImageSearch.execute(images) + + {:ok, + %TagPage{ + tag: tag, + images: images, + interactions: Interactions.user_interactions(actor, images), + search_query: maybe_escape_name(tag) + }} + end + end end @doc """ - Performs the actual deletion of a tag. + Loads the tag named by `slug` for editing, on behalf of `actor`. + + Write access and `:edit` authorization match the update action. Missing and + malformed slugs are always not found. - Removes the tag from the database, deletes its search index, - reindexes all images that were tagged with it, and cleans up - any empty tag changes. + Returns `{:ok, {tag, changeset}}`, `{:error, :not_found}`, or + `{:error, :unauthorized}`. ## Examples - iex> perform_delete(123) - :ok + iex> edit_tag(moderator, "safe") + {:ok, {%Tag{}, %Ecto.Changeset{}}} """ - def perform_delete(tag_id) do - tag = get_tag!(tag_id) + @spec edit_tag(Actor.t(), String.t()) :: + {:ok, {Tag.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_tag(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :edit, slug, @show_preloads) do + {:ok, {tag, Tag.changeset(tag)}} + end + end - image_ids = - Image - |> join(:inner, [i], _ in assoc(i, :tags)) - |> where([_i, t], t.id == ^tag.id) - |> select([i, _t], i.id) - |> Repo.all() + @doc """ + Loads the tag spoiler-image form on behalf of `actor`. - {:ok, tag} = Repo.delete(tag) + Write access and `:edit_image` authorization match both image mutations. - Search.delete_document(tag.id, Tag) + ## Examples - TagChanges.delete_empty_tag_changes() + iex> edit_tag_image(moderator, "safe") + {:ok, {%Tag{}, %Ecto.Changeset{}}} - Image - |> where([i], i.id in ^image_ids) - |> preload(^Images.indexing_preloads()) - |> Search.reindex(Image) + """ + @spec edit_tag_image(Actor.t(), String.t()) :: + {:ok, {Tag.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_tag_image(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :edit_image, slug, @image_preloads) do + {:ok, {tag, Tag.changeset(tag)}} + end end @doc """ - Creates an alias from one tag to another. + Loads the tag named by `slug` for editing its aliasing, on behalf of `actor`. - Takes a source tag and target tag name, creating an alias relationship - where the source tag becomes an alias of the target tag. Once the alias - is created, a job is queued to finish processing the alias. + Write access and `:edit_alias` authorization match alias mutations. Missing + and malformed slugs are always not found. + + Returns `{:ok, {tag, changeset}}`, `{:error, :not_found}`, or + `{:error, :unauthorized}`. ## Examples - iex> alias_tag(source_tag, %{"target_tag" => "destination"}) - {:ok, %Tag{}} + iex> edit_tag_alias(admin, "safe") + {:ok, {%Tag{}, %Ecto.Changeset{}}} """ - def alias_tag(%Tag{} = tag, attrs) do - target_tag = Repo.get_by(Tag, name: String.downcase(attrs["target_tag"])) - - tag - |> Repo.preload(:aliased_tag) - |> Tag.alias_changeset(target_tag) - |> Repo.update() - |> case do - {:ok, tag} -> - Exq.enqueue(Exq, "indexing", TagAliasWorker, [tag.id, target_tag.id]) - - {:ok, tag} - - error -> - error + @spec edit_tag_alias(Actor.t(), String.t()) :: + {:ok, {Tag.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def edit_tag_alias(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :edit_alias, slug, @alias_preloads) do + {:ok, {tag, Tag.alias_form_changeset(tag)}} end end @doc """ - Performs the actual tag aliasing operation. + Assembles the tag usage detail for the tag named by `slug`, on behalf of + `actor`. - Transfers all associations from the source tag to the target tag, - including image taggings, filters, user watches, and other relationships. - Updates counters and reindexes affected records. + Authorizes `:show_details` on the loaded tag. On success the result carries the + tag, the filters that spoiler it, the filters that hide it, and the users + watching it. ## Examples - iex> perform_alias(123, 456) - :ok + iex> list_tag_details(moderator, "safe") + {:ok, %TagDetail{tag: %Tag{}}} """ - def perform_alias(tag_id, target_tag_id) do - tag = get_tag!(tag_id) - target_tag = get_tag!(target_tag_id) + @spec list_tag_details(Actor.t(), String.t()) :: + {:ok, TagDetail.t()} | {:error, :not_found | :unauthorized} + def list_tag_details(%Actor{} = actor, slug) do + with {:ok, tag} <- load_tag_for_action(actor, :show_details, slug, []) do + filters_spoilering = + Filter + |> where( + [filter], + fragment("? @> ARRAY[?]::integer[]", filter.spoilered_tag_ids, ^tag.id) + ) + |> preload(:user) + |> Repo.all() + + filters_hiding = + Filter + |> where([filter], fragment("? @> ARRAY[?]::integer[]", filter.hidden_tag_ids, ^tag.id)) + |> preload(:user) + |> Repo.all() + + users_watching = + User + |> where([user], fragment("? @> ARRAY[?]::integer[]", user.watched_tag_ids, ^tag.id)) + |> Repo.all() + + {:ok, + %TagDetail{ + tag: tag, + filters_spoilering: filters_spoilering, + filters_hiding: filters_hiding, + users_watching: users_watching + }} + end + end - filters_hidden = - where(Filter, [f], fragment("? @> ARRAY[?]::integer[]", f.hidden_tag_ids, ^tag.id)) + @doc """ + Adds the tag named by `slug` to `actor`'s watched tags. - filters_spoilered = - where(Filter, [f], fragment("? @> ARRAY[?]::integer[]", f.spoilered_tag_ids, ^tag.id)) + This personal preference update is deliberately exempt from + `verify_write_access/1`. An unknown slug is `{:error, :not_found}`. + Otherwise this defers to the watched-tags update, which reindexes the user. - users_watching = - where(User, [u], fragment("? @> ARRAY[?]::integer[]", u.watched_tag_ids, ^tag.id)) + Returns `{:ok, user}`, `{:error, %Ecto.Changeset{}}`, or + `{:error, :not_found}`. - array_replace(filters_hidden, :hidden_tag_ids, tag.id, target_tag.id) - array_replace(filters_spoilered, :spoilered_tag_ids, tag.id, target_tag.id) - array_replace(users_watching, :watched_tag_ids, tag.id, target_tag.id) + ## Examples - # Create taggings with the new tag ID on images where the old tag ID is used. - retag_query = - from i in Image, - inner_join: it in Tagging, - on: it.image_id == i.id, - select: %{image_id: i.id, tag_id: ^target_tag.id}, - where: it.tag_id == ^tag.id + iex> create_tag_watch(actor, "safe") + {:ok, %User{}} - Repo.insert_all(Tagging, retag_query, on_conflict: :nothing) + """ + @spec create_tag_watch(Actor.t(), String.t()) :: + {:ok, User.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def create_tag_watch(%Actor{} = actor, slug) do + with {:ok, tag} <- load_tag_for_action(actor, :show, slug, []) do + Users.watch_tag(actor, tag) + end + end - # Delete taggings on the source tag - Tagging - |> where(tag_id: ^tag.id) - |> Repo.delete_all() + @doc """ + Removes the tag named by `slug` from `actor`'s watched tags. - # Update other associations - ArtistLink - |> where(tag_id: ^tag.id) - |> Repo.update_all(set: [tag_id: target_tag.id]) + This personal preference update is deliberately exempt from + `verify_write_access/1`. An unknown slug is `{:error, :not_found}`. + Otherwise this defers to the watched-tags update, which reindexes the user. - DnpEntry - |> where(tag_id: ^tag.id) - |> Repo.update_all(set: [tag_id: target_tag.id]) + Returns `{:ok, user}`, `{:error, %Ecto.Changeset{}}`, or + `{:error, :not_found}`. - Channel - |> where(associated_artist_tag_id: ^tag.id) - |> Repo.update_all(set: [associated_artist_tag_id: target_tag.id]) - - # Update counter - Tag - |> where(id: ^tag.id) - |> Repo.update_all( - set: [images_count: 0, aliased_tag_id: target_tag.id, updated_at: DateTime.utc_now()] - ) + ## Examples - # Finally, reindex - reindex_tag_images(target_tag) - reindex_tags([tag, target_tag]) + iex> delete_tag_watch(actor, "safe") + {:ok, %User{}} - :ok + """ + @spec delete_tag_watch(Actor.t(), String.t()) :: + {:ok, User.t()} + | {:error, :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_tag_watch(%Actor{} = actor, slug) do + with {:ok, tag} <- load_tag_for_action(actor, :show, slug, []) do + Users.unwatch_tag(actor, tag) + end end @doc """ - Enqueues reindexing of all images associated with a tag. + Updates the tag named by `slug` on behalf of `actor`. + + Write access, `:update` authorization, the tag update, and its audit log share + one workflow. Search and affected-image reindexing run only after commit. ## Examples - iex> reindex_tag_images(tag) + iex> update_tag(moderator, "safe", %{"category" => "rating"}) {:ok, %Tag{}} """ - def reindex_tag_images(%Tag{} = tag) do - Exq.enqueue(Exq, "indexing", TagReindexWorker, [tag.id]) + @spec update_tag(Actor.t(), String.t(), map()) :: + {:ok, Tag.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_tag(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :update, slug, []) do + implied_tag_names = + %Tag{} + |> Tag.implication_form_changeset(attrs) + |> Ecto.Changeset.get_field(:implied_tag_list) + |> Tag.parse_tag_list() + + locked_tags_query = + Tag + |> where([t], t.name in ^[tag.name | implied_tag_names]) + |> order_by(asc: :id) - {:ok, tag} + locked_tag_query = + Tag + |> where(id: ^tag.id) + |> preload(:implied_tags) + + Multi.new() + |> Multi.lock_all(:locked_tags, locked_tags_query) + |> Multi.lock_one(:locked_tag, locked_tag_query) + |> Multi.update(:tag, fn %{locked_tags: locked_tags, locked_tag: tag} -> + locked_tags + |> Enum.filter(&(&1.name in implied_tag_names)) + |> then(&Tag.changeset(tag, attrs, &1)) + end) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Tag:update", + Paths.tag_path(tag), + "Updated details on tag '#{tag.name}'" + ) + |> Multi.on_commit(fn %{tag: updated_tag} -> + # credo:disable-for-next-line + if updated_tag.category != tag.category do + reindex_tag_images(updated_tag) + end + + reindex_tags([updated_tag]) + end) + |> Multi.transact() + |> case do + {:ok, %{tag: %Tag{} = tag}} -> + {:ok, tag} + + {:error, :tag, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Performs reindexing of all images associated with a tag. + Updates the spoiler image of the tag named by `slug`, on behalf of `actor`. - Updates the tag's image count to reflect the current number of non-hidden images, - then reindexes all associated images and filters that reference this tag. + Write access, `:update_image` authorization, the database update, and its + audit log share one transaction. Object persistence occurs after commit. ## Examples - iex> perform_reindex_images(123) + iex> update_tag_image(moderator, "safe", upload) + {:ok, %Tag{}} """ - def perform_reindex_images(tag_id) do - tag = get_tag!(tag_id) - - # First recount the tag - image_count = - Image - |> join(:inner, [i], _ in assoc(i, :tags)) - |> where([i, t], i.hidden_from_users == false and t.id == ^tag.id) - |> Repo.aggregate(:count, :id) - - Tag - |> where(id: ^tag.id) - |> Repo.update_all(set: [images_count: image_count]) + @spec update_tag_image(Actor.t(), String.t(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, Tag.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_tag_image(%Actor{} = actor, slug, upload) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :update_image, slug, @image_preloads) do + tag_image_changeset = Uploader.analyze_upload(tag, upload) - # Then reindex - Image - |> join(:inner, [i], _ in assoc(i, :tags)) - |> where([_i, t], t.id == ^tag.id) - |> preload(^Images.indexing_preloads()) - |> Search.reindex(Image) + Multi.new() + |> Multi.lock_one(:locked_tag, where(Tag, id: ^tag.id)) + |> Multi.update(:tag, tag_image_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Tag.Image:update", + Paths.tag_path(tag), + "Updated image on tag '#{tag.name}'" + ) + |> Multi.on_commit(fn %{tag: tag} -> + Uploader.persist_upload(tag) + Uploader.unpersist_old_upload(tag) + end) + |> Multi.transact() + |> case do + {:ok, %{tag: %Tag{} = tag}} -> + {:ok, tag} - Filter - |> where([f], fragment("? @> ARRAY[?]::integer[]", f.hidden_tag_ids, ^tag.id)) - |> or_where([f], fragment("? @> ARRAY[?]::integer[]", f.spoilered_tag_ids, ^tag.id)) - |> preload(^Filters.indexing_preloads()) - |> Search.reindex(Filter) + {:error, :tag, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Enqueues removal of a tag alias. + Removes the spoiler image of the tag named by `slug`, on behalf of `actor`. + + Write access, `:delete_image` authorization, the database update, and its + audit log share one transaction. Object deletion occurs after commit. ## Examples - iex> unalias_tag(tag) + iex> delete_tag_image(moderator, "safe") {:ok, %Tag{}} """ - def unalias_tag(%Tag{} = tag) do - Exq.enqueue(Exq, "indexing", TagUnaliasWorker, [tag.id]) + @spec delete_tag_image(Actor.t(), String.t()) :: + {:ok, Tag.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_tag_image(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :delete_image, slug, @image_preloads) do + Multi.new() + |> Multi.lock_one(:locked_tag, where(Tag, id: ^tag.id)) + |> Multi.update(:tag, Tag.remove_image_changeset(tag)) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Tag.Image:delete", + Paths.tag_path(tag), + "Removed image on tag '#{tag.name}'" + ) + |> Multi.on_commit(fn %{tag: tag} -> Uploader.unpersist_old_upload(tag) end) + |> Multi.transact() + |> case do + {:ok, %{tag: %Tag{} = tag}} -> + {:ok, tag} - {:ok, tag} + {:error, :tag, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Performs removal of a tag alias. + Queues the tag named by `slug` for deletion on behalf of `actor`. - Removes the alias relationship between two tags and reindexes - the images of the formerly aliased tag. + Write access and `:delete` authorization precede an atomic audit insert. The + destruction worker is released only after that audit commits. ## Examples - iex> perform_unalias(123) + iex> delete_tag(admin, "garbage-tag") {:ok, %Tag{}} + """ - def perform_unalias(tag_id) do - tag = get_tag!(tag_id) - former_alias = Repo.preload(tag, :aliased_tag).aliased_tag + @spec delete_tag(Actor.t(), String.t()) :: + {:ok, Tag.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_tag(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :delete, slug, @show_preloads), + {:ok, _tag} <- + tag + |> Tag.deletion_changeset() + |> Ecto.Changeset.apply_action(:update) do + Multi.new() + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Tag:delete", + Paths.tag_path(tag), + "Deleted tag '#{tag.name}'" + ) + |> Multi.on_commit(fn _changes -> + Exq.enqueue(Exq, "indexing", TagDeleteWorker, [tag.id]) + end) + |> Multi.transact() + |> case do + {:ok, _changes} -> + {:ok, tag} + end + end + end - tag - |> Tag.unalias_changeset() - |> Repo.update() - |> case do - {:ok, _} = result -> - reindex_tag_images(former_alias) - reindex_tags([tag, former_alias]) + @doc """ + Aliases the tag named by `slug` on behalf of `actor`. - result + Write access, `:alias` authorization, the association migration, and its + audit log share one transaction. Tagging migration and alias finalization + are queued after commit. - result -> - result - end - end + ## Examples - defp array_replace(queryable, column, old_value, new_value) do - queryable - |> update( - [q], - set: [ + iex> update_tag_alias(admin, "artist-colon-somebody", %{"target_tag" => "somebody"}) + {:ok, %Tag{}} + + """ + @spec update_tag_alias(Actor.t(), String.t(), map()) :: + {:ok, Tag.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def update_tag_alias(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, %{name: source_tag_name}} <- load_tag_for_action(actor, :alias, slug, []), + {:ok, %{target_tag: target_tag_name}} = + %Tag{} + |> Tag.alias_form_changeset(attrs) + |> Ecto.Changeset.apply_action(:update) do + tag_query = + Tag + |> where([t], t.name in ^[source_tag_name, target_tag_name]) + |> preload(^@alias_preloads) + |> order_by(asc: :id) + + Multi.new() + |> Multi.lock_all(:locked_tags, tag_query) + |> Multi.run(:tags, fn _repo, %{locked_tags: locked_tags} -> + source_tag = Enum.find(locked_tags, &(&1.name == source_tag_name)) + target_tag = Enum.find(locked_tags, &(&1.name == target_tag_name)) + + if is_nil(source_tag) or is_nil(target_tag) do + {:error, :not_found} + else + {:ok, {source_tag, target_tag}} + end + end) + |> Multi.exists?(:incoming_aliases, fn %{tags: {source_tag, _target_tag}} -> + where(Tag, aliased_tag_id: ^source_tag.id) + end) + |> Multi.exists?(:implied_by_tags, fn %{tags: {source_tag, _target_tag}} -> + where(Implication, implied_tag_id: ^source_tag.id) + end) + |> Multi.update(:tag, fn + %{ + tags: {source_tag, target_tag}, + incoming_aliases: incoming_aliases, + implied_by_tags: implied_by_tags + } -> + Tag.alias_changeset(source_tag, target_tag, incoming_aliases, implied_by_tags) + end) + |> Multi.merge(fn %{tags: {source_tag, target_tag}} -> + Multi.new() + |> Filters.put_replace_tag_references( + :update_hidden_filters, + :update_spoilered_filters, + source_tag.id, + target_tag.id + ) + |> Users.put_replace_watched_tag(:update_users_watching, source_tag.id, target_tag.id) + |> ArtistLinks.put_alias_tag(source_tag.id, target_tag.id) + |> DnpEntries.put_replace_tag(:update_dnp_entries, source_tag.id, target_tag.id) + |> Channels.put_replace_artist_tag(:update_channels, source_tag.id, target_tag.id) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{tags: {source_tag, target_tag}} -> { - ^column, - fragment("array_replace(?, ?, ?)", field(q, ^column), ^old_value, ^new_value) + "Tag.Alias:update", + Paths.tag_path(source_tag), + "Aliased tag '#{source_tag.name}' into '#{target_tag.name}'" } - ] - ) - |> Repo.update_all([]) + end) + |> Multi.on_commit(fn %{tags: {source_tag, target_tag}} -> + Exq.enqueue(Exq, "indexing", TagAliasWorker, [source_tag.id, target_tag.id]) + end) + |> Multi.transact() + |> case do + {:ok, %{tag: %Tag{} = source_tag}} -> + {:ok, source_tag} + + {:error, :tags, :not_found, _changes} -> + {:error, :not_found} + + {:error, :tag, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Copies tags from one image to another. + Enqueues reindexing of the tag named by `slug` and its images, on behalf of + `actor`. - Creates new taggings on the target image for all tags present on the source image, - updates tag counters, and returns the list of copied tags. + Write access and `:reindex` authorization precede both jobs. Missing and + malformed slugs are always not found. ## Examples - iex> copy_tags(source_image, target_image) - [%Tag{}, ...] + iex> create_tag_reindex(admin, "safe") + {:ok, %Tag{}} """ - def copy_tags(source, target) do - # Ecto bug: - # ** (DBConnection.EncodeError) Postgrex expected a binary, got 5. - # - # what I would like to do: - # |> select([t], %{image_id: ^target.id, tag_id: t.tag_id}) - # - # what I have to do instead: + @spec create_tag_reindex(Actor.t(), String.t()) :: + {:ok, Tag.t()} | {:error, :ban | :not_found | :unauthorized} + def create_tag_reindex(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :reindex, slug, @alias_preloads) do + reindex_tag_images(tag) + reindex_tags([tag]) + + {:ok, tag} + end + end - taggings = - Tagging - |> where(image_id: ^source.id) - |> select([t], %{image_id: ^to_string(target.id), tag_id: t.tag_id}) - |> Repo.all() - |> Enum.map(&%{&1 | image_id: String.to_integer(&1.image_id)}) + @doc """ + Removes the alias on the tag named by `slug`, on behalf of `actor`. - {:ok, tag_ids} = - Repo.transaction(fn -> - {_count, taggings} = - Repo.insert_all(Tagging, taggings, on_conflict: :nothing, returning: [:tag_id]) + Write access, `:unalias` authorization, the relationship update, and the + audit log share one transaction. Image and tag reindexing runs after commit. - tag_ids = Enum.map(taggings, & &1.tag_id) + ## Examples - update_image_counts(Repo, 1, tag_ids) + iex> delete_tag_alias(admin, "artist-colon-somebody") + {:ok, %Tag{}} - tag_ids + """ + @spec delete_tag_alias(Actor.t(), String.t()) :: + {:ok, Tag.t()} + | {:error, :ban | :not_found | :unauthorized | Ecto.Changeset.t()} + def delete_tag_alias(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, tag} <- load_tag_for_action(actor, :unalias, slug, @alias_preloads) do + tag_query = + Tag + |> where(id: ^tag.id) + |> preload(:aliased_tag) + + Multi.new() + |> Multi.lock_one(:locked_tag, tag_query) + |> Multi.update(:tag, fn %{locked_tag: tag} -> + Tag.unalias_changeset(tag) + end) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Tag.Alias:delete", + Paths.tag_path(tag), + "Dealiased tag '#{tag.name}'" + ) + |> Multi.on_commit(fn %{locked_tag: %{aliased_tag: former_alias}, tag: tag} -> + reindex_tag_images(former_alias) + reindex_tags([tag, former_alias]) end) + |> Multi.transact() + |> case do + {:ok, %{tag: %Tag{} = tag}} -> + {:ok, tag} - Tag - |> where([t], t.id in ^tag_ids) - |> Repo.all() + {:error, :tag, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end @doc """ - Accepts IDs of tags and increments their `images_count` by 1. + Applies `diff` to each tag's image count inside the caller's transaction. + + Overlapping bulk updates must lock tag rows in ascending primary-key order. + PostgreSQL otherwise may acquire the update locks in different orders and + deadlock concurrent image writes; this invariant was reproduced by parallel + uploads in philomena-dev/philomena#483. + + ## Examples + + iex> update_image_counts(repo, 1, [12, 13]) + 2 + """ - @spec update_image_counts(term(), integer(), [integer()]) :: integer() + @spec update_image_counts(module(), integer(), [integer()]) :: integer() def update_image_counts(repo, diff, tag_ids) def update_image_counts(_repo, _diff, []), do: 0 def update_image_counts(repo, diff, tag_ids) do - locked_tags = vectorized_mutation_lock("FOR NO KEY UPDATE", tag_ids) + locked_tags = locked_tag_ids(tag_ids) {rows_affected, _} = Tag @@ -654,179 +1249,341 @@ defmodule Philomena.Tags do end @doc """ - Returns an `%Ecto.Changeset{}` for tracking tag changes. - - ## Examples - - iex> change_tag(tag) - %Ecto.Changeset{source: %Tag{}} + Adds tag image count maintenance to `multi`. - """ - def change_tag(%Tag{} = tag) do - Tag.changeset(tag, %{}) - end - - @doc """ - Queues a single tag for search index updates. - Returns the tag struct unchanged, for use in a pipeline. + `image_step` must resolve to an `%Image{}` with added and removed tag lists. + Tags owns its image count update rule: hidden images do not contribute to tag + image counts. Counter updates are performed in ascending tag ID order and + affected tags are reindexed after the transaction commits. ## Examples - iex> reindex_tag(tag) - %Tag{} + iex> Multi.new() |> put_image_tag_count_changes() + %Philomena.Multi{} """ - def reindex_tag(%Tag{} = tag) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", [tag.id]]) - - tag + @spec put_image_tag_count_changes(Multi.t(), Multi.name()) :: Multi.t() + def put_image_tag_count_changes(%Multi{} = multi, image_step \\ :image) do + multi + |> Multi.run(:image_tag_counts_tag_ids, fn repo, %{^image_step => image} -> + {:ok, update_image_count_changes(repo, image)} + end) + |> Multi.on_commit(fn %{image_tag_counts_tag_ids: tag_ids} -> + reindex_tag_ids(tag_ids) + end) end @doc """ - Queues a list of tags for search index updates. - Returns the list of tags unchanged, for use in a pipeline. - - ## Examples - - iex> reindex_tags([%Tag{}, %Tag{}, ...]) - [%Tag{}, %Tag{}, ...] + Adds a tag image-count adjustment to `multi`. + The callback form allows reading tag IDs from prior Multi changes. For an + image tag change result, use `put_image_tag_count_changes/2` so Tags can + apply its visibility rule and combine the additions and removals. """ - def reindex_tags(tags) do - Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", Enum.map(tags, & &1.id)]) - - tags + @spec put_image_count_delta( + Multi.t(), + Multi.name(), + (Multi.changes() -> [integer()]), + integer() + ) :: Multi.t() + def put_image_count_delta(%Multi{} = multi, step, tag_ids_callback, diff) do + Multi.run(multi, step, fn repo, changes -> + {:ok, update_image_counts(repo, diff, tag_ids_callback.(changes))} + end) + |> Multi.on_commit(fn changes -> + reindex_tag_ids(tag_ids_callback.(changes)) + end) end @doc """ - Returns the list of associations to preload for tag indexing. - - ## Examples - - iex> indexing_preloads() - [:aliased_tag, :aliases, :implied_tags, :implied_by_tags] + Upserts image count deltas for bulk image tag changes inside `multi`. + Images supplies inserted and deleted tagging steps. This function + updates image counts for visible images and queues every affected tag for + indexing after commit. `visible_image_step` must resolve to the images + matching the `hidden_from_users == false` precondition. """ - def indexing_preloads do - [:aliased_tag, :aliases, :implied_tags, :implied_by_tags] + @spec put_batch_image_count_changes(Multi.t(), Multi.name(), Multi.name(), Multi.name()) :: + Multi.t() + def put_batch_image_count_changes( + %Multi{} = multi, + inserted_step, + deleted_step, + visible_image_step + ) do + multi + |> Multi.run(:batch_tag_counts, fn + repo, + %{ + ^visible_image_step => visible_images, + ^inserted_step => {_inserted_count, inserted_taggings}, + ^deleted_step => {_deleted_count, deleted_taggings} + } -> + visible_image_ids = MapSet.new(visible_images, & &1.id) + + inserted = + inserted_taggings + |> Enum.filter(&MapSet.member?(visible_image_ids, &1.image_id)) + |> Enum.map(fn %{tag_id: tag_id} -> {tag_id, 1} end) + + deleted = + deleted_taggings + |> Enum.filter(fn [image_id, _] -> MapSet.member?(visible_image_ids, image_id) end) + |> Enum.map(fn [_, tag_id] -> {tag_id, -1} end) + + now = DateTime.utc_now(:second) + + # In order to merge into the existing tables here in one go, insert_all + # is used with a query that is guaranteed to conflict on every row by + # using the primary key. This will update the image counts via the + # ON CONFLICT DO UPDATE clause. + rows = + inserted + |> Enum.concat(deleted) + |> Enum.reduce(%{}, fn {tag_id, delta}, acc -> + Map.update(acc, tag_id, delta, &(&1 + delta)) + end) + |> Enum.map(fn {tag_id, delta} -> + %{ + id: tag_id, + name: "", + slug: "", + created_at: now, + updated_at: now, + images_count: delta + } + end) + + {_count, nil} = + repo.insert_all(Tag, rows, + on_conflict: update(Tag, inc: [images_count: fragment("EXCLUDED.images_count")]), + conflict_target: [:id] + ) + + {:ok, Enum.map(rows, & &1.id)} + end) + |> Multi.on_commit(fn %{batch_tag_counts: tag_ids} -> reindex_tag_ids(tag_ids) end) end @doc """ - Performs reindexing of tags based on a column condition. + Adds tag copying from `source` to `target` to an image merge transaction. - Takes a column name and a list of values to match against that column, - then reindexes all matching tags. + Only newly inserted target taggings increment tag image counters. ## Examples - iex> perform_reindex(:id, [1, 2, 3]) - {:ok, []} - - iex> perform_reindex(:name, ["safe", "suggestive"]) - {:ok, []} + iex> put_copy_tags(multi, source_image, target_image) + %Philomena.Multi{} """ - def perform_reindex(column, condition) do - Tag - |> preload(^indexing_preloads()) - |> where([t], field(t, ^column) in ^condition) - |> Search.reindex(Tag) + @spec put_copy_tags(Multi.t(), Image.t(), Image.t()) :: Multi.t() + def put_copy_tags(%Multi{} = multi, %Image{} = source, %Image{} = target) do + multi + |> Images.put_copy_taggings(source, target) + |> put_image_count_delta( + :update_image_counts, + fn %{copied_tag_ids: copied_tag_ids} -> copied_tag_ids end, + 1 + ) end - alias Philomena.Tags.Implication - @doc """ - Returns the list of tags_implied_tags. + Worker entry point that deletes a queued tag and repairs dependent indexes. + + Taggings are deleted in batches to avoid holding locks for the full migration. ## Examples - iex> list_tags_implied_tags() - [%Implication{}, ...] + iex> perform_delete(42) + :ok """ - def list_tags_implied_tags do - Repo.all(Implication) - end - - @doc """ - Gets a single implication. - - Raises `Ecto.NoResultsError` if the Implication does not exist. + @spec perform_delete(integer()) :: :ok + def perform_delete(tag_id) do + tag = Repo.get!(Tag, tag_id) - ## Examples + tagging_query = where(Tagging, tag_id: ^tag.id) + tag_change_tag_query = where(TagChangeTag, tag_id: ^tag.id) - iex> get_implication!(123) - %Implication{} + # Clean up image taggings - iex> get_implication!(456) - ** (Ecto.NoResultsError) + tagging_query + |> Batch.query_batches_until_empty(batch_size: 1_000, id_field: :image_id) + |> Enum.each(fn batch_query -> + Multi.new() + |> put_delete_taggings_in_query(tag, batch_query) + |> Multi.transact() + end) - """ - def get_implication!(id), do: Repo.get!(Implication, id) + # Clean up tag changes - @doc """ - Creates a implication. + tag_change_tag_query + |> Batch.query_batches_until_empty(batch_size: 10_000, id_field: :tag_change_id) + |> Enum.each(fn batch_query -> + Multi.new() + |> put_delete_tag_change_tags_in_query(batch_query) + |> Multi.transact() + end) - ## Examples + # Deletion now proceeds - iex> create_implication(%{field: value}) - {:ok, %Implication{}} + Multi.new() + |> put_delete_taggings_in_query(tag, tagging_query) + |> put_delete_tag_change_tags_in_query(tag_change_tag_query) + |> Multi.delete(:tag, tag) + |> Multi.on_commit(fn _changes -> Search.delete_document(tag.id, Tag) end) + |> Multi.transact() - iex> create_implication(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + TagChanges.cleanup_empty_for_tag_deletion() - """ - def create_implication(attrs \\ %{}) do - %Implication{} - |> Implication.changeset(attrs) - |> Repo.insert() + :ok end @doc """ - Updates a implication. + Worker entry point that migrates an alias's taggings to its target. - ## Examples + Taggings are moved in batches to avoid holding locks for the full migration. - iex> update_implication(implication, %{field: new_value}) - {:ok, %Implication{}} + ## Examples - iex> update_implication(implication, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> perform_alias(12, 13) + :ok """ - def update_implication(%Implication{} = implication, attrs) do - implication - |> Implication.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a Implication. - - ## Examples + @spec perform_alias(integer(), integer()) :: :ok | {:error, :stale_target} + def perform_alias(tag_id, target_tag_id) do + tag = Repo.get!(Tag, tag_id) + target_tag = Repo.get!(Tag, target_tag_id) - iex> delete_implication(implication) - {:ok, %Implication{}} + Tagging + |> where(tag_id: ^tag.id) + |> Batch.query_batches_until_empty(batch_size: 1_000, id_field: :image_id) + |> Enum.find(:ok, fn batch_query -> + # Lock all images in the batch to prevent image operations from racing tag updates. + image_query = + from image in Image, + where: image.id in subquery(select(batch_query, [tagging], tagging.image_id)), + order_by: [asc: :id] + + # Lock alias source and target to prevent alias migration from racing aliasing. + tag_query = + from tag in Tag, + where: tag.id in ^[tag_id, target_tag_id], + order_by: [asc: :id] + + # The image counter represents only the count of visible images. + # To preserve this meaning, the operation must be split into migrating + # taggings of visible and non-visible images. + # + # The image counter is updated so the partial migration state is resumable. + + visible_taggings = filtered_taggings_for_query(batch_query, hidden_from_users: false) + visible_insert_all = insert_all_for_alias(visible_taggings, target_tag) + + hidden_taggings = filtered_taggings_for_query(batch_query, hidden_from_users: true) + hidden_insert_all = insert_all_for_alias(hidden_taggings, target_tag) - iex> delete_implication(implication) - {:error, %Ecto.Changeset{}} + Multi.new() + |> Multi.lock_all(:locked_images, image_query) + |> Multi.lock_all(:locked_tags, tag_query) + |> Multi.lock_one(:locked_source_tag, where(Tag, id: ^tag.id)) + |> Multi.run(:check_source_tag, fn _repo, %{locked_source_tag: tag} -> + # Abort processing if the target is changed during batch scanning. + # + # This check can be ABA, but ABA will not have any deleterious effect + # on the migration. + if tag.aliased_tag_id == target_tag.id do + {:ok, nil} + else + {:error, :stale_target} + end + end) + |> Images.put_insert_taggings(:new_visible, visible_insert_all) + |> Images.put_insert_taggings(:new_hidden, hidden_insert_all) + |> Images.put_delete_taggings(:old_visible, visible_taggings) + |> Images.put_delete_taggings(:old_hidden, hidden_taggings) + |> Multi.update_all( + :target_tag, + fn %{new_visible: {count, _}} -> + Tag + |> where(id: ^target_tag.id) + |> update(inc: [images_count: ^count]) + end, + [] + ) + |> Multi.update_all( + :source_tag, + fn %{old_visible: {count, _}} -> + Tag + |> where(id: ^tag.id) + |> update(inc: [images_count: ^(-count)]) + end, + [] + ) + |> Multi.on_commit(fn _changes -> + reindex_tag_images(target_tag) + reindex_tags([tag, target_tag]) + end) + |> Multi.transact() + |> case do + {:ok, _changes} -> + nil - """ - def delete_implication(%Implication{} = implication) do - Repo.delete(implication) + {:error, :check_source_tag, _reason, _changes} -> + {:error, :stale_target} + end + end) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking implication changes. + Performs reindexing of all images associated with a tag. + + Updates the tag's image count to reflect the current number of non-hidden images, + then reindexes all associated images and filters that reference this tag. ## Examples - iex> change_implication(implication) - %Ecto.Changeset{source: %Implication{}} + iex> perform_reindex_images(123) """ - def change_implication(%Implication{} = implication) do - Implication.changeset(implication, %{}) + @spec perform_reindex_images(integer()) :: :ok + def perform_reindex_images(tag_id) do + tag = Repo.get!(Tag, tag_id) + + # First, recount the tag. + # Recount failure is permitted and ignored. + Multi.new() + |> Multi.run(:image_count, fn repo, _changes -> + {:ok, + Image + |> join(:inner, [i], _ in assoc(i, :tags)) + |> where([i, t], i.hidden_from_users == false and t.id == ^tag.id) + |> repo.aggregate(:count)} + end) + |> Multi.update_all( + :update_tag, + fn %{image_count: image_count} -> + Tag + |> where(id: ^tag.id) + |> update(set: [images_count: ^image_count]) + end, + [] + ) + |> Multi.on_commit(fn _changes -> reindex_tags([tag]) end) + |> Multi.transact_with_automatic_retry(isolation: :serializable) + + # Then, reindex. + Image + |> join(:inner, [i], _ in assoc(i, :tags)) + |> where([_i, t], t.id == ^tag.id) + |> preload(^Images.indexing_preloads()) + |> Search.reindex(Image) + + Filter + |> where([f], fragment("? @> ARRAY[?]::integer[]", f.hidden_tag_ids, ^tag.id)) + |> or_where([f], fragment("? @> ARRAY[?]::integer[]", f.spoilered_tag_ids, ^tag.id)) + |> preload(^Filters.indexing_preloads()) + |> Search.reindex(Filter) end @doc """ @@ -849,38 +1606,146 @@ defmodule Philomena.Tags do ## Examples iex> cleanup!() - {3, [1, 2, 3]} + [1, 2, 3] """ + @spec cleanup!() :: [integer()] def cleanup! do - tag_ids = - from(t in Tag, + cleanup_query = + from tag in Tag, as: :tag, - where: t.description == "", - where: is_nil(t.short_description) or t.short_description == "", - where: is_nil(t.category) or t.category == "", - where: is_nil(t.mod_notes) or t.mod_notes == "", - where: is_nil(t.image), - where: is_nil(t.aliased_tag_id), + where: tag.description == "", + where: is_nil(tag.short_description) or tag.short_description == "", + where: is_nil(tag.category) or tag.category == "", + where: is_nil(tag.mod_notes) or tag.mod_notes == "", + where: is_nil(tag.image), + where: is_nil(tag.aliased_tag_id), where: not exists(where(Images.Tagging, tag_id: parent_as(:tag).id)), where: not exists(where(Tag, aliased_tag_id: parent_as(:tag).id)), where: not exists(where(Implication, tag_id: parent_as(:tag).id)), where: not exists(where(Implication, implied_tag_id: parent_as(:tag).id)), where: not exists(where(ArtistLink, tag_id: parent_as(:tag).id)), where: not exists(where(DnpEntry, tag_id: parent_as(:tag).id)), - select: t.id - ) - |> Repo.all() - - {count, _} = - Tag - |> where([t], t.id in ^tag_ids) - |> Repo.delete_all() + order_by: [asc: tag.id], + select: tag.id + + Multi.new() + |> Multi.lock_all(:locked_tags, cleanup_query) + |> Multi.delete_all(:tags, fn %{locked_tags: tag_ids} -> + where(Tag, [tag], tag.id in ^tag_ids) + end) + |> Multi.transact() + |> case do + {:ok, %{locked_tags: tag_ids}} -> + if Enum.any?(tag_ids) do + PhilomenaQuery.Search.delete_documents(tag_ids, Tag) + end - if count > 0 do - PhilomenaQuery.Search.delete_documents(tag_ids, Tag) + tag_ids end + end - {count, tag_ids} + @doc """ + Queues a list of tags for search index updates. + Returns the list of tags unchanged, for use in a pipeline. + + ## Examples + + iex> reindex_tags([%Tag{}, %Tag{}, ...]) + [%Tag{}, %Tag{}, ...] + + """ + @spec reindex_tags([Tag.t()]) :: [Tag.t()] + def reindex_tags(tags) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Tags", "id", Enum.map(tags, & &1.id)]) + tags + end + + @doc """ + Returns the list of associations to preload for tag indexing. + + ## Examples + + iex> indexing_preloads() + [:aliased_tag, :aliases, :implied_tags, :implied_by_tags] + + """ + @spec indexing_preloads() :: [atom()] + def indexing_preloads do + [:aliased_tag, :aliases, :implied_tags, :implied_by_tags] + end + + @doc """ + Performs reindexing of tags based on a column condition. + + Takes a column name and a list of values to match against that column, + then reindexes all matching tags. + + ## Examples + + iex> perform_reindex(:id, [1, 2, 3]) + {:ok, []} + + iex> perform_reindex(:name, ["safe", "suggestive"]) + {:ok, []} + + """ + @spec perform_reindex(atom(), [term()]) :: :ok + def perform_reindex(column, condition) do + Tag + |> preload(^indexing_preloads()) + |> where([t], field(t, ^column) in ^condition) + |> Search.reindex(Tag) + end + + @doc """ + Replaces aliased tags in existing implied-tag relationships with their + canonical targets. + + This is a one-time repair for relationships created before aliased tags were + rejected in implied-tag lists. Existing canonical relationships are kept. + + ## Examples + + iex> replace_aliases_in_implied_tags!() + :ok + + """ + @spec replace_aliases_in_implied_tags!() :: :ok + def replace_aliases_in_implied_tags! do + aliased_implications_query = + from implication in Implication, + join: implied_tag in Tag, + on: implied_tag.id == implication.implied_tag_id, + where: not is_nil(implied_tag.aliased_tag_id), + select: %{ + tag_id: implication.tag_id, + implied_tag_id: implication.implied_tag_id, + canonical_implied_tag_id: implied_tag.aliased_tag_id + } + + Multi.new() + |> Multi.all(:aliased_implications, aliased_implications_query) + |> Multi.delete_all(:alias_sources, fn %{aliased_implications: implications} -> + alias_source_ids = Enum.map(implications, & &1.implied_tag_id) + where(Implication, [implication], implication.implied_tag_id in ^alias_source_ids) + end) + |> Multi.insert_all( + :canonical_implications, + Implication, + fn %{aliased_implications: implications} -> + Enum.map(implications, fn implication -> + %{ + tag_id: implication.tag_id, + implied_tag_id: implication.canonical_implied_tag_id + } + end) + end, + on_conflict: :nothing + ) + |> Multi.transact() + |> case do + {:ok, _changes} -> :ok + end end end diff --git a/lib/philomena/tags/query.ex b/lib/philomena/tags/query.ex index 6af254542..e83590739 100644 --- a/lib/philomena/tags/query.ex +++ b/lib/philomena/tags/query.ex @@ -1,4 +1,6 @@ defmodule Philomena.Tags.Query do + @moduledoc false + alias PhilomenaQuery.Parse.Parser defp fields do diff --git a/lib/philomena/tags/query_builder.ex b/lib/philomena/tags/query_builder.ex new file mode 100644 index 000000000..6d235fd6b --- /dev/null +++ b/lib/philomena/tags/query_builder.ex @@ -0,0 +1,33 @@ +defmodule Philomena.Tags.QueryBuilder do + @moduledoc false + + alias Philomena.Tags.QueryForm + + @doc """ + Builds a tag search query based on the given parameters. + + ## Parameters + + * `params` - Map of optional search parameters: + * `query` - Search query + + Returns `{:ok, query, query_form}` with an OpenSearch query body for `Tags` that + can be used with `PhilomenaQuery.Search`, or `{:error, changeset}` if the provided + parameters are invalid. + """ + @spec build_query(map()) :: + {:ok, map(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + body = %{ + query: query_form.compiled_query, + sort: [%{images: :desc}, %{name: :asc}, %{id: :asc}] + } + + {:ok, body, query_form} + end + end +end diff --git a/lib/philomena/tags/query_form.ex b/lib/philomena/tags/query_form.ex new file mode 100644 index 000000000..e06b5289f --- /dev/null +++ b/lib/philomena/tags/query_form.ex @@ -0,0 +1,23 @@ +defmodule Philomena.Tags.QueryForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + import PhilomenaQuery.Ecto.QueryValidator + + alias Philomena.Tags.Query + + @type t :: %__MODULE__{} + + embedded_schema do + field :query, :string + field :compiled_query, :map, virtual: true + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + query_form + |> cast(attrs, [:query]) + |> validate_query(:query, with: &Query.compile/1, into: :compiled_query) + end +end diff --git a/lib/philomena/tags/quick_tag_table.ex b/lib/philomena/tags/quick_tag_table.ex new file mode 100644 index 000000000..2fdae0689 --- /dev/null +++ b/lib/philomena/tags/quick_tag_table.ex @@ -0,0 +1,93 @@ +defmodule Philomena.Tags.QuickTagTable do + import Ecto.Query, warn: false + + alias Philomena.Config + alias Philomena.Repo + alias Philomena.Tags.Tag + alias PhilomenaQuery.Search + + @persistent_key {__MODULE__, :table} + + @enforce_keys [:tags, :shipping, :data] + defstruct [:tags, :shipping, :data] + + @type t :: %__MODULE__{ + tags: %{String.t() => Tag.t()}, + shipping: %{String.t() => [Tag.t()]}, + data: map() + } + + @spec get() :: t() + def get do + case :persistent_term.get(@persistent_key, :missing) do + :missing -> refresh() + table -> table + end + end + + @spec refresh() :: t() + def refresh do + table = build(Config.get(:quick_tag_table)) + :persistent_term.put(@persistent_key, table) + table + end + + defp build(%{"tabs" => tabs, "tab_modes" => tab_modes} = data) do + tags = + tabs + |> Enum.flat_map(&names_in_tab(tab_modes[&1], data[&1])) + |> tags_indexed_by_name() + + shipping = + tabs + |> Enum.filter(&(tab_modes[&1] == "shipping")) + |> Map.new(fn tab -> + shipping_data = data[tab] + + {tab, implied_by_multitag(shipping_data["implying"], shipping_data["not_implying"])} + end) + + %__MODULE__{tags: tags, shipping: shipping, data: data} + end + + defp names_in_tab("default", data) do + data + |> Map.values() + |> List.flatten() + end + + defp names_in_tab("season", data), do: Enum.map(data, fn [_number, name] -> name end) + + defp names_in_tab("shorthand", data) do + data + |> Enum.map(fn [_title, tags] -> tags end) + |> Enum.flat_map(&Enum.map(&1, fn [_shorthand, tag] -> tag end)) + end + + defp names_in_tab(_mode, _data), do: [] + + defp tags_indexed_by_name(names) do + Tag + |> where([tag], tag.name in ^names) + |> preload(:implied_tags) + |> Repo.all() + |> Map.new(&{&1.name, &1}) + end + + defp implied_by_multitag(tag_names, ignore_tag_names) do + Tag + |> Search.search_definition( + %{ + query: %{ + bool: %{ + must: Enum.map(tag_names, &%{term: %{implied_tags: &1}}), + must_not: Enum.map(ignore_tag_names, &%{term: %{implied_tags: &1}}) + } + }, + sort: %{images: :desc} + }, + %{page_size: 40} + ) + |> Search.search_records(preload(Tag, :implied_tags)) + end +end diff --git a/lib/philomena/tags/search_index.ex b/lib/philomena/tags/search_index.ex index d9372cbad..0bbf0b890 100644 --- a/lib/philomena/tags/search_index.ex +++ b/lib/philomena/tags/search_index.ex @@ -1,4 +1,6 @@ defmodule Philomena.Tags.SearchIndex do + @moduledoc false + @behaviour PhilomenaQuery.Search.Index @impl true diff --git a/lib/philomena/tags/tag.ex b/lib/philomena/tags/tag.ex index e5cc75bf7..456fda02a 100644 --- a/lib/philomena/tags/tag.ex +++ b/lib/philomena/tags/tag.ex @@ -1,14 +1,12 @@ defmodule Philomena.Tags.Tag do use Ecto.Schema import Ecto.Changeset - import Ecto.Query alias Philomena.Channels.Channel alias Philomena.DnpEntries.DnpEntry alias Philomena.ArtistLinks.ArtistLink alias Philomena.Tags.Tag alias Philomena.Slug - alias Philomena.Repo @namespaces [ "artist", @@ -62,6 +60,8 @@ defmodule Philomena.Tags.Tag do @derive {Phoenix.Param, key: :slug} + @type t :: %__MODULE__{} + schema "tags" do belongs_to :aliased_tag, Tag, source: :aliased_tag_id, on_replace: :nilify has_many :aliases, Tag, foreign_key: :aliased_tag_id @@ -99,23 +99,45 @@ defmodule Philomena.Tags.Tag do field :removed_image, :string, virtual: true field :implied_tag_list, :string, virtual: true + field :target_tag, :string, virtual: true timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(tag, attrs) do + def insert_fields do + [ + :slug, + :name, + :category, + :images_count, + :description, + :short_description, + :namespace, + :name_in_namespace, + :image, + :image_format, + :image_mime_type, + :mod_notes + ] + end + + @doc false + def changeset(tag, attrs \\ %{}) do tag - |> cast(attrs, [:category, :description, :short_description, :mod_notes]) - |> put_change(:implied_tag_list, Enum.map_join(tag.implied_tags, ",", & &1.name)) + |> cast(attrs, [:category, :description, :short_description, :mod_notes, :implied_tag_list]) + |> maybe_put_implied_tag_list(tag) |> validate_required([]) + |> validate_inclusion(:category, categories()) end def changeset(tag, attrs, implied_tags) do tag - |> cast(attrs, [:category, :description, :short_description, :mod_notes]) + |> cast(attrs, [:category, :description, :short_description, :mod_notes, :implied_tag_list]) |> put_assoc(:implied_tags, implied_tags) + |> validate_no_aliased_implied_tags(implied_tags) |> validate_required([]) + |> validate_inclusion(:category, categories()) end def image_changeset(tag, attrs) do @@ -131,17 +153,49 @@ defmodule Philomena.Tags.Tag do |> put_change(:image, nil) end - def alias_changeset(tag, target_tag) do - change(tag) + def implication_form_changeset(tag, attrs \\ %{}) do + cast(tag, attrs, [:implied_tag_list]) + end + + def alias_form_changeset(tag, attrs \\ %{}) do + tag + |> cast(attrs, [:target_tag]) + |> validate_required(:target_tag) + end + + def alias_changeset(tag, target_tag, incoming_aliases?, implied_by_tags?) do + tag + |> change() + |> validate_not_aliased() |> put_assoc(:aliased_tag, target_tag) - |> validate_required([:aliased_tag]) + |> validate_required(:aliased_tag) |> validate_not_aliased_to_self() |> validate_alias_not_transitive() - |> validate_incoming_aliases() + |> validate_incoming_aliases(incoming_aliases?) + |> validate_implied_by_tags(implied_by_tags?) end def unalias_changeset(tag) do - change(tag, aliased_tag_id: nil) + tag + |> change() + |> validate_aliased() + |> put_change(:aliased_tag_id, nil) + end + + defp validate_not_aliased(changeset) do + if get_field(changeset, :aliased_tag_id) do + add_error(changeset, :aliased_tag, "is already aliased") + else + changeset + end + end + + defp validate_aliased(changeset) do + if get_field(changeset, :aliased_tag_id) do + changeset + else + add_error(changeset, :aliased_tag, "is not aliased") + end end def creation_changeset(tag, attrs) do @@ -158,6 +212,24 @@ defmodule Philomena.Tags.Tag do |> put_namespace_category() end + def deletion_changeset(tag) do + changeset = change(tag) + + if get_field(changeset, :category) == "rating" do + add_error(changeset, :category, "cannot delete a rating tag") + else + changeset + end + end + + defp maybe_put_implied_tag_list(changeset, tag) do + if get_field(changeset, :implied_tag_list) do + changeset + else + put_change(changeset, :implied_tag_list, Enum.map_join(tag.implied_tags, ",", & &1.name)) + end + end + def parse_tag_list(list) do list |> to_string() @@ -167,9 +239,16 @@ defmodule Philomena.Tags.Tag do |> Enum.uniq() end - # Oversized names must never reach get_or_create_tags: its bulk insert - # bypasses changeset validation and would trip tags_name_length_check, - # failing the entire tag list. + def original_character_tag?(%__MODULE__{} = tag) do + tag.namespace == "oc" + end + + def original_character_tag_name do + "oc" + end + + # Oversized names are filtered before bulk tag insertion so they cannot + # bypass changeset validation and trip tags_name_length_check. defp oversized_name?(name), do: byte_size(name) > @name_length_limit def display_order(tags) do @@ -318,16 +397,25 @@ defmodule Philomena.Tags.Tag do end end - defp validate_incoming_aliases(changeset) do - id = get_field(changeset, :id) + defp validate_incoming_aliases(changeset, incoming_aliases?) do + if incoming_aliases? do + add_error(changeset, :tag, "has incoming aliases and cannot be aliased") + else + changeset + end + end - count = - Tag - |> where(aliased_tag_id: ^id) - |> Repo.aggregate(:count, :id) + defp validate_no_aliased_implied_tags(changeset, implied_tags) do + if Enum.any?(implied_tags, &(not is_nil(&1.aliased_tag_id))) do + add_error(changeset, :implied_tag_list, "contains aliased tags") + else + changeset + end + end - if count > 0 do - add_error(changeset, :tag, "has incoming aliases and cannot be aliased") + defp validate_implied_by_tags(changeset, implied_by_tags?) do + if implied_by_tags? do + add_error(changeset, :tag, "is implied by other tags and cannot be aliased") else changeset end diff --git a/lib/philomena/tags/tag_detail.ex b/lib/philomena/tags/tag_detail.ex new file mode 100644 index 000000000..94652685d --- /dev/null +++ b/lib/philomena/tags/tag_detail.ex @@ -0,0 +1,19 @@ +defmodule Philomena.Tags.TagDetail do + @moduledoc """ + Staff-facing usage metadata for one resolved tag. + """ + + alias Philomena.Filters.Filter + alias Philomena.Tags.Tag + alias Philomena.Users.User + + @enforce_keys [:tag, :filters_spoilering, :filters_hiding, :users_watching] + defstruct [:tag, :filters_spoilering, :filters_hiding, :users_watching] + + @type t :: %__MODULE__{ + tag: Tag.t(), + filters_spoilering: [Filter.t()], + filters_hiding: [Filter.t()], + users_watching: [User.t()] + } +end diff --git a/lib/philomena/tags/tag_page.ex b/lib/philomena/tags/tag_page.ex new file mode 100644 index 000000000..7a0d81c34 --- /dev/null +++ b/lib/philomena/tags/tag_page.ex @@ -0,0 +1,24 @@ +defmodule Philomena.Tags.TagPage do + @moduledoc """ + The assembled tag page: the tag with its preloads, the executed + page of images tagged with it, the viewer's interactions with those images, + and the escaped search query that lists the tag. + + The tag carries raw records, not rendered output. + """ + + alias Philomena.Tags.Tag + + @enforce_keys [:tag, :images, :interactions, :search_query] + defstruct tag: nil, + images: nil, + interactions: nil, + search_query: nil + + @type t :: %__MODULE__{ + tag: Tag.t(), + images: Scrivener.Page.t(), + interactions: list(), + search_query: String.t() + } +end diff --git a/lib/philomena/tags/tag_suggestion.ex b/lib/philomena/tags/tag_suggestion.ex new file mode 100644 index 000000000..b79794667 --- /dev/null +++ b/lib/philomena/tags/tag_suggestion.ex @@ -0,0 +1,14 @@ +defmodule Philomena.Tags.TagSuggestion do + @moduledoc """ + A search-as-you-type match with its canonical tag and current image count. + """ + + @enforce_keys [:alias, :canonical, :images] + defstruct [:alias, :canonical, :images] + + @type t :: %__MODULE__{ + alias: String.t() | nil, + canonical: String.t(), + images: non_neg_integer() + } +end diff --git a/lib/philomena/tags/uploader.ex b/lib/philomena/tags/uploader.ex index d7ab63d62..d744720ec 100644 --- a/lib/philomena/tags/uploader.ex +++ b/lib/philomena/tags/uploader.ex @@ -6,8 +6,8 @@ defmodule Philomena.Tags.Uploader do alias Philomena.Tags.Tag alias PhilomenaMedia.Uploader - def analyze_upload(tag, params) do - Uploader.analyze_upload(tag, "image", params["image"], &Tag.image_changeset/2) + def analyze_upload(tag, upload) do + Uploader.analyze_upload(tag, "image", upload, &Tag.image_changeset/2) end def persist_upload(tag) do diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index b8a19e4f5..4660740d4 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -1,358 +1,1029 @@ defmodule Philomena.Topics do @moduledoc """ - The Topics context. + Topic reads, creation, subscriptions, and moderation. """ import Ecto.Query, warn: false - alias Ecto.Multi + + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + + import Philomena.Forums.TransactionWorkflow + + alias Philomena.Multi alias Philomena.Repo - alias Philomena.Topics.Topic + alias Philomena.Topics.{MoveForm, Topic, TopicPage} alias Philomena.Forums alias Philomena.Forums.Forum + alias Philomena.Forums.Visibility alias Philomena.Posts - alias Philomena.UserStatistics + alias Philomena.Posts.Post + alias Philomena.Polls + alias Philomena.Polls.Poll + alias Philomena.PollVotes + alias Philomena.PollOptions.PollOption alias Philomena.Notifications + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths + alias Philomena.Loader + alias Philomena.RateLimiter + alias Philomena.Attribution.Actor + alias Philomena.Users.User + alias Philomena.UserStatistics + + @topic_create_window 300 use Philomena.Subscriptions, on_delete: :clear_topic_notification, id_name: :topic_id + defp broadcast_topic_creation(%{forum: %{access_level: "normal"}} = result) do + PhilomenaWeb.Endpoint.broadcast!( + "firehose", + "post:create", + PhilomenaWeb.Api.Json.Forum.Topic.PostView.render("firehose.json", result) + ) + + result + end + + defp broadcast_topic_creation(result), do: result + + defp notify_topic(_repo, %{topic: topic}) do + Notifications.broadcast_forum_topic(topic.user, topic) + end + + defp topic_pagination(%Actor{} = actor, %Topic{} = topic, post_id, pagination) do + page_number = + Post + |> where(topic_id: ^topic.id) + |> Visibility.available_posts(actor) + |> Loader.fetch_and_authorize(actor, :show, post_id) + |> case do + {:ok, post} -> + div(post.topic_position, pagination.page_size) + 1 + + _ -> + pagination.page_number + end + + %{pagination | page_number: page_number} + end + + defp load_topic_posts( + %Actor{} = actor, + %Topic{} = topic, + %{page_number: page_number, page_size: page_size} + ) do + entries = + Post + |> where(topic_id: ^topic.id) + |> Visibility.available_posts(actor) + |> where([p], p.topic_position >= ^(page_size * (page_number - 1))) + |> where([p], p.topic_position < ^(page_size * page_number)) + |> order_by(asc: :topic_position) + |> preload([:deleted_by, :topic, topic: :forum, user: [awards: :badge]]) + |> Repo.all() + + %Scrivener.Page{ + entries: entries, + page_number: page_number, + page_size: page_size, + total_entries: topic.post_count, + total_pages: div(topic.post_count + page_size - 1, page_size) + } + end + + defp hide_topic_steps(user, forum_slug, topic_slug, params) do + Multi.new() + |> put_forum_and_topic_locks(user, forum_slug, :show, topic_slug, :hide) + |> Multi.update(:topic, fn %{locked_topic: topic} -> + Topic.hide_changeset(topic, user, params) + end) + |> put_topic_visibility_counters(visible?: false) + |> put_refresh_last_post() + |> Forums.put_refresh_last_post() + |> Posts.put_reindex_posts_in_topic() + end + @doc """ - Gets a single topic. + Paginates homepage topics visible to `actor`. - Raises `Ecto.NoResultsError` if the Topic does not exist. + Forum access uses the shared forum hierarchy scopes. Topics with titles + containing `"NSFW"` are omitted. Forums and last post users are preloaded. ## Examples - iex> get_topic!(123) - %Topic{} - - iex> get_topic!(456) - ** (Ecto.NoResultsError) + iex> list_front_page_topics(actor, 6) + [%Topic{}, ...] """ - def get_topic!(id), do: Repo.get!(Topic, id) + @spec list_front_page_topics(Actor.t(), pos_integer()) :: [Topic.t()] + def list_front_page_topics(%Actor{} = actor, strip_size) do + visible_forums = Visibility.visible_forums(Forum, actor) + + Topic + |> join(:inner, [topic], forum in subquery(visible_forums), on: forum.id == topic.forum_id) + |> where(hidden_from_users: false) + |> where([topic], fragment("? !~ ?", topic.title, "NSFW")) + |> order_by(desc: :last_replied_to_at, desc: :id) + |> preload([:forum, last_post: :user]) + |> limit(^strip_size) + |> Repo.all() + end @doc """ - Creates a topic. + Subscribes `actor` to the topic named by `topic_slug` within the forum + named by `forum_slug`. - ## Examples + Subscription management is deliberately exempt from + `verify_write_access/1`. The forum is authorized for `:show`, and the topic + is queried beneath it and authorized for `:subscribe`. - iex> create_topic(%{field: value}) - {:ok, %Topic{}} + Returns `{:ok, {forum, topic}}` (both are returned for the caller to reuse), + `{:error, :unauthorized}` when the forum or topic is not visible to + the actor, `{:error, :not_found}` when the forum exists but the topic does + not, or `{:error, %Ecto.Changeset{}}` if the subscription insert is rejected. - iex> create_topic(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + ## Examples - """ - def create_topic(forum, attribution, attrs \\ %{}) do - now = DateTime.utc_now(:second) + iex> create_topic_subscription(user, "dis", "some-topic") + {:ok, {%Forum{}, %Topic{}}} - topic = - %Topic{} - |> Topic.creation_changeset(attrs, forum, attribution) + iex> create_topic_subscription(user, "dis", "nonexistent") + {:error, :not_found} - Multi.new() - |> Multi.insert(:topic, topic) - |> Multi.run(:update_topic, fn repo, %{topic: topic} -> - {count, nil} = - Topic - |> where(id: ^topic.id) - |> repo.update_all(set: [last_post_id: hd(topic.posts).id, last_replied_to_at: now]) - - {:ok, count} - end) - |> Multi.run(:update_forum, fn repo, %{topic: topic} -> - {count, nil} = - Forum - |> where(id: ^topic.forum_id) - |> repo.update_all( - inc: [post_count: 1, topic_count: 1], - set: [last_post_id: hd(topic.posts).id] - ) - - {:ok, count} - end) - |> Multi.run(:notification, ¬ify_topic/2) - |> maybe_subscribe_on(:topic, attribution[:user], :watch_on_new_topic) - |> Repo.transaction() - |> case do - {:ok, %{topic: topic}} = result -> - UserStatistics.inc_stat(topic.user_id, :topics_count) - Posts.reindex_post(hd(topic.posts)) - Posts.report_non_approved(hd(topic.posts)) - - result - - error -> - error + """ + @spec create_topic_subscription( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t() + ) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, :unauthorized | :not_found | Ecto.Changeset.t()} + def create_topic_subscription(%Actor{} = actor, forum_slug, topic_slug) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :subscribe), + {:ok, _subscription} <- create_subscription(topic, actor.user) do + {:ok, {forum, topic}} end end - defp notify_topic(_repo, %{topic: topic}) do - Notifications.create_forum_topic_notification(topic.user, topic) + @doc """ + Unsubscribes `actor` from the topic named by `topic_slug` within the forum + named by `forum_slug`. + + Subscription management is deliberately exempt from + `verify_write_access/1`. Loading mirrors `subscribe/3`, but the topic uses + the separate `:unsubscribe` action so a user may stop watching a topic that + became hidden after subscription. + + Returns `{:ok, {forum, topic}}`, `{:error, :unauthorized}` when the forum is + not visible to the actor, or `{:error, :not_found}` when the forum exists but + the topic does not. + + ## Examples + + iex> delete_topic_subscription(user, "dis", "some-topic") + {:ok, {%Forum{}, %Topic{}}} + + """ + @spec delete_topic_subscription( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t() + ) :: + {:ok, {Forum.t(), Topic.t()}} | {:error, :unauthorized | :not_found} + def delete_topic_subscription(%Actor{} = actor, forum_slug, topic_slug) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :unsubscribe) do + # Deletion is idempotent and cannot fail; the hard match crashes if it does. + {:ok, _subscription} = delete_subscription(topic, actor.user) + {:ok, {forum, topic}} + end end @doc """ - Updates a topic. + Loads the the topic named by `topic_slug` within `forum`, for `action`, + on behalf of `actor`. + + The topic is queried by slug and `forum.id` before authorization. Malformed or + missing route members are not-found, while a loaded member forbidden for that + action is unauthorized. ## Examples - iex> update_topic(topic, %{field: new_value}) + iex> show_forum_topic(moderator_actor, forum, "some-topic", :show) {:ok, %Topic{}} - iex> update_topic(topic, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ - def update_topic(%Topic{} = topic, attrs) do - topic - |> Topic.changeset(attrs) - |> Repo.update() + @spec show_forum_topic(Actor.t(), Forum.t(), String.t(), atom()) :: + {:ok, Topic.t()} | {:error, :unauthorized | :not_found} + def show_forum_topic(%Actor{} = actor, %Forum{} = forum, topic_slug, action) do + Topic + |> where(forum_id: ^forum.id, slug: ^topic_slug) + |> preload([:user, :forum]) + |> Loader.one_and_authorize(actor, action) end @doc """ - Deletes a Topic. + Clears `actor`'s unread notifications for the topic named by `topic_slug` + within the forum named by `forum_slug`. + + This personal read-state operation is deliberately exempt from + `verify_write_access/1`. The forum is authorized for `:show`, then the topic + is queried beneath it and authorized for `:mark_read`. That action permits a + subscribed user to clear notifications after the topic itself becomes + hidden. + + Returns `{:ok, topic}` after clearing the notifications, not-found for a + missing route member, or unauthorized for a forbidden forum/topic. ## Examples - iex> delete_topic(topic) + iex> create_topic_read(actor, "dis", "some-topic") {:ok, %Topic{}} - iex> delete_topic(topic) - {:error, %Ecto.Changeset{}} + iex> create_topic_read(actor, "dis", "nonexistent") + {:error, :not_found} """ - def delete_topic(%Topic{} = topic) do - Repo.delete(topic) + @spec create_topic_read(Actor.t(), String.t(), String.t()) :: + {:ok, Topic.t()} | {:error, :not_found | :unauthorized} + def create_topic_read(%Actor{} = actor, forum_slug, topic_slug) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :mark_read) do + clear_topic_notification(topic, actor.user) + {:ok, topic} + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking topic changes. + Assembles the `TopicPage` for the topic named by `topic_slug` within the + forum named by `forum_slug`, on behalf of `actor`. + + The forum is loaded by short name and authorized for `:show`, and the topic is + loaded by slug with hidden topics visible only to actors who may `:show` them. + As a side effect, `actor`'s unread notifications for the topic are cleared. + + `post_id_param` is the `post_id` to jump to (or `nil`): when it + parses to an integer naming an existing post, the returned page is the one + containing that post (by its position over the page size), but only when the + post belongs to the loaded topic. Otherwise `pagination` is used as-is. + + The `posts` field is a `Scrivener.Page` of available `Post` structs, ordered + by creation, with the topic, forum, and author preloaded. Pending posts are + limited to the actor's account or IP and destroyed posts are limited to + moderators. Hidden posts remain in the page so callers can render their + redacted representation. Markdown bodies are left raw for the caller. + + Returns `{:ok, %TopicPage{}}`, `{:error, :unauthorized}` when the forum or the + topic is not visible to `actor`, or `{:error, :not_found}` when the forum + exists but the topic does not. ## Examples - iex> change_topic(topic) - %Ecto.Changeset{source: %Topic{}} + iex> show_topic_page(user, "dis", "some-topic", nil, %{page_number: 1}) + {:ok, %TopicPage{}} """ - def change_topic(%Topic{} = topic) do - Topic.changeset(topic, %{}) + @spec show_topic_page( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + post_id :: String.t() | nil, + pagination :: Repo.pagination_params() + ) :: + {:ok, TopicPage.t()} | {:error, :unauthorized | :not_found} + def show_topic_page(%Actor{} = actor, forum_slug, topic_slug, post_id, pagination) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :show) do + topic = Repo.preload(topic, [:user, :forum, :deleted_by, :locked_by, poll: :options]) + pagination = topic_pagination(actor, topic, post_id, pagination) + + clear_topic_notification(topic, actor.user) + + {:ok, + %TopicPage{ + forum: forum, + topic: topic, + posts: load_topic_posts(actor, topic, pagination), + watching: subscribed?(topic, actor.user), + voted: PollVotes.voted?(actor, topic.poll), + poll_active: Polls.active?(topic.poll), + post_changeset: Posts.change_post(%Post{}), + topic_changeset: Topic.changeset(topic) + }} + end end @doc """ - Makes a topic sticky, appearing at the top of its forum. + Loads a visible topic beneath its route forum, with its author preloaded. ## Examples - iex> stick_topic(topic) + iex> show_topic(actor, "dis", "some-topic") {:ok, %Topic{}} + iex> show_topic(actor, "dis", "nonexistent") + {:error, :not_found} + """ - def stick_topic(topic) do - Topic.stick_changeset(topic) - |> Repo.update() + @spec show_topic(Actor.t(), String.t(), String.t()) :: + {:ok, Topic.t()} | {:error, :not_found | :unauthorized} + def show_topic(%Actor{} = actor, forum_slug, topic_slug) do + with {:ok, forum} <- Forums.show_forum(actor, forum_slug) do + show_forum_topic(actor, forum, topic_slug, :show) + end end @doc """ - Removes sticky status from a topic. + Creates a topic, on behalf of `actor`. + + `actor`'s write access and the topic-creation rate limit are verified first. + The forum is then locked, loaded by short name, and authorized for + `:create_topic` before the topic and its first post are inserted from + `params`. The topic's last post and the forum's visible counters are updated + in the same transaction. On success, the returned map carries the topic, + forum, and first post; the topic is also indexed, reported if unapproved, + and broadcast when the forum is public. + + Returns `{:ok, %{topic: topic, forum: forum, post: post}}` on success, + `{:error, forum, changeset}` when the topic changeset is rejected, or + `{:error, :ban | :unauthorized | :not_found}` from the access, rate-limit, + forum, or transaction checks. A non-exempt actor who has created a topic + within the last 5 minutes gets`{:error, :rate_limited}`. ## Examples - iex> unstick_topic(topic) - {:ok, %Topic{}} + iex> create_topic(actor, "dis", %{"title" => "Hi", "posts" => %{"0" => %{"body" => "Yo"}}}) + {:ok, %{topic: %Topic{}, forum: %Forum{}, post: %Post{}}} """ - def unstick_topic(topic) do - Topic.unstick_changeset(topic) - |> Repo.update() + @spec create_topic(Actor.t(), String.t(), map() | nil) :: + {:ok, %{topic: Topic.t(), forum: Forum.t(), post: Post.t()}} + | {:error, Forum.t(), Ecto.Changeset.t()} + | {:error, :ban | :not_found | :unauthorized | :rate_limited} + @spec create_topic(Forum.t(), keyword(), map()) :: + {:ok, %{topic: Topic.t()}} | {:error, atom(), Ecto.Changeset.t(), map()} + def create_topic(%Actor{user: creator} = actor, forum_slug, params) do + with :ok <- verify_write_access(actor) do + Multi.new() + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :topic_create, @topic_create_window) end, + fn -> RateLimiter.rollback_action(actor, :topic_create) end + ) + |> put_forum_lock(actor, forum_slug, :create_topic) + |> Multi.insert(:topic, fn %{locked_forum: forum} -> + Topic.creation_changeset(%Topic{}, params, forum, actor) + end) + |> put_topic_visibility_counters(visible?: true) + |> UserStatistics.put_increment(creator, :posts_count) + |> put_refresh_last_post(:topic) + |> Forums.put_refresh_last_post() + |> maybe_subscribe_on(:topic, creator, :watch_on_new_topic) + |> Multi.run(:notification, ¬ify_topic/2) + |> Posts.put_approval_report(fn %{topic: %{posts: [post]}} -> post end) + |> Posts.put_reindex_posts_in_topic() + |> Multi.transact() + |> case do + {:ok, %{locked_forum: %Forum{} = forum, topic: %Topic{} = topic}} -> + result = %{topic: topic, forum: forum, post: hd(topic.posts)} + + {:ok, broadcast_topic_creation(result)} + + {:error, :action_reservation, :rate_limited, _changes} -> + {:error, :rate_limited} + + {:error, :topic, %Ecto.Changeset{} = changeset, %{locked_forum: %Forum{} = forum}} -> + {:error, forum, changeset} + + error -> + map_lock_errors(error) + end + end end @doc """ - Locks a topic to prevent further posting. + Returns a new-topic changeset for `actor` in the forum named by `forum_slug`. + + Write access is verified first; the forum is then loaded by short name + and authorized for `:create_topic`. The returned changeset is + seeded with an empty first post and a two-option poll so those nested fields + are present. + + Returns `{:ok, {forum, changeset}}` (the forum is returned for the caller to + reuse), `{:error, :ban}` for a banned actor, or `{:error, :unauthorized}` when + the forum is not visible to `actor`. ## Examples - iex> lock_topic(topic, %{"lock_reason" => "Off topic"}, user) - {:ok, %Topic{}} + iex> new_topic(actor, "dis") + {:ok, {%Forum{}, %Ecto.Changeset{}}} """ - def lock_topic(%Topic{} = topic, attrs, user) do - Topic.lock_changeset(topic, attrs, user) - |> Repo.update() + @spec new_topic(Actor.t(), String.t()) :: + {:ok, {Forum.t(), Ecto.Changeset.t()}} + | {:error, :ban | :not_found | :unauthorized} + def new_topic(%Actor{} = actor, forum_slug) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + :ok <- authorize(actor, :create_topic, forum) do + changeset = + Topic.changeset(%Topic{ + poll: %Poll{options: [%PollOption{}, %PollOption{}]}, + posts: [%Post{}] + }) + + {:ok, {forum, changeset}} + end end @doc """ - Unlocks a topic to allow posting again. + Hides the topic named by `topic_slug` within the forum named by `forum_slug`, + recording `deletion_reason`, on behalf of `actor`. + + Write access is verified first, then the parent-scoped topic is authorized + for `:hide`. On success the forum post/topic counts are updated, the topic's + posts are reindexed, and a moderation log is written attributing the deletion + to the actor. + + Returns `{:ok, {forum, topic}}` on success, `{:error, forum, topic}` when the hide + changeset is rejected (e.g. a blank reason, so the caller can still act on it), + `{:error, :unauthorized}` when the actor may not see the forum/topic or hide the + topic, or `{:error, :not_found}` when the topic does not exist. ## Examples - iex> unlock_topic(topic) - {:ok, %Topic{}} + iex> create_topic_hide(moderator, "dis", "some-topic", %{"deletion_reason" => "Rule violation"}) + {:ok, {%Forum{}, %Topic{}}} + + iex> create_topic_hide(moderator, "dis", "some-topic", %{}) + {:error, %Forum{}, %Topic{}} """ - def unlock_topic(%Topic{} = topic) do - Topic.unlock_changeset(topic) - |> Repo.update() + @spec create_topic_hide( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + params :: map() + ) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_topic_hide(%Actor{user: user} = actor, forum_slug, topic_slug, params) do + with :ok <- verify_write_access(actor) do + user + |> hide_topic_steps(forum_slug, topic_slug, params) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_forum: forum, topic: topic} -> + { + "Topic.Hide:create", + Paths.topic_path(forum, topic), + "Deleted topic '#{topic.title}' (#{topic.deletion_reason}) in #{forum.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{locked_forum: %Forum{} = forum, topic: %Topic{} = topic}} -> + {:ok, {forum, topic}} + + {:error, :topic, _changeset, + %{locked_forum: %Forum{} = forum, locked_topic: %Topic{} = topic}} -> + {:error, forum, topic} + + error -> + map_lock_errors(error) + end + end end @doc """ - Moves a topic to a different forum, updating post counts for both forums. + Restores the topic named by `topic_slug` within the forum named by + `forum_slug`, on behalf of `actor`. - ## Examples + The forum and topic are locked together; the forum is authorized for `:show` + and the topic for `:unhide`, allowing a moderator to see the hidden topic. + On success the forum counts are restored, the topic's posts are reindexed, + and a moderation log is written. - iex> move_topic(topic, 123) - {:ok, %{topic: %Topic{}}} + Returns `{:ok, {forum, topic}}` on success, `{:error, forum, topic}` if the + restore is rejected (so the caller can act on it), + `{:error, :unauthorized}`, or `{:error, :not_found}`. - iex> move_topic(topic, 456) - {:error, %Ecto.Changeset{}} + ## Examples + + iex> delete_topic_hide(moderator, "dis", "some-topic") + {:ok, {%Forum{}, %Topic{}}} """ - def move_topic(topic, new_forum_id) do - old_forum_id = topic.forum_id + @spec delete_topic_hide(actor :: Actor.t(), forum_slug :: String.t(), topic_slug :: String.t()) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def delete_topic_hide(%Actor{} = actor, forum_slug, topic_slug) do + with :ok <- verify_write_access(actor) do + Multi.new() + |> put_forum_and_topic_locks(actor, forum_slug, :show, topic_slug, :unhide) + |> Multi.update(:topic, fn %{locked_topic: topic} -> Topic.unhide_changeset(topic) end) + |> put_topic_visibility_counters(visible?: true) + |> put_refresh_last_post() + |> Forums.put_refresh_last_post() + |> Posts.put_reindex_posts_in_topic() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_forum: forum, topic: topic} -> + { + "Topic.Hide:delete", + Paths.topic_path(forum, topic), + "Restored topic '#{topic.title}' in #{forum.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{locked_forum: %Forum{} = forum, topic: %Topic{} = topic}} -> + {:ok, {forum, topic}} + + {:error, :topic, _changeset, + %{locked_forum: %Forum{} = forum, locked_topic: %Topic{} = topic}} -> + {:error, forum, topic} + + error -> + map_lock_errors(error) + end + end + end - Multi.new() - |> Multi.update(:topic, Topic.move_changeset(topic, new_forum_id)) - |> Multi.update_all( - :old_forum, - Forums.update_forum_last_post_query(old_forum_id), - inc: [post_count: -topic.post_count, topic_count: -1] - ) - |> Multi.update_all( - :new_forum, - Forums.update_forum_last_post_query(new_forum_id), - inc: [post_count: topic.post_count, topic_count: 1] - ) - |> Repo.transaction() - |> normalize_multi_error() + @doc """ + Moves the topic named by `topic_slug` within the source forum named by + `source_forum_slug` to the forum identified by the `target_forum` key of + `params`, on behalf of `actor`. + + Write access is verified first. The source and target forums are locked in a + consistent order, both are authorized for `:show`, and the topic is + authorized for `:move`. On success the target forum is returned for the + caller to reuse, post/topic counts are updated for both forums, and a + moderation log is written. + + Returns `{:ok, {new_forum, topic}}` on success (the target forum is returned + for the caller to reuse), `{:error, forum, topic}` carrying the source forum and + topic when the move cannot happen, `{:error, :unauthorized}` when the actor may + not see the forum/topic or move the topic, or `{:error, :not_found}` when the + topic does not exist. + + ## Examples + + iex> create_topic_move(moderator, "dis", "some-topic", %{"target_forum" => "generals"}) + {:ok, {%Forum{}, %Topic{}}} + + iex> create_topic_move(moderator, "dis", "some-topic", %{"target_forum" => "bogus"}) + {:error, %Forum{}, %Topic{}} + + """ + @spec create_topic_move( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + topic_params :: map() | nil + ) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_topic_move(%Actor{} = actor, source_forum_slug, topic_slug, params) do + with :ok <- verify_write_access(actor), + {:ok, target_forum_slug} <- MoveForm.fetch_target_forum_short_name(params) do + Multi.new() + |> put_source_and_target_forum_and_topic_locks( + actor, + source_forum_slug, + :show, + target_forum_slug, + :show, + topic_slug, + :move + ) + |> Multi.update(:topic, fn %{locked_topic: topic, locked_target_forum: target_forum} -> + Topic.move_changeset(topic, target_forum.id) + end) + |> Forums.put_topic_transfer_counters() + |> Forums.put_refresh_last_post(:locked_source_forum) + |> Forums.put_refresh_last_post(:locked_target_forum) + |> Posts.put_reindex_posts_in_topic() + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{locked_target_forum: target_forum, topic: topic} -> + { + "Topic.Move:create", + Paths.topic_path(target_forum, topic), + "Topic '#{topic.title}' moved to #{target_forum.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{locked_target_forum: %Forum{} = target_forum, topic: %Topic{} = topic}} -> + {:ok, {target_forum, topic}} + + {:error, :topic, _changeset, + %{locked_source_forum: %Forum{} = source_forum, locked_topic: %Topic{} = topic}} -> + {:error, source_forum, topic} + + error -> + map_lock_errors(error) + end + end end @doc """ - Hides a topic and updates related forum data. + Sticks the topic named by `topic_slug` within the forum named by + `forum_slug`, on behalf of `actor`. + + Write access is verified first, the forum is authorized for `:show`, and the + topic is authorized for `:stick`. On success a moderation log is written + attributing the change. + + Returns `{:ok, {forum, topic}}` on success (both are returned for the caller + to reuse), `{:error, forum, topic}` when the stick changeset is rejected, + `{:error, :unauthorized}` when the actor may not see the forum/topic or + stick the topic, or `{:error, :not_found}` when the topic does not exist. ## Examples - iex> hide_topic(topic, "Violates rules", moderator) - {:ok, %Topic{}} + iex> create_topic_stick(moderator, "dis", "some-topic") + {:ok, {%Forum{}, %Topic{}}} + + """ + @spec create_topic_stick(actor :: Actor.t(), forum_slug :: String.t(), topic_slug :: String.t()) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_topic_stick(%Actor{} = actor, forum_slug, topic_slug) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :stick) do + topic_changeset = Topic.stick_changeset(topic) + + Multi.new() + |> Multi.update(:topic, topic_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Topic.Stick:create", + Paths.topic_path(topic), + "Stickied topic '#{topic.title}' in #{forum.name}" + ) + |> Multi.transact() + |> case do + {:ok, %{topic: %Topic{} = topic}} -> + {:ok, {forum, topic}} + + _error -> + {:error, forum, topic} + end + end + end + + @doc """ + Unsticks the topic named by `topic_slug` within the forum named by + `forum_slug`, on behalf of `actor`. - iex> hide_topic(topic, "", moderator) - {:error, %Ecto.Changeset{}} + Loading and authorization mirror `stick_topic/3`, using the distinct + `:unstick` action. On success a moderation log is written. + + Returns `{:ok, {forum, topic}}` on success, `{:error, forum, topic}` if the + unstick is rejected (so the caller can act on it), `{:error, :unauthorized}`, + or `{:error, :not_found}`. + + ## Examples + + iex> delete_topic_stick(moderator, "dis", "some-topic") + {:ok, {%Forum{}, %Topic{}}} """ - def hide_topic(topic, deletion_reason, user) do - topic = topic |> Repo.preload(:user) + @spec delete_topic_stick(actor :: Actor.t(), forum_slug :: String.t(), topic_slug :: String.t()) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def delete_topic_stick(%Actor{} = actor, forum_slug, topic_slug) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :unstick) do + topic_changeset = Topic.unstick_changeset(topic) + + Multi.new() + |> Multi.update(:topic, topic_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Topic.Stick:delete", + Paths.topic_path(topic), + "Unstickied topic '#{topic.title}' in #{forum.name}" + ) + |> Multi.transact() + |> case do + {:ok, %{topic: %Topic{} = topic}} -> + {:ok, {forum, topic}} + + _error -> + {:error, forum, topic} + end + end + end - Multi.new() - |> Multi.update(:topic, Topic.hide_changeset(topic, deletion_reason, user)) - |> Multi.update_all( - :forum, - Forums.update_forum_last_post_query(topic.forum_id), - inc: [post_count: -topic.post_count, topic_count: -1] - ) - |> Repo.transaction() - |> case do - {:ok, %{topic: topic}} -> - UserStatistics.inc_stat(topic.user_id, :topics_count, -1) - Posts.reindex_posts_in_topic(topic.id) + @doc """ + Locks the topic named by `topic_slug` within the forum named by `forum_slug`, + recording the lock reason from `topic_params`, on behalf of `actor` (the + acting user). - {:ok, topic} + Write access is verified first, then the parent-scoped topic is authorized + for `:lock`. On success a moderation log is written attributing the change. - error -> - normalize_multi_error(error) + Returns `{:ok, {forum, topic}}` on success (both are returned for the caller + to reuse), `{:error, forum, topic}` when the lock changeset is rejected + (e.g. a blank reason, so the caller can still act on it), + `{:error, :unauthorized}` when the actor may not see the forum/topic + or lock the topic, or `{:error, :not_found}` when the topic does not exist. + + ## Examples + + iex> create_topic_lock(moderator, "dis", "some-topic", %{"lock_reason" => "Off topic"}) + {:ok, {%Forum{}, %Topic{}}} + + iex> create_topic_lock(moderator, "dis", "some-topic", %{"lock_reason" => ""}) + {:error, %Forum{}, %Topic{}} + + """ + @spec create_topic_lock( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + topic_params :: map() + ) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def create_topic_lock(%Actor{user: user} = actor, forum_slug, topic_slug, params) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :lock) do + topic_changeset = Topic.lock_changeset(topic, params, user) + + Multi.new() + |> Multi.update(:topic, topic_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + fn %{topic: topic} -> + { + "Topic.Lock:create", + Paths.topic_path(topic), + "Locked topic '#{topic.title}' (#{topic.lock_reason}) in #{forum.name}" + } + end + ) + |> Multi.transact() + |> case do + {:ok, %{topic: %Topic{} = topic}} -> + {:ok, {forum, topic}} + + _error -> + {:error, forum, topic} + end end end @doc """ - Unhides a previously hidden topic. + Unlocks the topic named by `topic_slug` within the forum named by + `forum_slug`, on behalf of `actor`. + + Loading and authorization mirror `lock_topic/4`, using the distinct + `:unlock` action. On success a moderation log is written. + + Returns `{:ok, {forum, topic}}` on success, `{:error, forum, topic}` if the + unlock is rejected (so the caller can act on it), + `{:error, :unauthorized}`, or `{:error, :not_found}`. ## Examples - iex> unhide_topic(topic) - {:ok, %Topic{}} + iex> delete_topic_lock(moderator, "dis", "some-topic") + {:ok, {%Forum{}, %Topic{}}} """ - def unhide_topic(topic) do - topic = topic |> Repo.preload(:user) + @spec delete_topic_lock(actor :: Actor.t(), forum_slug :: String.t(), topic_slug :: String.t()) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def delete_topic_lock(%Actor{} = actor, forum_slug, topic_slug) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :unlock) do + topic_changeset = Topic.unlock_changeset(topic) + + Multi.new() + |> Multi.update(:topic, topic_changeset) + |> ModerationLogs.put_log( + :moderation_log, + actor, + "Topic.Lock:delete", + Paths.topic_path(topic), + "Unlocked topic '#{topic.title}' in #{forum.name}" + ) + |> Multi.transact() + |> case do + {:ok, %{topic: %Topic{} = topic}} -> + {:ok, {forum, topic}} + + _error -> + {:error, forum, topic} + end + end + end - Multi.new() - |> Multi.update(:topic, Topic.unhide_changeset(topic)) - |> Multi.update_all( - :forum, - Forums.update_forum_last_post_query(topic.forum_id), - inc: [post_count: topic.post_count, topic_count: 1] - ) - |> Repo.transaction() - |> case do - {:ok, %{topic: topic}} -> - UserStatistics.inc_stat(topic.user_id, :topics_count) - Posts.reindex_posts_in_topic(topic.id) + @doc """ + Updates the title of the topic named by `topic_slug` within the forum named by + `forum_slug` from `params`, on behalf of `actor`. - {:ok, topic} + The forum is loaded by short name and authorized for `:show`, and the topic is + loaded beneath it and authorized using the `:update_title` action. Only the title + is updated; the slug is left intact. - error -> - error + Returns `{:ok, {forum, topic}}` on success (both are returned for the caller to + reuse), `{:error, forum, topic}` when the title changeset is rejected (carrying + the forum and pre-update topic for the caller to reuse), + `{:error, :unauthorized}` when the forum or topic is not visible or may not be + edited, or `{:error, :not_found}` when the topic does not exist. + + ## Examples + + iex> update_topic(moderator, "dis", "some-topic", %{"title" => "New Title"}) + {:ok, {%Forum{}, %Topic{}}} + + """ + @spec update_topic( + actor :: Actor.t(), + forum_slug :: String.t(), + topic_slug :: String.t(), + params :: map() + ) :: + {:ok, {Forum.t(), Topic.t()}} + | {:error, Forum.t(), Topic.t()} + | {:error, :ban | :unauthorized | :not_found} + def update_topic(%Actor{} = actor, forum_slug, topic_slug, params) do + with :ok <- verify_write_access(actor), + {:ok, forum} <- Forums.show_forum(actor, forum_slug), + {:ok, topic} <- show_forum_topic(actor, forum, topic_slug, :update_title) do + topic + |> Topic.title_changeset(params) + |> Repo.update() + |> case do + {:ok, topic} -> + {:ok, {forum, topic}} + + _error -> + {:error, forum, topic} + end end end @doc """ - Updates a topic's title. + Adds an update step that recalculates a topic's cached last visible post. + + Maintains `Topic.last_post_id` and `Topic.last_replied_to_at` from the + newest post visible to users in that topic. The step reads the locked topic + from `topic_step`, which defaults to `:locked_topic`. Add it after inserting, + hiding, or restoring a post, and after hiding or restoring a topic. For topic + creation, pass `:topic` because the inserted topic is the row whose initial + post must be considered. A destruction-only operation does not need this + step because posts must already be hidden before they can be destroyed. ## Examples - iex> update_topic_title(topic, %{"title" => "New Title"}) - {:ok, %Topic{}} + iex> (Multi.new() + ...> |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :create_post) + ...> |> Multi.insert(:post, post_changeset) + ...> |> Topics.put_refresh_topic_last_post()) + %Multi{} """ - def update_topic_title(topic, attrs) do - topic - |> Topic.title_changeset(attrs) - |> Repo.update() + @spec put_refresh_last_post(Multi.t(), Multi.name()) :: Multi.t() + def put_refresh_last_post(%Multi{} = multi, topic_step \\ :locked_topic) do + Multi.update_all( + multi, + {:refresh_topic_last_post, topic_step}, + fn %{^topic_step => topic} -> update_last_post_query(topic.id) end, + [] + ) end @doc """ - Removes all topic notifications for a given topic and user. + Adds counter updates for a topic becoming visible or hidden. + + Maintains `Forum.topic_count`, `Forum.post_count`, and the topic author's + `topics_count`. `visible?: true` adds one topic and that topic's complete + non-destroyed `post_count`; `false` removes the same contribution. The + transaction must contain `:locked_forum` and the updated `:topic`, and must + call this immediately after changing `Topic.hidden_from_users`. ## Examples - iex> clear_topic_notification(topic, user) - :ok + iex> (Multi.new() + ...> |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :unhide) + ...> |> Multi.update(:topic, topic_changeset) + ...> |> Topics.put_topic_visibility_counters(visible?: true)) + %Multi{} """ - def clear_topic_notification(%Topic{} = topic, user) do - Notifications.clear_forum_post_notification(topic, user) - Notifications.clear_forum_topic_notification(topic, user) - :ok + @spec put_topic_visibility_counters(Multi.t(), [{:visible?, boolean()}]) :: Multi.t() + def put_topic_visibility_counters(%Multi{} = multi, [{:visible?, visible?}]) do + scale = if visible?, do: 1, else: -1 + + multi + |> UserStatistics.put_increment(fn %{topic: topic} -> topic.user_id end, :topics_count, scale) + |> Forums.put_topic_visibility_counters(visible?: visible?) end @doc """ - Returns an `m:Ecto.Query` which updates the last post for the given topic. + Adds a topic post-counter update for a post becoming or ceasing to be + non-destroyed. + + Maintains `Topic.post_count`, which includes every non-destroyed post, + whether or not it is hidden from users. `visible?: true` increments the + counter and `false` decrements it. The topic is read from `:locked_topic`; + call this after the post mutation and pair it with + `put_post_forum_visibility_counters/2` when the topic is visible. ## Examples - iex> update_topic_last_post_query(1) - #Ecto.Query<...> + iex> Multi.new() + ...> |> put_forum_and_topic_locks(actor, "dis", :show, "topic", :create_post) + ...> |> Multi.insert(:post, post_changeset) + ...> |> Topics.put_post_visibility_counters(visible?: true) + %Multi{} """ - def update_topic_last_post_query(topic_id) do + @spec put_post_visibility_counters(Multi.t(), [{:visible?, boolean()}]) :: Multi.t() + def put_post_visibility_counters(%Multi{} = multi, [{:visible?, visible?}]) do + scale = if visible?, do: 1, else: -1 + + Multi.update_all( + multi, + :topic_post_count, + fn %{locked_topic: topic} -> + Topic |> where(id: ^topic.id) |> update(inc: [post_count: ^scale]) + end, + [] + ) + end + + defp update_last_post_query(topic_id) do Topic |> where(id: ^topic_id) |> update( set: [ last_post_id: fragment( - "SELECT max(id) FROM posts WHERE topic_id = ? AND hidden_from_users IS FALSE", + """ + SELECT id FROM posts + WHERE topic_id = ? + AND hidden_from_users IS FALSE + ORDER BY topic_position DESC LIMIT 1 + """, + ^topic_id + ), + last_replied_to_at: + fragment( + """ + SELECT created_at FROM posts + WHERE topic_id = ? + AND hidden_from_users IS FALSE + ORDER BY topic_position DESC LIMIT 1 + """, ^topic_id ) ] ) end - # `Repo.transaction/1` reports a failed step as `{:error, name, value, changes}`. - # Callers only ever want the changeset that failed, in the shape every other - # context function returns it. - defp normalize_multi_error({:error, _name, %Ecto.Changeset{} = changeset, _changes}), - do: {:error, changeset} + @doc """ + Removes all topic notifications for a given topic and user. + + ## Examples + + iex> clear_topic_notification(topic, user) + :ok - defp normalize_multi_error(result), do: result + """ + @spec clear_topic_notification(Topic.t(), User.t() | nil) :: :ok + def clear_topic_notification(%Topic{} = topic, user) do + Notifications.clear_forum_post(topic, user) + Notifications.clear_forum_topic(topic, user) + :ok + end end diff --git a/lib/philomena/topics/move_form.ex b/lib/philomena/topics/move_form.ex new file mode 100644 index 000000000..22c10d52f --- /dev/null +++ b/lib/philomena/topics/move_form.ex @@ -0,0 +1,29 @@ +defmodule Philomena.Topics.MoveForm do + use Ecto.Schema + import Ecto.Changeset + + embedded_schema do + field :target_forum, :string + end + + @doc false + def changeset(move_form, attrs \\ %{}) do + move_form + |> cast(attrs, [:target_forum]) + |> validate_required(:target_forum) + end + + @doc false + def fetch_target_forum_short_name(attrs \\ %{}) do + %__MODULE__{} + |> changeset(attrs) + |> apply_action(:create) + |> case do + {:ok, move_form} -> + {:ok, move_form.target_forum} + + _ -> + {:error, :not_found} + end + end +end diff --git a/lib/philomena/topics/topic.ex b/lib/philomena/topics/topic.ex index 504af1bc1..84324f3fb 100644 --- a/lib/philomena/topics/topic.ex +++ b/lib/philomena/topics/topic.ex @@ -2,6 +2,7 @@ defmodule Philomena.Topics.Topic do use Ecto.Schema import Ecto.Changeset + alias Philomena.Attribution.Actor alias Philomena.Forums.Forum alias Philomena.Users.User alias Philomena.Polls.Poll @@ -9,6 +10,8 @@ defmodule Philomena.Topics.Topic do alias Philomena.Topics.Subscription alias Philomena.Slug + @type t :: %__MODULE__{} + @derive {Phoenix.Param, key: :slug} schema "topics" do belongs_to :user, User @@ -36,14 +39,14 @@ defmodule Philomena.Topics.Topic do end @doc false - def changeset(topic, attrs) do + def changeset(topic, attrs \\ %{}) do topic |> cast(attrs, []) |> validate_required([]) end @doc false - def creation_changeset(topic, attrs, forum, attribution) do + def creation_changeset(topic, attrs, %Forum{} = forum, %Actor{} = actor) do changes = topic |> cast(attrs, [:title, :anonymous]) @@ -56,10 +59,10 @@ defmodule Philomena.Topics.Topic do changes |> validate_length(:title, min: 4, max: 96, count: :bytes) |> put_slug() - |> change(forum: forum, user: attribution[:user]) + |> change(forum: forum, user: actor.user) |> validate_required(:forum) |> cast_assoc(:poll, with: &Poll.changeset/2) - |> cast_assoc(:posts, with: &Post.topic_creation_changeset(&1, &2, attribution, anonymous?)) + |> cast_assoc(:posts, with: &Post.topic_creation_changeset(&1, &2, actor, anonymous?)) |> validate_length(:posts, is: 1) |> unique_constraint(:slug, name: :index_topics_on_forum_id_and_slug) end @@ -96,16 +99,21 @@ defmodule Philomena.Topics.Topic do |> foreign_key_constraint(:forum_id, name: :fk_rails_eac66eb971) end - def hide_changeset(topic, deletion_reason, user) do - change(topic) + @doc false + def hide_changeset(topic, user, attrs) do + topic + |> cast(attrs, [:deletion_reason]) + |> validate_required([:deletion_reason]) + |> validate_hidden(false, "is already hidden") |> put_change(:hidden_from_users, true) |> put_change(:deleted_by_id, user.id) - |> put_change(:deletion_reason, deletion_reason) - |> validate_required([:deletion_reason]) end + @doc false def unhide_changeset(topic) do - change(topic) + topic + |> change() + |> validate_hidden(true, "is not hidden") |> put_change(:hidden_from_users, false) |> put_change(:deleted_by_id, nil) |> put_change(:deletion_reason, "") @@ -128,4 +136,12 @@ defmodule Philomena.Topics.Topic do |> put_change(:slug, slug) |> validate_required(:slug, message: "must be printable") end + + defp validate_hidden(changeset, required_state, message) do + if get_field(changeset, :hidden_from_users) != required_state do + add_error(changeset, :hidden_from_users, message) + else + changeset + end + end end diff --git a/lib/philomena/topics/topic_page.ex b/lib/philomena/topics/topic_page.ex new file mode 100644 index 000000000..334ad707b --- /dev/null +++ b/lib/philomena/topics/topic_page.ex @@ -0,0 +1,38 @@ +defmodule Philomena.Topics.TopicPage do + @moduledoc """ + Assembled state for a single forum topic: the forum and topic being viewed, + the current page of posts, the viewer's subscription and poll-vote state, + whether the topic's poll is currently accepting votes, and the two changesets + for replying and for editing the title. + + `posts` is a `Scrivener.Page` whose entries are raw `Post` structs; this struct + never carries rendered Markdown. + """ + + alias Philomena.Forums.Forum + alias Philomena.Topics.Topic + + @enforce_keys [ + :forum, + :topic, + :posts, + :watching, + :voted, + :poll_active, + :post_changeset, + :topic_changeset + ] + + defstruct @enforce_keys + + @type t :: %__MODULE__{ + forum: Forum.t(), + topic: Topic.t(), + posts: Scrivener.Page.t(), + watching: boolean(), + voted: boolean(), + poll_active: boolean(), + post_changeset: Ecto.Changeset.t(), + topic_changeset: Ecto.Changeset.t() + } +end diff --git a/lib/philomena/user_downvote_wipe.ex b/lib/philomena/user_downvote_wipe.ex deleted file mode 100644 index bfde79e96..000000000 --- a/lib/philomena/user_downvote_wipe.ex +++ /dev/null @@ -1,74 +0,0 @@ -defmodule Philomena.UserDownvoteWipe do - alias PhilomenaQuery.Batch - alias PhilomenaQuery.Search - alias Philomena.Users - alias Philomena.Users.User - alias Philomena.Images.Image - alias Philomena.Images - alias Philomena.ImageVotes.ImageVote - alias Philomena.ImageFaves.ImageFave - alias Philomena.Repo - import Ecto.Query - - def perform(user_id, upvotes_and_faves_too \\ false) do - user = Users.get_user!(user_id) - - ImageVote - |> where(user_id: ^user.id, up: false) - |> Batch.query_batches(id_field: :image_id) - |> Enum.each(fn queryable -> - {_, image_ids} = Repo.delete_all(select(queryable, [i_v], i_v.image_id)) - - {count, nil} = - Repo.update_all(where(Image, [i], i.id in ^image_ids), - inc: [downvotes_count: -1, score: 1] - ) - - Repo.update_all(where(User, id: ^user.id), inc: [image_votes_count: -count]) - - reindex(image_ids) - end) - - if upvotes_and_faves_too do - ImageVote - |> where(user_id: ^user.id, up: true) - |> Batch.query_batches(id_field: :image_id) - |> Enum.each(fn queryable -> - {_, image_ids} = Repo.delete_all(select(queryable, [i_v], i_v.image_id)) - - {count, nil} = - Repo.update_all(where(Image, [i], i.id in ^image_ids), - inc: [upvotes_count: -1, score: -1] - ) - - Repo.update_all(where(User, id: ^user.id), inc: [image_votes_count: -count]) - - reindex(image_ids) - end) - - ImageFave - |> where(user_id: ^user.id) - |> Batch.query_batches(id_field: :image_id) - |> Enum.each(fn queryable -> - {_, image_ids} = Repo.delete_all(select(queryable, [i_f], i_f.image_id)) - - {count, nil} = - Repo.update_all(where(Image, [i], i.id in ^image_ids), inc: [faves_count: -1]) - - Repo.update_all(where(User, id: ^user.id), inc: [image_faves_count: -count]) - - reindex(image_ids) - end) - end - end - - defp reindex(image_ids) do - Image - |> where([i], i.id in ^image_ids) - |> preload(^Images.indexing_preloads()) - |> Search.reindex(Image) - - # allow time for indexing to catch up - :timer.sleep(:timer.seconds(10)) - end -end diff --git a/lib/philomena/user_fingerprints.ex b/lib/philomena/user_fingerprints.ex index 9c7ba1761..0a86edfcf 100644 --- a/lib/philomena/user_fingerprints.ex +++ b/lib/philomena/user_fingerprints.ex @@ -1,104 +1,227 @@ defmodule Philomena.UserFingerprints do @moduledoc """ - The UserFingerprints context. + Fingerprint profiles, user history, and browser fingerprint validation. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3] + alias Philomena.Repo + alias Philomena.Attribution.Actor + alias Philomena.Bans alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.UserFingerprints.FingerprintProfile + alias Philomena.UserFingerprints.Server + alias Philomena.Users.User + + defp cast_fingerprint(fingerprint) when is_binary(fingerprint) do + fingerprint = + fingerprint + |> String.trim() + |> String.downcase() + + if valid_format?(fingerprint) do + {:ok, fingerprint} + else + {:error, :not_found} + end + end - @doc """ - Returns the list of user_fingerprints. + defp cast_fingerprint(_fingerprint), do: {:error, :not_found} - ## Examples + defp user_fingerprints_for(fingerprint) do + UserFingerprint + |> where(fingerprint: ^fingerprint) + |> order_by(desc: :updated_at) + |> preload(:user) + |> Repo.all() + end - iex> list_user_fingerprints() - [%UserFingerprint{}, ...] + defp history_query(%User{id: user_id}) do + UserFingerprint + |> where(user_id: ^user_id) + |> order_by(desc: :updated_at, desc: :id) + end - """ - def list_user_fingerprints do - Repo.all(UserFingerprint) + defp cross_references(fingerprints) do + UserFingerprint + |> where([u], u.fingerprint in ^fingerprints) + |> preload(:user) + |> order_by(desc: :updated_at) + |> Repo.all() + |> Enum.group_by(& &1.fingerprint) end @doc """ - Gets a single user_fingerprint. + Asynchronously records usage of a fingerprint by `user`. - Raises `Ecto.NoResultsError` if the User fingerprint does not exist. - - ## Examples + Invalid fingerprints return `:error`. - iex> get_user_fingerprint!(123) - %UserFingerprint{} + ## Example - iex> get_user_fingerprint!(456) - ** (Ecto.NoResultsError) + iex> record_usage(user, "d63c4581f8cf58d", ~U[2024-01-01 00:00:00Z]) + :ok """ - def get_user_fingerprint!(id), do: Repo.get!(UserFingerprint, id) + @spec record_usage(User.t(), term(), DateTime.t()) :: :ok | :error + def record_usage(%User{id: user_id}, fingerprint, updated_at) do + Server.record_usage(user_id, fingerprint, updated_at) + end @doc """ - Creates a user_fingerprint. - - ## Examples - - iex> create_user_fingerprint(%{field: value}) - {:ok, %UserFingerprint{}} + Assembles the fingerprint profile page for `actor` from the raw + `fingerprint` string. - iex> create_user_fingerprint(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + The input is trimmed, lowercased, and validated before the `:identity_metadata` + permission is checked. Malformed fingerprints are therefore always not found. + Valid fingerprints with no matching history return an empty profile. + Returns `{:ok, %FingerprintProfile{}}` carrying the users seen with the + fingerprint and the fingerprint bans matching it. """ - def create_user_fingerprint(attrs \\ %{}) do - %UserFingerprint{} - |> UserFingerprint.changeset(attrs) - |> Repo.insert() + @spec show_fingerprint_profile(Actor.t(), String.t()) :: + {:ok, FingerprintProfile.t()} | {:error, :unauthorized | :not_found} + def show_fingerprint_profile(%Actor{} = actor, fingerprint) do + with {:ok, fingerprint} <- cast_fingerprint(fingerprint), + :ok <- authorize(actor, :show, :identity_metadata) do + {:ok, + %FingerprintProfile{ + fingerprint: fingerprint, + user_fingerprints: user_fingerprints_for(fingerprint), + fingerprint_bans: Bans.fingerprint_bans_for(fingerprint) + }} + end end @doc """ - Updates a user_fingerprint. + Loads a paginated fingerprint history for `user` and cross-references the + fingerprints on the current page for `actor`. - ## Examples + `actor` must be authorized to show `:identity_metadata`. - iex> update_user_fingerprint(user_fingerprint, %{field: new_value}) - {:ok, %UserFingerprint{}} + ## Examples - iex> update_user_fingerprint(user_fingerprint, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> load_user_history(moderator, user, page: 1, page_size: 25) + {:ok, {%Scrivener.Page{}, %{"fingerprint" => [%UserFingerprint{}]}}} """ - def update_user_fingerprint(%UserFingerprint{} = user_fingerprint, attrs) do - user_fingerprint - |> UserFingerprint.changeset(attrs) - |> Repo.update() + @spec load_user_history(Actor.t(), User.t(), Repo.pagination_params()) :: + {:ok, {Scrivener.Page.t(UserFingerprint.t()), map()}} | {:error, :unauthorized} + def load_user_history(%Actor{} = actor, %User{} = user, pagination) do + with :ok <- authorize(actor, :show, :identity_metadata) do + user_fingerprints = + user + |> history_query() + |> Repo.paginate(pagination) + + fingerprints = + user_fingerprints.entries + |> Enum.map(& &1.fingerprint) + |> Enum.uniq() + + {:ok, {user_fingerprints, cross_references(fingerprints)}} + end end @doc """ - Deletes a UserFingerprint. + Returns the latest fingerprint history row for `user`, if any, after authorizing + the `:identity_metadata` permission. ## Examples - iex> delete_user_fingerprint(user_fingerprint) + iex> latest_for_user(moderator, user) {:ok, %UserFingerprint{}} - iex> delete_user_fingerprint(user_fingerprint) - {:error, %Ecto.Changeset{}} + """ + @spec latest_for_user(Actor.t(), User.t()) :: + {:ok, UserFingerprint.t() | nil} | {:error, :unauthorized} + def latest_for_user(%Actor{} = actor, %User{} = user) do + with :ok <- authorize(actor, :show, :identity_metadata) do + {:ok, + user + |> history_query() + |> limit(1) + |> Repo.one()} + end + end + + @doc """ + Deletes all stored fingerprint history for a user. + """ + @spec delete_for_user!(integer()) :: :ok + def delete_for_user!(user_id) do + Repo.delete_all(where(UserFingerprint, user_id: ^user_id)) + :ok + end + @doc """ + Persists the batching server's coalesced fingerprint usage. """ - def delete_user_fingerprint(%UserFingerprint{} = user_fingerprint) do - Repo.delete(user_fingerprint) + @spec persist_usage_batch(%{{pos_integer(), String.t()} => DateTime.t()}) :: :ok + def persist_usage_batch(user_fingerprints) when is_map(user_fingerprints) do + if map_size(user_fingerprints) > 0 do + update_query = + update(UserFingerprint, + inc: [uses: 1], + set: [updated_at: fragment("EXCLUDED.updated_at")] + ) + + usage_rows = + Enum.map(user_fingerprints, fn {{user_id, fingerprint}, updated_at} -> + %UserFingerprint{user_id: user_id} + |> UserFingerprint.changeset(%{fingerprint: fingerprint}) + |> Ecto.Changeset.apply_changes() + |> Map.take(UserFingerprint.insert_fields()) + |> Map.merge(%{created_at: updated_at, updated_at: updated_at}) + end) + + Repo.insert_all( + UserFingerprint, + usage_rows, + on_conflict: update_query, + conflict_target: [:user_id, :fingerprint] + ) + end + + :ok end @doc """ - Returns an `%Ecto.Changeset{}` for tracking user_fingerprint changes. + Determine whether the fingerprint corresponds to a valid format. + + Valid formats start with `c` or `d` (for the version). The `c` format is a legacy format + corresponding to an integer-valued hash from the frontend. The `d` format is the current + format corresponding to a hex-valued hash from the frontend. By design, it is not + possible to infer anything else about these values from the server. + + See assets/js/fp.ts for additional information on the generation of the `d` format. ## Examples - iex> change_user_fingerprint(user_fingerprint) - %Ecto.Changeset{source: %UserFingerprint{}} + iex> valid_format?("b2502085657") + false + + iex> valid_format?("c637334158") + true + + iex> valid_format?("d63c4581f8cf58d") + true + + iex> valid_format?("5162549b16e8448") + false """ - def change_user_fingerprint(%UserFingerprint{} = user_fingerprint) do - UserFingerprint.changeset(user_fingerprint, %{}) + @spec valid_format?(term()) :: boolean() + def valid_format?(fingerprint) + + def valid_format?(<<"c", rest::binary>>) when byte_size(rest) <= 12 do + match?({_result, ""}, Integer.parse(rest)) + end + + def valid_format?(<<"d", rest::binary>>) when byte_size(rest) == 14 do + match?({:ok, _result}, Base.decode16(rest, case: :lower)) end + + def valid_format?(_fingerprint), do: false end diff --git a/lib/philomena/user_fingerprints/fingerprint_profile.ex b/lib/philomena/user_fingerprints/fingerprint_profile.ex new file mode 100644 index 000000000..57f23567d --- /dev/null +++ b/lib/philomena/user_fingerprints/fingerprint_profile.ex @@ -0,0 +1,18 @@ +defmodule Philomena.UserFingerprints.FingerprintProfile do + @moduledoc """ + The assembled fingerprint profile page: the fingerprint, the users seen with + it, and the fingerprint bans matching it. + """ + + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.Bans.Fingerprint + + @enforce_keys [:fingerprint, :user_fingerprints, :fingerprint_bans] + defstruct [:fingerprint, :user_fingerprints, :fingerprint_bans] + + @type t :: %__MODULE__{ + fingerprint: String.t(), + user_fingerprints: [UserFingerprint.t()], + fingerprint_bans: [Fingerprint.t()] + } +end diff --git a/lib/philomena/user_fingerprints/server.ex b/lib/philomena/user_fingerprints/server.ex new file mode 100644 index 000000000..5affb4f55 --- /dev/null +++ b/lib/philomena/user_fingerprints/server.ex @@ -0,0 +1,60 @@ +defmodule Philomena.UserFingerprints.Server do + @moduledoc """ + Batches user fingerprint usage updates and submits them to the database every + 60 seconds. + """ + + use GenServer + + alias Philomena.UserFingerprints + + @timeout 0 + @flush_interval to_timeout(second: 60) + + @doc """ + Starts the user fingerprint usage server. + """ + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @doc """ + Asynchronously records usage of a fingerprint by a user. + + Invalid fingerprints return `:error`. + + ## Example + + iex> record_usage(user, "d63c4581f8cf58d", ~U[2024-01-01 00:00:00Z]) + :ok + + """ + @spec record_usage(pos_integer(), term(), DateTime.t()) :: :ok | :error + def record_usage(user_id, fingerprint, updated_at) do + if UserFingerprints.valid_format?(fingerprint) do + GenServer.cast(__MODULE__, {user_id, fingerprint, updated_at}) + else + :error + end + end + + @impl true + @doc false + def init(_) do + {:ok, %{}, @timeout} + end + + @impl true + @doc false + def handle_cast({user_id, fingerprint, updated_at}, user_fingerprints) do + {:noreply, Map.put(user_fingerprints, {user_id, fingerprint}, updated_at), @timeout} + end + + @impl true + @doc false + def handle_info(:timeout, user_fingerprints) do + UserFingerprints.persist_usage_batch(user_fingerprints) + + {:noreply, %{}, @flush_interval} + end +end diff --git a/lib/philomena/user_fingerprints/user_fingerprint.ex b/lib/philomena/user_fingerprints/user_fingerprint.ex index 6f7075853..7b3ceea96 100644 --- a/lib/philomena/user_fingerprints/user_fingerprint.ex +++ b/lib/philomena/user_fingerprints/user_fingerprint.ex @@ -4,19 +4,26 @@ defmodule Philomena.UserFingerprints.UserFingerprint do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "user_fingerprints" do belongs_to :user, User field :fingerprint, :string - field :uses, :integer, default: 0 + field :uses, :integer, default: 1 timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(user_fingerprint, attrs) do + def insert_fields do + [:user_id, :fingerprint, :uses] + end + + @doc false + def changeset(user_fingerprint, attrs \\ %{}) do user_fingerprint - |> cast(attrs, []) - |> validate_required([]) + |> cast(attrs, [:fingerprint]) + |> validate_required([:fingerprint]) end end diff --git a/lib/philomena/user_ips.ex b/lib/philomena/user_ips.ex index d3cd40721..368e007d1 100644 --- a/lib/philomena/user_ips.ex +++ b/lib/philomena/user_ips.ex @@ -1,116 +1,197 @@ defmodule Philomena.UserIps do @moduledoc """ - The UserIps context. + IP profiles, user history, and latest IP lookup for automatic ban enforcement. """ import Ecto.Query, warn: false + import Philomena.Authorization, only: [authorize: 3] + alias Philomena.Repo + alias Philomena.Attribution.Actor + alias Philomena.Bans alias Philomena.UserIps.UserIp + alias Philomena.UserIps.IpProfile + alias Philomena.UserIps.Server + alias Philomena.Users.User + + defp cast_ip(ip) do + case EctoNetwork.INET.cast(ip) do + {:ok, ip} -> + {:ok, ip} + + _error -> + {:error, :not_found} + end + end - @doc """ - Gets a single user_ip. - - Raises `Ecto.NoResultsError` if the User ip does not exist. - - ## Examples - - iex> get_user_ip!(123) - %UserIp{} - - iex> get_user_ip!(456) - ** (Ecto.NoResultsError) - - """ - def get_user_ip!(id), do: Repo.get!(UserIp, id) - - @doc """ - Gets this user's most recent IP address, if the user has one - recorded. - """ - def get_ip_for_user(user_id) do + defp user_ips_for(ip) do UserIp - |> where(user_id: ^user_id) + |> where(fragment("? >>= ip", ^ip)) |> order_by(desc: :updated_at) - |> limit(1) - |> select([u], u.ip) - |> Repo.one() + |> preload(:user) + |> Repo.all() end - @doc """ - Sets the appropriate netmask for correctly banning an IPv6-enabled - client per RFC4941. IPv4 addresses are not changed. - """ - def masked_ip(%Postgrex.INET{address: {_1, _2, _3, _4}} = ip) do - ip + defp history_query(%User{id: user_id}) do + UserIp + |> where(user_id: ^user_id) + |> order_by(desc: :updated_at, desc: :id) end - def masked_ip(%Postgrex.INET{address: {h1, h2, h3, h4, _5, _6, _7, _8}} = ip) do - %{ip | address: {h1, h2, h3, h4, 0, 0, 0, 0}, netmask: 64} + defp cross_references(ips) do + UserIp + |> where([u], u.ip in ^ips) + |> preload(:user) + |> order_by(desc: :updated_at) + |> Repo.all() + |> Enum.group_by(& &1.ip) end @doc """ - Creates a user_ip. + Asynchronously records usage of an IP address by `user`. - ## Examples + Invalid IP addresses return `:error`. - iex> create_user_ip(%{field: value}) - {:ok, %UserIp{}} + ## Example - iex> create_user_ip(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> record_usage(user, {127, 0, 0, 1}, ~U[2024-01-01 00:00:00Z]) + :ok """ - def create_user_ip(attrs \\ %{}) do - %UserIp{} - |> UserIp.changeset(attrs) - |> Repo.insert() + @spec record_usage(User.t(), term(), DateTime.t()) :: :ok | :error + def record_usage(%User{id: user_id}, ip_address, updated_at) do + Server.record_usage(user_id, ip_address, updated_at) end @doc """ - Updates a user_ip. + Assembles the IP profile page for `actor` from the raw address string `ip`. - ## Examples + The address is parsed and canonicalized before the `:identity_metadata` + permission is checked. A malformed address is therefore always not found; a + valid address with no matching history returns an empty profile. - iex> update_user_ip(user_ip, %{field: new_value}) - {:ok, %UserIp{}} + Returns `{:ok, %IpProfile{}}` carrying the users seen on the address and the + subnet bans covering it. + """ + @spec show_ip_profile(Actor.t(), String.t()) :: + {:ok, IpProfile.t()} | {:error, :unauthorized | :not_found} + def show_ip_profile(%Actor{} = actor, ip) do + with {:ok, ip} <- cast_ip(ip), + :ok <- authorize(actor, :show, :identity_metadata) do + {:ok, + %IpProfile{ + ip: ip, + user_ips: user_ips_for(ip), + subnet_bans: Bans.subnet_bans_for_ip(ip) + }} + end + end + + @doc """ + Loads a paginated IP history for `user` and cross-references the IPs on the + current page for `actor`. + + `actor` must be authorized to show `:identity_metadata`. + + ## Examples - iex> update_user_ip(user_ip, %{field: bad_value}) - {:error, %Ecto.Changeset{}} + iex> load_user_history(moderator, user, page: 1, page_size: 25) + {:ok, {%Scrivener.Page{}, %{ip => [%UserIp{}]}}} """ - def update_user_ip(%UserIp{} = user_ip, attrs) do - user_ip - |> UserIp.changeset(attrs) - |> Repo.update() + @spec load_user_history(Actor.t(), User.t(), Repo.pagination_params()) :: + {:ok, {Scrivener.Page.t(UserIp.t()), map()}} | {:error, :unauthorized} + def load_user_history(%Actor{} = actor, %User{} = user, pagination) do + with :ok <- authorize(actor, :show, :identity_metadata) do + user_ips = + user + |> history_query() + |> Repo.paginate(pagination) + + ips = + user_ips.entries + |> Enum.map(& &1.ip) + |> Enum.uniq() + + {:ok, {user_ips, cross_references(ips)}} + end end @doc """ - Deletes a UserIp. + Returns the latest IP history row for `user`, if any, after authorizing the + `:identity_metadata` permission. ## Examples - iex> delete_user_ip(user_ip) + iex> latest_for_user(moderator, user) {:ok, %UserIp{}} - iex> delete_user_ip(user_ip) - {:error, %Ecto.Changeset{}} - """ - def delete_user_ip(%UserIp{} = user_ip) do - Repo.delete(user_ip) + @spec latest_for_user(Actor.t(), User.t()) :: + {:ok, UserIp.t() | nil} | {:error, :unauthorized} + def latest_for_user(%Actor{} = actor, %User{} = user) do + with :ok <- authorize(actor, :show, :identity_metadata) do + {:ok, + user + |> history_query() + |> limit(1) + |> Repo.one()} + end end @doc """ - Returns an `%Ecto.Changeset{}` for tracking user_ip changes. + Returns the latest recorded IP for `user_id`, if any. - ## Examples + This function is designed for automatic subnet creation when a user is + banned. Request-facing code must use `load_ip_profile/2`, which applies the + `:identity_metadata` permission. + """ + @spec latest_ip_for_user(pos_integer()) :: Postgrex.INET.t() | nil + def latest_ip_for_user(user_id) do + UserIp + |> where(user_id: ^user_id) + |> order_by(desc: :updated_at) + |> limit(1) + |> select([u], u.ip) + |> Repo.one() + end - iex> change_user_ip(user_ip) - %Ecto.Changeset{source: %UserIp{}} + @doc """ + Deletes all stored IP history for a user. + """ + @spec delete_for_user!(integer()) :: :ok + def delete_for_user!(user_id) do + Repo.delete_all(where(UserIp, user_id: ^user_id)) + :ok + end + @doc """ + Persists the batching server's coalesced IP usage. """ - def change_user_ip(%UserIp{} = user_ip) do - UserIp.changeset(user_ip, %{}) + @spec persist_usage_batch(%{{pos_integer(), Postgrex.INET.t()} => DateTime.t()}) :: :ok + def persist_usage_batch(user_ips) when is_map(user_ips) do + if map_size(user_ips) > 0 do + update_query = + update(UserIp, inc: [uses: 1], set: [updated_at: fragment("EXCLUDED.updated_at")]) + + usage_rows = + Enum.map(user_ips, fn {{user_id, ip_address}, updated_at} -> + %UserIp{user_id: user_id} + |> UserIp.changeset(%{ip: ip_address}) + |> Ecto.Changeset.apply_changes() + |> Map.take(UserIp.insert_fields()) + |> Map.merge(%{created_at: updated_at, updated_at: updated_at}) + end) + + Repo.insert_all( + UserIp, + usage_rows, + on_conflict: update_query, + conflict_target: [:user_id, :ip] + ) + end + + :ok end end diff --git a/lib/philomena/user_ips/ip_profile.ex b/lib/philomena/user_ips/ip_profile.ex new file mode 100644 index 000000000..71fc9dc2c --- /dev/null +++ b/lib/philomena/user_ips/ip_profile.ex @@ -0,0 +1,18 @@ +defmodule Philomena.UserIps.IpProfile do + @moduledoc """ + The assembled IP profile page: the IP address, the users seen on it, and the + subnet bans covering it. + """ + + alias Philomena.UserIps.UserIp + alias Philomena.Bans.Subnet + + @enforce_keys [:ip, :user_ips, :subnet_bans] + defstruct [:ip, :user_ips, :subnet_bans] + + @type t :: %__MODULE__{ + ip: Postgrex.INET.t(), + user_ips: [UserIp.t()], + subnet_bans: [Subnet.t()] + } +end diff --git a/lib/philomena/user_ips/server.ex b/lib/philomena/user_ips/server.ex new file mode 100644 index 000000000..9d5eefb65 --- /dev/null +++ b/lib/philomena/user_ips/server.ex @@ -0,0 +1,57 @@ +defmodule Philomena.UserIps.Server do + @moduledoc """ + Batches user IP usage updates and submits them to the database every 60 seconds. + """ + + use GenServer + + alias Philomena.UserIps + + @timeout 0 + @flush_interval to_timeout(second: 60) + + @doc """ + Starts the user IP usage server. + """ + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @doc """ + Asynchronously records usage of an IP address by a user. + + Invalid IP addresses return `:error`. + + ## Example + + iex> record_usage(user, {127, 0, 0, 1}, ~U[2024-01-01 00:00:00Z]) + :ok + + """ + @spec record_usage(pos_integer(), term(), DateTime.t()) :: :ok | :error + def record_usage(user_id, ip_address, updated_at) do + with {:ok, ip_address} <- EctoNetwork.INET.cast(ip_address) do + GenServer.cast(__MODULE__, {user_id, ip_address, updated_at}) + end + end + + @impl true + @doc false + def init(_) do + {:ok, %{}, @timeout} + end + + @impl true + @doc false + def handle_cast({user_id, ip_address, updated_at}, user_ips) do + {:noreply, Map.put(user_ips, {user_id, ip_address}, updated_at), @timeout} + end + + @impl true + @doc false + def handle_info(:timeout, user_ips) do + UserIps.persist_usage_batch(user_ips) + + {:noreply, %{}, @flush_interval} + end +end diff --git a/lib/philomena/user_ips/user_ip.ex b/lib/philomena/user_ips/user_ip.ex index 8a0b81060..986ade3d6 100644 --- a/lib/philomena/user_ips/user_ip.ex +++ b/lib/philomena/user_ips/user_ip.ex @@ -4,19 +4,26 @@ defmodule Philomena.UserIps.UserIp do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "user_ips" do belongs_to :user, User field :ip, EctoNetwork.INET - field :uses, :integer, default: 0 + field :uses, :integer, default: 1 timestamps(inserted_at: :created_at, type: :utc_datetime) end @doc false - def changeset(user_ip, attrs) do + def insert_fields do + [:user_id, :ip, :uses] + end + + @doc false + def changeset(user_ip, attrs \\ %{}) do user_ip - |> cast(attrs, []) - |> validate_required([]) + |> cast(attrs, [:ip]) + |> validate_required([:ip]) end end diff --git a/lib/philomena/user_name_changes.ex b/lib/philomena/user_name_changes.ex index d9963e3c0..2765b84c6 100644 --- a/lib/philomena/user_name_changes.ex +++ b/lib/philomena/user_name_changes.ex @@ -1,104 +1,71 @@ defmodule Philomena.UserNameChanges do @moduledoc """ - The UserNameChanges context. + Name change history persistence and staff history auditing. + + `Philomena.Users` manages rename authorization and account mutation, and uses + this context's transaction step to record the prior name atomically. History + is retained indefinitely. """ import Ecto.Query, warn: false - alias Philomena.Repo + import Philomena.Authorization, only: [authorize: 3] + alias Philomena.Multi + alias Philomena.Attribution.Actor + alias Philomena.Repo alias Philomena.UserNameChanges.UserNameChange + alias Philomena.Users.User - @doc """ - Returns the list of user_name_changes. - - ## Examples - - iex> list_user_name_changes() - [%UserNameChange{}, ...] - - """ - def list_user_name_changes do - Repo.all(UserNameChange) + defp history_query(user_id) do + UserNameChange + |> where(user_id: ^user_id) + |> order_by(desc: :id) end @doc """ - Gets a single user_name_change. + Records a name change entry for `user` to `multi` under `step`. - Raises `Ecto.NoResultsError` if the User name change does not exist. + This is a transaction composition function for `Philomena.Users`, not a + request-facing rename operation. Every successful rename records the exact + prior spelling, including case-only changes. If any later step in the + owning transaction fails, the history insert rolls back with it. ## Examples - iex> get_user_name_change!(123) - %UserNameChange{} - - iex> get_user_name_change!(456) - ** (Ecto.NoResultsError) + iex> record_rename(Multi.new(), :name_change, user) + %Multi{} """ - def get_user_name_change!(id), do: Repo.get!(UserNameChange, id) - - @doc """ - Creates a user_name_change. - - ## Examples - - iex> create_user_name_change(%{field: value}) - {:ok, %UserNameChange{}} + @spec record_rename(Multi.t(), Multi.name(), User.t()) :: Multi.t() + def record_rename(%Multi{} = multi, step, %User{} = user) do + changeset = UserNameChange.changeset(%UserNameChange{user_id: user.id}, user.name) - iex> create_user_name_change(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_user_name_change(attrs \\ %{}) do - %UserNameChange{} - |> UserNameChange.changeset(attrs) - |> Repo.insert() + Multi.insert(multi, step, changeset) end @doc """ - Updates a user_name_change. + Returns `user`'s rename history for `actor`, newest first and paginated. - ## Examples - - iex> update_user_name_change(user_name_change, %{field: new_value}) - {:ok, %UserNameChange{}} - - iex> update_user_name_change(user_name_change, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def update_user_name_change(%UserNameChange{} = user_name_change, attrs) do - user_name_change - |> UserNameChange.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a UserNameChange. + The collection authorizes `:index` on `UserNameChange`. Forbidden viewers + receive `{:error, :unauthorized}`. ## Examples - iex> delete_user_name_change(user_name_change) - {:ok, %UserNameChange{}} - - iex> delete_user_name_change(user_name_change) - {:error, %Ecto.Changeset{}} - - """ - def delete_user_name_change(%UserNameChange{} = user_name_change) do - Repo.delete(user_name_change) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking user_name_change changes. - - ## Examples + iex> load_history(moderator, user, pagination) + {:ok, %Scrivener.Page{}} - iex> change_user_name_change(user_name_change) - %Ecto.Changeset{source: %UserNameChange{}} + iex> load_history(ordinary_user, user, pagination) + {:error, :unauthorized} """ - def change_user_name_change(%UserNameChange{} = user_name_change) do - UserNameChange.changeset(user_name_change, %{}) + @spec load_history(Actor.t(), User.t(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(UserNameChange.t())} | {:error, :unauthorized} + def load_history(%Actor{} = actor, %User{} = user, pagination) do + with :ok <- authorize(actor, :index, UserNameChange) do + {:ok, + user.id + |> history_query() + |> Repo.paginate(pagination)} + end end end diff --git a/lib/philomena/user_name_changes/user_name_change.ex b/lib/philomena/user_name_changes/user_name_change.ex index f4434a8e0..839f5ed1d 100644 --- a/lib/philomena/user_name_changes/user_name_change.ex +++ b/lib/philomena/user_name_changes/user_name_change.ex @@ -4,6 +4,8 @@ defmodule Philomena.UserNameChanges.UserNameChange do alias Philomena.Users.User + @type t :: %__MODULE__{} + schema "user_name_changes" do belongs_to :user, User field :name, :string diff --git a/lib/philomena/user_statistics.ex b/lib/philomena/user_statistics.ex index 9870a7ee5..b5219460d 100644 --- a/lib/philomena/user_statistics.ex +++ b/lib/philomena/user_statistics.ex @@ -1,14 +1,16 @@ defmodule Philomena.UserStatistics do @moduledoc """ - The UserStatistics context. + Atomic daily counters derived from user activity. + + This module performs no authorization. It accepts a statistic key, and + updates the user's lifetime counter and UTC daily row together. """ - import Ecto.Query, warn: false + alias Philomena.Multi alias Philomena.Repo - - alias Philomena.UserStatistics.UserStatistic alias Philomena.Users alias Philomena.Users.User + alias Philomena.UserStatistics.UserStatistic @permitted_actions [ :images_count, @@ -20,47 +22,194 @@ defmodule Philomena.UserStatistics do :topics_count ] + @typedoc "A daily and lifetime counter owned by this context." + @type statistic :: + :images_count + | :image_faves_count + | :comments_count + | :image_votes_count + | :metadata_updates_count + | :posts_count + | :topics_count + + defp persist_increment(user_id, statistic, amount) do + day = Date.utc_today() + + Repo.transact(fn -> + case Users.increment_counter(Repo, user_id, statistic, amount) do + {1, nil} -> + Repo.insert( + Map.put(%UserStatistic{day: day, user_id: user_id}, statistic, amount), + on_conflict: [inc: [{statistic, amount}]], + conflict_target: [:day, :user_id] + ) + + {0, nil} -> + {:error, :not_found} + end + end) + end + + defp reindex_result({:ok, %UserStatistic{}}, user_id) do + Users.reindex_user(%User{id: user_id}) + {:ok, nil} + end + + defp reindex_result(error, _user_id), do: error + + defp persist_bulk_increment(repo, user_ids, statistic, amount) do + case Users.increment_counters(repo, user_ids, statistic, amount) do + {count, nil} when count == length(user_ids) -> + entries = + Enum.map(user_ids, fn user_id -> + %{day: Date.utc_today(), user_id: user_id} + |> Map.put(statistic, amount) + end) + + repo.insert_all(UserStatistic, entries, + on_conflict: [inc: [{statistic, amount}]], + conflict_target: [:day, :user_id] + ) + + {:ok, nil} + + _ -> + {:error, :not_found} + end + end + @doc """ - Updates a user statistic. + Adds an atomic statistic increment to `multi`. - ## Examples + The Multi updates both the user's lifetime counter and current UTC-daily + counter. Passing `nil` leaves the Multi unchanged, which supports anonymous + activity. After the transaction commits, it reindexes the user. + + ## Example - iex> inc_stat(user, :images_count, -1) - {:ok, %UserStatistic{}} + iex> Multi.new() |> put_increment(user, :images_count, 2) + %Multi{} """ - def inc_stat(user_or_id, action, amount \\ 1) + @spec put_increment( + multi :: Multi.t(), + user_or_id_or_nil_or_callback :: + User.t() | integer() | nil | (Multi.changes() -> User.t() | integer() | nil), + statistic :: statistic(), + amount :: integer() + ) :: + Multi.t() + def put_increment(multi, user_or_id_or_nil_or_callback, statistic, amount \\ 1) + + def put_increment(multi, nil, statistic, amount) + when statistic in @permitted_actions and is_integer(amount), + do: multi + + def put_increment(multi, %User{} = user, statistic, amount) + when statistic in @permitted_actions and is_integer(amount), + do: put_increment(multi, user.id, statistic, amount) + + def put_increment(multi, callback, statistic, amount) + when is_function(callback, 1) and statistic in @permitted_actions and is_integer(amount) do + Multi.merge(multi, fn changes -> + put_increment(Multi.new(), callback.(changes), statistic, amount) + end) + end - def inc_stat(nil, action, _amount) when action in @permitted_actions, - do: {:ok, nil} + def put_increment(multi, user_id, statistic, amount) + when is_integer(user_id) and statistic in @permitted_actions and is_integer(amount) do + multi + |> Multi.run({:put_increment, make_ref()}, fn _repo, _changes -> + persist_increment(user_id, statistic, amount) + end) + |> Multi.on_commit(fn _changes -> + Users.reindex_user(%User{id: user_id}) + end) + end - def inc_stat(%User{} = user, action, amount) - when action in @permitted_actions, - do: inc_stat(user.id, action, amount) + @doc """ + Adds atomic increments for multiple users to `multi`. - def inc_stat(user_id, action, amount) - when action in @permitted_actions do - today = Date.utc_today() + Every distinct user in `users_or_ids` receives the same lifetime and current + UTC-daily increment. The updates use one query per table, and users are + reindexed only after the owning Multi commits. An empty list leaves the Multi + unchanged. - user_query = where(User, id: ^user_id) + ## Example - Repo.transact(fn -> - Repo.update_all(user_query, inc: [{action, amount}]) + iex> Multi.new() |> put_bulk_increment([user_a, user_b], :image_votes_count) + %Multi{} - Repo.insert( - Map.put(%UserStatistic{day: today, user_id: user_id}, action, amount), - on_conflict: [inc: [{action, amount}]], - conflict_target: [:day, :user_id] - ) + """ + @spec put_bulk_increment( + multi :: Multi.t(), + users_or_ids :: [User.t() | integer()], + statistic :: statistic(), + amount :: integer() + ) :: Multi.t() + def put_bulk_increment(multi, users_or_ids, statistic, amount \\ 1) + + def put_bulk_increment(multi, [], statistic, amount) + when statistic in @permitted_actions and is_integer(amount), + do: multi + + def put_bulk_increment(multi, users_or_ids, statistic, amount) + when is_list(users_or_ids) and statistic in @permitted_actions and is_integer(amount) do + user_ids = + users_or_ids + |> Enum.map(fn + %User{id: user_id} -> user_id + user_id when is_integer(user_id) -> user_id + end) + |> Enum.uniq() + + multi + |> Multi.run({:put_bulk_increment, make_ref()}, fn repo, _changes -> + persist_bulk_increment(repo, user_ids, statistic, amount) end) - |> case do - {:ok, _} -> - Users.reindex_user(%User{id: user_id}) + |> Multi.on_commit(fn _changes -> Users.reindex_user_ids(user_ids) end) + end - {:ok, nil} + @doc """ + Atomically increments one lifetime and UTC-daily statistic for `user_or_id`. - error -> - error - end + A `nil` user is an intentional no-op for anonymous activity. A missing user + ID is `{:error, :not_found}`. Negative amounts decrement both counters. + Unknown statistic keys and non-integer amounts do not match this API. + + The database increments join an ambient transaction when called from an + `Ecto.Multi` callback, so an owning action rollback also rolls them back. A + successful call enqueues a user reindex; that queue side effect is best-effort + and is not part of the database transaction. + + ## Examples + + iex> increment(user, :images_count) + {:ok, nil} + + iex> increment(user.id, :images_count, -1) + {:ok, nil} + + iex> increment(nil, :comments_count) + {:ok, nil} + + """ + @spec increment(User.t() | integer() | nil, statistic(), integer()) :: + {:ok, nil} | {:error, :not_found | Ecto.Changeset.t()} + def increment(user_or_id, statistic, amount \\ 1) + + def increment(nil, statistic, amount) + when statistic in @permitted_actions and is_integer(amount), + do: {:ok, nil} + + def increment(%User{} = user, statistic, amount) + when statistic in @permitted_actions and is_integer(amount), + do: increment(user.id, statistic, amount) + + def increment(user_id, statistic, amount) + when is_integer(user_id) and statistic in @permitted_actions and is_integer(amount) do + user_id + |> persist_increment(statistic, amount) + |> reindex_result(user_id) end end diff --git a/lib/philomena/user_wipe.ex b/lib/philomena/user_wipe.ex deleted file mode 100644 index ca4e2c851..000000000 --- a/lib/philomena/user_wipe.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule Philomena.UserWipe do - @wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32} - @wipe_fp "ffff" - - alias Philomena.Comments.Comment - alias Philomena.Images.Image - alias Philomena.Posts.Post - alias Philomena.Reports.Report - alias Philomena.SourceChanges.SourceChange - alias Philomena.TagChanges.TagChange - alias Philomena.UserIps.UserIp - alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.Users - alias Philomena.Users.User - alias Philomena.Repo - alias PhilomenaQuery.Batch - import Ecto.Query - - def perform(user_id) do - user = Users.get_user!(user_id) - - random_hex = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) - - for schema <- [Comment, Image, Post, Report, SourceChange, TagChange] do - schema - |> where(user_id: ^user.id) - |> Batch.query_batches() - |> Enum.each(&Repo.update_all(&1, set: [ip: @wipe_ip, fingerprint: @wipe_fp])) - end - - UserIp - |> where(user_id: ^user.id) - |> Repo.delete_all() - - UserFingerprint - |> where(user_id: ^user.id) - |> Repo.delete_all() - - User - |> where(id: ^user.id) - |> Repo.update_all(set: [email: "deactivated#{random_hex}@example.com"]) - - Users.reindex_user(user) - end -end diff --git a/lib/philomena/users.ex b/lib/philomena/users.ex index c18e1b1d3..21f9da660 100644 --- a/lib/philomena/users.ex +++ b/lib/philomena/users.ex @@ -1,20 +1,46 @@ defmodule Philomena.Users do @moduledoc """ - The Users context. + Authentication, registration, profiles, account settings, and staff user + management. + + Authentication token services deliberately have no actor because the + token is the credential. Loaded record entry points are limited to explicit + worker, indexing, filter, and erasure collaboration services. """ import Ecto.Query, warn: false - alias Ecto.Multi + + import Philomena.Authorization, + only: [authorize: 3, verify_write_access: 1] + + alias Philomena.Multi alias Philomena.Repo + alias Philomena.Tags - alias Philomena.Schema.Approval + alias Philomena.Attribution.Actor alias PhilomenaQuery.Search alias Philomena.Users - alias Philomena.Users.{User, UserToken, UserNotifier, Uploader, Settings} + + alias Philomena.Users.{ + AdminUserForm, + AliasMatches, + QueryBuilder, + QueryForm, + RoleForm, + Settings, + Uploader, + User, + UserNotifier, + UserToken + } + alias Philomena.{Forums, Forums.Forum} alias Philomena.Bans alias Philomena.Topics alias Philomena.Roles.Role + alias Philomena.UserIps.UserIp + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.UserNameChanges alias Philomena.UserNameChanges.UserNameChange alias Philomena.Images alias Philomena.Comments @@ -24,176 +50,583 @@ defmodule Philomena.Users do alias Philomena.Filters alias Philomena.TagChanges alias Philomena.Filters.Filter + alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.Paths alias Philomena.IndexWorker + alias Philomena.Loader alias Philomena.UserEraseWorker alias Philomena.UserRenameWorker + alias Philomena.UserUnvoteWorker + alias Philomena.UserWipeWorker - @typedoc """ - Describes the entity performing the action. - The term `principal` was borrowed from AWS IAM terminology. - """ - @type principal :: [ - ip: EctoNetwork.INET.t(), - fingerprint: String.t(), - user: %User{} | nil - ] + ## Shared locators + + defp load_user_by_slug(actor, action, slug, preloads \\ []) + + defp load_user_by_slug(actor, action, slug, preloads) when is_binary(slug) do + User + |> where([user], user.slug == ^slug) + |> preload(^preloads) + |> Loader.one_and_authorize(actor, action) + end + + defp load_user_by_slug(_actor, _action, _slug, _preloads), do: {:error, :not_found} + + ## Authentication and token transaction composition + + defp user_email_multi(user, email, context) do + changeset = + user + |> User.email_changeset(%{email: email}) + |> User.confirm_changeset() + + Multi.new() + |> Multi.update(:user, changeset) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) + end + + defp unlock_user_multi(user) do + changeset = User.unlock_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["unlock"])) + end + + defp confirm_user_multi(user) do + Multi.new() + |> Multi.update(:user, User.confirm_changeset(user)) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) + end + + defp verify_user_for_authentication(user) do + if is_nil(user.confirmed_at) do + {:error, :unconfirmed} + else + {:ok, user} + end + end + + defp verify_user_for_password_authentication(user, password_compromised?) do + if password_compromised? do + # Immediately delete user sessions to notify the user that a reset is needed. + delete_user_sessions(user) + + {:error, :password_compromised} + else + verify_user_for_authentication(user) + end + end + + ## Settings and role assignment + + defp admin_user_form(changeset) do + %AdminUserForm{ + changeset: changeset, + roles: Repo.all(Role) + } + end + + defp fetch_roles(role_ids) do + Role + |> where([role], role.id in ^role_ids) + |> Repo.all() + |> case do + roles when length(roles) == length(role_ids) -> + {:ok, roles} + + _roles -> + {:error, :not_found} + end + end + + defp update_user_changeset(user, attrs) do + with {:ok, role_ids} <- RoleForm.fetch_role_ids(attrs), + {:ok, roles} <- fetch_roles(role_ids) do + User.update_changeset(user, attrs, roles) + else + {:error, :not_found} -> + User.role_error_changeset(user) + end + end + + defp setup_roles(nil), do: nil + + defp setup_roles(user) do + role_map = + user.roles + |> Enum.group_by(& &1.resource_type, & &1.name) + |> Map.new(fn {type, names} -> {type, Map.new(names, &{&1, []})} end) + + %{user | role_map: role_map} + end + + defp load_with_roles(query) do + query |> Repo.one() |> load_user_with_roles() + end + + defp load_user_with_roles(nil), do: nil + + defp load_user_with_roles(user) do + user + |> Repo.preload([:roles, :current_filter, :settings]) + |> setup_roles() + end + + ## Avatar persistence + + defp clear_avatar(%User{} = user) do + changeset = User.remove_avatar_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> Uploader.put_unpersist_old_upload(:user) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + + # Transaction composition + + defp user_lock_query(%User{id: id}) do + User + |> where(id: ^id) + |> preload([:roles, :settings]) + end + + defp put_unsubscribe_restricted_actors(multi, step) do + Multi.run(multi, step, fn repo, %{user: user} -> + forum_ids = + Forum + |> order_by(asc: :name) + |> repo.all() + |> Enum.reject(&(authorize(user, :show, &1) == :ok)) + |> Enum.map(& &1.id) + + {_count, nil} = + Forums.Subscription + |> where( + [subscription], + subscription.user_id == ^user.id and subscription.forum_id in ^forum_ids + ) + |> repo.delete_all() + + {_count, nil} = + Topics.Subscription + |> join(:inner, [subscription], _ in assoc(subscription, :topic)) + |> where( + [subscription, topic], + subscription.user_id == ^user.id and topic.forum_id in ^forum_ids + ) + |> repo.delete_all() + + {:ok, nil} + end) + end + + ## Post-commit hooks + + defp put_reindex_user(multi) do + Multi.on_commit(multi, fn %{user: user} -> reindex_user(user) end) + end + + defp put_wipe_user_votes_job(multi, [{:upvotes_and_faves?, upvotes_and_faves?}]) do + Multi.on_commit(multi, fn %{user: user} -> + Exq.enqueue(Exq, "indexing", UserUnvoteWorker, [user.id, upvotes_and_faves?]) + end) + end - ## Database getters + defp put_wipe_user_job(multi) do + Multi.on_commit(multi, fn %{user: user} -> + Exq.enqueue(Exq, "indexing", UserWipeWorker, [user.id]) + end) + end + + defp put_rename_user_job(multi, [{:old_name, old_name}]) do + Multi.on_commit(multi, fn %{user: user} -> + Exq.enqueue(Exq, "indexing", UserRenameWorker, [old_name, user.name]) + end) + end + + defp put_erase_user_job(multi, %Actor{} = actor) do + Multi.on_commit(multi, fn %{user: user} -> + Exq.enqueue(Exq, "indexing", UserEraseWorker, [user.id, actor.user.id]) + end) + end + ## Public reads + + @doc group: "Public reads" @doc """ - Gets a user by API token. + Loads the active profile named by integer `id` for `actor`, with public links + and badge awards preloaded. + + Malformed, missing, and deactivated IDs are consistently not-found. A real + active user the actor may not show is unauthorized. ## Examples - iex> get_user_by_authentication_token("5Ow89k7nW24E0K34d3zX") - %User{} + iex> show_profile(actor, "1") + {:ok, %User{}} - iex> get_user_by_authentication_token("invalid") - nil + iex> show_profile(actor, "not-an-id") + {:error, :not_found} """ - def get_user_by_authentication_token(token) when is_binary(token) do + @spec show_profile(Actor.t(), Loader.integer_id()) :: + {:ok, User.t()} | {:error, :unauthorized | :not_found} + def show_profile(%Actor{} = actor, id) do User - |> Repo.get_by(authentication_token: token) - |> Repo.preload(:settings) + |> where([user], is_nil(user.deleted_at)) + |> Loader.fetch_and_authorize(actor, :show, id, public_links: :tag, awards: :badge) end + @doc group: "Public reads" @doc """ - Gets a user by email. + Loads the visible, active profile named by `slug` for `actor`. + + Missing and deactivated profiles are always not found. A real active profile + that the actor may not show is unauthorized. ## Examples - iex> get_user_by_email("foo@example.com") - %User{} + iex> load_profile(actor, "somebody") + {:ok, %User{}} - iex> get_user_by_email("unknown@example.com") - nil + iex> load_profile(actor, "missing") + {:error, :not_found} """ - def get_user_by_email(email) when is_binary(email) do - Repo.get_by(User, email: email) + @spec load_profile(Actor.t(), String.t(), list()) :: + {:ok, User.t()} | {:error, :unauthorized | :not_found} + def load_profile(%Actor{} = actor, slug, preloads \\ []) do + User + |> where([user], user.slug == ^slug and is_nil(user.deleted_at)) + |> preload(^preloads) + |> Loader.one_and_authorize(actor, :show) end + @doc group: "Public reads" @doc """ - Gets a user by name. + Loads an active user by exact name for an actor-scoped cross-context lookup. + + Conversations use this locator when resolving a recipient. Missing, + malformed, and deactivated recipients are always not found. ## Examples - iex> get_user_by_name("Administrator") - %User{} + iex> load_active_user_by_name(actor, "Somebody") + {:ok, %User{}} - iex> get_user_by_name("nonexistent") - nil + iex> load_active_user_by_name(actor, "missing") + {:error, :not_found} """ - def get_user_by_name(name) when is_binary(name) do - Repo.get_by(User, name: name) + @spec load_active_user_by_name(Actor.t(), term()) :: + {:ok, User.t()} | {:error, :unauthorized | :not_found} + def load_active_user_by_name(%Actor{} = actor, name) when is_binary(name) do + User + |> where([user], user.name == ^name and is_nil(user.deleted_at)) + |> Loader.one_and_authorize(actor, :show) end + def load_active_user_by_name(%Actor{}, _name), do: {:error, :not_found} + + @doc group: "Public reads" @doc """ - Gets a user by email and password. + Loads a visible profile by slug as a report target on behalf of `actor`. + + Deactivated and missing profiles are always not-found. ## Examples - iex> get_user_by_email_and_password("foo@example.com", "correct_password") - %User{} + iex> load_report_target(actor, "somebody") + {:ok, %User{}} + """ + @spec load_report_target(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :unauthorized | :not_found} + def load_report_target(%Actor{} = actor, slug) do + with {:ok, user} <- load_profile(actor, slug) do + {:ok, Repo.preload(user, public_links: :tag, awards: :badge)} + end + end + + @doc group: "Public reads" + @doc """ + Preloads a user's awards and their badges. + + Returns `nil` when given `nil`. + + ## Examples + + iex> preload_preview_awards(user) + %User{awards: [%Award{badge: %Badge{}}]} - iex> get_user_by_email_and_password("foo@example.com", "invalid_password") + iex> preload_preview_awards(nil) nil """ - def get_user_by_email_and_password(email, password, unlock_url_fun) - when is_binary(email) and is_binary(password) do - user = Repo.get_by(User, email: email) + @spec preload_preview_awards(User.t() | nil) :: User.t() | nil + def preload_preview_awards(user), do: Repo.preload(user, awards: :badge) - cond do - is_nil(user) or not is_nil(user.locked_at) -> - nil + @doc group: "Public reads" + @doc """ + Returns the site staff grouped into categories, as a map keyed by semantic + category names. - User.valid_password?(user, password) -> - user - |> User.successful_attempt_changeset() - |> Repo.update!() - |> reindex_user() + Staff are the users whose role is `"admin"`, `"moderator"`, or `"assistant"`, + ordered by name. - true -> - user - |> User.failed_attempt_changeset() - |> Repo.update!() - |> reindex_user() - |> maybe_send_unlock_instructions(unlock_url_fun) + ## Examples - nil - end + iex> staff_categories() + %{administrators: [%User{}], developers: [], ...} + + """ + @spec staff_categories() :: %{atom() => [User.t()]} + def staff_categories do + users = + User + |> where([u], u.role in ["admin", "moderator", "assistant"]) + |> order_by(asc: :name) + |> Repo.all() + + {others, staff} = Enum.split_with(users, & &1.hide_default_role) + + {developers, staff} = + Enum.split_with(staff, &(&1.secondary_role in ["Site Developer", "Devops"])) + + {public_relations, staff} = + Enum.split_with(staff, &(&1.secondary_role == "Public Relations")) + + %{ + administrators: Enum.filter(staff, &(&1.role == "admin")), + moderators: Enum.filter(staff, &(&1.role == "moderator")), + assistants: Enum.filter(staff, &(&1.role == "assistant")), + developers: developers, + public_relations: public_relations, + others: others + } end - defp maybe_send_unlock_instructions(%{failed_attempts: attempts}, _unlock_url_fun) - when attempts < 10 do - nil + ## Authentication + + @doc group: "Authentication" + @doc """ + Checks whether a password has appeared in a known data breach. + + The check is disabled when the `:pwned_passwords` application setting is + `false`. Network failures are treated as a password that was not found. + + ## Examples + + iex> password_compromised?(:crypto.strong_rand_bytes(16)) + false + + iex> password_compromised?("password") + true + + """ + @spec password_compromised?(String.t()) :: boolean() + def password_compromised?(password) when is_binary(password) do + if Application.get_env(:philomena, :pwned_passwords) == false do + false + else + <> = + :sha + |> :crypto.hash(password) + |> Base.encode16() + + case PhilomenaProxy.Http.get("https://api.pwnedpasswords.com/range/#{prefix}") do + {:ok, %{body: body, status: 200}} -> + String.contains?(body, rest <> ":") + + _ -> + false + end + end end - defp maybe_send_unlock_instructions(%User{} = user, unlock_url_fun) do - user - |> User.lock_changeset() - |> Repo.update!() - |> reindex_user() - |> deliver_user_unlock_instructions(unlock_url_fun) + @doc group: "Authentication" + @doc """ + Deletes every active session, including incomplete TOTP login sessions, for a user. + + ## Example + + iex> delete_user_sessions(user) + :ok - nil + """ + @spec delete_user_sessions(User.t()) :: :ok + def delete_user_sessions(user) do + Repo.delete_all(UserToken.user_and_contexts_query(user, ["session", "totp"])) + :ok end + @doc group: "Authentication" @doc """ - Gets a single user. + Gets a user by API token. + + ## Examples + + iex> get_user_by_authentication_token("5Ow89k7nW24E0K34d3zX") + %User{} + + iex> get_user_by_authentication_token("invalid") + nil + + """ + @spec get_user_by_authentication_token(String.t()) :: User.t() | nil + def get_user_by_authentication_token(token) when is_binary(token) do + User + |> Repo.get_by(authentication_token: token) + |> Repo.preload(:settings) + end - Raises `Ecto.NoResultsError` if the User does not exist. + @doc group: "Authentication" + @doc """ + Gets a user by email. ## Examples - iex> get_user!(123) + iex> get_user_by_email("foo@example.com") %User{} - iex> get_user!(456) - ** (Ecto.NoResultsError) + iex> get_user_by_email("unknown@example.com") + nil + + """ + @spec get_user_by_email(String.t()) :: User.t() | nil + def get_user_by_email(email) when is_binary(email) do + Repo.get_by(User, email: email) + end + + @doc group: "Authentication" + @doc """ + Gets a user by email and password. + + Users which are locked, unconfirmed, or whose password is valid and matches + a password found in a public data breach return an error. + + ## Examples + + iex> fetch_user_by_email_and_password("foo@example.com", "correct_password", &unlock_url/1) + {:ok, %User{}} + + iex> fetch_user_by_email_and_password("foo@example.com", "invalid_password", &unlock_url/1) + {:error, :not_found} """ - def get_user!(id), do: Repo.get!(User, id) + @spec fetch_user_by_email_and_password(String.t(), String.t(), (String.t() -> String.t())) :: + {:ok, User.t()} + | {:error, :unconfirmed | :password_compromised | :not_found} + def fetch_user_by_email_and_password(email, password, unlock_url_fun) + when is_binary(email) and is_binary(password) do + user_query = + from user in User, + where: user.email == ^email, + where: is_nil(user.locked_at) + + Multi.new() + |> Multi.lock_one(:locked_user, user_query) + |> Multi.run(:valid_password?, fn _repo, %{locked_user: user} -> + {:ok, User.valid_password?(user, password)} + end) + |> Multi.update(:user, fn %{valid_password?: valid_password?, locked_user: user} -> + if valid_password? do + User.successful_attempt_changeset(user) + else + User.failed_attempt_changeset(user) + end + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{valid_password?: true, user: %User{} = user}} -> + verify_user_for_password_authentication(user, password_compromised?(password)) + + {:ok, %{valid_password?: false, user: %User{} = user}} -> + if user.locked_at do + deliver_user_unlock_instructions(user, unlock_url_fun) + end + + {:error, :not_found} + + {:error, :locked_user, :not_found, _changes} -> + {:error, :not_found} + end + end ## User registration + @doc group: "Registration" @doc """ - Registers a user. + Registers a user on behalf of actor. ## Examples - iex> register_user(%{field: value}) + iex> create_registration(actor, %{field: value}) {:ok, %User{}} - iex> register_user(%{field: bad_value}) + iex> create_registration(actor, %{field: bad_value}) {:error, %Ecto.Changeset{}} + iex> create_registration(banned_actor, %{field: value}) + {:error, :ban} + """ - def register_user(attrs) do - %User{} - |> User.registration_changeset(attrs) - |> Repo.insert() - |> reindex_after_update() + @spec create_registration(Actor.t(), map()) :: + {:ok, User.t()} + | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def create_registration(%Actor{} = actor, params) do + with :ok <- verify_write_access(actor) do + changeset = User.registration_changeset(%User{}, &password_compromised?/1, params) + + Multi.new() + |> Multi.insert(:user, changeset) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Registration" @doc """ - Returns an `%Ecto.Changeset{}` for tracking user changes. + Returns an `%Ecto.Changeset{}` for tracking registration changes on behalf + of actor. ## Examples - iex> change_user_registration(user) - %Ecto.Changeset{data: %User{}} + iex> new_registration(actor, user) + {:ok, %Ecto.Changeset{data: %User{}}} + + iex> new_registration(banned_actor, user) + {:error, :ban} """ - def change_user_registration(%User{} = user, attrs \\ %{}) do - User.registration_changeset(user, attrs) + @spec new_registration(Actor.t(), User.t(), map()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def new_registration(%Actor{} = actor, %User{} = user, attrs \\ %{}) do + with :ok <- verify_write_access(actor) do + {:ok, User.registration_changeset(user, &password_compromised?/1, attrs)} + end end ## Settings + @doc group: "Account settings" @doc """ Returns an `%Ecto.Changeset{}` for changing the user email. @@ -203,58 +636,68 @@ defmodule Philomena.Users do %Ecto.Changeset{data: %User{}} """ + @spec change_user_email(User.t(), map()) :: Ecto.Changeset.t() def change_user_email(user, attrs \\ %{}) do User.email_changeset(user, attrs) end + @doc group: "Account settings" @doc """ Emulates that the email will change without actually changing it in the database. ## Examples - iex> apply_user_email(user, "valid password", %{email: ...}) + iex> create_email(user, "valid password", %{email: ...}) {:ok, %User{}} - iex> apply_user_email(user, "invalid password", %{email: ...}) + iex> create_email(user, "invalid password", %{email: ...}) {:error, %Ecto.Changeset{}} """ - def apply_user_email(user, password, attrs) do + @spec create_email(User.t(), String.t(), map()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def create_email(user, password, attrs) do user |> User.email_changeset(attrs) |> User.validate_current_password(password) |> Ecto.Changeset.apply_action(:update) end + @doc group: "Account settings" @doc """ Updates the user email in token. If the token matches, the user email is updated and the token is deleted. The confirmed_at date is also updated to the current time. + + ## Examples + + iex> show_email(user, token) + :ok + + iex> show_email(user, "invalid") + :error + """ - def update_user_email(user, token) do + @spec show_email(User.t(), String.t()) :: :ok | :error + def show_email(user, token) do context = "change:#{user.email}" with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), %UserToken{sent_to: email} <- Repo.one(query), - {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do - reindex_user(user) - + {:ok, _changes} <- + user + |> user_email_multi(email, context) + |> put_reindex_user() + |> Multi.transact() do :ok else _ -> :error end end - defp user_email_multi(user, email, context) do - changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset() - - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) - end - + @doc group: "Account settings" @doc ~S""" Delivers the update email instructions to the given user. @@ -264,6 +707,11 @@ defmodule Philomena.Users do {:ok, %{to: ..., body: ...}} """ + @spec deliver_update_email_instructions( + User.t(), + String.t(), + (String.t() -> String.t()) + ) :: term() def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun) when is_function(update_email_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") @@ -272,57 +720,48 @@ defmodule Philomena.Users do UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) end + @doc group: "Account settings" @doc """ Unlocks the user by the given token. If the token matches, the user is marked as unlocked and the token is deleted. + + ## Examples + + iex> show_unlock(token) + {:ok, %User{}} + + iex> show_unlock("invalid") + :error + """ - def unlock_user_by_token(token) do + @spec show_unlock(String.t()) :: {:ok, User.t()} | :error + def show_unlock(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "unlock"), %User{} = user <- Repo.one(query), - {:ok, %{user: user}} <- Repo.transaction(unlock_user_multi(user)) do - reindex_user(user) - + {:ok, %{user: %User{} = user}} <- + user + |> unlock_user_multi() + |> put_reindex_user() + |> Multi.transact() do {:ok, user} else _ -> :error end end - defp unlock_user_multi(user) do - changeset = User.unlock_changeset(user) - - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["unlock"])) - end - - @doc """ - Unconditionally unlocks the given user. - - ## Examples - - iex> unlock_user(user) - {:ok, %User{}} - - """ - def unlock_user(user) do - user - |> User.unlock_changeset() - |> Repo.update() - |> reindex_after_update() - end - + @doc group: "Account settings" @doc ~S""" Delivers the unlock instructions to the given user. ## Examples - iex> deliver_user_unlock_instructions(user, &url(~p"/unlocks/#{&1}")) - {:ok, %{to: ..., body: ...}} + iex> deliver_user_unlock_instructions(user, &url(~p"/unlocks/#{&1}")) + {:ok, %{to: ..., body: ...}} """ + @spec deliver_user_unlock_instructions(User.t(), (String.t() -> String.t())) :: term() def deliver_user_unlock_instructions(%User{} = user, unlock_url_fun) when is_function(unlock_url_fun, 1) do if is_nil(user.locked_at) do @@ -334,80 +773,116 @@ defmodule Philomena.Users do end end + @doc group: "Account settings" @doc """ Returns an `%Ecto.Changeset{}` for changing the user password. ## Examples - iex> change_user_password(user) + iex> edit_password(user) %Ecto.Changeset{data: %User{}} """ - def change_user_password(user, attrs \\ %{}) do - User.password_changeset(user, attrs) + @spec edit_password(User.t(), map()) :: Ecto.Changeset.t() + def edit_password(user, attrs \\ %{}) do + User.password_changeset(user, &password_compromised?/1, attrs) end + @doc group: "Account settings" @doc """ Updates the user password. ## Examples - iex> update_user_password(user, "valid password", %{password: ...}) + iex> update_password(user, "valid password", %{password: ...}) {:ok, %User{}} - iex> update_user_password(user, "invalid password", %{password: ...}) + iex> update_password(user, "invalid password", %{password: ...}) {:error, %Ecto.Changeset{}} """ - def update_user_password(user, password, attrs) do + @spec update_password(User.t(), String.t(), map()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_password(user, password, attrs) do changeset = user - |> User.password_changeset(attrs) + |> User.password_changeset(&password_compromised?/1, attrs) |> User.validate_current_password(password) - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) - |> Repo.transaction() + Multi.new() + |> Multi.update(:user, changeset) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + |> put_reindex_user() + |> Multi.transact() |> case do - {:ok, %{user: user}} -> - reindex_user(user) - + {:ok, %{user: %User{} = user}} -> {:ok, user} - {:error, :user, changeset, _} -> + {:error, :user, %Ecto.Changeset{} = changeset, _} -> {:error, changeset} end end - ## Session + ## Two-factor authentication + @doc group: "Two-factor authentication" @doc """ - Generates a session token. - """ - def generate_user_session_token(user) do - {token, user_token} = UserToken.build_session_token(user) - Repo.insert!(user_token) - token - end + Generates and stores a fresh TOTP secret for the user's account. + + The secret must exist before two-factor authentication can be confirmed. Does + not reindex. + + ## Examples + + iex> edit_totp(user) + {:ok, %User{}} - @doc """ - Generates a TOTP token. """ - def generate_user_totp_token(user) do - {token, user_token} = UserToken.build_totp_token(user) - Repo.insert!(user_token) - token + @spec edit_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def edit_totp(%User{} = user) do + user + |> User.create_totp_secret_changeset() + |> Repo.update() end + @doc group: "Two-factor authentication" @doc """ - Gets the user with the given signed token. + Enables or disables two-factor authentication for the user's account. + + Accepts `params` carrying the current password and second-factor token. When + TOTP is off and both checks pass, it is enabled and a fresh set of backup + codes is generated; when TOTP is on it is disabled. On success the user is + reindexed. + + Returns `{:ok, user, backup_codes}` - the plaintext backup codes cannot be + retrieved afterward and are always freshly generated, even when disabling - + or `{:error, %Ecto.Changeset{}}` when the password or token is rejected. + + ## Examples + + iex> update_totp(user, %{"user" => %{"current_password" => "...", "twofactor_token" => "..."}}) + {:ok, %User{}, ["a1b2c3d4e5f6", ...]} + """ - def get_user_by_session_token(token) do - {:ok, query} = UserToken.verify_session_token_query(token) - load_with_roles(query) + @spec update_totp(User.t(), map()) :: + {:ok, User.t(), [String.t()]} | {:error, Ecto.Changeset.t()} + def update_totp(%User{} = user, params) do + backup_codes = User.random_backup_codes() + + Multi.new() + |> Multi.update(:user, User.totp_changeset(user, params, backup_codes)) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user, backup_codes} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end + @doc group: "Two-factor authentication" @doc """ Checks if a TOTP token is valid for a given user. @@ -422,6 +897,7 @@ defmodule Philomena.Users do false """ + @spec user_totp_token_valid?(User.t() | nil, binary()) :: boolean() def user_totp_token_valid?(nil, _token) do false end @@ -431,32 +907,155 @@ defmodule Philomena.Users do Repo.exists?(query) end + @doc group: "Two-factor authentication" @doc """ - Deletes the signed token with the given context. + Returns the TOTP form changeset for a loaded user. + + ## Examples + + iex> totp_changeset(user) + %Ecto.Changeset{} + """ - def delete_session_token(token) do - Repo.delete_all(UserToken.token_and_context_query(token, "session")) - :ok + @spec totp_changeset(User.t()) :: Ecto.Changeset.t() + def totp_changeset(%User{} = user), do: User.changeset(user) + + @doc group: "Two-factor authentication" + @doc """ + Generates a TOTP token. + + ## Examples + + iex> generate_user_totp_token(user) + "signed-token" + + """ + @spec generate_user_totp_token(User.t()) :: binary() + def generate_user_totp_token(user) do + {token, user_token} = UserToken.build_totp_token(user) + Repo.insert!(user_token) + token end + @doc group: "Two-factor authentication" @doc """ - Deletes every active session, including incomplete TOTP login sessions, for a user. + Consumes a second-factor token for the given user during sign-in. + + Accepts the `params` (with the `"user"` / `"twofactor_token"` + keys), validating the token against the user's TOTP secret or, failing that, + remaining backup codes. A matching TOTP code records the consumed timestep; + a matching backup code removes it from the list. + + Returns `{:ok, user}` when the token is accepted, or + `{:error, %Ecto.Changeset{}}` when it is not. + + ## Examples + + iex> create_session_totp(user, %{"user" => %{"twofactor_token" => "123456"}}) + {:ok, %User{}} + """ - def delete_user_sessions(user) do - Repo.delete_all(UserToken.user_and_contexts_query(user, ["session", "totp"])) - :ok + @spec create_session_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def create_session_totp(%User{} = user, params) do + user + |> User.consume_totp_token_changeset(params) + |> Repo.update() end + @doc group: "Two-factor authentication" @doc """ Deletes the signed token with the given context. + + ## Examples + + iex> delete_totp_token(token) + :ok + """ + @spec delete_totp_token(binary()) :: :ok def delete_totp_token(token) do Repo.delete_all(UserToken.token_and_context_query(token, "totp")) :ok end + ## Session + + @doc group: "Session" + @doc """ + Generates a session token. + + ## Examples + + iex> generate_user_session_token(user) + "signed-token" + + """ + @spec generate_user_session_token(User.t()) :: binary() + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc group: "Session" + @doc """ + Gets the user with the given signed token. + + ## Examples + + iex> get_user_by_session_token(token) + %User{} + + iex> get_user_by_session_token("invalid") + nil + + """ + @spec get_user_by_session_token(binary()) :: User.t() | nil + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + load_with_roles(query) + end + + @doc group: "Session" + @doc """ + Gets the user with the given signed token and the token's creation time. + + The timestamp is used by the web authentication plug to periodically + replace active session tokens. + """ + @spec get_user_by_session_token_with_timestamp(binary()) :: + {User.t(), DateTime.t()} | nil + def get_user_by_session_token_with_timestamp(token) do + {:ok, query} = UserToken.verify_session_token_query_with_timestamp(token) + + case Repo.one(query) do + {user, token_inserted_at} -> + {load_user_with_roles(user), token_inserted_at} + + nil -> + nil + end + end + + @doc group: "Session" + @doc """ + Deletes the signed token with the given context. + + ## Examples + + iex> delete_session_token(token) + :ok + + """ + @spec delete_session_token(binary()) :: :ok + def delete_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end + ## Confirmation + @doc group: "Confirmation" @doc ~S""" Delivers the confirmation email instructions to the given user. @@ -469,6 +1068,7 @@ defmodule Philomena.Users do {:error, :already_confirmed} """ + @spec deliver_user_confirmation_instructions(User.t(), (String.t() -> String.t())) :: term() def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) when is_function(confirmation_url_fun, 1) do if user.confirmed_at do @@ -480,32 +1080,40 @@ defmodule Philomena.Users do end end + @doc group: "Confirmation" @doc """ Confirms a user by the given token. If the token matches, the user account is marked as confirmed and the token is deleted. + + ## Examples + + iex> update_confirmation(token) + {:ok, %User{}} + + iex> update_confirmation("invalid") + :error + """ - def confirm_user(token) do + @spec update_confirmation(String.t()) :: {:ok, User.t()} | :error + def update_confirmation(token) do with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), %User{} = user <- Repo.one(query), - {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do - reindex_user(user) - + {:ok, %{user: %User{} = user}} <- + user + |> confirm_user_multi() + |> put_reindex_user() + |> Multi.transact() do {:ok, user} else _ -> :error end end - defp confirm_user_multi(user) do - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.confirm_changeset(user)) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) - end - ## Reset password + @doc group: "Password reset" @doc ~S""" Delivers the reset password email to the given user. @@ -515,6 +1123,7 @@ defmodule Philomena.Users do {:ok, %{to: ..., body: ...}} """ + @spec deliver_user_reset_password_instructions(User.t(), (String.t() -> String.t())) :: term() def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) when is_function(reset_password_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") @@ -522,6 +1131,60 @@ defmodule Philomena.Users do UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) end + @doc group: "Password reset" + @doc """ + Gets the user by reset password token. + + ## Examples + + iex> get_user_by_reset_password_token("validtoken") + %User{} + + iex> get_user_by_reset_password_token("invalidtoken") + nil + + """ + @spec get_user_by_reset_password_token(String.t()) :: User.t() | nil + def get_user_by_reset_password_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), + %User{} = user <- Repo.one(query) do + user + else + _ -> nil + end + end + + @doc group: "Password reset" + @doc """ + Resets the user password. + + ## Examples + + iex> update_password(user, %{password: "new long password", password_confirmation: "new long password"}) + {:ok, %User{}} + + iex> update_password(user, %{password: "valid", password_confirmation: "not the same"}) + {:error, %Ecto.Changeset{}} + + """ + @spec update_password(User.t(), map()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def update_password(user, attrs) do + Multi.new() + |> Multi.update(:user, User.password_changeset(user, &password_compromised?/1, attrs)) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changeset} -> + {:error, changeset} + end + end + + @doc group: "Account activation" @doc ~S""" Delivers the reactivate account email to the given user. @@ -531,6 +1194,7 @@ defmodule Philomena.Users do {:ok, %{to: ..., body: ...}} """ + @spec deliver_user_reactivation_instructions(User.t(), (String.t() -> String.t())) :: term() def deliver_user_reactivation_instructions(%User{} = user, reactivation_url_fun) when is_function(reactivation_url_fun, 1) do {encoded_token, user_token} = UserToken.build_email_token(user, "reactivate") @@ -538,614 +1202,1560 @@ defmodule Philomena.Users do UserNotifier.deliver_reactivation_instructions(user, reactivation_url_fun.(encoded_token)) end + @doc group: "Account activation" @doc """ - Gets the user by reset password token. + Reactivates an account by one-time email token. + + Invalid, expired, and already consumed tokens all return `:error` without + revealing whether an account exists. ## Examples - iex> get_user_by_reset_password_token("validtoken") - %User{} + iex> create_reactivation(token) + {:ok, %User{}} - iex> get_user_by_reset_password_token("invalidtoken") - nil + iex> create_reactivation("invalid") + :error """ - def get_user_by_reset_password_token(token) do - with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), - %User{} = user <- Repo.one(query) do - user + @spec create_reactivation(String.t()) :: {:ok, User.t()} | :error + def create_reactivation(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "reactivate"), + %User{} = user <- Repo.one(query), + {:ok, %{user: %User{} = user}} <- + Multi.transact( + Multi.new() + |> Multi.update(:user, User.reactivate_changeset(user)) + |> Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["reactivate"])) + |> put_reindex_user() + ) do + {:ok, user} else - _ -> nil + _ -> :error end end + @doc group: "Account activation" @doc """ - Resets the user password. + Deactivates the acting user's own account and sends a reactivation token. + + The database change commits before token delivery and indexing are queued. ## Examples - iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) + iex> delete_deactivation(actor, &reactivation_url/1) {:ok, %User{}} - iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) + iex> delete_deactivation(banned_actor, &reactivation_url/1) + {:error, :ban} + + """ + @spec delete_deactivation(Actor.t(), (String.t() -> String.t())) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def delete_deactivation(%Actor{user: %User{} = user} = actor, reactivation_url_fun) do + with :ok <- verify_write_access(actor), + :ok <- authorize(actor, :deactivate_account, user) do + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user} -> + User.deactivate_changeset(user, user) + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + deliver_user_reactivation_instructions(user, reactivation_url_fun) + + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc group: "Settings" + @doc """ + Returns the general settings changeset for a loaded user. + + ## Examples + + iex> settings_changeset(user) + %Ecto.Changeset{} + + """ + @spec settings_changeset(User.t()) :: Ecto.Changeset.t() + def settings_changeset(%User{} = user), do: User.changeset(user) + + @doc group: "Settings" + @doc """ + Returns the filter-selection changeset for a loaded user. + + ## Examples + + iex> filter_selection_changeset(user) + %Ecto.Changeset{} + + """ + @spec filter_selection_changeset(User.t()) :: Ecto.Changeset.t() + def filter_selection_changeset(%User{} = user), do: User.changeset(user) + + @doc group: "Settings" + @doc """ + Returns an `%Ecto.Changeset{}` for changing a user's spoiler type. + + ## Examples + + iex> spoiler_type_changeset(user) + %Ecto.Changeset{data: %Settings{}} + + """ + @spec spoiler_type_changeset(User.t()) :: Ecto.Changeset.t() + def spoiler_type_changeset(%User{} = user) do + Settings.spoiler_type_changeset(user.settings, %{}) + end + + @doc group: "Settings" + @doc """ + Updates a user's spoiler type settings. + + This personal preference update is deliberately exempt from + `verify_write_access/1`, but the actor must be authorized to update their + own settings. + + ## Examples + + iex> update_spoiler_type(actor, %{spoiler_type: "click"}) + {:ok, %Settings{}} + + iex> update_spoiler_type(actor, %{spoiler_type: bad_value}) {:error, %Ecto.Changeset{}} """ - def reset_user_password(user, attrs) do - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) - |> Repo.transaction() - |> case do - {:ok, %{user: user}} -> - reindex_user(user) + @spec update_spoiler_type(Actor.t(), map()) :: + {:ok, Settings.t()} | {:error, :unauthorized | Ecto.Changeset.t()} + def update_spoiler_type(%Actor{user: user} = actor, attrs) do + with :ok <- authorize(actor, :update_spoiler_type, user) do + user.settings + |> Settings.spoiler_type_changeset(attrs) + |> Repo.update() + end + end + + @doc group: "Settings" + @doc """ + Updates a user's current filter. + + ## Examples + + iex> set_current_filter(user, filter) + {:ok, %User{}} + """ + @spec set_current_filter(User.t(), Filter.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def set_current_filter(%User{} = user, %Filter{} = filter) do + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user} -> User.filter_changeset(user, filter) end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> {:ok, user} - {:error, :user, changeset, _} -> + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset} end end + @doc group: "Settings" @doc """ - Returns an `%Ecto.Changeset{}` for tracking user changes. + Clears a user's recent filter history. + + This personal preference update is deliberately exempt from + `verify_write_access/1`, but the actor must be authorized to clear their own + history. ## Examples - iex> change_user(user) - %Ecto.Changeset{data: %User{}} + iex> delete_recent_filters(actor) + {:ok, %User{}} """ - def change_user(%User{} = user) do - User.changeset(user, %{}) + @spec delete_recent_filters(Actor.t()) :: + {:ok, User.t()} | {:error, :unauthorized | Ecto.Changeset.t()} + def delete_recent_filters(%Actor{user: user} = actor) do + with :ok <- authorize(actor, :delete_recent_filters, user) do + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user} -> + User.clear_recent_filters_changeset(user) + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Settings" @doc """ - Updates a user. + Updates a user's general settings. + + This personal preference update is deliberately exempt from + `verify_write_access/1`. ## Examples - iex> update_user(user, %{field: new_value}) + iex> update_settings(actor, %{"theme" => "dark"}) {:ok, %User{}} - iex> update_user(user, %{field: bad_value}) + iex> update_settings(actor, %{"theme" => bad_value}) {:error, %Ecto.Changeset{}} """ - def update_user(%User{} = user, attrs) do - roles = - Role - |> where([r], r.id in ^clean_roles(attrs["roles"])) - |> Repo.all() + @spec update_settings(Actor.t(), map()) :: + {:ok, User.t()} | {:error, :unauthorized | Ecto.Changeset.t()} + def update_settings(%Actor{user: %User{} = user}, attrs) do + watched_tag_names = User.watched_tag_names(attrs) - changeset = - user - |> User.update_changeset(attrs, roles) + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:watched_tags, watched_tag_names, []}]) + |> Multi.lock_one(:locked_user, preload(user_lock_query(user), :settings)) + |> Multi.update(:user, fn + %{ + locked_user: user, + canonical_tags: %{watched_tags: watched_tags} + } -> + user + |> User.settings_changeset(attrs) + |> User.put_watched_tag_ids(Enum.map(watched_tags, & &1.id)) + end) + |> put_reindex_user() + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + + @doc group: "Settings" + @doc """ + Loads the user named by the profile `slug` for editing the description, on + behalf of `actor`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`. Real targets are authorized for `:edit_description`. + + Returns the description `%Ecto.Changeset{}`; the loaded user is in + `changeset.data`. + + ## Examples + + iex> edit_profile_description(actor, "somebody") + {:ok, %Ecto.Changeset{}} + + iex> edit_profile_description(actor, "missing") + {:error, :not_found} + + """ + @spec edit_profile_description(Actor.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized | :not_found} + def edit_profile_description(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :edit_description, slug) do + {:ok, User.changeset(user)} + end + end + + @doc group: "Settings" + @doc """ + Updates the description and personal title of the user named by the profile + `slug`, on behalf of `actor`, from `attrs`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`. Real targets are authorized for `:edit_description` + following `load_profile_for_description_edit/2`. On success + the description and personal title are updated and the user reindexed. A + profile that gains an unapproved external link files a system report. + + Returns `{:ok, user}`, or the rejected `%Ecto.Changeset{}`. + + ## Examples + iex> update_profile_description(actor, "somebody", %{"description" => "About me"}) + {:ok, %User{}} + + iex> update_profile_description(actor, "missing", %{}) + {:error, :not_found} + + """ + @spec update_profile_description(Actor.t(), String.t(), map()) :: + {:ok, User.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_profile_description(%Actor{} = actor, slug, attrs) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :edit_description, slug) do + changeset = User.description_changeset(user, attrs) + + Multi.new() + |> Multi.update(:user, changeset) + |> Multi.merge(fn %{user: user} -> + # credo:disable-for-next-line + if user.became_unapproved? do + Reports.put_create_system_report( + Multi.new(), + "Review", + "Profile contains external links", + :reported_user_id, + user.id + ) + else + Multi.new() + end + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc group: "Settings" + @doc """ + Adds a tag to a user's watched tags list. + + This personal preference update is deliberately exempt from + `verify_write_access/1`. + + ## Examples + + iex> watch_tag(actor, tag) + {:ok, %User{}} + + """ + @spec watch_tag(Actor.t(), Philomena.Tags.Tag.t()) :: + {:ok, User.t()} | {:error, :unauthorized | Ecto.Changeset.t()} + def watch_tag(%Actor{user: %User{} = user}, tag) do Multi.new() - |> Multi.update(:user, changeset) - |> Multi.run(:unsubscribe, fn _repo, %{user: user} -> - unsubscribe_restricted_actors(user) + |> Tags.put_canonicalize_tag_name_sets([{:tag, [tag.name], []}]) + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user, canonical_tags: %{tag: [tag]}} -> + User.watched_tags_changeset(user, Enum.uniq([tag.id | user.watched_tag_ids])) end) - |> Repo.transaction() + |> Multi.transact_with_automatic_retry() |> case do - {:ok, %{user: user}} -> - reindex_user(user) + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + + @doc group: "Settings" + @doc """ + Removes a tag from a user's watched tags list. + This personal preference update is deliberately exempt from + `verify_write_access/1`. + + ## Examples + + iex> unwatch_tag(actor, tag) + {:ok, %User{}} + + """ + @spec unwatch_tag(Actor.t(), Philomena.Tags.Tag.t()) :: + {:ok, User.t()} | {:error, :unauthorized | Ecto.Changeset.t()} + def unwatch_tag(%Actor{user: %User{} = user}, tag) do + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tag, [tag.name], []}]) + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user, canonical_tags: %{tag: [tag]}} -> + User.watched_tags_changeset(user, user.watched_tag_ids -- [tag.id]) + end) + |> put_reindex_user() + |> Multi.transact_with_automatic_retry() + |> case do + {:ok, %{user: %User{} = user}} -> {:ok, user} - {:error, :user, changeset, _} -> + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> {:error, changeset} end end - defp clean_roles(nil), do: [] - defp clean_roles(roles), do: Enum.filter(roles, &("" != &1)) + @doc group: "Settings" + @doc """ + Loads the avatar changeset for the acting user's own account, on behalf of + `actor`. + + Write access is checked first; otherwise returns an `%Ecto.Changeset{}`. + + ## Examples + + iex> edit_avatar(actor) + {:ok, %Ecto.Changeset{}} + + iex> edit_avatar(banned_actor) + {:error, :ban} + + """ + @spec edit_avatar(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def edit_avatar(%Actor{user: user} = actor) do + with :ok <- verify_write_access(actor) do + {:ok, User.changeset(user)} + end + end + + @doc group: "Settings" + @doc """ + Updates the acting user's own avatar from `attrs`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`. On success the uploaded file is analyzed, persisted, + and the user reindexed. + + Returns `{:ok, user}`, or the rejected `%Ecto.Changeset{}` when analysis or + validation rejects the update. + ## Examples + + iex> update_avatar(actor, upload) + {:ok, %User{}} + + iex> update_avatar(banned_actor, upload) + {:error, :ban} + + """ + @spec update_avatar(Actor.t(), PhilomenaMedia.Upload.t() | nil) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def update_avatar(%Actor{user: user} = actor, upload) do + with :ok <- verify_write_access(actor) do + changeset = Uploader.analyze_upload(user, upload) + + Multi.new() + |> Multi.update(:user, changeset) + |> Uploader.put_persist_upload_and_unpersist_old(:user) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc group: "Settings" @doc """ - Returns an `%Ecto.Changeset{}` for changing a user's spoiler type. + Removes the acting user's own avatar, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`. + + Returns `{:ok, user}`. ## Examples - iex> change_spoiler_type(user) - %Ecto.Changeset{data: %Settings{}} + iex> delete_avatar(actor) + {:ok, %User{}} + + iex> delete_avatar(banned_actor) + {:error, :ban} """ - def change_spoiler_type(%User{} = user) do - Settings.spoiler_type_changeset(user.settings, %{}) + @spec delete_avatar(Actor.t()) :: {:ok, User.t()} | {:error, :ban | :unauthorized} + def delete_avatar(%Actor{user: user} = actor) do + with :ok <- verify_write_access(actor) do + clear_avatar(user) + end end + @doc group: "Settings" @doc """ - Updates a user's spoiler type settings. + Loads the rename changeset for the acting user's own account, on behalf of + `actor`. + + Write access is checked before lookup. Renaming is authorized with `:change_username` + against the actor's own user, which the ability rules gate on the 90-day rename window. + + Returns the editable `%Ecto.Changeset{}`. ## Examples - iex> update_spoiler_type(user, %{spoiler_type: "click"}) - {:ok, %Settings{}} + iex> edit_name(actor) + {:ok, %Ecto.Changeset{}} - iex> update_spoiler_type(user, %{spoiler_type: bad_value}) - {:error, %Ecto.Changeset{}} + iex> edit_name(recently_renamed_actor) + {:error, :unauthorized} """ - def update_spoiler_type(%User{} = user, attrs) do - user.settings - |> Settings.spoiler_type_changeset(attrs) - |> Repo.update() + @spec edit_name(Actor.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized} + def edit_name(%Actor{user: user} = actor) do + with :ok <- verify_write_access(actor), + :ok <- authorize(user, :change_username, user) do + {:ok, User.changeset(user)} + end end + @doc group: "Settings" @doc """ - Updates a user's general settings. + Updates the acting user's own name from `user_params`, on behalf of `actor`, + recording the change in history. + + Write access is checked before lookup. Renaming is authorized with + `:change_username` against the actor's own user, which the ability rules gate on + the 90-day rename window. On success the old name becomes a name-change row, + the account is reindexed, and a background job rewrites references to the old + username. + + Returns `{:ok, user}`, or the rejected `%Ecto.Changeset{}`. ## Examples - iex> update_settings(user, %{"theme" => "dark"}) + iex> update_name(actor, %{"name" => "new_name"}) {:ok, %User{}} - iex> update_settings(user, %{"theme" => bad_value}) + iex> update_name(actor, %{"name" => ""}) {:error, %Ecto.Changeset{}} """ - def update_settings(%User{} = user, attrs) do - user - |> User.settings_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec update_name(Actor.t(), map()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | Ecto.Changeset.t()} + def update_name(%Actor{user: user} = actor, user_params) do + with :ok <- verify_write_access(actor) do + old_name = user.name + + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.run(:authorize, fn _repo, %{locked_user: user} -> + # credo:disable-for-next-line + with :ok <- authorize(user, :change_username, user) do + {:ok, nil} + end + end) + |> Multi.update(:user, fn %{locked_user: user} -> + User.name_changeset(user, user_params) + end) + |> UserNameChanges.record_rename(:name_change, user) + |> put_reindex_user() + |> put_rename_user_job(old_name: old_name) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + + {:error, :authorize, :unauthorized, _changes} -> + {:error, :unauthorized} + end + end end + ## Administration + + @doc group: "Administration" @doc """ - Updates a user's profile description and personal title. + Runs the staff user search on behalf of `actor`, from `params` and + `pagination`. + + Reading the user listing requires authorization to index users. + + The `query` param supplies the query, and `sf`/`sd` select the sort field + and direction. Returns a `m:Scrivener.Page` of matching users and a + changeset for a new search. ## Examples - iex> update_description(user, %{"description" => "Hello world"}) - {:ok, %User{}} + iex> query_users(actor, %{"query" => "name:somebody"}, pagination) + {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} - iex> update_description(user, %{"personal_title" => "Site Admin"}) + iex> query_users(actor, %{"query" => "("}, pagination) {:error, %Ecto.Changeset{}} """ - def update_description(%User{} = user, attrs) do - user - |> User.description_changeset(attrs) - |> Repo.update() - |> reindex_after_update() - |> case do - {:ok, user} -> - if not Approval.approved?(user, user.description, :external_links) or - not Approval.approved?(user, user.personal_title, :external_links) do - Reports.create_system_report( - "Review", - "Profile contains external links", - reported_user_id: user.id - ) - end - - {:ok, user} - - error -> - error + @spec query_users(Actor.t(), map(), Repo.pagination_params()) :: + {:ok, Scrivener.Page.t(), Ecto.Changeset.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :unauthorized} + def query_users(%Actor{} = actor, params, pagination) do + with :ok <- authorize(actor, :index, User), + {:ok, query, form} <- QueryBuilder.build_query(params) do + users = + User + |> Search.search_definition(query, pagination) + |> Search.search_records(User) + + {:ok, users, QueryForm.changeset(form)} end end + @doc group: "Administration" @doc """ - Updates a user's moderation scratchpad content. + Loads the user named by `slug` for editing, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`; real targets are authorized for `:edit`. + + Returns an `%AdminUserForm{}` containing the changeset and assignable roles. ## Examples - iex> update_scratchpad(user, %{"scratchpad" => "My notes"}) - {:ok, %User{}} + iex> edit_user(actor, "somebody") + {:ok, %AdminUserForm{}} + + iex> edit_user(actor, "missing") + {:error, :not_found} """ - def update_scratchpad(%User{} = user, attrs) do - user - |> User.scratchpad_changeset(attrs) - |> Repo.update() - |> reindex_after_update() + @spec edit_user(Actor.t(), String.t()) :: + {:ok, AdminUserForm.t()} | {:error, :ban | :unauthorized | :not_found} + def edit_user(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :edit, slug, [:roles]) do + {:ok, admin_user_form(User.changeset(user))} + end end + @doc group: "Administration" @doc """ - Adds a tag to a user's watched tags list. + Updates the details of the user named by `slug`, on behalf of `actor`, from + `params`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`; real targets are authorized for `:update`. On success + the user is updated, reindexed, unsubscribed from any now-restricted forums, + and a moderation log is written in the transaction. + + Returns `{:ok, user}`, or an `%AdminUserForm{}` containing the rejected + changeset and assignable roles. ## Examples - iex> watch_tag(user, tag) + iex> update_user(actor, "somebody", %{"role" => "assistant"}) {:ok, %User{}} - """ - def watch_tag(%User{} = user, tag) do - watched_tag_ids = Enum.uniq([tag.id | user.watched_tag_ids]) + iex> update_user(actor, "missing", %{}) + {:error, :not_found} - user - |> User.watched_tags_changeset(watched_tag_ids) - |> Repo.update() - |> reindex_after_update() + """ + @spec update_user(Actor.t(), String.t(), map()) :: + {:ok, User.t()} + | {:error, :ban | :unauthorized | :not_found | AdminUserForm.t()} + def update_user(%Actor{} = actor, slug, params) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :update, slug, [:roles]) do + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user} -> + update_user_changeset(user, params) + end) + |> put_unsubscribe_restricted_actors(:unsubscribe_restricted_actors) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: updated_user} -> + { + "Admin.User:update", + Paths.profile_path(updated_user), + "Updated user details for #{updated_user.name}" + } + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = updated_user}} -> + {:ok, updated_user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, admin_user_form(changeset)} + end + end end + @doc group: "Administration" @doc """ - Removes a tag from a user's watched tags list. + Reactivates the deactivated user named by `slug`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`; + real targets are authorized with the action-specific ability. On success the + account is reactivated, reindexed, and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> unwatch_tag(user, tag) + iex> create_user_activation(actor, "somebody") {:ok, %User{}} - """ - def unwatch_tag(%User{} = user, tag) do - watched_tag_ids = user.watched_tag_ids -- [tag.id] + iex> create_user_activation(actor, "missing") + {:error, :not_found} - user - |> User.watched_tags_changeset(watched_tag_ids) - |> Repo.update() - |> reindex_after_update() + """ + @spec create_user_activation(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def create_user_activation(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :reactivate, slug) do + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user} -> User.reactivate_changeset(user) end) + |> Multi.delete_all( + :reactivation_tokens, + UserToken.user_and_contexts_query(user, ["reactivate"]) + ) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + { + "Admin.User.Activation:create", + Paths.profile_path(user), + "Reactivated #{user.name}" + } + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Administration" @doc """ - Updates a user's avatar with the provided file. + Deactivates the user named by `slug`, on behalf of `actor`, recording `actor` + as the deactivator. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`; + real targets are authorized with the action-specific ability. On success the + account is deactivated, reindexed, and a moderation log is written. - Handles file analysis and persistence. + Returns `{:ok, user}`. ## Examples - iex> update_avatar(user, %{"avatar" => upload}) + iex> delete_user_activation(actor, "somebody") {:ok, %User{}} - """ - def update_avatar(%User{} = user, attrs) do - user - |> Uploader.analyze_upload(attrs) - |> Repo.update() - |> case do - {:ok, user} -> - Uploader.persist_upload(user) - Uploader.unpersist_old_upload(user) - - reindex_user(user) - - {:ok, user} + iex> delete_user_activation(actor, "missing") + {:error, :not_found} - error -> - error + """ + @spec delete_user_activation(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_activation(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :deactivate, slug) do + Multi.new() + |> Multi.lock_one(:locked_user, user_lock_query(user)) + |> Multi.update(:user, fn %{locked_user: user} -> + User.deactivate_changeset(user, actor.user) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Activation:delete", Paths.profile_path(user), "Deactivated #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end + @doc group: "Administration" @doc """ - Removes a user's avatar. + Resets the API token of the user named by `slug`, on behalf of `actor`. - ## Examples + Write access is checked before lookup. Missing targets are `{:error, :not_found}`; + real targets are authorized with the action-specific ability. On success a + fresh token is generated, the account reindexed, and a moderation log is + written. - iex> remove_avatar(user) - {:ok, %User{}} + Returns `{:ok, user}`. - """ - def remove_avatar(%User{} = user) do - user - |> User.remove_avatar_changeset() - |> Repo.update() - |> case do - {:ok, user} -> - Uploader.unpersist_old_upload(user) + ## Examples - reindex_user(user) + iex> delete_user_api_key(actor, "somebody") + {:ok, %User{}} - {:ok, user} + iex> delete_user_api_key(actor, "missing") + {:error, :not_found} - error -> - error + """ + @spec delete_user_api_key(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_api_key(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :reset_api_key, slug) do + changeset = User.api_key_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.ApiKey:delete", Paths.profile_path(user), "Reset API key for #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end + @doc group: "Administration" @doc """ - Updates a user's name and records the change in history. + Removes the avatar of the user named by `slug`, on behalf of `actor`. - Triggers a background job to update references to the old username. + Write access is checked before lookup. Missing targets are `{:error, :not_found}`; + real targets are authorized with the action-specific ability. On success the + avatar is cleared, the old file unpersisted, the account reindexed, and a + moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> update_name(user, %{"name" => "new_name"}) + iex> delete_user_avatar(actor, "somebody") {:ok, %User{}} + iex> delete_user_avatar(actor, "missing") + {:error, :not_found} + """ - def update_name(user, user_params) do - old_name = user.name + @spec delete_user_avatar(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_avatar(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :remove_avatar, slug) do + changeset = User.remove_avatar_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Avatar:delete", Paths.profile_path(user), "Removed avatar for #{user.name}"} + end) + |> put_reindex_user() + |> Uploader.put_unpersist_old_upload(:user) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end - name_change = UserNameChange.changeset(%UserNameChange{user_id: user.id}, user.name) - account = User.name_changeset(user, user_params) + @doc group: "Administration" + @doc """ + Starts a downvote wipe for the user named by `slug`, on behalf of `actor`. - Multi.new() - |> Multi.insert(:name_change, name_change) - |> Multi.update(:account, account) - |> Repo.transaction() - |> case do - {:ok, %{account: %{name: new_name} = account}} -> - Exq.enqueue(Exq, "indexing", UserRenameWorker, [old_name, new_name]) + Write access is checked before lookup. Missing targets are `{:error, :not_found}`; + real targets are authorized with the action-specific ability. On success a + background job removes the user's downvotes and a moderation log is written. - reindex_user(account) + Returns `{:ok, user}`. - {:ok, account} + ## Examples - {:error, :account, changeset, _changes} -> - {:error, changeset} + iex> delete_user_downvotes(actor, "somebody") + {:ok, %User{}} + + iex> delete_user_downvotes(actor, "missing") + {:error, :not_found} + + """ + @spec delete_user_downvotes(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_downvotes(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :wipe_downvotes, slug) do + Multi.new() + |> Multi.put(:user, user) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Downvote:delete", Paths.profile_path(user), + "Wiped downvotes for #{user.name}"} + end) + |> put_wipe_user_votes_job(upvotes_and_faves?: false) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + end end end + @doc group: "Administration" @doc """ - Updates all search engine references to a user's old name with their new name. + Loads the user named by `slug` for erasure, on behalf of `actor`, applying the + eligibility guards. - This is called as a background job after a user requests a name change. + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`; real targets are authorized with the `:erase` ability. + Only ordinary, unverified accounts may be erased: + + * a privileged (non-`"user"` role) target is `{:error, {:privileged, user}}`; + * a verified target is `{:error, {:verified, user}}`. + + Returns `{:ok, user}` for an erasable user, with its roles preloaded. ## Examples - iex> perform_rename("old_name", "new_name") - :ok + iex> new_user_erase(actor, "somebody") + {:ok, %User{}} + + iex> new_user_erase(actor, "missing") + {:error, :not_found} """ - def perform_rename(old_name, new_name) do - Images.user_name_reindex(old_name, new_name) - Comments.user_name_reindex(old_name, new_name) - Posts.user_name_reindex(old_name, new_name) - Galleries.user_name_reindex(old_name, new_name) - Reports.user_name_reindex(old_name, new_name) - Filters.user_name_reindex(old_name, new_name) - TagChanges.user_name_reindex(old_name, new_name) - Users.user_name_reindex(old_name, new_name) + @spec new_user_erase(Actor.t(), String.t()) :: + {:ok, User.t()} + | {:error, + :ban | :unauthorized | :not_found | {:privileged, User.t()} | {:verified, User.t()}} + def new_user_erase(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :erase, slug, [:roles]) do + cond do + user.role != "user" -> {:error, {:privileged, user}} + user.verified -> {:error, {:verified, user}} + true -> {:ok, user} + end + end end + @doc group: "Administration" @doc """ - Reactivates a previously deactivated user account. Removes all "reactivate" user tokens for that user if they exist. + Erases the user named by `slug`, on behalf of `actor`. + + The target is loaded and guarded following `load_user_for_erase/2`. On success + the account is deactivated, renamed to a random handle, enqueued for the + remaining data deletion, and a moderation log is written naming the original + account. + + Returns `{:ok, user}` with the renamed account. ## Examples - iex> reactivate_user(user) - {:ok, %User{}} + iex> create_user_erase(actor, "somebody") + {:ok, %User{name: "deactivated_..."}} - """ - def reactivate_user(%User{} = user) do - UserToken.user_and_contexts_query(user, ["reactivate"]) |> Repo.delete_all() + iex> create_user_erase(actor, "missing") + {:error, :not_found} - user - |> User.reactivate_changeset() - |> Repo.update() - |> reindex_after_update() + """ + @spec create_user_erase(Actor.t(), String.t()) :: + {:ok, User.t()} + | {:error, + :ban | :unauthorized | :not_found | {:privileged, User.t()} | {:verified, User.t()}} + def create_user_erase(%Actor{} = actor, slug) do + with {:ok, user} <- new_user_erase(actor, slug) do + original_name = user.name + random_hex = Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) + + Multi.new() + |> Multi.update(:user, fn _ -> + user + |> update_user_changeset(%{"name" => "deactivated_#{random_hex}"}) + |> User.deactivate_changeset(actor.user) + end) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Erase:create", Paths.profile_path(user), "Erased #{original_name}"} + end) + |> put_reindex_user() + |> put_erase_user_job(actor) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Administration" @doc """ - Deactivates a user account. + Loads the user named by `slug` for forcing a filter, on behalf of `actor`. - Takes a moderator who is recorded as performing the deactivation. + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:force_filter`. + + Returns the force-filter `%Ecto.Changeset{}`; the loaded user is in + `changeset.data`. ## Examples - iex> deactivate_user(moderator, user) - {:ok, %User{}} + iex> new_user_force_filter(actor, "somebody") + {:ok, %Ecto.Changeset{}} + + iex> new_user_force_filter(actor, "missing") + {:error, :not_found} """ - def deactivate_user(moderator, %User{} = user) do - user - |> User.deactivate_changeset(moderator) - |> Repo.update() - |> reindex_after_update() + @spec new_user_force_filter(Actor.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized | :not_found} + def new_user_force_filter(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :force_filter, slug) do + {:ok, User.changeset(user)} + end end + @doc group: "Administration" @doc """ - Deactivates a user account with the user recorded performing the deactivation. + Forces a filter on the user named by `slug`, on behalf of `actor`, from + `params`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:force_filter`. On success the + filter is forced, the account reindexed, and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> deactivate_user(user) + iex> create_user_force_filter(actor, "somebody", %{"forced_filter_id" => filter.id}) {:ok, %User{}} + iex> create_user_force_filter(actor, "missing", %{}) + {:error, :not_found} + """ - def deactivate_user(%User{} = user) do - user - |> User.deactivate_changeset(user) - |> Repo.update() + @spec create_user_force_filter(Actor.t(), String.t(), map()) :: + {:ok, User.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def create_user_force_filter(%Actor{} = actor, slug, params) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :force_filter, slug) do + changeset = User.force_filter_changeset(user, params) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.ForceFilter:create", Paths.profile_path(user), + "Forced filter #{user.forced_filter_id} for #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Administration" @doc """ - Gets the user by reactivation token. + Removes the forced filter from the user named by `slug`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:unforce_filter`. On success the + forced filter is cleared, the account reindexed, and a moderation log is + written. + + Returns `{:ok, user}`. ## Examples - iex> get_user_by_reactivation_token("validtoken") - %User{} + iex> delete_user_force_filter(actor, "somebody") + {:ok, %User{}} - iex> get_user_by_reactivation_token("invalidtoken") - nil + iex> delete_user_force_filter(actor, "missing") + {:error, :not_found} """ - def get_user_by_reactivation_token(token) do - with {:ok, query} <- UserToken.verify_email_token_query(token, "reactivate"), - %User{} = user <- Repo.one(query) do - user - else - _ -> nil + @spec delete_user_force_filter(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_force_filter(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :unforce_filter, slug) do + changeset = User.unforce_filter_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.ForceFilter:delete", Paths.profile_path(user), + "Removed forced filter for #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end end end + @doc group: "Administration" @doc """ - Generates a new API key for the user. + Unlocks the user named by `slug`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:unlock`. On success the account is unlocked, + reindexed, and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> reset_api_key(user) + iex> create_user_unlock(actor, "somebody") {:ok, %User{}} + iex> create_user_unlock(actor, "missing") + {:error, :not_found} + """ - def reset_api_key(%User{} = user) do - user - |> User.api_key_changeset() - |> Repo.update() - |> reindex_after_update() + @spec create_user_unlock(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def create_user_unlock(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :unlock, slug) do + changeset = User.unlock_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Unlock:create", Paths.profile_path(user), "Unlocked #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Administration" @doc """ - Updates a user's current filter. + Grants verification to the user named by `slug`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:verify`. On success the account is verified, + reindexed, and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> update_filter(user, filter) + iex> create_user_verification(actor, "somebody") {:ok, %User{}} + iex> create_user_verification(actor, "missing") + {:error, :not_found} + """ - def update_filter(%User{} = user, %Filter{} = filter) do - user - |> User.filter_changeset(filter) - |> Repo.update() - |> reindex_after_update() + @spec create_user_verification(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def create_user_verification(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :verify, slug) do + changeset = User.verify_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Verification:create", Paths.profile_path(user), + "Granted verification to #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Administration" @doc """ - Forces a specific filter on a user's account, which will be applied in - conjunction to the user's current filter. + Revokes verification from the user named by `slug`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`; + real targets are authorized for `:unverify`. On success verification is revoked, + the account reindexed, and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> force_filter(user, %{"forced_filter_id" => 123}) + iex> delete_user_verification(actor, "somebody") {:ok, %User{}} - iex> force_filter(user, %{"forced_filter_id" => bad_value}) - {:error, %Ecto.Changeset{}} + iex> delete_user_verification(actor, "missing") + {:error, :not_found} """ - def force_filter(%User{} = user, user_params) do - user - |> User.force_filter_changeset(user_params) - |> Repo.update() - |> reindex_after_update() + @spec delete_user_verification(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_verification(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :unverify, slug) do + changeset = User.unverify_changeset(user) + + Multi.new() + |> Multi.update(:user, changeset) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Verification:delete", Paths.profile_path(user), + "Revoked verification from #{user.name}"} + end) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end end + @doc group: "Administration" @doc """ - Removes a forced filter from a user's account. + Starts a vote and fave wipe for the user named by `slug`, on behalf of + `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:wipe_votes`. On success a background job removes + the user's votes and favorites and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> unforce_filter(user) + iex> delete_user_votes(actor, "somebody") {:ok, %User{}} + iex> delete_user_votes(actor, "missing") + {:error, :not_found} + """ - def unforce_filter(%User{} = user) do - user - |> User.unforce_filter_changeset() - |> Repo.update() - |> reindex_after_update() + @spec delete_user_votes(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def delete_user_votes(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :wipe_votes, slug) do + Multi.new() + |> Multi.put(:user, user) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Vote:delete", Paths.profile_path(user), + "Wiped votes and faves for #{user.name}"} + end) + |> put_wipe_user_votes_job(upvotes_and_faves?: true) + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + end + end end + @doc group: "Administration" @doc """ - Clears a user's recent filter history. + Queues a PII wipe for the user named by `slug`, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are `{:error, :not_found}`. + Real targets are authorized for `:wipe`. On success a background job wipes the + user's personally identifying information and a moderation log is written. + + Returns `{:ok, user}`. ## Examples - iex> clear_recent_filters(user) + iex> create_user_wipe(actor, "somebody") {:ok, %User{}} - """ - def clear_recent_filters(%User{} = user) do - user - |> User.clear_recent_filters_changeset() - |> Repo.update() - |> reindex_after_update() - end + iex> create_user_wipe(actor, "missing") + {:error, :not_found} - defp load_with_roles(query) do - query - |> Repo.one() - |> Repo.preload([:roles, :current_filter, :settings]) - |> setup_roles() + """ + @spec create_user_wipe(Actor.t(), String.t()) :: + {:ok, User.t()} | {:error, :ban | :unauthorized | :not_found} + def create_user_wipe(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :wipe, slug) do + Multi.new() + |> Multi.put(:user, user) + |> ModerationLogs.put_log(:moderation_log, actor, fn %{user: user} -> + {"Admin.User.Wipe:create", Paths.profile_path(user), "Wiped PII for #{user.name}"} + end) + |> put_wipe_user_job() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + end + end end + @doc group: "Administration" @doc """ - Marks a user as verified for the purposes of automatically approving uploads, - and posting images in comments/posts/messages without moderator review. + Loads the potential aliases of the user named by the profile `slug`, on behalf + of `actor`: other users who share one of the subject's IP addresses, one of + its fingerprints, or both. + + Missing targets are `{:error, :not_found}`. Real targets are authorized for + `:show_details`. + + Returns a typed alias page result with each match list carrying the matched + users and their bans. ## Examples - iex> verify_user(user) - {:ok, %User{}} + iex> list_profile_aliases(actor, "somebody") + {:ok, %AliasMatches{}} + + iex> list_profile_aliases(actor, "missing") + {:error, :not_found} """ - def verify_user(%User{} = user) do - user - |> User.verify_changeset() - |> Repo.update() - |> reindex_after_update() + @spec list_profile_aliases(Actor.t(), String.t()) :: + {:ok, AliasMatches.t()} | {:error, :unauthorized | :not_found} + def list_profile_aliases(%Actor{} = actor, slug) do + with {:ok, user} <- load_user_by_slug(actor, :show_details, slug) do + # Select all IPs and fingerprints known from this user + user_ips = + UserIp + |> where(user_id: ^user.id) + |> select([ip], ip.ip) + + user_fingerprints = + UserFingerprint + |> where(user_id: ^user.id) + |> select([fingerprint], fingerprint.fingerprint) + + # Select all user IDs that have ever shared those IPs/fingerprints + ip_user_ids = + UserIp + |> where([ip], ip.ip in subquery(user_ips)) + |> select([ip], ip.user_id) + + fingerprint_user_ids = + UserFingerprint + |> where([fingerprint], fingerprint.fingerprint in subquery(user_fingerprints)) + |> select([fingerprint], fingerprint.user_id) + + # Select all users that match those user IDs and are not the target user + ip_matches = + User + |> where([user], user.id != ^user.id and user.id in subquery(ip_user_ids)) + |> preload(:bans) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + fingerprint_matches = + User + |> where([user], user.id != ^user.id and user.id in subquery(fingerprint_user_ids)) + |> preload(:bans) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + both_matches = Map.take(ip_matches, Map.keys(fingerprint_matches)) + ip_matches = Map.drop(ip_matches, Map.keys(both_matches)) + fingerprint_matches = Map.drop(fingerprint_matches, Map.keys(both_matches)) + + {:ok, + %AliasMatches{ + user: user, + both_matches: Map.values(both_matches), + ip_matches: Map.values(ip_matches), + fp_matches: Map.values(fingerprint_matches) + }} + end end + @doc group: "Administration" @doc """ - Unverifies a user, removing the automatic approval status. + Loads the user named by the profile `slug` for editing the moderation + scratchpad, on behalf of `actor`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`. Real targets are authorized for `:edit_scratchpad`. + + Returns the scratchpad `%Ecto.Changeset{}`; the loaded user is in + `changeset.data`. ## Examples - iex> unverify_user(user) - {:ok, %User{}} + iex> edit_profile_scratchpad(actor, "somebody") + {:ok, %Ecto.Changeset{}} + + iex> edit_profile_scratchpad(actor, "missing") + {:error, :not_found} """ - def unverify_user(%User{} = user) do - user - |> User.unverify_changeset() - |> Repo.update() - |> reindex_after_update() + @spec edit_profile_scratchpad(Actor.t(), String.t()) :: + {:ok, Ecto.Changeset.t()} | {:error, :ban | :unauthorized | :not_found} + def edit_profile_scratchpad(%Actor{} = actor, slug) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :edit_scratchpad, slug) do + {:ok, User.changeset(user)} + end end + @doc group: "Administration" @doc """ - Erases all changes associated with a user account, removing all personal - data and anonymizing the account. + Updates the moderation scratchpad of the user named by the profile `slug`, on + behalf of `actor`, from `params`. + + Write access is checked before lookup. Missing targets are + `{:error, :not_found}`. Real targets are authorized for `:edit_scratchpad`. + On success the scratchpad is updated and the user reindexed. - This is primarily intended for use with spam accounts or other situations - where all of a user's data should be removed from the system. + Returns `{:ok, user}`, or the rejected `%Ecto.Changeset{}`. ## Examples - iex> erase_user(user, moderator) + iex> update_profile_scratchpad(actor, "somebody", %{"scratchpad" => "Staff note"}) {:ok, %User{}} + iex> update_profile_scratchpad(actor, "missing", %{}) + {:error, :not_found} + + """ + @spec update_profile_scratchpad(Actor.t(), String.t(), map()) :: + {:ok, User.t()} + | {:error, :ban | :unauthorized | :not_found | Ecto.Changeset.t()} + def update_profile_scratchpad(%Actor{} = actor, slug, params) do + with :ok <- verify_write_access(actor), + {:ok, user} <- load_user_by_slug(actor, :edit_scratchpad, slug) do + changeset = User.scratchpad_changeset(user, params) + + Multi.new() + |> Multi.update(:user, changeset) + |> put_reindex_user() + |> Multi.transact() + |> case do + {:ok, %{user: %User{} = user}} -> + {:ok, user} + + {:error, :user, %Ecto.Changeset{} = changeset, _changes} -> + {:error, changeset} + end + end + end + + @doc group: "Cross-context helpers" + @doc """ + Replaces a watched tag ID in users' watched-tag arrays within `multi`. """ - def erase_user(%User{} = user, %User{} = moderator) do - # Deactivate to prevent the user from racing these changes - {:ok, user} = deactivate_user(moderator, user) + @spec put_replace_watched_tag(Multi.t(), Multi.name(), integer(), integer()) :: Multi.t() + def put_replace_watched_tag(%Multi{} = multi, step, old_id, new_id) do + query = + User + |> where([u], fragment("? @> ARRAY[?]::integer[]", u.watched_tag_ids, ^old_id)) + |> update([u], + set: [ + watched_tag_ids: fragment("array_replace(?, ?, ?)", u.watched_tag_ids, ^old_id, ^new_id) + ] + ) - # Rename to prevent usage for brand recognition SEO - random_hex = Base.encode16(:crypto.strong_rand_bytes(16), case: :lower) - {:ok, user} = update_user(user, %{name: "deactivated_#{random_hex}"}) + Multi.update_all(multi, step, query, []) + end - # Enqueue a background job to perform the rest of the deletion - Exq.enqueue(Exq, "indexing", UserEraseWorker, [user.id, moderator.id]) + @doc group: "Cross-context helpers" + @doc """ + Increments one lifetime counter on a user through the supplied repository. - {:ok, user} + The return value is the normal `update_all/3` row count. + """ + @spec increment_counter(module(), integer(), atom(), integer()) :: {non_neg_integer(), nil} + def increment_counter(repo, user_id, field, amount) + when is_integer(user_id) and is_atom(field) and is_integer(amount) do + repo.update_all(where(User, id: ^user_id), inc: [{field, amount}]) end - defp setup_roles(nil), do: nil + @doc group: "Cross-context helpers" + @doc """ + Increments one lifetime counter for each supplied user through the supplied + repository. - defp setup_roles(user) do - role_map = - user.roles - |> Enum.group_by(& &1.resource_type, & &1.name) - |> Map.new(fn {type, names} -> {type, Map.new(names, &{&1, []})} end) + The return value is the normal `update_all/3` row count. + """ + @spec increment_counters(module(), [integer()], atom(), integer()) :: {non_neg_integer(), nil} + def increment_counters(repo, user_ids, field, amount) + when is_list(user_ids) and is_atom(field) and is_integer(amount) do + repo.update_all(where(User, [user], user.id in ^user_ids), inc: [{field, amount}]) + end - %{user | role_map: role_map} + @doc group: "Cross-context helpers" + @doc """ + Replaces a user's email with the supplied erased address. + """ + @spec replace_email_for_wipe!(integer(), String.t()) :: {non_neg_integer(), nil} + def replace_email_for_wipe!(user_id, email) when is_integer(user_id) and is_binary(email) do + Repo.update_all(where(User, id: ^user_id), set: [email: email]) end - defp unsubscribe_restricted_actors(%User{} = user) do - forum_ids = - Forum - |> order_by(asc: :name) - |> Repo.all() - |> Enum.reject(&Canada.Can.can?(user, :show, &1)) - |> Enum.map(& &1.id) + @doc group: "Background jobs" + @doc """ + Updates all search engine references to a user's old name with their new name. + + This is called as a background job after a user requests a name change. - {_count, nil} = - Forums.Subscription - |> where([s], s.user_id == ^user.id and s.forum_id in ^forum_ids) - |> Repo.delete_all() + ## Examples - {_count, nil} = - Topics.Subscription - |> join(:inner, [s], _ in assoc(s, :topic)) - |> where([s, t], s.user_id == ^user.id and t.forum_id in ^forum_ids) - |> Repo.delete_all() + iex> perform_rename("old_name", "new_name") + :ok - {:ok, nil} + """ + @spec perform_rename(String.t(), String.t()) :: term() + def perform_rename(old_name, new_name) do + Images.user_name_reindex(old_name, new_name) + Comments.user_name_reindex(old_name, new_name) + Posts.user_name_reindex(old_name, new_name) + Galleries.user_name_reindex(old_name, new_name) + Reports.user_name_reindex(old_name, new_name) + Filters.user_name_reindex(old_name, new_name) + TagChanges.user_name_reindex(old_name, new_name) + Users.user_name_reindex(old_name, new_name) end + @doc group: "Background jobs" @doc """ Queues a single user for search index updates. Returns the user struct unchanged, for use in a pipeline. @@ -1156,12 +2766,64 @@ defmodule Philomena.Users do %User{} """ + @spec reindex_user(User.t()) :: User.t() def reindex_user(%User{} = user) do Exq.enqueue(Exq, "indexing", IndexWorker, ["Users", "id", [user.id]]) user end + @doc group: "Background jobs" + @doc """ + Queues a list of user IDs for search index updates. + Returns the list unchanged, for use in a pipeline. + + ## Examples + + iex> reindex_user_ids([1, 2, 3]) + [1, 2, 3] + + """ + @spec reindex_user_ids(list(integer())) :: list(integer()) + def reindex_user_ids(user_ids) do + Exq.enqueue(Exq, "indexing", IndexWorker, ["Users", "id", user_ids]) + + user_ids + end + + @doc group: "Background jobs" + @doc """ + Loads a user by ID from a trusted background job. + + Job arguments originate from already persisted users, so an absent row is an + invariant violation and intentionally raises. + + ## Examples + + iex> fetch_user_for_worker!(user.id) + %User{} + + """ + @spec fetch_user_for_worker!(integer()) :: User.t() + def fetch_user_for_worker!(id) when is_integer(id), do: Repo.get!(User, id) + + @doc group: "Background jobs" + @doc """ + Loads the moderator for the account-erasure worker with role grants + available for authorization. + + The erasure workflow uses the loaded role map when its synthetic system actor + calls actor-scoped administration actions. + """ + @spec fetch_user_for_erase!(integer()) :: User.t() + def fetch_user_for_erase!(id) when is_integer(id) do + User + |> Repo.get!(id) + |> Repo.preload(:roles) + |> setup_roles() + end + + @doc group: "Background jobs" @doc """ Returns the preload configuration for user indexing. @@ -1174,6 +2836,7 @@ defmodule Philomena.Users do [deleted_by_user: query, bans: query, name_changes: query] """ + @spec indexing_preloads() :: keyword(Ecto.Query.t()) def indexing_preloads do user_query = select(User, [u], map(u, [:name])) ban_query = select(Bans.User, [b], map(b, [:enabled, :valid_until])) @@ -1186,6 +2849,7 @@ defmodule Philomena.Users do ] end + @doc group: "Background jobs" @doc """ Performs a search reindex operation on users matching the given criteria. @@ -1199,6 +2863,7 @@ defmodule Philomena.Users do :ok """ + @spec perform_reindex(atom(), [term()]) :: term() def perform_reindex(column, condition) do User |> preload(^indexing_preloads()) @@ -1206,18 +2871,7 @@ defmodule Philomena.Users do |> Search.reindex(User) end - defp reindex_after_update(result) do - case result do - {:ok, user} -> - reindex_user(user) - - {:ok, user} - - error -> - error - end - end - + @doc group: "Background jobs" @doc """ Updates user search indices when a user's name changes. @@ -1227,6 +2881,7 @@ defmodule Philomena.Users do :ok """ + @spec user_name_reindex(String.t(), String.t()) :: term() def user_name_reindex(old_name, new_name) do data = Users.SearchIndex.user_name_update_by_query(old_name, new_name) diff --git a/lib/philomena/users/ability.ex b/lib/philomena/users/ability.ex index 03a84eb2b..3d52f2822 100644 --- a/lib/philomena/users/ability.ex +++ b/lib/philomena/users/ability.ex @@ -1,34 +1,108 @@ # Permissions for logged-in users. defimpl Canada.Can, for: Philomena.Users.User do - alias Philomena.Users.User - alias Philomena.Roles.Role - alias Philomena.Bans + alias Philomena.Activities.FrontPage + alias Philomena.Adverts.Advert + alias Philomena.ArtistLinks.ArtistLink alias Philomena.Badges.Award alias Philomena.Badges.Badge + alias Philomena.Bans alias Philomena.Channels.Channel alias Philomena.Comments.Comment alias Philomena.Commissions.Commission alias Philomena.Conversations.Conversation alias Philomena.Conversations.Message - alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DnpEntries.DnpEntry - alias Philomena.Images.Image + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.Filters.Filter alias Philomena.Forums.Forum - alias Philomena.Topics.Topic + alias Philomena.Galleries.Gallery + alias Philomena.Images.Image + alias Philomena.ModerationLogs.ModerationLog alias Philomena.ModNotes.ModNote alias Philomena.Posts.Post - alias Philomena.Filters.Filter - alias Philomena.Galleries.Gallery - alias Philomena.ArtistLinks.ArtistLink - alias Philomena.Tags.Tag - alias Philomena.TagChanges.TagChange alias Philomena.Reports.Report - alias Philomena.StaticPages.StaticPage + alias Philomena.Roles.Role alias Philomena.Rules.Rule - alias Philomena.Adverts.Advert alias Philomena.SiteNotices.SiteNotice - alias Philomena.ModerationLogs.ModerationLog + alias Philomena.StaticPages.StaticPage + alias Philomena.TagChanges.TagChange + alias Philomena.Tags.Tag + alias Philomena.Topics.Topic alias Philomena.UserNameChanges.UserNameChange + alias Philomena.Users.User + + @award_class_actions [:new, :create] + @award_member_actions [:edit, :update, :delete] + @badge_class_actions [:index, :new, :create] + @badge_member_actions [:edit, :update, :update_image, :show_users] + @commission_management_actions [ + :new, + :create, + :edit, + :update, + :delete, + :new_item, + :create_item, + :edit_item, + :update_item, + :delete_item + ] + @conversation_class_actions [:index, :new, :create] + @topic_moderation_actions [ + :show, + :subscribe, + :unsubscribe, + :mark_read, + :stick, + :unstick, + :lock, + :unlock, + :move, + :hide, + :unhide, + :update_title, + :edit_poll, + :update_poll, + :list_poll_votes, + :delete_poll_vote + ] + @post_moderation_actions [:edit, :update, :hide, :unhide, :approve] + @tag_moderation_actions [ + :edit, + :update, + :edit_image, + :update_image, + :delete_image, + :show_details + ] + @dnp_entry_class_actions [:index, :new, :create, :select_any_tag] + @dnp_entry_member_actions [ + :show, + :show_reason, + :show_feedback, + :edit, + :update, + :transition + ] + @duplicate_report_member_actions [:show, :accept, :accept_reverse, :claim, :unclaim, :reject] + @user_management_actions [ + :index, + :edit, + :update, + :reactivate, + :deactivate, + :reset_api_key, + :remove_avatar, + :wipe_downvotes, + :erase, + :force_filter, + :unforce_filter, + :unlock, + :verify, + :unverify, + :wipe_votes, + :wipe + ] # Admins can do anything def can?(%User{role: "admin"}, _action, _model), do: true @@ -44,6 +118,7 @@ defimpl Canada.Can, for: Philomena.Users.User do # View filters def can?(%User{role: "moderator"}, :show, %Filter{}), do: true + def can?(%User{role: "moderator"}, :search_all, Filter), do: true # Privileged mods can hard-delete images def can?(%User{role: "moderator", role_map: %{"Image" => %{"admin" => _}}}, :destroy, %Image{}), @@ -62,9 +137,12 @@ defimpl Canada.Can, for: Philomena.Users.User do # View comments def can?(%User{role: "moderator"}, :show, %Comment{}), do: true + def can?(%User{role: "moderator"}, :search_sensitive, Comment), do: true # View forums - def can?(%User{role: "moderator"}, :show, %Forum{}), do: true + def can?(%User{role: "moderator"}, action, %Forum{}) + when action in [:show, :subscribe, :unsubscribe, :create_topic], + do: true def can?(%User{role: "moderator"}, :show, %Topic{hidden_from_users: true}), do: true @@ -72,67 +150,119 @@ defimpl Canada.Can, for: Philomena.Users.User do # View and approve conversations def can?(%User{role: "moderator"}, :show, %Conversation{}), do: true + def can?(%User{role: "moderator"}, :reply, %Conversation{}), do: true def can?(%User{role: "moderator"}, :approve, %Message{}), do: true - # View IP addresses and fingerprints - def can?(%User{role: "moderator"}, :show, :ip_address), do: true + # View sensitive identity metadata such as IP addresses and fingerprints + def can?(%User{role: "moderator"}, :show, :identity_metadata), do: true # Manage duplicate reports def can?(%User{role: "moderator"}, :index, DuplicateReport), do: true - def can?(%User{role: "moderator"}, :edit, %DuplicateReport{}), do: true + + def can?(%User{role: "moderator"}, action, %DuplicateReport{}) + when action in @duplicate_report_member_actions, + do: true # Manage reports def can?(%User{role: "moderator"}, :index, Report), do: true - def can?(%User{role: "moderator"}, :show, %Report{}), do: true - def can?(%User{role: "moderator"}, :edit, %Report{}), do: true + + def can?(%User{role: "moderator"}, action, %Report{}) + when action in [:show, :claim, :unclaim, :close], + do: true + + def can?(%User{role: role}, :bypass_submission_limit, Report) + when role in ["assistant", "moderator"], + do: true # Manage artist links def can?(%User{role: "moderator"}, :create_links, %User{}), do: true def can?(%User{role: "moderator"}, :edit_links, %User{}), do: true - def can?(%User{role: "moderator"}, _action, ArtistLink), do: true - def can?(%User{role: "moderator"}, _action, %ArtistLink{}), do: true + + def can?(%User{role: "moderator"}, :index, ArtistLink), do: true + + def can?(%User{role: "moderator"}, action, %ArtistLink{}) + when action in [:show, :edit, :update, :verify, :reject, :contact], + do: true # Reveal anon users def can?(%User{role: "moderator"}, :reveal_anon, _object), do: true # Edit posts and comments - def can?(%User{role: "moderator"}, :edit, %Post{}), do: true - def can?(%User{role: "moderator"}, :hide, %Post{}), do: true + def can?(%User{role: "moderator"}, action, %Post{}) + when action in @post_moderation_actions, + do: true + def can?(%User{role: "moderator"}, :delete, %Post{}), do: true - def can?(%User{role: "moderator"}, :approve, %Post{}), do: true def can?(%User{role: "moderator"}, :edit, %Comment{}), do: true def can?(%User{role: "moderator"}, :hide, %Comment{}), do: true def can?(%User{role: "moderator"}, :delete, %Comment{}), do: true def can?(%User{role: "moderator"}, :approve, %Comment{}), do: true - # Show the DNP list - def can?(%User{role: "moderator"}, _action, DnpEntry), do: true - def can?(%User{role: "moderator"}, _action, %DnpEntry{}), do: true + # Manage DNP entries + def can?(%User{role: "moderator"}, action, DnpEntry) + when action in @dnp_entry_class_actions, + do: true + + def can?(%User{role: "moderator"}, action, %DnpEntry{}) + when action in @dnp_entry_member_actions, + do: true + + # Manage bans, but not delete them + @ban_management_actions [:index, :new, :create, :edit, :update] + + def can?(%User{role: "moderator"}, action, Bans.User) + when action in @ban_management_actions, + do: true - # Create bans - def can?(%User{role: "moderator"}, _action, Bans.User), do: true - def can?(%User{role: "moderator"}, _action, Bans.Subnet), do: true - def can?(%User{role: "moderator"}, _action, Bans.Fingerprint), do: true + def can?(%User{role: "moderator"}, action, %Bans.User{}) + when action in @ban_management_actions, + do: true + + def can?(%User{role: "moderator"}, action, Bans.Subnet) + when action in @ban_management_actions, + do: true + + def can?(%User{role: "moderator"}, action, %Bans.Subnet{}) + when action in @ban_management_actions, + do: true + + def can?(%User{role: "moderator"}, action, Bans.Fingerprint) + when action in @ban_management_actions, + do: true + + def can?(%User{role: "moderator"}, action, %Bans.Fingerprint{}) + when action in @ban_management_actions, + do: true # Hide topics - def can?(%User{role: "moderator"}, :show, %Topic{}), do: true - def can?(%User{role: "moderator"}, :hide, %Topic{}), do: true - def can?(%User{role: "moderator"}, :edit, %Topic{}), do: true + def can?(%User{role: "moderator"}, action, %Topic{}) + when action in @topic_moderation_actions, + do: true + def can?(%User{role: "moderator"}, :create_post, %Topic{}), do: true - # Edit tags - def can?(%User{role: "moderator"}, :edit, %Tag{}), do: true + # Moderate tags + def can?(%User{role: "moderator"}, action, %Tag{}) + when action in @tag_moderation_actions, + do: true # Award badges - def can?(%User{role: "moderator"}, _action, %Award{}), do: true - def can?(%User{role: "moderator"}, _action, Award), do: true + def can?(%User{role: "moderator"}, action, %Award{}) + when action in @award_member_actions, + do: true + + def can?(%User{role: "moderator"}, action, Award) when action in @award_class_actions, + do: true # Revert tag changes + def can?(%User{role: "moderator"}, :index, TagChange), do: true def can?(%User{role: "moderator"}, :revert, TagChange), do: true def can?(%User{role: "moderator"}, :delete, %TagChange{}), do: true # Manage commissions - def can?(%User{role: "moderator"}, _action, %Commission{}), do: true + def can?(%User{role: "moderator"}, action, %Commission{}) + when action in @commission_management_actions, + do: true # Manage galleries def can?(%User{role: "moderator"}, _action, %Gallery{}), do: true @@ -161,11 +291,21 @@ defimpl Canada.Can, for: Philomena.Users.User do do: true # Manage badges - def can?(%User{role: "moderator", role_map: %{"Badge" => %{"admin" => _}}}, _action, Badge), - do: true + def can?( + %User{role: "moderator", role_map: %{"Badge" => %{"admin" => _}}}, + action, + Badge + ) + when action in @badge_class_actions, + do: true - def can?(%User{role: "moderator", role_map: %{"Badge" => %{"admin" => _}}}, _action, %Badge{}), - do: true + def can?( + %User{role: "moderator", role_map: %{"Badge" => %{"admin" => _}}}, + action, + %Badge{} + ) + when action in @badge_member_actions, + do: true # Manage tags def can?(%User{role: "moderator", role_map: %{"Tag" => %{"admin" => _}}}, _action, Tag), @@ -179,25 +319,36 @@ defimpl Canada.Can, for: Philomena.Users.User do do: true # Manage users - def can?(%User{role: "moderator", role_map: %{"User" => %{"moderator" => _}}}, _action, User), - do: true + def can?(%User{role: "moderator", role_map: %{"User" => %{"moderator" => _}}}, action, User) + when action in @user_management_actions, + do: true def can?( %User{role: "moderator", role_map: %{"User" => %{"moderator" => _}}}, - _action, + action, %User{} - ), + ) + when action in @user_management_actions, do: true # Manage advertisements - def can?(%User{role: "moderator", role_map: %{"Advert" => %{"admin" => _}}}, _action, Advert), - do: true + @advert_class_actions [:index, :new, :create] + @advert_member_actions [:edit, :update, :update_image, :delete] def can?( %User{role: "moderator", role_map: %{"Advert" => %{"admin" => _}}}, - _action, + action, + Advert + ) + when action in @advert_class_actions, + do: true + + def can?( + %User{role: "moderator", role_map: %{"Advert" => %{"admin" => _}}}, + action, %Advert{} - ), + ) + when action in @advert_member_actions, do: true # Manage static pages @@ -229,6 +380,25 @@ defimpl Canada.Can, for: Philomena.Users.User do when role in ~W(assistant moderator) and action in [:edit, :update, :delete], do: true + # Read or annotate mod note targets + @mod_note_target_actions [:show_mod_notes, :annotate] + + def can?(%User{role: role}, action, %User{}) + when role in ~W(assistant moderator) and action in @mod_note_target_actions, + do: true + + def can?(%User{role: role}, :edit_scratchpad, %User{}) + when role in ~W(assistant moderator), + do: true + + def can?(%User{role: role}, action, %Report{}) + when role in ~W(assistant moderator) and action in @mod_note_target_actions, + do: true + + def can?(%User{role: role}, action, %DnpEntry{}) + when role in ~W(assistant moderator) and action in @mod_note_target_actions, + do: true + # # Assistants can... # @@ -276,6 +446,26 @@ defimpl Canada.Can, for: Philomena.Users.User do ), do: true + def can?( + %User{role: "assistant", role_map: %{"Image" => %{"moderator" => _}}}, + action, + %Image{} + ) + when action in [ + :feature, + :lock_comments, + :lock_description, + :lock_tags, + :remove_hash, + :edit_scratchpad, + :remove_source_history, + :repair, + :replace_file, + :update_hide_reason, + :unhide + ], + do: true + # Dupe assistant actions def can?( %User{role: "assistant", role_map: %{"DuplicateReport" => %{"moderator" => _}}}, @@ -286,9 +476,10 @@ defimpl Canada.Can, for: Philomena.Users.User do def can?( %User{role: "assistant", role_map: %{"DuplicateReport" => %{"moderator" => _}}}, - :edit, + action, %DuplicateReport{} - ), + ) + when action in @duplicate_report_member_actions, do: true def can?( @@ -344,44 +535,27 @@ defimpl Canada.Can, for: Philomena.Users.User do # Topic assistant actions def can?( %User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, - :show, + action, %Topic{} - ), - do: true - - def can?( - %User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, - :edit, - %Topic{} - ), - do: true - - def can?( - %User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, - :hide, - %Topic{} - ), + ) + when action in @topic_moderation_actions, do: true def can?(%User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, :show, %Post{}), do: true - def can?(%User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, :edit, %Post{}), - do: true - - def can?(%User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, :hide, %Post{}), - do: true - def can?( %User{role: "assistant", role_map: %{"Topic" => %{"moderator" => _}}}, - :approve, + action, %Post{} - ), + ) + when action in @post_moderation_actions, do: true # Tag assistant actions - def can?(%User{role: "assistant", role_map: %{"Tag" => %{"moderator" => _}}}, :edit, %Tag{}), - do: true + def can?(%User{role: "assistant", role_map: %{"Tag" => %{"moderator" => _}}}, action, %Tag{}) + when action in @tag_moderation_actions, + do: true def can?( %User{role: "assistant", role_map: %{"Tag" => %{"moderator" => _}}}, @@ -427,14 +601,20 @@ defimpl Canada.Can, for: Philomena.Users.User do do: true # View forums - def can?(%User{role: "assistant"}, :show, %Forum{access_level: level}) - when level in ["normal", "assistant"], + def can?(%User{role: "assistant"}, action, %Forum{access_level: level}) + when action in [:show, :subscribe, :unsubscribe, :create_topic] and + level in ["normal", "assistant"], do: true def can?(%User{role: "assistant"}, :show, %Topic{hidden_from_users: true}), do: true # # Regular users can... + + def can?(%User{}, :index, TagChange), do: true + + # View their own reports + def can?(%User{}, :index_own, Report), do: true # # Batch tag @@ -443,6 +623,11 @@ defimpl Canada.Can, for: Philomena.Users.User do # Edit their description and personal title def can?(%User{id: id}, :edit_description, %User{id: id}), do: true def can?(%User{id: id}, :edit_title, %User{id: id}), do: true + def can?(%User{id: id}, :deactivate_account, %User{id: id}), do: true + + # Update their personal filter settings + def can?(%User{id: id}, :update_spoiler_type, %User{id: id}), do: true + def can?(%User{id: id}, :delete_recent_filters, %User{id: id}), do: true # Edit their username def can?(%User{id: id}, :change_username, %User{id: id} = user) do @@ -450,40 +635,64 @@ defimpl Canada.Can, for: Philomena.Users.User do DateTime.diff(user.last_renamed_at, time_ago) < 0 end - # View conversations they are involved in + # List and create conversations, and view/reply to ones they participate in. + def can?(%User{}, action, Conversation) when action in @conversation_class_actions, do: true def can?(%User{id: id}, :show, %Conversation{to_id: id}), do: true def can?(%User{id: id}, :show, %Conversation{from_id: id}), do: true + def can?(%User{id: id}, :reply, %Conversation{to_id: id}), do: true + def can?(%User{id: id}, :reply, %Conversation{from_id: id}), do: true # View filters they own and public/system filters def can?(%User{}, :show, %Filter{system: true}), do: true def can?(%User{}, :show, %Filter{public: true}), do: true - def can?(%User{}, action, Filter) when action in [:index, :new, :create], do: true - def can?(%User{id: id}, action, %Filter{user_id: id}) when action in [:show, :edit, :update], - do: true + def can?(%User{}, action, Filter) + when action in [:index, :index_system, :index_own, :search, :switch, :new, :create], + do: true - # Edit filters they own - def can?(%User{id: id}, action, %Filter{user_id: id}) when action in [:edit, :update, :delete], - do: true + # View the homepage + def can?(%User{}, :show, FrontPage), do: true + + def can?(%User{id: id}, action, %Filter{user_id: id}) + when action in [ + :show, + :edit, + :update, + :publish, + :delete, + :hide_tag, + :unhide_tag, + :spoiler_tag, + :unspoiler_tag + ], + do: true # View artist links they've created def can?(%User{id: id}, :create_links, %User{id: id}), do: true def can?(%User{id: id}, :show, %ArtistLink{user_id: id}), do: true - # Edit their commissions + # View the directory/listings and manage their own commission + def can?(%User{}, :index, Commission), do: true + def can?(%User{}, :show, %Commission{}), do: true + def can?(%User{id: id}, action, %Commission{user_id: id}) - when action in [:edit, :update, :delete], + when action in @commission_management_actions, do: true - # View non-deleted images + # View non-deleted and watched images def can?(%User{}, action, Image) - when action in [:new, :create, :index], + when action in [:new, :create, :index, :index_watched], do: true def can?(%User{}, action, %Image{hidden_from_users: false}) - when action in [:show, :index], + when action in [:show, :index, :subscribe, :unsubscribe, :mark_read], do: true + # Submit and inspect duplicate reports involving visible images. + def can?(%User{}, action, DuplicateReport) when action in [:create, :search], do: true + def can?(%User{}, :show, %DuplicateReport{}), do: true + + def can?(%User{}, :index, Tag), do: true def can?(%User{}, :show, %Tag{}), do: true # Comment on images where that is allowed @@ -514,8 +723,16 @@ defimpl Canada.Can, for: Philomena.Users.User do # View forums def can?(%User{}, :index, Forum), do: true - def can?(%User{}, :show, %Forum{access_level: "normal"}), do: true - def can?(%User{}, :show, %Topic{hidden_from_users: false}), do: true + + def can?(%User{}, action, %Forum{access_level: "normal"}) + when action in [:show, :subscribe, :unsubscribe, :create_topic], + do: true + + def can?(%User{}, action, %Topic{hidden_from_users: false}) + when action in [:show, :subscribe, :unsubscribe, :mark_read, :vote], + do: true + + def can?(%User{}, action, %Topic{}) when action in [:unsubscribe, :mark_read], do: true def can?(%User{}, :show, %Post{hidden_from_users: false}), do: true # Create and edit posts @@ -539,20 +756,32 @@ defimpl Canada.Can, for: Philomena.Users.User do # Create and edit galleries def can?(%User{}, :show, %Gallery{}), do: true - def can?(%User{}, action, Gallery) when action in [:new, :create], do: true + + def can?(%User{}, action, Gallery) + when action in [:index, :search, :new, :create, :select_for_image], + do: true + + def can?(%User{}, action, %Gallery{}) when action in [:subscribe, :unsubscribe, :mark_read], + do: true def can?(%User{id: id}, action, %Gallery{user_id: id}) - when action in [:edit, :update, :delete], + when action in [:edit, :update, :delete, :add_image, :remove_image, :reorder], do: true - # Index and show rules + # Index and show public rules + def can?(%User{} = user, :show, %Rule{hidden: hidden, internal: internal} = rule) + when hidden or internal, + do: can?(user, :edit, rule) + def can?(%User{}, action, %Rule{}) when action in [:index, :show], do: true # Show static pages def can?(%User{}, :show, %StaticPage{}), do: true - # Show channels - def can?(%User{}, :show, %Channel{}), do: true + # View channels and manage personal channel state + def can?(%User{}, action, %Channel{}) + when action in [:show, :visit, :mark_read, :subscribe, :unsubscribe], + do: true # Otherwise... def can?(%User{}, _action, _model), do: false @@ -560,25 +789,38 @@ end # Permissions for non-logged-in users. defimpl Canada.Can, for: Atom do + alias Philomena.Activities.FrontPage alias Philomena.Channels.Channel alias Philomena.Comments.Comment + alias Philomena.Commissions.Commission alias Philomena.DnpEntries.DnpEntry - alias Philomena.Images.Image - alias Philomena.Forums.Forum - alias Philomena.Topics.Topic - alias Philomena.Posts.Post + alias Philomena.DuplicateReports.DuplicateReport alias Philomena.Filters.Filter + alias Philomena.Forums.Forum alias Philomena.Galleries.Gallery - alias Philomena.Tags.Tag + alias Philomena.Images.Image + alias Philomena.Posts.Post alias Philomena.Rules.Rule alias Philomena.StaticPages.StaticPage + alias Philomena.TagChanges.TagChange + alias Philomena.Tags.Tag + alias Philomena.Topics.Topic alias Philomena.Users.User # # Anonymous / non-logged-in users can... + + def can?(_user, :index, TagChange), do: true + def can?(_user, :index, Tag), do: true # + # View the assembled homepage + def can?(_user, :show, FrontPage), do: true + # View filters they own and public/system filters + def can?(_user, action, Filter) when action in [:index, :index_system, :search, :switch], + do: true + def can?(_user, :show, %Filter{system: true}), do: true def can?(_user, :show, %Filter{public: true}), do: true @@ -591,6 +833,10 @@ defimpl Canada.Can, for: Atom do when action in [:show, :index], do: true + # Submit and inspect duplicate reports involving visible images. + def can?(_user, action, DuplicateReport) when action in [:create, :search], do: true + def can?(_user, :show, %DuplicateReport{}), do: true + def can?(_user, :show, %Tag{}), do: true # Comment on images where that is allowed @@ -608,6 +854,7 @@ defimpl Canada.Can, for: Atom do def can?(_user, :index, Forum), do: true def can?(_user, :show, %Forum{access_level: "normal"}), do: true def can?(_user, :show, %Topic{hidden_from_users: false}), do: true + def can?(_user, :mark_read, %Topic{hidden_from_users: false}), do: true def can?(_user, :show, %Post{hidden_from_users: false}), do: true # Create and edit posts @@ -616,20 +863,27 @@ defimpl Canada.Can, for: Atom do # View profile pages def can?(_user, :show, %User{}), do: true + # View the commission directory and listings. + def can?(_user, :index, Commission), do: true + def can?(_user, :show, %Commission{}), do: true + def can?(_user, :show, %DnpEntry{aasm_state: "listed"}), do: true def can?(_user, :show_reason, %DnpEntry{aasm_state: "listed", hide_reason: false}), do: true # Create and edit galleries + def can?(_user, action, Gallery) when action in [:index, :search], do: true def can?(_user, :show, %Gallery{}), do: true - # Index and show rules + # Index and show public rules + def can?(_user, :show, %Rule{hidden: true}), do: false + def can?(_user, :show, %Rule{internal: true}), do: false def can?(_user, action, %Rule{}) when action in [:index, :show], do: true # Show static pages def can?(_user, :show, %StaticPage{}), do: true - # Show channels - def can?(_user, :show, %Channel{}), do: true + # Show and visit channels + def can?(_user, action, %Channel{}) when action in [:show, :visit], do: true # Otherwise... def can?(_user, _action, _model), do: false diff --git a/lib/philomena/users/admin_user_form.ex b/lib/philomena/users/admin_user_form.ex new file mode 100644 index 000000000..23a2ed765 --- /dev/null +++ b/lib/philomena/users/admin_user_form.ex @@ -0,0 +1,14 @@ +defmodule Philomena.Users.AdminUserForm do + @moduledoc """ + A staff-managed user changeset and assignable roles. + """ + + alias Philomena.Roles.Role + @enforce_keys [:changeset, :roles] + defstruct [:changeset, :roles] + + @type t :: %__MODULE__{ + changeset: Ecto.Changeset.t(), + roles: [Role.t()] + } +end diff --git a/lib/philomena/users/alias_matches.ex b/lib/philomena/users/alias_matches.ex new file mode 100644 index 000000000..a7fc3bec4 --- /dev/null +++ b/lib/philomena/users/alias_matches.ex @@ -0,0 +1,17 @@ +defmodule Philomena.Users.AliasMatches do + @moduledoc """ + A user and potential aliases grouped by shared IP, fingerprint, or both. + """ + + alias Philomena.Users.User + + @enforce_keys [:user, :both_matches, :ip_matches, :fp_matches] + defstruct [:user, :both_matches, :ip_matches, :fp_matches] + + @type t :: %__MODULE__{ + user: User.t(), + both_matches: [User.t()], + ip_matches: [User.t()], + fp_matches: [User.t()] + } +end diff --git a/lib/philomena/users/eraser.ex b/lib/philomena/users/eraser.ex index 3769f836f..366bcef73 100644 --- a/lib/philomena/users/eraser.ex +++ b/lib/philomena/users/eraser.ex @@ -2,6 +2,7 @@ defmodule Philomena.Users.Eraser do import Ecto.Query alias Philomena.Repo + alias Philomena.Attribution.Actor alias Philomena.Bans alias Philomena.Comments.Comment alias Philomena.Comments @@ -11,8 +12,9 @@ defmodule Philomena.Users.Eraser do alias Philomena.Posts alias Philomena.Topics.Topic alias Philomena.Topics - alias Philomena.Images + alias Philomena.Reports.Report alias Philomena.SourceChanges.SourceChange + alias Philomena.SourceChanges alias Philomena.Reports alias Philomena.Users @@ -21,19 +23,42 @@ defmodule Philomena.Users.Eraser do @wipe_fp "ffff" def erase_permanently!(user, moderator) do + system_actor = system_actor(moderator) + # Erase avatar - {:ok, user} = Users.remove_avatar(user) + {:ok, user} = Users.delete_user_avatar(system_actor, user.slug) # Erase "about me" and personal title - {:ok, user} = Users.update_description(user, %{description: "", personal_title: ""}) + {:ok, user} = + Users.update_profile_description(system_actor, user.slug, %{ + description: "", + personal_title: "" + }) # Delete all forum posts Post |> where(user_id: ^user.id) + |> preload(topic: :forum) |> Repo.all() |> Enum.each(fn post -> - {:ok, post} = Posts.hide_post(post, %{deletion_reason: @reason}, moderator) - {:ok, _post} = Posts.destroy_post(post) + if not post.destroyed_content do + {:ok, _post} = + Posts.create_post_hide( + system_actor, + post.topic.forum.short_name, + post.topic.slug, + post.id, + %{deletion_reason: @reason} + ) + + {:ok, _post} = + Posts.create_post_delete( + system_actor, + post.topic.forum.short_name, + post.topic.slug, + post.id + ) + end end) # Delete all comments @@ -41,86 +66,81 @@ defmodule Philomena.Users.Eraser do |> where(user_id: ^user.id) |> Repo.all() |> Enum.each(fn comment -> - {:ok, comment} = Comments.hide_comment(comment, %{deletion_reason: @reason}, moderator) - {:ok, _comment} = Comments.destroy_comment(comment) + if not comment.destroyed_content do + {:ok, _comment} = + Comments.create_comment_hide( + system_actor, + comment.image_id, + comment.id, + %{deletion_reason: @reason} + ) + + {:ok, _comment} = + Comments.create_comment_delete(system_actor, comment.image_id, comment.id) + end end) # Delete all galleries Gallery |> where(user_id: ^user.id) + |> select([gallery], gallery.id) |> Repo.all() - |> Enum.each(fn gallery -> - {:ok, _gallery} = Galleries.delete_gallery(gallery, moderator) + |> Enum.each(fn gallery_id -> + {:ok, _gallery} = Galleries.delete_gallery(system_actor, gallery_id) end) # Delete all posted topics Topic |> where(user_id: ^user.id) + |> preload(:forum) |> Repo.all() |> Enum.each(fn topic -> - {:ok, _topic} = Topics.hide_topic(topic, @reason, moderator) + if not topic.hidden_from_users do + {:ok, _topic} = + Topics.create_topic_hide( + system_actor, + topic.forum.short_name, + topic.slug, + %{deletion_reason: @reason} + ) + end end) # Revert all source changes SourceChange |> where(user_id: ^user.id) |> order_by(desc: :created_at) - |> preload(:image) |> Repo.all() |> Enum.each(fn source_change -> - if source_change.added do - revert_added_source_change(source_change, user) - else - revert_removed_source_change(source_change, user) - end + {:ok, _change} = + SourceChanges.erase_source_change(system_actor, source_change.id) end) - # Delete all source changes - SourceChange - |> where(user_id: ^user.id) - |> Repo.delete_all() - # Ban the user {:ok, _ban} = - Bans.create_user( - moderator, + Bans.create_user_ban( + system_actor, + user.id, %{ - "user_id" => user.id, - "reason" => @reason, - "valid_until" => "permanent" + reason: @reason, + valid_until: "permanent" } ) # Close all reports against the user - {:ok, _} = Reports.close_reports(moderator, reported_user_id: user.id) + Report + |> where(reported_user_id: ^user.id, open: true) + |> select([report], report.id) + |> Repo.all() + |> Enum.each(fn report_id -> + {:ok, _report} = Reports.create_report_close(system_actor, report_id) + end) # We succeeded :ok end - defp revert_removed_source_change(source_change, user) do - old_sources = %{} - new_sources = %{"0" => %{"source" => source_change.source_url}} - - revert_source_change(source_change, user, old_sources, new_sources) - end - - defp revert_added_source_change(source_change, user) do - old_sources = %{"0" => %{"source" => source_change.source_url}} - new_sources = %{} - - revert_source_change(source_change, user, old_sources, new_sources) - end - - defp revert_source_change(source_change, user, old_sources, new_sources) do - attrs = %{"old_sources" => old_sources, "sources" => new_sources} - - attribution = [ - user: user, - ip: @wipe_ip, - fingerprint: @wipe_fp - ] - - {:ok, _} = Images.update_sources(source_change.image, attribution, attrs) + defp system_actor(moderator) do + %Actor{user: moderator, ip: @wipe_ip, fingerprint: @wipe_fp} end end diff --git a/lib/philomena/users/query_builder.ex b/lib/philomena/users/query_builder.ex new file mode 100644 index 000000000..db4d1aea4 --- /dev/null +++ b/lib/philomena/users/query_builder.ex @@ -0,0 +1,55 @@ +defmodule Philomena.Users.QueryBuilder do + @moduledoc false + + alias Philomena.Users.QueryForm + + @doc """ + Builds a user search query based on the given parameters. + + ## Parameters + + * `params` - Map of optional search parameters: + * `query` - Search query + * `sf` - Sort field: + * `name` - Account name + * `confirmed_at` - Account confirmation time + * `updated_at` - Last update time + * `deleted_at` - Deactivation time + * `images_count` - Count of images posted + * `comments_count` - Count of comments on images + * `image_faves_count` - Count of faves on images + * `image_votes_count` - Count of votes on images + * `metadata_updates_count` - Count of tag and source changes + * `posts_count` - Count of forum posts posted + * `topics_count` - Count of forum topics posted + * `_score` - Relevance + * `sd` - Sort direction: + * `asc` - Results ascending by `sf` + * `desc` - Results descending by `sf` + + Returns `{:ok, query, query_form}` with an OpenSearch query body for `Users` that + can be used with `PhilomenaQuery.Search`, or `{:error, changeset}` if the provided + parameters are invalid. + """ + @spec build_query(map()) :: {:ok, map(), QueryForm.t()} | {:error, Ecto.Changeset.t()} + def build_query(params \\ %{}) do + with {:ok, query_form} <- + %QueryForm{} + |> QueryForm.changeset(params) + |> Ecto.Changeset.apply_action(:create) do + {:ok, apply_sort(query_form.compiled_query, query_form), query_form} + end + end + + defp apply_sort(query, %QueryForm{sf: sf, sd: sd}) do + %{ + query: query, + sort: + if sf == "id" do + [%{id: sd}] + else + [%{sf => sd}, %{id: sd}] + end + } + end +end diff --git a/lib/philomena/users/query_form.ex b/lib/philomena/users/query_form.ex new file mode 100644 index 000000000..0ebe044ff --- /dev/null +++ b/lib/philomena/users/query_form.ex @@ -0,0 +1,42 @@ +defmodule Philomena.Users.QueryForm do + use Ecto.Schema + + import Ecto.Changeset + import PhilomenaQuery.Ecto.QueryValidator + + alias Philomena.Users.Query + + @type t :: %__MODULE__{} + + embedded_schema do + field :query, :string + field :sf, :string, default: "id" + field :sd, :string, default: "desc" + + field :compiled_query, :map, virtual: true + end + + @doc false + def changeset(%__MODULE__{} = query_form, attrs \\ %{}) do + query_form + |> cast(attrs, [:query, :sf, :sd]) + |> validate_inclusion(:sf, ~W( + id + name + confirmed_at + updated_at + deleted_at + images_count + image_faves_count + comments_count + image_votes_count + metadata_updates_count + posts_count + topics_count + _score + )) + |> validate_inclusion(:sd, ~w(asc desc)) + |> validate_required([:sf, :sd]) + |> validate_query(:query, with: &Query.compile/1, default: "*", into: :compiled_query) + end +end diff --git a/lib/philomena/users/role_form.ex b/lib/philomena/users/role_form.ex new file mode 100644 index 000000000..de7a223c2 --- /dev/null +++ b/lib/philomena/users/role_form.ex @@ -0,0 +1,27 @@ +defmodule Philomena.Users.RoleForm do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + embedded_schema do + field :roles, {:array, :integer}, default: [] + end + + @doc false + def fetch_role_ids(attrs) do + %__MODULE__{} + |> cast(attrs, [:roles]) + |> update_change(:roles, &Enum.uniq/1) + |> apply_action(:create) + |> case do + {:ok, %{roles: role_ids}} -> + {:ok, role_ids} + + _error -> + {:error, :not_found} + end + end +end diff --git a/lib/philomena/users/settings.ex b/lib/philomena/users/settings.ex index 8d659fce5..b467dd27f 100644 --- a/lib/philomena/users/settings.ex +++ b/lib/philomena/users/settings.ex @@ -5,6 +5,8 @@ defmodule Philomena.Users.Settings do alias Philomena.Images.Query + @type t :: %__MODULE__{} + @primary_key false schema "user_settings" do belongs_to :user, Philomena.Users.User, primary_key: true @@ -88,8 +90,12 @@ defmodule Philomena.Users.Settings do |> validate_inclusion(:images_per_page, 1..50) |> validate_inclusion(:comments_per_page, 1..100) |> validate_inclusion(:scale_large_images, ["false", "partscaled", "true"]) - |> validate_query(:watched_images_query_str, &Query.compile(&1, user: user, watch: true)) - |> validate_query(:watched_images_exclude_str, &Query.compile(&1, user: user, watch: true)) + |> validate_query(:watched_images_query_str, + with: &Query.compile(&1, user: user, watch: true) + ) + |> validate_query(:watched_images_exclude_str, + with: &Query.compile(&1, user: user, watch: true) + ) end def spoiler_type_changeset(settings, attrs) do diff --git a/lib/philomena/users/uploader.ex b/lib/philomena/users/uploader.ex index 7e6f0ced5..8af4a81ea 100644 --- a/lib/philomena/users/uploader.ex +++ b/lib/philomena/users/uploader.ex @@ -3,19 +3,25 @@ defmodule Philomena.Users.Uploader do Upload and processing callback logic for User avatars. """ + alias Philomena.Multi alias Philomena.Users.User alias PhilomenaMedia.Uploader - def analyze_upload(user, params) do - Uploader.analyze_upload(user, "avatar", params["avatar"], &User.avatar_changeset/2) + def analyze_upload(user, upload) do + Uploader.analyze_upload(user, "avatar", upload, &User.avatar_changeset/2) end - def persist_upload(user) do - Uploader.persist_upload(user, avatar_file_root(), "avatar") + def put_persist_upload_and_unpersist_old(multi, step) do + Multi.on_commit(multi, fn %{^step => user} -> + Uploader.persist_upload(user, avatar_file_root(), "avatar") + Uploader.unpersist_old_upload(user, avatar_file_root(), "avatar") + end) end - def unpersist_old_upload(user) do - Uploader.unpersist_old_upload(user, avatar_file_root(), "avatar") + def put_unpersist_old_upload(multi, step) do + Multi.on_commit(multi, fn %{^step => user} -> + Uploader.unpersist_old_upload(user, avatar_file_root(), "avatar") + end) end defp avatar_file_root do diff --git a/lib/philomena/users/user.ex b/lib/philomena/users/user.ex index 197b9c229..2b3f106d9 100644 --- a/lib/philomena/users/user.ex +++ b/lib/philomena/users/user.ex @@ -5,7 +5,7 @@ defmodule Philomena.Users.User do use Ecto.Schema import Ecto.Changeset - alias Philomena.Schema.TagList + alias Philomena.Schema.Approval alias Philomena.Filters.Filter alias Philomena.ArtistLinks.ArtistLink @@ -15,11 +15,15 @@ defmodule Philomena.Users.User do alias Philomena.Users.Settings alias Philomena.Commissions.Commission alias Philomena.Roles.Role + alias Philomena.Reports.Report alias Philomena.UserFingerprints.UserFingerprint alias Philomena.UserIps.UserIp alias Philomena.Bans alias Philomena.Donations.Donation alias Philomena.UserNameChanges.UserNameChange + alias Philomena.Tags.Tag + + @type t :: %__MODULE__{} @derive {Phoenix.Param, key: :slug} @derive {Inspect, except: [:password]} @@ -38,6 +42,8 @@ defmodule Philomena.Users.User do many_to_many :roles, Role, join_through: "users_roles", on_replace: :delete has_many :name_changes, UserNameChange has_one :settings, Settings, on_replace: :update + has_many :reports, Report, foreign_key: :reported_user_id + has_many :created_reports, Report, foreign_key: :user_id belongs_to :current_filter, Filter belongs_to :forced_filter, Filter @@ -102,8 +108,9 @@ defmodule Philomena.Users.User do field :avatar_mime_type, :string, virtual: true field :uploaded_avatar, :string, virtual: true field :removed_avatar, :string, virtual: true + field :became_unapproved?, :boolean, virtual: true, default: false - # For mod stuff + # For authorization field :role_map, :any, virtual: true timestamps(inserted_at: :created_at, type: :utc_datetime) @@ -117,12 +124,13 @@ defmodule Philomena.Users.User do could lead to unpredictable or insecure behaviour. Long passwords may also be very expensive to hash for certain algorithms. """ - def registration_changeset(user, attrs) do + def registration_changeset(user, password_compromised_fn, attrs) + when is_function(password_compromised_fn, 1) do user |> cast(attrs, [:name, :email, :password]) |> validate_name() |> validate_email() - |> validate_password() + |> validate_password(password_compromised_fn) |> put_api_key() |> put_slug() |> unique_constraints() @@ -142,22 +150,43 @@ defmodule Philomena.Users.User do defp trim_name(name), do: String.trim(name) defp validate_email(changeset) do + # The unsafe_validate_unique is used to generate form errors + # when users generate an update token with an email that has + # already been taken. It is not used to prevent duplicate + # registrations - that is done with a real unique constraint. + changeset |> validate_required([:email]) - |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+\.[^@,;\s]+$/, message: "must be valid (e.g., user@example.com)" ) |> validate_length(:email, max: 160) |> unsafe_validate_unique(:email, Philomena.Repo) end - defp validate_password(changeset) do + defp validate_password(changeset, password_compromised_fn) do changeset |> validate_required([:password]) |> validate_length(:password, min: 12, max: 80) + |> validate_compromised_password(password_compromised_fn) |> prepare_changes(&hash_password/1) end + defp validate_compromised_password( + %Ecto.Changeset{valid?: true} = changeset, + password_compromised_fn + ) do + validate_change(changeset, :password, fn :password, password -> + if password_compromised_fn.(password) do + [password: "has been compromised in a data breach"] + else + [] + end + end) + end + + defp validate_compromised_password(changeset, _password_compromised_fn), do: changeset + defp hash_password(changeset) do password = get_change(changeset, :password) @@ -184,11 +213,12 @@ defmodule Philomena.Users.User do @doc """ A user changeset for changing the password. """ - def password_changeset(user, attrs) do + def password_changeset(user, password_compromised_fn, attrs) + when is_function(password_compromised_fn, 1) do user |> cast(attrs, [:password]) |> validate_confirmation(:password, message: "does not match password") - |> validate_password() + |> validate_password(password_compromised_fn) end @doc """ @@ -230,10 +260,13 @@ defmodule Philomena.Users.User do end def failed_attempt_changeset(user) do - if not is_integer(user.failed_attempts) or user.failed_attempts < 0 do - change(user, failed_attempts: 1) + failed_attempts = max(0, user.failed_attempts || 0) + 1 + changeset = change(user, failed_attempts: failed_attempts) + + if failed_attempts >= 10 do + lock_changeset(changeset) else - change(user, failed_attempts: user.failed_attempts + 1) + changeset end end @@ -245,7 +278,7 @@ defmodule Philomena.Users.User do change(user, locked_at: nil, failed_attempts: 0) end - def changeset(user, attrs) do + def changeset(user, attrs \\ %{}) do cast(user, attrs, []) end @@ -268,6 +301,12 @@ defmodule Philomena.Users.User do |> unique_constraints() end + def role_error_changeset(user) do + user + |> change() + |> add_error(:roles, "contains an invalid role") + end + def filter_changeset(user, filter) do changeset = change(user) user = changeset.data @@ -283,10 +322,22 @@ defmodule Philomena.Users.User do def settings_changeset(user, attrs) do user |> cast(attrs, [:watched_tag_list]) - |> TagList.propagate_tag_list(:watched_tag_list, :watched_tag_ids) |> cast_assoc(:settings, with: &Settings.changeset(&1, &2, user)) end + @doc false + def watched_tag_names(attrs) do + %__MODULE__{} + |> cast(attrs, [:watched_tag_list]) + |> get_field(:watched_tag_list) + |> Tag.parse_tag_list() + end + + @doc false + def put_watched_tag_ids(changeset, watched_tag_ids) do + put_change(changeset, :watched_tag_ids, watched_tag_ids) + end + def description_changeset(user, attrs) do user |> cast(attrs, [:description, :personal_title]) @@ -296,8 +347,23 @@ defmodule Philomena.Users.User do :personal_title, ~r/\A((?!site|admin|moderator|assistant|developer|\p{C}).)*\z/iu ) + |> maybe_put_description_approval(user) end + defp maybe_put_description_approval(%{valid?: true} = changeset, user) do + was_approved? = + Approval.approved?(user, user.description, :external_links) and + Approval.approved?(user, user.personal_title, :external_links) + + approved? = + Approval.approved?(user, get_field(changeset, :description), :external_links) and + Approval.approved?(user, get_field(changeset, :personal_title), :external_links) + + change(changeset, became_unapproved?: was_approved? and not approved?) + end + + defp maybe_put_description_approval(changeset, _user), do: changeset + def scratchpad_changeset(user, attrs) do user |> cast(attrs, [:scratchpad]) @@ -348,13 +414,24 @@ defmodule Philomena.Users.User do end def reactivate_changeset(user) do - change(user, deleted_at: nil, deleted_by_user_id: nil) + changeset = change(user) + + if get_field(changeset, :deleted_at) do + change(user, deleted_at: nil, deleted_by_user_id: nil) + else + add_error(changeset, :deleted_at, "is already active") + end end def deactivate_changeset(user, deactivator) do - now = DateTime.utc_now(:second) + changeset = change(user) - change(user, deleted_at: now, deleted_by_user_id: deactivator.id) + if get_field(changeset, :deleted_at) do + add_error(changeset, :deleted_at, "is already deactivated") + else + now = DateTime.utc_now(:second) + change(user, deleted_at: now, deleted_by_user_id: deactivator.id) + end end def api_key_changeset(user) do diff --git a/lib/philomena/users/user_downvote_wipe.ex b/lib/philomena/users/user_downvote_wipe.ex new file mode 100644 index 000000000..955a89eab --- /dev/null +++ b/lib/philomena/users/user_downvote_wipe.ex @@ -0,0 +1,67 @@ +defmodule Philomena.Users.UserDownvoteWipe do + @moduledoc """ + Performs the asynchronous vote/favorite cleanup owned by the Users context. + + The public entry point accepts only a trusted persisted user ID and is called + by `Philomena.UserUnvoteWorker` after an authorized Users service enqueues it. + """ + + import Ecto.Query + + alias PhilomenaQuery.Search + alias Philomena.Users + alias Philomena.Images.Image + alias Philomena.Images + alias Philomena.ImageVotes + alias Philomena.ImageFaves + alias Philomena.Repo + + defp reindex(image_ids) do + Image + |> where([i], i.id in ^image_ids) + |> preload(^Images.indexing_preloads()) + |> Search.reindex(Image) + + # Allow time for indexing to catch up before the next destructive batch. + :timer.sleep(:timer.seconds(10)) + end + + @doc """ + Removes a user's downvotes and, when requested, their upvotes and favorites. + + A missing ID is an invariant violation and raises. Affected image and user + counters are updated in batches, and affected images are reindexed after each + batch. + + ## Examples + + iex> UserDownvoteWipe.perform(user.id) + :ok + + iex> UserDownvoteWipe.perform(user.id, true) + :ok + """ + @spec perform(integer(), boolean()) :: :ok + def perform(user_id, upvotes_and_faves_too \\ false) do + user = Users.fetch_user_for_worker!(user_id) + + {count, image_ids} = ImageVotes.delete_user_votes!(user.id, false) + Images.decrement_vote_counters!(image_ids, false) + Users.increment_counter(Repo, user.id, :image_votes_count, -count) + reindex(image_ids) + + if upvotes_and_faves_too do + {count, image_ids} = ImageVotes.delete_user_votes!(user.id, true) + Images.decrement_vote_counters!(image_ids, true) + Users.increment_counter(Repo, user.id, :image_votes_count, -count) + reindex(image_ids) + + {count, image_ids} = ImageFaves.delete_user_faves!(user.id) + Images.decrement_fave_counters!(image_ids) + Users.increment_counter(Repo, user.id, :image_faves_count, -count) + reindex(image_ids) + end + + :ok + end +end diff --git a/lib/philomena/users/user_token.ex b/lib/philomena/users/user_token.ex index 21e22b4bf..fd1155c9e 100644 --- a/lib/philomena/users/user_token.ex +++ b/lib/philomena/users/user_token.ex @@ -48,6 +48,20 @@ defmodule Philomena.Users.UserToken do {:ok, query} end + @doc """ + Checks if the token is valid and returns its underlying lookup query along + with the token's creation time. + """ + def verify_session_token_query_with_timestamp(token) do + query = + from token in token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.created_at > ago(@session_validity_in_days, "day"), + select: {user, token.created_at} + + {:ok, query} + end + @doc """ Generates a token that will be stored in a signed place, such as session or cookie. As they are signed, those @@ -129,7 +143,7 @@ defmodule Philomena.Users.UserToken do The query returns the user token record. """ - def verify_change_email_token_query(token, context) do + def verify_change_email_token_query(token, "change:" <> _ = context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) @@ -145,6 +159,8 @@ defmodule Philomena.Users.UserToken do end end + def verify_change_email_token_query(_token, _context), do: :error + @doc """ Returns the given token with the given context. """ diff --git a/lib/philomena/users/user_wipe.ex b/lib/philomena/users/user_wipe.ex new file mode 100644 index 000000000..941cc88bd --- /dev/null +++ b/lib/philomena/users/user_wipe.ex @@ -0,0 +1,53 @@ +defmodule Philomena.Users.UserWipe do + @moduledoc """ + Performs the asynchronous personally identifying information cleanup owned by + the Users context. + + The public entry point accepts only a trusted persisted user ID and is called + by `Philomena.UserWipeWorker` after an authorized Users service enqueues it. + """ + + alias Philomena.Comments + alias Philomena.Images + alias Philomena.Posts + alias Philomena.Reports + alias Philomena.SourceChanges + alias Philomena.TagChanges + alias Philomena.UserIps + alias Philomena.UserFingerprints + alias Philomena.Users + alias Philomena.Users.User + + @wipe_ip %Postgrex.INET{address: {127, 0, 1, 1}, netmask: 32} + @wipe_fp "ffff" + + @doc """ + Replaces a user's stored IPs, fingerprints, and email with erased values. + + A missing ID is an invariant violation and raises. Attribution-bearing rows + are updated in batches and the user search document is reindexed afterward. + + ## Examples + + iex> UserWipe.perform(user.id) + %User{} + """ + @spec perform(integer()) :: User.t() + def perform(user_id) do + user = Users.fetch_user_for_worker!(user_id) + + random_hex = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower) + + Comments.wipe_user_attribution!(user.id, @wipe_ip, @wipe_fp) + Images.wipe_user_attribution!(user.id, @wipe_ip, @wipe_fp) + Posts.wipe_user_attribution!(user.id, @wipe_ip, @wipe_fp) + Reports.wipe_user_attribution!(user.id, @wipe_ip, @wipe_fp) + SourceChanges.wipe_user_attribution!(user.id, @wipe_ip, @wipe_fp) + TagChanges.wipe_user_attribution!(user.id, @wipe_ip, @wipe_fp) + UserIps.delete_for_user!(user.id) + UserFingerprints.delete_for_user!(user.id) + Users.replace_email_for_wipe!(user.id, "deactivated#{random_hex}@example.com") + + Users.reindex_user(user) + end +end diff --git a/lib/philomena/versions.ex b/lib/philomena/versions.ex index a2e05e7ec..0dd9c8328 100644 --- a/lib/philomena/versions.ex +++ b/lib/philomena/versions.ex @@ -1,43 +1,75 @@ defmodule Philomena.Versions do @moduledoc """ - The Versions context. - - Edit histories for posts and comments. Version rows are after-edit - snapshots: each row holds the body and edit reason as of one edit, made by - `user_id` at `created_at`. The state an item had before its first edit - lives in an initial row stamped with the item's author and creation time, - created lazily when the item is first edited — never-edited items have no - version rows at all. + Edit history for posts and comments. + + History reads accept loaded parents only. Authorization remains in + `Philomena.Posts` and `Philomena.Comments`. History writes compose into the + same `Ecto.Multi` as the parent update, so neither change can commit alone. + + Version rows are after-edit snapshots. On the first meaningful edit, an + initial row captures the parent's original state and attribution before the + edited snapshot is inserted. An update that changes neither the body nor the + edit reason creates no history rows. """ import Ecto.Query, warn: false - alias Philomena.Repo + alias Philomena.Multi + alias Philomena.Attribution.Actor alias Philomena.Comments.Comment alias Philomena.Comments.CommentVersion alias Philomena.Posts.Post alias Philomena.Posts.PostVersion + alias Philomena.Repo + alias Philomena.Users.User - @doc """ - Returns the most recent versions of a post, prepared for display. + defp meaningful_edit?(original, updated) do + original.body != updated.body or original.edit_reason != updated.edit_reason + end - Each returned version carries `previous_body` from the next-older row and - `parent` for attribution; the oldest row of an item's history only serves - as a diff base and is not returned as an entry. Versions are returned - newest-first, with `user` (and awards) preloaded, at most 25. - """ - def load_post_versions(post), do: load_versions(PostVersion, :post_id, post) + defp maybe_insert_initial(repo, schema, foreign_key, original) do + if repo.exists?(where(schema, [version], field(version, ^foreign_key) == ^original.id)) do + :ok + else + schema + |> struct([ + {foreign_key, original.id}, + {:user_id, original.user_id}, + {:body, original.body}, + {:created_at, original.created_at} + ]) + |> repo.insert() + |> case do + {:ok, _version} -> :ok + {:error, changeset} -> {:error, changeset} + end + end + end - @doc """ - Returns the most recent versions of a comment, prepared for display. + defp insert_snapshot(repo, schema, foreign_key, updated, editor_id) do + schema + |> struct([ + {foreign_key, updated.id}, + {:user_id, editor_id}, + {:body, updated.body}, + {:edit_reason, updated.edit_reason} + ]) + |> repo.insert() + end - See `load_post_versions/1`. - """ - def load_comment_versions(comment), do: load_versions(CommentVersion, :comment_id, comment) + defp persist_edit(repo, schema, foreign_key, original, updated, %User{} = editor) do + if meaningful_edit?(original, updated) do + with :ok <- maybe_insert_initial(repo, schema, foreign_key, original) do + insert_snapshot(repo, schema, foreign_key, updated, editor.id) + end + else + {:ok, nil} + end + end - defp load_versions(schema, fk, parent) do + defp load_versions(schema, foreign_key, parent) do schema - |> where([v], field(v, ^fk) == ^parent.id) + |> where([version], field(version, ^foreign_key) == ^parent.id) |> order_by(desc: :created_at, desc: :id) |> limit(26) |> preload(user: [awards: :badge]) @@ -49,43 +81,82 @@ defmodule Philomena.Versions do end @doc """ - Records an edit of a post or comment, given the item as it was before the - edit and as it is after. + Loads version history for an already authorized post. + + Results are newest first, limited to 25, and carry their parent and previous + body for rendering a diff. A never-edited post returns `[]`. - Inserts the after-edit version row, preceded by the initial row capturing - the pre-first-edit state if this is the item's first recorded edit. Must - run inside the transaction that updated the item: the item's row lock - serializes concurrent edits, making the first-edit check race-free. + ## Examples + + iex> for_post(authorized_post) + [%PostVersion{}, ...] - Returns `{:ok, version}`, shaped for `Ecto.Multi.run/3`. """ - def record_edit(repo, %Post{} = original, %Post{} = updated, editor) do - record_edit(repo, PostVersion, :post_id, original, updated, editor) - end + @spec for_post(Post.t()) :: [PostVersion.t()] + def for_post(%Post{} = post), do: load_versions(PostVersion, :post_id, post) - def record_edit(repo, %Comment{} = original, %Comment{} = updated, editor) do - record_edit(repo, CommentVersion, :comment_id, original, updated, editor) - end + @doc """ + Loads version history for an already authorized comment. - defp record_edit(repo, schema, fk, original, updated, editor) do - unless repo.exists?(where(schema, [v], field(v, ^fk) == ^original.id)) do - repo.insert!( - struct(schema, [ - {fk, original.id}, - {:user_id, original.user_id}, - {:body, original.body || ""}, - {:created_at, original.created_at} - ]) - ) - end + Results are newest first, limited to 25, and carry their parent and previous + body for rendering a diff. A never-edited comment returns `[]`. - repo.insert( - struct(schema, [ - {fk, updated.id}, - {:user_id, editor.id}, - {:body, updated.body || ""}, - {:edit_reason, updated.edit_reason} - ]) - ) + ## Examples + + iex> for_comment(authorized_comment) + [%CommentVersion{}, ...] + + """ + @spec for_comment(Comment.t()) :: [CommentVersion.t()] + def for_comment(%Comment{} = comment), + do: load_versions(CommentVersion, :comment_id, comment) + + @doc """ + Adds post or comment version history to an owning update Multi. + + `original_step` and `updated_step` must name prior steps returning the loaded + parent before and after its update. Both must have the same supported parent + type. The actor's user attributes the edited snapshot. This step inserts the + initial and edited snapshots atomically with the parent update, or returns + `nil` without inserting history when body and edit reason are unchanged. + + The parent's update lock serializes concurrent edits. Same-second snapshots + are ordered by their increasing database ids. + + ## Examples + + iex> record_edit(multi, :version, :original, :updated, actor) + %Ecto.Multi{} + + """ + @spec record_edit( + multi :: Multi.t(), + name :: Multi.name(), + original_step :: Multi.name(), + updated_step :: Multi.name(), + actor :: Actor.t() + ) :: Multi.t() + def record_edit( + %Multi{} = multi, + name, + original_step, + updated_step, + %Actor{user: %User{} = editor} + ) do + Multi.run(multi, name, fn repo, changes -> + original = Map.fetch!(changes, original_step) + updated = Map.fetch!(changes, updated_step) + + case {original, updated} do + {%Post{}, %Post{}} -> + persist_edit(repo, PostVersion, :post_id, original, updated, editor) + + {%Comment{}, %Comment{}} -> + persist_edit(repo, CommentVersion, :comment_id, original, updated, editor) + + _other -> + {:error, :invalid_parent} + end + end) end end diff --git a/lib/philomena/versions/legacy_backfill.ex b/lib/philomena/versions/legacy_backfill.ex index 095333b9a..42c438f30 100644 --- a/lib/philomena/versions/legacy_backfill.ex +++ b/lib/philomena/versions/legacy_backfill.ex @@ -16,8 +16,11 @@ defmodule Philomena.Versions.LegacyBackfill do application code begins serving edits, via `Philomena.Release.backfill_versions/0`. - Deprecated as of Release 1.4.0 - TODO: remove in 2.1.0 + This remains supported while deployed installations may still have the + `versions_legacy` table and `Philomena.Release.backfill_versions/0` remains a + release entry point. Remove it only in a dedicated schema-cleanup change after + that compatibility window is explicitly closed and deployed backfills have + been verified. """ alias Philomena.Repo diff --git a/lib/philomena/workers/gallery_reorder_worker.ex b/lib/philomena/workers/gallery_reorder_worker.ex deleted file mode 100644 index ceea174b4..000000000 --- a/lib/philomena/workers/gallery_reorder_worker.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Philomena.GalleryReorderWorker do - alias Philomena.Galleries - - def perform(gallery_id, image_ids) do - Galleries.perform_reorder(gallery_id, image_ids) - end -end diff --git a/lib/philomena/workers/tag_change_revert_worker.ex b/lib/philomena/workers/tag_change_revert_worker.ex index e1474cafa..f4dbc8303 100644 --- a/lib/philomena/workers/tag_change_revert_worker.ex +++ b/lib/philomena/workers/tag_change_revert_worker.ex @@ -4,10 +4,8 @@ defmodule Philomena.TagChangeRevertWorker do image so each image's tag history is reverted in a single operation. """ - alias Philomena.TagChanges.TagChange alias Philomena.TagChanges - alias PhilomenaQuery.Batch - alias Philomena.Repo + alias Philomena.TagChanges.TagChange import Ecto.Query def perform(%{"user_id" => user_id, "attributes" => attributes}) do @@ -29,17 +27,12 @@ defmodule Philomena.TagChangeRevertWorker do end defp revert_all(queryable, attributes) do - batch_size = attributes["batch_size"] || 100 - attributes = Map.delete(attributes, "batch_size") - - # Batch on image_id, never on tag change id: a batch boundary that splits - # one image's tag history would un-cancel a `+tag`/`-tag` pair - queryable - |> Batch.query_batches(batch_size: batch_size, id_field: :image_id) - |> Enum.each(fn queryable -> - ids = Repo.all(select(queryable, [tc], tc.id)) - TagChanges.mass_revert(ids, cast_ip(atomify_keys(attributes))) - end) + attributes = cast_ip(atomify_keys(attributes)) + + case TagChanges.revert_all_for_worker(queryable, attributes) do + :ok -> :ok + {:error, reason} -> raise "tag change batch revert failed: #{inspect(reason)}" + end end defp atomify_keys(map) do diff --git a/lib/philomena/workers/tag_unalias_worker.ex b/lib/philomena/workers/tag_unalias_worker.ex deleted file mode 100644 index 2f39b45c4..000000000 --- a/lib/philomena/workers/tag_unalias_worker.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Philomena.TagUnaliasWorker do - alias Philomena.Tags - - def perform(tag_id) do - Tags.perform_unalias(tag_id) - end -end diff --git a/lib/philomena/workers/thumbnail_worker.ex b/lib/philomena/workers/thumbnail_worker.ex index fb943c4cd..225023664 100644 --- a/lib/philomena/workers/thumbnail_worker.ex +++ b/lib/philomena/workers/thumbnail_worker.ex @@ -12,7 +12,7 @@ defmodule Philomena.ThumbnailWorker do ) image_id - |> Images.get_image!() + |> Images.load_image_for_reindex!() |> Images.reindex_image() end end diff --git a/lib/philomena/workers/user_erase_worker.ex b/lib/philomena/workers/user_erase_worker.ex index 32c862d37..895058e93 100644 --- a/lib/philomena/workers/user_erase_worker.ex +++ b/lib/philomena/workers/user_erase_worker.ex @@ -3,8 +3,8 @@ defmodule Philomena.UserEraseWorker do alias Philomena.Users def perform(user_id, moderator_id) do - moderator = Users.get_user!(moderator_id) - user = Users.get_user!(user_id) + moderator = Users.fetch_user_for_erase!(moderator_id) + user = Users.fetch_user_for_worker!(user_id) Eraser.erase_permanently!(user, moderator) end diff --git a/lib/philomena/workers/user_unvote_worker.ex b/lib/philomena/workers/user_unvote_worker.ex index 9db5d3eae..8c8f8cb23 100644 --- a/lib/philomena/workers/user_unvote_worker.ex +++ b/lib/philomena/workers/user_unvote_worker.ex @@ -1,5 +1,5 @@ defmodule Philomena.UserUnvoteWorker do - alias Philomena.UserDownvoteWipe + alias Philomena.Users.UserDownvoteWipe def perform(user_id, votes_and_faves_too?) do UserDownvoteWipe.perform(user_id, votes_and_faves_too?) diff --git a/lib/philomena/workers/user_wipe_worker.ex b/lib/philomena/workers/user_wipe_worker.ex index ef86ffe5d..c4f2475a1 100644 --- a/lib/philomena/workers/user_wipe_worker.ex +++ b/lib/philomena/workers/user_wipe_worker.ex @@ -1,5 +1,5 @@ defmodule Philomena.UserWipeWorker do - alias Philomena.UserWipe + alias Philomena.Users.UserWipe def perform(user_id) do UserWipe.perform(user_id) diff --git a/lib/philomena_media/analyzers.ex b/lib/philomena_media/analyzers.ex index 7c97e8454..81fd9c141 100644 --- a/lib/philomena_media/analyzers.ex +++ b/lib/philomena_media/analyzers.ex @@ -6,6 +6,7 @@ defmodule PhilomenaMedia.Analyzers do alias PhilomenaMedia.Analyzers.{Gif, Jpeg, Png, Svg, Webm} alias PhilomenaMedia.Analyzers.Result alias PhilomenaMedia.Mime + alias PhilomenaMedia.Upload @doc """ Returns an `{:ok, analyzer}` tuple, with the analyzer being a module capable @@ -40,17 +41,17 @@ defmodule PhilomenaMedia.Analyzers do def analyzer(_content_type), do: :error @doc """ - Attempts a MIME type check and analysis on the given `m:Plug.Upload`. + Attempts a MIME type check and analysis on the given `m:PhilomenaMedia.Upload`. ## Examples - file = %Plug.Upload{...} + file = %PhilomenaMedia.Upload{...} {:ok, %Result{...}} = Analyzers.analyze_upload(file) """ - @spec analyze_upload(Plug.Upload.t()) :: + @spec analyze_upload(Upload.t()) :: {:ok, Result.t()} | {:unsupported_mime, Mime.t()} | :error - def analyze_upload(%Plug.Upload{path: path}), do: analyze_path(path) + def analyze_upload(%Upload{path: path}), do: analyze_path(path) def analyze_upload(_upload), do: :error @doc """ diff --git a/lib/philomena_media/upload.ex b/lib/philomena_media/upload.ex new file mode 100644 index 000000000..f402e7f63 --- /dev/null +++ b/lib/philomena_media/upload.ex @@ -0,0 +1,43 @@ +defmodule PhilomenaMedia.Upload do + @moduledoc """ + An uploaded file. + + Contains two fields: + * `:path` - the path to the uploaded file on the filesystem + * `:filename` - the chosen filename for the file + """ + + @enforce_keys [:path, :filename] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + path: String.t(), + filename: String.t() + } + + @doc """ + Converts a `m:Plug.Upload` to a `m:PhilomenaMedia.Upload`. + """ + @spec from_plug(Plug.Upload.t() | nil) :: t() | nil + def from_plug(nil), do: nil + + def from_plug(%Plug.Upload{path: path, filename: filename}) do + %__MODULE__{path: path, filename: filename} + end + + @doc """ + Extracts the `m:Plug.Upload` parameter named `name` from `params` + and wraps it in `m:PhilomenaMedia.Upload`. Returns `nil` if + the parameter did not exist or was the wrong type. + """ + @spec cast(params :: term(), name :: String.t()) :: t() | nil + def cast(params, name) do + case params do + %{^name => %Plug.Upload{} = upload} -> + from_plug(upload) + + _ -> + nil + end + end +end diff --git a/lib/philomena_media/uploader.ex b/lib/philomena_media/uploader.ex index 7248d92c6..cb683e783 100644 --- a/lib/philomena_media/uploader.ex +++ b/lib/philomena_media/uploader.ex @@ -39,8 +39,8 @@ defmodule PhilomenaMedia.Uploader do @field_name "foo" - def analyze_upload(schema, params) do - Uploader.analyze_upload(schema, @field_name, params[@field_name], &Schema.foo_changeset/2) + def analyze_upload(schema, upload) do + Uploader.analyze_upload(schema, @field_name, upload, &Schema.foo_changeset/2) end def persist_upload(schema) do @@ -61,10 +61,11 @@ defmodule PhilomenaMedia.Uploader do alias Philomena.Schemas.Schema alias Philomena.Schemas.Uploader - @spec create_schema(map()) :: {:ok, Schema.t()} | {:error, Ecto.Changeset.t()} - def create_schema(attrs) do + @spec create_schema(map(), Upload.t() | nil) :: + {:ok, Schema.t()} | {:error, Ecto.Changeset.t()} + def create_schema(attrs, upload) do %Schema{} - |> Uploader.analyze_upload(attrs) + |> Uploader.analyze_upload(upload) |> Repo.insert() |> case do {:ok, schema} -> @@ -77,10 +78,11 @@ defmodule PhilomenaMedia.Uploader do end end - @spec update_schema(Schema.t(), map()) :: {:ok, Schema.t()} | {:error, Ecto.Changeset.t()} - def update_schema(%Schema{} = schema, attrs) do + @spec update_schema(Schema.t(), map(), Upload.t() | nil) :: + {:ok, Schema.t()} | {:error, Ecto.Changeset.t()} + def update_schema(%Schema{} = schema, attrs, upload) do schema - |> Uploader.analyze_upload(attrs) + |> Uploader.analyze_upload(upload) |> Repo.update() |> case do {:ok, schema} -> @@ -109,6 +111,7 @@ defmodule PhilomenaMedia.Uploader do alias PhilomenaMedia.Filename alias PhilomenaMedia.Objects alias PhilomenaMedia.Sha512 + alias PhilomenaMedia.Upload import Ecto.Changeset @type schema :: struct() @@ -118,8 +121,8 @@ defmodule PhilomenaMedia.Uploader do @type file_root :: String.t() @doc """ - Performs analysis of the specified `m:Plug.Upload`, and invokes a changeset callback on the schema - or changeset passed in. + Performs analysis of the specified `m:PhilomenaMedia.Upload`, and invokes a changeset callback + on the schema or changeset passed in. The file name which will be written to is set by the assignment to the schema's `field_name`, and the below attributes are prefixed by the `field_name`. @@ -197,21 +200,21 @@ defmodule PhilomenaMedia.Uploader do ## Example - @spec analyze_upload(Uploader.schema_or_changeset(), map()) :: Ecto.Changeset.t() - def analyze_upload(schema, params) do - Uploader.analyze_upload(schema, "foo", params["foo"], &Schema.foo_changeset/2) + @spec analyze_upload(Uploader.schema_or_changeset(), Upload.t() | nil) :: Ecto.Changeset.t() + def analyze_upload(schema, upload) do + Uploader.analyze_upload(schema, "foo", upload, &Schema.foo_changeset/2) end """ @spec analyze_upload( schema_or_changeset(), field_name(), - Plug.Upload.t(), + Upload.t() | nil, (schema_or_changeset(), map() -> Ecto.Changeset.t()) ) :: Ecto.Changeset.t() - def analyze_upload(schema_or_changeset, field_name, upload_parameter, changeset_fn) do - with {:ok, analysis} <- Analyzers.analyze_upload(upload_parameter), - analysis <- extra_attributes(analysis, upload_parameter) do + def analyze_upload(schema_or_changeset, field_name, upload, changeset_fn) do + with {:ok, analysis} <- Analyzers.analyze_upload(upload), + analysis <- extra_attributes(analysis, upload) do removed = schema_or_changeset |> change() @@ -234,7 +237,7 @@ defmodule PhilomenaMedia.Uploader do } |> prefix_attributes(field_name) |> Map.put(field_name, analysis.new_name) - |> Map.put(upload_key(field_name), upload_parameter.path) + |> Map.put(upload_key(field_name), upload.path) |> Map.put(remove_key(field_name), removed) changeset_fn.(schema_or_changeset, attributes) @@ -324,7 +327,7 @@ defmodule PhilomenaMedia.Uploader do |> try_remove(file_root) end - defp extra_attributes(analysis, %Plug.Upload{path: path, filename: filename}) do + defp extra_attributes(analysis, %Upload{path: path, filename: filename}) do {width, height} = analysis.dimensions aspect_ratio = aspect_ratio(width, height) diff --git a/lib/philomena_query/batch.ex b/lib/philomena_query/batch.ex index bb9c2c74d..5927543dd 100644 --- a/lib/philomena_query/batch.ex +++ b/lib/philomena_query/batch.ex @@ -86,7 +86,7 @@ defmodule PhilomenaQuery.Batch do > #### Info {: .info} > > If you are looking to receive schema structures (e.g., you are querying for `Image`s, - > and you want to receive `Image` objects, then use `record_batches/3` instead. + > and you want to receive `Image` structs), then use `record_batches/2` instead. `m:Ecto.Query` structs which select the IDs in each batch are streamed out. @@ -120,6 +120,47 @@ defmodule PhilomenaQuery.Batch do ) end + @doc """ + Stream bulk queries on a queryable in batches until the query no longer + matches any rows. + + `m:Ecto.Query` structs which select the IDs in each batch are streamed out. + + This is intended for maintenance operations which change the rows matching + the query while they run. The operation consuming the stream must cause + processed rows to stop matching the query. Otherwise, the stream will cycle + infinitely. + + Valid options: + * `batch_size` (integer) - the number of records to load per batch + * `id_field` (atom) - the name of the field containing the ID + + ## Example + + queryable = from ui in ImageVote, where: ui.user_id == 1234 + + queryable + |> PhilomenaQuery.Batch.query_batches_until_empty(id_field: :image_id) + |> Enum.each(fn batch_query -> Repo.delete_all(batch_query) end) + + """ + @spec query_batches_until_empty(queryable(), batch_options()) :: Enumerable.t(Ecto.Query.t()) + def query_batches_until_empty(queryable, opts \\ []) do + id_field = Keyword.get(opts, :id_field, :id) + + Stream.unfold(nil, fn nil -> + case load_ids(queryable, -1, opts) do + [] -> + # Stop when no more results are produced + nil + + ids -> + # Process results + {where(queryable, [m], field(m, ^id_field) in ^ids), nil} + end + end) + end + defp load_ids(queryable, max_id, opts) do id_field = Keyword.get(opts, :id_field, :id) batch_size = Keyword.get(opts, :batch_size, 1000) diff --git a/lib/philomena_query/ecto/query_validator.ex b/lib/philomena_query/ecto/query_validator.ex index 61b55183e..6812a408b 100644 --- a/lib/philomena_query/ecto/query_validator.ex +++ b/lib/philomena_query/ecto/query_validator.ex @@ -13,7 +13,7 @@ defmodule PhilomenaQuery.Ecto.QueryValidator do filter |> cast(attrs, [:complex]) |> validate_required([:complex]) - |> validate_query([:complex], with: &Query.compile(&1, user: user)) + |> validate_query(:complex, with: &Query.compile(&1, user: user)) end end @@ -23,44 +23,41 @@ defmodule PhilomenaQuery.Ecto.QueryValidator do alias PhilomenaQuery.Parse.String @doc """ - Validates a query string using the provided attribute(s) and compiler. + Validates a query string using the provided attribute and compiler. - Returns the changeset as-is, or with an `"is invalid"` error added to validated field. + Returns the changeset as-is, or with an `"is invalid"` error added to the validated field. ## Examples - # With single attribute + # Simple validation filter |> cast(attrs, [:complex]) - |> validate_query(:complex, &Query.compile(&1, user: user)) + |> validate_query(:complex, with: &Query.compile(&1, user: user)) - # With list of attributes - filter - |> cast(attrs, [:spoilered_complex, :hidden_complex]) - |> validate_query([:spoilered_complex, :hidden_complex], &Query.compile(&1, user: user)) + # Persisting the compiled query + query_form + |> cast(attrs, [:query]) + |> validate_query(:query, with: &Query.compile/1, into: :compiled_query) """ - def validate_query(changeset, attr_or_attr_list, callback) - - def validate_query(changeset, attr_list, callback) when is_list(attr_list) do - Enum.reduce(attr_list, changeset, fn attr, changeset -> - validate_query(changeset, attr, callback) - end) - end - - def validate_query(changeset, attr, callback) do - if changed?(changeset, attr) do - validate_assuming_changed(changeset, attr, callback) + @spec validate_query(Ecto.Changeset.t(), atom(), Keyword.t()) :: Ecto.Changeset.t() + def validate_query(changeset, attr, opts) do + callback = Keyword.fetch!(opts, :with) + default = Keyword.get(opts, :default, "") + into = Keyword.get(opts, :into) + + if changed?(changeset, attr) or not is_nil(into) do + validate_assuming_changed(changeset, attr, callback, default, into) else changeset end end - defp validate_assuming_changed(changeset, attr, callback) do - with value when is_binary(value) <- fetch_change!(changeset, attr) || "", + defp validate_assuming_changed(changeset, attr, callback, default, into) do + with value when is_binary(value) <- fetch_field!(changeset, attr) || default, value <- String.normalize(value), - {:ok, _} <- callback.(value) do - changeset + {:ok, compiled} <- callback.(value) do + maybe_persist_compilation(changeset, compiled, into) else {:error, msg} -> add_error(changeset, attr, "is invalid: #{msg}") @@ -69,4 +66,9 @@ defmodule PhilomenaQuery.Ecto.QueryValidator do add_error(changeset, attr, "is invalid") end end + + defp maybe_persist_compilation(changeset, _result, nil), do: changeset + + defp maybe_persist_compilation(changeset, result, field), + do: put_change(changeset, field, result) end diff --git a/lib/philomena_query/parse/parser.ex b/lib/philomena_query/parse/parser.ex index 59e0382b9..f7ee3ee4b 100644 --- a/lib/philomena_query/parse/parser.ex +++ b/lib/philomena_query/parse/parser.ex @@ -235,6 +235,51 @@ defmodule PhilomenaQuery.Parse.Parser do end end + @doc """ + Returns the values referenced by exact `term` clauses for `field`. + + This metadata helper understands every query container emitted by the + parser, so consumers do not need partial, domain-specific query-shape + matchers. + + ## Examples + + iex> referenced_term_values(%{term: %{"tags" => "safe"}}, "tags") + ["safe"] + + """ + @spec referenced_term_values(query(), String.t()) :: [term()] + def referenced_term_values(query, field) when is_map(query) and is_binary(field) do + query + |> collect_term_values(field) + |> Enum.uniq() + end + + defp collect_term_values(%{term: terms} = query, field) when is_map(terms) do + own = + case Map.fetch(terms, field) do + {:ok, value} -> [value] + :error -> [] + end + + own ++ + (query + |> Map.delete(:term) + |> Map.values() + |> Enum.flat_map(&collect_term_values(&1, field))) + end + + defp collect_term_values(map, field) when is_map(map) do + map + |> Map.values() + |> Enum.flat_map(&collect_term_values(&1, field)) + end + + defp collect_term_values(list, field) when is_list(list), + do: Enum.flat_map(list, &collect_term_values(&1, field)) + + defp collect_term_values(_value, _field), do: [] + defp coerce_string(term) when is_binary(term), do: {:ok, term} defp coerce_string(nil), do: {:ok, ""} defp coerce_string(_), do: {:error, "search query is not a string"} diff --git a/lib/philomena_query/search.ex b/lib/philomena_query/search.ex index a62f46a7d..773a5c7ff 100644 --- a/lib/philomena_query/search.ex +++ b/lib/philomena_query/search.ex @@ -76,6 +76,9 @@ defmodule PhilomenaQuery.Search do page_size: integer() } + @type named_search_definitions :: [{atom(), search_definition()}] + @type named_record_searches :: [{atom(), {search_definition(), queryable()}}] + @type pagination_params :: %{ optional(:page_number) => integer(), optional(:page_size) => integer() @@ -441,7 +444,7 @@ defmodule PhilomenaQuery.Search do each instance of a schema struct and can index with hundreds of times the throughput. The queryable should be a schema type with its indexing preloads included in - the query. The options are forwarded to `PhilomenaQuery.Batch.record_batches/3`. + the query. The options are forwarded to `PhilomenaQuery.Batch.record_batches/2`. Note that indexing is near real-time and requires an index refresh before documents will become visible. Unless changed in the mapping, this happens after 5 seconds have elapsed. @@ -518,7 +521,7 @@ defmodule PhilomenaQuery.Search do each instance of a schema struct and can index with hundreds of times the throughput. The queryable should be a schema type with its indexing preloads included in - the query. The options are forwarded to `PhilomenaQuery.Batch.record_batches/3`. + the query. The options are forwarded to `PhilomenaQuery.Batch.record_batches/2`. Note that indexing is near real-time and requires an index refresh before documents will become visible. Unless changed in the mapping, this happens after 5 seconds have elapsed. @@ -668,7 +671,8 @@ defmodule PhilomenaQuery.Search do end @doc ~S""" - Given maps of module and body, searches each index with the respective body. + Given a keyword list of names and search definitions, searches each index with the + respective body. Results retain the supplied names. `POST /_msearch` @@ -678,17 +682,17 @@ defmodule PhilomenaQuery.Search do ## Example iex> Search.msearch([ - ...> %{module: Image, body: %{query: %{match_all: %{}}}}, - ...> %{module: Post, body: %{query: %{match_all: %{}}}} + ...> images: %{module: Image, body: %{query: %{match_all: %{}}}}, + ...> posts: %{module: Post, body: %{query: %{match_all: %{}}}} ...> ]) - [ - %{"_shards" => ..., "hits" => ..., "timed_out" => false, "took" => 1}, - %{"_shards" => ..., "hits" => ..., "timed_out" => false, "took" => 2} - ] + %{images: %{"_shards" => ..., "hits" => ...}, posts: %{"_shards" => ..., "hits" => ...}} """ - @spec msearch([search_definition()]) :: [map()] - def msearch(definitions) do + @spec msearch(named_search_definitions()) :: %{atom() => map()} + def msearch(named_definitions) do + names = Keyword.keys(named_definitions) + definitions = Keyword.values(named_definitions) + msearch_body = Enum.flat_map(definitions, fn def -> [ @@ -700,7 +704,13 @@ defmodule PhilomenaQuery.Search do {:ok, %{body: results, status: 200}} = Api.msearch(@policy.opensearch_url(), msearch_body) - results["responses"] + responses = results["responses"] + + if length(responses) != length(definitions) do + raise "multi-search returned #{length(responses)} responses for #{length(definitions)} queries" + end + + Map.new(Enum.zip(names, responses)) end @doc """ @@ -711,9 +721,9 @@ defmodule PhilomenaQuery.Search do - `search_results/1` - `msearch_results/1` - `search_records/2` - - `msearch_records/2` + - `msearch_records/1` - `search_records_with_hits/2` - - `msearch_records_with_hits/2` + - `msearch_records_with_hits/1` ## Example @@ -796,35 +806,41 @@ defmodule PhilomenaQuery.Search do end @doc """ - Given a list of search definitions, each generated by `search_definition/3`, submit the query - and return a corresponding list of `m:Scrivener.Page` for each query. + Given a keyword list of names and search definitions, submit the queries and return a map of + names to `m:Scrivener.Page` results. The `entries` in the page are a list of tuples of record IDs paired with the hit that generated them. ## Example - iex> Search.msearch_results([definition]) - [ - %Scrivener.Page{ + iex> Search.msearch_results(example: definition) + %{example: %Scrivener.Page{ entries: [{1, %{"_id" => "1", ...}}, ...], page_number: 1, page_size: 25, total_entries: 6, total_pages: 1 - } - ] + }} """ - @spec msearch_results([search_definition()]) :: [Scrivener.Page.t()] - def msearch_results(definitions) do - Enum.map(Enum.zip(msearch(definitions), definitions), fn {result, definition} -> - process_results(result, definition) + @spec msearch_results(named_search_definitions()) :: %{atom() => Scrivener.Page.t()} + def msearch_results(named_definitions) do + responses = msearch(named_definitions) + + Map.new(named_definitions, fn {name, definition} -> + {name, process_results(Map.fetch!(responses, name), definition)} end) end - defp load_records_from_results(results, ecto_queries) do - Enum.map(Enum.zip(results, ecto_queries), fn {page, ecto_query} -> + defp load_records_from_results(named_searches) do + named_definitions = + Keyword.new(named_searches, fn {name, {definition, _query}} -> {name, definition} end) + + pages = msearch_results(named_definitions) + + Map.new(named_searches, fn {name, {_definition, ecto_query}} -> + page = Map.fetch!(pages, name) {ids, hits} = Enum.unzip(page.entries) records = @@ -833,7 +849,7 @@ defmodule PhilomenaQuery.Search do |> Repo.all() |> Enum.sort_by(&Enum.find_index(ids, fn el -> el == &1.id end)) - %{page | entries: Enum.zip(records, hits)} + {name, %{page | entries: Enum.zip(records, hits)}} end) end @@ -858,35 +874,33 @@ defmodule PhilomenaQuery.Search do """ @spec search_records_with_hits(search_definition(), queryable()) :: Scrivener.Page.t() def search_records_with_hits(definition, ecto_query) do - [page] = load_records_from_results([search_results(definition)], [ecto_query]) - + %{default: page} = load_records_from_results(default: {definition, ecto_query}) page end @doc """ - Given a list of search definitions, each generated by `search_definition/3`, submit the query - and return a corresponding list of `m:Scrivener.Page` for each query. + Given a keyword list pairing names with `{definition, queryable}` tuples, submit the queries and + return a map of names to `m:Scrivener.Page` results. The `entries` in the page are a list of tuples of schema structs paired with the hit that generated them. ## Example - iex> Search.msearch_records_with_hits([definition], [preload(Image, :tags)]) - [ + iex> Search.msearch_records_with_hits(example: {definition, preload(Image, :tags)}) + %{example: %Scrivener.Page{ entries: [{%Image{id: 1, ...}, %{"_id" => "1", ...}}, ...], page_number: 1, page_size: 25, total_entries: 6, total_pages: 1 - } - ] + }} """ - @spec msearch_records_with_hits([search_definition()], [queryable()]) :: [Scrivener.Page.t()] - def msearch_records_with_hits(definitions, ecto_queries) do - load_records_from_results(msearch_results(definitions), ecto_queries) + @spec msearch_records_with_hits(named_record_searches()) :: %{atom() => Scrivener.Page.t()} + def msearch_records_with_hits(named_searches) do + load_records_from_results(named_searches) end @doc """ @@ -916,31 +930,30 @@ defmodule PhilomenaQuery.Search do end @doc """ - Given a list of search definitions, each generated by `search_definition/3`, submit the query - and return a corresponding list of `m:Scrivener.Page` for each query. + Given a keyword list pairing names with `{definition, queryable}` tuples, submit the queries and + return a map of names to `m:Scrivener.Page` results. The `entries` in the page are a list of schema structs. ## Example - iex> Search.msearch_records([definition], [preload(Image, :tags)]) - [ + iex> Search.msearch_records(example: {definition, preload(Image, :tags)}) + %{example: %Scrivener.Page{ entries: [%Image{id: 1, ...}, ...], page_number: 1, page_size: 25, total_entries: 6, total_pages: 1 - } - ] + }} """ - @spec msearch_records([search_definition()], [queryable()]) :: [Scrivener.Page.t()] - def msearch_records(definitions, ecto_queries) do - Enum.map(load_records_from_results(msearch_results(definitions), ecto_queries), fn page -> + @spec msearch_records(named_record_searches()) :: %{atom() => Scrivener.Page.t()} + def msearch_records(named_searches) do + Map.new(msearch_records_with_hits(named_searches), fn {name, page} -> {records, _hits} = Enum.unzip(page.entries) - %{page | entries: records} + {name, %{page | entries: records}} end) end diff --git a/lib/philomena_web.ex b/lib/philomena_web.ex index c1d533abd..b66366147 100644 --- a/lib/philomena_web.ex +++ b/lib/philomena_web.ex @@ -28,8 +28,6 @@ defmodule PhilomenaWeb do use Gettext, backend: PhilomenaWeb.Gettext import Plug.Conn - import PhilomenaWeb.CanaryPlugs - import PhilomenaWeb.ModerationLogPlug, only: [moderation_log: 2] unquote(verified_routes()) end diff --git a/lib/philomena_web/comment_loader.ex b/lib/philomena_web/comment_loader.ex deleted file mode 100644 index c97bac81a..000000000 --- a/lib/philomena_web/comment_loader.ex +++ /dev/null @@ -1,140 +0,0 @@ -defmodule PhilomenaWeb.CommentLoader do - alias Philomena.Comments.Comment - alias Philomena.Repo - alias PhilomenaQuery.Search - import Ecto.Query - - def load_comments(conn, image) do - user = conn.assigns.current_user - direction = load_direction(user) - - query_all(conn, image) - |> order_by([{^direction, :created_at}]) - |> preload([:image, :deleted_by, user: [awards: :badge]]) - |> Repo.paginate(conn.assigns.comment_scrivener) - end - - def find_page(conn, image, comment_id) do - user = conn.assigns.current_user - - comment = - Comment - |> where(image_id: ^image.id) - |> where(id: ^comment_id) - |> Repo.one!() - - offset = - query_all(conn, image) - |> filter_direction(comment.created_at, user) - |> Repo.aggregate(:count, :id) - - page_size = conn.assigns.comment_scrivener[:page_size] - - # Pagination starts at page 1 - div(offset, page_size) + 1 - end - - def last_page(conn, image) do - offset = - query_all(conn, image) - |> Repo.aggregate(:count, :id) - - page_size = conn.assigns.comment_scrivener[:page_size] - - # Pagination starts at page 1 - div(offset, page_size) + 1 - end - - defp query_all(conn, image) do - user = conn.assigns.current_user - show_hidden? = staff?(user) - - Comment - |> where(image_id: ^image.id) - |> filter_deleted(show_hidden?) - |> filter_non_approved(user, show_hidden?) - end - - defp staff?(%{role: role}) when role in ~W(assistant moderator admin), do: true - defp staff?(_user), do: false - - defp load_direction(%{settings: %{comments_newest_first: false}}), do: :asc - defp load_direction(_user), do: :desc - - defp filter_deleted(query, true), do: query - defp filter_deleted(query, _show_hidden?), do: where(query, [c], not c.destroyed_content) - - defp filter_non_approved(query, _user, true), do: query - - defp filter_non_approved(query, %{id: user_id}, _show_hidden?), - do: where(query, [c], c.approved or c.user_id == ^user_id) - - defp filter_non_approved(query, _user, _show_hidden?), - do: where(query, [c], c.approved) - - defp filter_direction(query, time, %{settings: %{comments_newest_first: false}}), - do: where(query, [c], c.created_at <= ^time) - - defp filter_direction(query, time, _user), - do: where(query, [c], c.created_at >= ^time) - - def query(conn, body, options \\ []) do - pagination = Keyword.get(options, :pagination, conn.assigns.pagination) - show_hidden? = Keyword.get(options, :show_hidden, true) - - user = conn.assigns.current_user - filter = conn.assigns.current_filter - filters = create_filters(user, filter, show_hidden?) - - Search.search_definition( - Comment, - %{ - query: %{ - bool: %{ - must: body, - must_not: filters - } - }, - sort: %{created_at: :desc} - }, - pagination - ) - end - - defp create_filters(user, filter, show_hidden?) do - show_hidden? = show_hidden? and staff?(user) - - [%{terms: %{"image.tag_ids" => filter.hidden_tag_ids}}] - |> hide_deleted(show_hidden?) - |> hide_non_approved(user, show_hidden?) - end - - defp hide_deleted(filters, true), do: filters - - defp hide_deleted(filters, _show_hidden?), - do: [ - %{term: %{hidden_from_users: true}}, - %{term: %{"image.hidden_from_users" => true}} - | filters - ] - - defp hide_non_approved(filters, _user, true), do: filters - - defp hide_non_approved(filters, %{id: user_id}, _show_hidden?), - do: [ - %{ - bool: %{ - should: [%{term: %{approved: false}}, %{term: %{"image.approved" => false}}], - must_not: [%{term: %{user_id: user_id}}] - } - } - | filters - ] - - defp hide_non_approved(filters, _user, _show_hidden?), - do: [ - %{term: %{approved: false}}, - %{term: %{"image.approved" => false}} - | filters - ] -end diff --git a/lib/philomena_web/controllers/activity_controller.ex b/lib/philomena_web/controllers/activity_controller.ex index 2c8dad54c..3ecc34fc4 100644 --- a/lib/philomena_web/controllers/activity_controller.ex +++ b/lib/philomena_web/controllers/activity_controller.ex @@ -1,161 +1,35 @@ defmodule PhilomenaWeb.ActivityController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias PhilomenaWeb.CommentLoader - alias PhilomenaQuery.Search + alias PhilomenaWeb.ImageScope + alias Philomena.Activities - alias Philomena.{ - Images.Image, - ImageFeatures.ImageFeature, - Comments.Comment, - Channels.Channel, - Topics.Topic, - Forums.Forum - } - - alias Philomena.Interactions - alias Philomena.Repo - import Ecto.Query + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - user = conn.assigns.current_user - - {images, _tags} = - ImageLoader.default_query(conn, - pagination: %{conn.assigns.image_pagination | page_number: 1} - ) - - {top_scoring, _tags} = - ImageLoader.query( + with {:ok, page} <- + Activities.show_activity( + conn.assigns.actor, + ImageScope.search_scope(conn), + conn.assigns.current_filter, + conn.cookies["chan_nsfw"] == "true" + ) do + render( conn, - %{range: %{first_seen_at: %{gt: "now-3d"}}}, - sorts: &%{query: &1, sorts: [%{wilson_score: :desc}, %{first_seen_at: :desc}]}, - pagination: %{page_number: :rand.uniform(6), page_size: 4} - ) - - comments = - CommentLoader.query( - conn, - %{range: %{created_at: %{gt: "now-1w"}}}, - pagination: %{page_number: 1, page_size: 6}, - show_hidden: false - ) - - watched = - if !!user do - {:ok, {watched_images, _tags}} = - ImageLoader.search_string( - conn, - "my:watched", - pagination: %{conn.assigns.image_pagination | page_number: 1} - ) - - watched_images - end - - [images, top_scoring, comments, watched] = - multi_search(images, top_scoring, comments, watched) - - featured_image = - Image - |> join(:inner, [i], f in ImageFeature, on: [image_id: i.id]) - |> where([i], i.hidden_from_users == false) - |> filter_hidden(user, conn.params["hidden"]) - |> order_by([i, f], desc: f.created_at) - |> limit(1) - |> preload([:sources, tags: :aliases]) - |> Repo.one() - - streams = - Channel - |> where([c], not is_nil(c.last_fetched_at)) - |> maybe_show_nsfw_channels(conn.cookies["chan_nsfw"]) - |> order_by(desc: :is_live, asc: :title) - |> limit(6) - |> Repo.all() - - topics = - Topic - |> join(:inner, [t], f in Forum, on: [id: t.forum_id]) - |> where([t, _f], t.hidden_from_users == false) - |> where([t, _f], fragment("? !~ ?", t.title, "NSFW")) - |> where([_t, f], f.access_level == "normal") - |> order_by(desc: :last_replied_to_at) - |> preload([:forum, last_post: :user]) - |> limit(6) - |> Repo.all() - - interactions = - Interactions.user_interactions( - [images, top_scoring, watched, featured_image], - user + "index.html", + title: "Homepage", + images: page.images, + comments: page.comments, + top_scoring: page.top_scoring, + watched: page.watched, + featured_image: page.featured_image, + streams: page.streams, + topics: page.topics, + interactions: page.interactions, + layout_class: "layout--wide", + show_sidebar: show_sidebar?(conn.assigns.current_user) ) - - render( - conn, - "index.html", - title: "Homepage", - images: images, - comments: comments, - top_scoring: top_scoring, - watched: watched, - featured_image: featured_image, - streams: streams, - topics: topics, - interactions: interactions, - layout_class: "layout--wide", - show_sidebar: show_sidebar?(user) - ) - end - - def filter_hidden(featured_image, nil, _hidden) do - featured_image - end - - def filter_hidden(featured_image, _user, "1") do - featured_image - end - - def filter_hidden(featured_image, user, _hidden) do - featured_image - |> where( - [i], - fragment( - "NOT EXISTS(SELECT 1 FROM image_hides WHERE image_id = ? AND user_id = ?)", - i.id, - ^user.id - ) - ) - end - - defp maybe_show_nsfw_channels(query, "true"), do: query - defp maybe_show_nsfw_channels(query, _falsy), do: where(query, [c], c.nsfw == false) - - defp multi_search(images, top_scoring, comments, nil) do - responses = - Search.msearch_records( - [images, top_scoring, comments], - [ - preload(Image, [:sources, tags: :aliases]), - preload(Image, [:sources, tags: :aliases]), - preload(Comment, [:user, image: [:sources, tags: :aliases]]) - ] - ) - - responses ++ [nil] - end - - defp multi_search(images, top_scoring, comments, watched) do - Search.msearch_records( - [images, top_scoring, comments, watched], - [ - preload(Image, [:sources, tags: :aliases]), - preload(Image, [:sources, tags: :aliases]), - preload(Comment, [:user, image: [:sources, tags: :aliases]]), - preload(Image, [:sources, tags: :aliases]) - ] - ) + end end defp show_sidebar?(%{settings: %{show_sidebar_and_watched_images: false}}), do: false diff --git a/lib/philomena_web/controllers/admin/advert/image_controller.ex b/lib/philomena_web/controllers/admin/advert/image_controller.ex index 931cb55f5..bf8eb6974 100644 --- a/lib/philomena_web/controllers/admin/advert/image_controller.ex +++ b/lib/philomena_web/controllers/admin/advert/image_controller.ex @@ -1,44 +1,31 @@ defmodule PhilomenaWeb.Admin.Advert.ImageController do use PhilomenaWeb, :controller - alias Philomena.Adverts.Advert alias Philomena.Adverts - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Advert, - id_name: "advert_id", - persisted: true, - only: [:edit, :update, :delete] - - def edit(conn, _params) do - changeset = Adverts.change_advert(conn.assigns.advert) - render(conn, "edit.html", title: "Editing Advert", changeset: changeset) + def edit(conn, %{"advert_id" => id}) do + with {:ok, {advert, changeset}} <- + Adverts.edit_advert(conn.assigns.actor, id) do + render(conn, "edit.html", title: "Editing Advert", advert: advert, changeset: changeset) + end end - def update(conn, %{"advert" => advert_params}) do - case Adverts.update_advert_image(conn.assigns.advert, advert_params) do - {:ok, advert} -> + def update(conn, %{"advert_id" => id, "advert" => advert_params}) do + upload = PhilomenaMedia.Upload.cast(advert_params, "image") + + case Adverts.update_advert_image(conn.assigns.actor, id, upload) do + {:ok, _advert} -> conn |> put_flash(:info, "Advert was successfully updated.") - |> moderation_log(details: &log_details/2, data: advert) |> redirect(to: ~p"/admin/adverts") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", advert: changeset.data, changeset: changeset) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Advert) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - - defp log_details(_action, advert) do - %{body: "Updated image for advert #{advert.id}", subject_path: ~p"/admin/adverts"} - end end diff --git a/lib/philomena_web/controllers/admin/advert_controller.ex b/lib/philomena_web/controllers/admin/advert_controller.ex index 467393d8b..f17bef1bb 100644 --- a/lib/philomena_web/controllers/admin/advert_controller.ex +++ b/lib/philomena_web/controllers/admin/advert_controller.ex @@ -1,88 +1,69 @@ defmodule PhilomenaWeb.Admin.AdvertController do use PhilomenaWeb, :controller - alias Philomena.Adverts.Advert alias Philomena.Adverts - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - plug :load_and_authorize_resource, model: Advert, only: [:edit, :update, :delete] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - adverts = - Advert - |> order_by(desc: :finish_date) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", - title: "Admin - Adverts", - layout_class: "layout--wide", - adverts: adverts - ) + with {:ok, adverts} <- Adverts.list_adverts(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", + title: "Admin - Adverts", + layout_class: "layout--wide", + adverts: adverts + ) + end end def new(conn, _params) do - changeset = Adverts.change_advert(%Advert{}) - render(conn, "new.html", title: "New Advert", changeset: changeset) + with {:ok, changeset} <- Adverts.new_advert(conn.assigns.actor) do + render(conn, "new.html", title: "New Advert", changeset: changeset) + end end def create(conn, %{"advert" => advert_params}) do - case Adverts.create_advert(advert_params) do - {:ok, advert} -> + upload = PhilomenaMedia.Upload.cast(advert_params, "image") + + case Adverts.create_advert(conn.assigns.actor, advert_params, upload) do + {:ok, _advert} -> conn |> put_flash(:info, "Advert was successfully created.") - |> moderation_log(details: &log_details/2, data: advert) |> redirect(to: ~p"/admin/adverts") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + {:error, reason} = error when reason in [:unauthorized, :ban] -> + error end end - def edit(conn, _params) do - changeset = Adverts.change_advert(conn.assigns.advert) - render(conn, "edit.html", title: "Editing Advert", changeset: changeset) + def edit(conn, %{"id" => id}) do + with {:ok, {advert, changeset}} <- Adverts.edit_advert(conn.assigns.actor, id) do + render(conn, "edit.html", title: "Editing Advert", advert: advert, changeset: changeset) + end end - def update(conn, %{"advert" => advert_params}) do - case Adverts.update_advert(conn.assigns.advert, advert_params) do - {:ok, advert} -> + def update(conn, %{"id" => id, "advert" => advert_params}) do + case Adverts.update_advert(conn.assigns.actor, id, advert_params) do + {:ok, _advert} -> conn |> put_flash(:info, "Advert was successfully updated.") - |> moderation_log(details: &log_details/2, data: advert) |> redirect(to: ~p"/admin/adverts") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, advert} = Adverts.delete_advert(conn.assigns.advert) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", advert: changeset.data, changeset: changeset) - conn - |> put_flash(:info, "Advert was successfully deleted.") - |> moderation_log(details: &log_details/2, data: advert) - |> redirect(to: ~p"/admin/adverts") + {:error, _} = error -> + error + end end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Advert) do + def delete(conn, %{"id" => id}) do + with {:ok, _advert} <- Adverts.delete_advert(conn.assigns.actor, id) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Advert was successfully deleted.") + |> redirect(to: ~p"/admin/adverts") end end - - defp log_details(action, advert) do - body = - case action do - :create -> "Created advert #{advert.id}" - :update -> "Updated advert #{advert.id}" - :delete -> "Deleted advert #{advert.id}" - end - - %{body: body, subject_path: ~p"/admin/adverts"} - end end diff --git a/lib/philomena_web/controllers/admin/approval_controller.ex b/lib/philomena_web/controllers/admin/approval_controller.ex index 731a8a136..33bcd1d20 100644 --- a/lib/philomena_web/controllers/admin/approval_controller.ex +++ b/lib/philomena_web/controllers/admin/approval_controller.ex @@ -1,28 +1,14 @@ defmodule PhilomenaWeb.Admin.ApprovalController do use PhilomenaWeb, :controller - alias Philomena.Images.Image - alias Philomena.Repo - import Ecto.Query + alias Philomena.Images - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - images = - Image - |> where(approved: false) - |> order_by(asc: :id) - |> preload([:user, :sources, tags: [:aliases, :aliased_tag]]) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", title: "Admin - Approval Queue", images: images) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :approve, %Image{}) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + with {:ok, images} <- + Images.list_approval_queue(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", title: "Admin - Approval Queue", images: images) end end end diff --git a/lib/philomena_web/controllers/admin/artist_link/contact_controller.ex b/lib/philomena_web/controllers/admin/artist_link/contact_controller.ex index df25d5bec..23d98a588 100644 --- a/lib/philomena_web/controllers/admin/artist_link/contact_controller.ex +++ b/lib/philomena_web/controllers/admin/artist_link/contact_controller.ex @@ -1,31 +1,15 @@ defmodule PhilomenaWeb.Admin.ArtistLink.ContactController do use PhilomenaWeb, :controller - alias Philomena.ArtistLinks.ArtistLink alias Philomena.ArtistLinks - plug PhilomenaWeb.CanaryMapPlug, create: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: ArtistLink, - id_name: "artist_link_id", - persisted: true, - preload: [:user] - - def create(conn, _params) do - {:ok, artist_link} = - ArtistLinks.contact_artist_link(conn.assigns.artist_link, conn.assigns.current_user) - - conn - |> put_flash(:info, "Artist successfully marked as contacted.") - |> moderation_log(details: &log_details/2, data: artist_link) - |> redirect(to: ~p"/admin/artist_links") - end - - defp log_details(_action, artist_link) do - %{ - body: "Contacted artist #{artist_link.user.name} at #{artist_link.uri}", - subject_path: ~p"/profiles/#{artist_link.user}/artist_links/#{artist_link}" - } + def create(conn, %{"artist_link_id" => id}) do + with {:ok, _artist_link} <- ArtistLinks.create_artist_link_contact(conn.assigns.actor, id) do + conn + |> put_flash(:info, "Artist successfully marked as contacted.") + |> redirect(to: ~p"/admin/artist_links") + end end end diff --git a/lib/philomena_web/controllers/admin/artist_link/reject_controller.ex b/lib/philomena_web/controllers/admin/artist_link/reject_controller.ex index b9aab7121..c2de71ead 100644 --- a/lib/philomena_web/controllers/admin/artist_link/reject_controller.ex +++ b/lib/philomena_web/controllers/admin/artist_link/reject_controller.ex @@ -1,30 +1,15 @@ defmodule PhilomenaWeb.Admin.ArtistLink.RejectController do use PhilomenaWeb, :controller - alias Philomena.ArtistLinks.ArtistLink alias Philomena.ArtistLinks - plug PhilomenaWeb.CanaryMapPlug, create: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: ArtistLink, - id_name: "artist_link_id", - persisted: true, - preload: [:user] - - def create(conn, _params) do - {:ok, artist_link} = ArtistLinks.reject_artist_link(conn.assigns.artist_link) - - conn - |> put_flash(:info, "Artist link successfully marked as rejected.") - |> moderation_log(details: &log_details/2, data: artist_link) - |> redirect(to: ~p"/admin/artist_links") - end - - defp log_details(_action, artist_link) do - %{ - body: "Rejected artist link #{artist_link.uri} created by #{artist_link.user.name}", - subject_path: ~p"/profiles/#{artist_link.user}/artist_links/#{artist_link}" - } + def create(conn, %{"artist_link_id" => id}) do + with {:ok, _artist_link} <- ArtistLinks.create_artist_link_reject(conn.assigns.actor, id) do + conn + |> put_flash(:info, "Artist link successfully marked as rejected.") + |> redirect(to: ~p"/admin/artist_links") + end end end diff --git a/lib/philomena_web/controllers/admin/artist_link/verification_controller.ex b/lib/philomena_web/controllers/admin/artist_link/verification_controller.ex index 3694f8125..21ffb6d55 100644 --- a/lib/philomena_web/controllers/admin/artist_link/verification_controller.ex +++ b/lib/philomena_web/controllers/admin/artist_link/verification_controller.ex @@ -1,31 +1,16 @@ defmodule PhilomenaWeb.Admin.ArtistLink.VerificationController do use PhilomenaWeb, :controller - alias Philomena.ArtistLinks.ArtistLink alias Philomena.ArtistLinks - plug PhilomenaWeb.CanaryMapPlug, create: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: ArtistLink, - id_name: "artist_link_id", - persisted: true, - preload: [:user] - - def create(conn, _params) do - {:ok, artist_link} = - ArtistLinks.verify_artist_link(conn.assigns.artist_link, conn.assigns.current_user) - - conn - |> put_flash(:info, "Artist link successfully verified.") - |> moderation_log(details: &log_details/2, data: artist_link) - |> redirect(to: ~p"/admin/artist_links") - end - - defp log_details(_action, artist_link) do - %{ - body: "Verified artist link #{artist_link.uri} created by #{artist_link.user.name}", - subject_path: ~p"/profiles/#{artist_link.user}/artist_links/#{artist_link}" - } + def create(conn, %{"artist_link_id" => id}) do + with {:ok, _artist_link} <- + ArtistLinks.create_artist_link_verification(conn.assigns.actor, id) do + conn + |> put_flash(:info, "Artist link successfully verified.") + |> redirect(to: ~p"/admin/artist_links") + end end end diff --git a/lib/philomena_web/controllers/admin/artist_link_controller.ex b/lib/philomena_web/controllers/admin/artist_link_controller.ex index 6c018526f..18e159732 100644 --- a/lib/philomena_web/controllers/admin/artist_link_controller.ex +++ b/lib/philomena_web/controllers/admin/artist_link_controller.ex @@ -1,51 +1,22 @@ defmodule PhilomenaWeb.Admin.ArtistLinkController do use PhilomenaWeb, :controller - alias Philomena.ArtistLinks.ArtistLink - alias Philomena.Repo - import Ecto.Query - - plug :verify_authorized - - def index(conn, %{"all" => _value}) do - load_links(ArtistLink, conn) - end - - def index(conn, %{"lq" => query}) do - query = "%#{query}%" - - ArtistLink - |> join(:inner, [ul], _ in assoc(ul, :user)) - |> where([ul, u], ilike(u.name, ^query) or ilike(ul.uri, ^query)) - |> load_links(conn) - end - - def index(conn, _params) do - ArtistLink - |> where([u], u.aasm_state in ^["unverified", "link_verified", "contacted"]) - |> load_links(conn) - end - - defp load_links(queryable, conn) do - links = - queryable - |> order_by(desc: :created_at) - |> preload([ - :tag, - :verified_by_user, - :contacted_by_user, - user: [:linked_tags, awards: :badge] - ]) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", title: "Admin - Artist Links", artist_links: links) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, %ArtistLink{}) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + alias Philomena.ArtistLinks + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + with {:ok, artist_links, changeset} <- + ArtistLinks.list_admin_artist_links( + conn.assigns.actor, + params["lq"] || %{}, + conn.assigns.scrivener + ) do + render(conn, "index.html", + title: "Admin - Artist Links", + artist_links: artist_links, + changeset: changeset + ) end end end diff --git a/lib/philomena_web/controllers/admin/badge/image_controller.ex b/lib/philomena_web/controllers/admin/badge/image_controller.ex index d4d11bbbd..8a061046c 100644 --- a/lib/philomena_web/controllers/admin/badge/image_controller.ex +++ b/lib/philomena_web/controllers/admin/badge/image_controller.ex @@ -1,39 +1,30 @@ defmodule PhilomenaWeb.Admin.Badge.ImageController do use PhilomenaWeb, :controller - alias Philomena.Badges.Badge alias Philomena.Badges - plug :verify_authorized - plug :load_resource, model: Badge, id_name: "badge_id", persisted: true, only: [:edit, :update] + action_fallback PhilomenaWeb.FallbackController - def edit(conn, _params) do - changeset = Badges.change_badge(conn.assigns.badge) - render(conn, "edit.html", title: "Editing Badge", changeset: changeset) + def edit(conn, %{"badge_id" => id}) do + with {:ok, {badge, changeset}} <- Badges.edit_badge(conn.assigns.actor, id) do + render(conn, "edit.html", title: "Editing Badge", badge: badge, changeset: changeset) + end end - def update(conn, %{"badge" => badge_params}) do - case Badges.update_badge_image(conn.assigns.badge, badge_params) do - {:ok, badge} -> + def update(conn, %{"badge_id" => id, "badge" => badge_params}) do + upload = PhilomenaMedia.Upload.cast(badge_params, "image") + + case Badges.update_badge_image(conn.assigns.actor, id, upload) do + {:ok, _badge} -> conn |> put_flash(:info, "Badge updated successfully.") - |> moderation_log(details: &log_details/2, data: badge) |> redirect(to: ~p"/admin/badges") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", badge: changeset.data, changeset: changeset) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Badge) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - - defp log_details(_action, badge) do - %{body: "Updated image of badge '#{badge.title}'", subject_path: ~p"/admin/badges"} - end end diff --git a/lib/philomena_web/controllers/admin/badge/user_controller.ex b/lib/philomena_web/controllers/admin/badge/user_controller.ex index 6d24b26b8..fcc3268da 100644 --- a/lib/philomena_web/controllers/admin/badge/user_controller.ex +++ b/lib/philomena_web/controllers/admin/badge/user_controller.ex @@ -1,32 +1,18 @@ defmodule PhilomenaWeb.Admin.Badge.UserController do use PhilomenaWeb, :controller - alias Philomena.Users.User - alias Philomena.Badges.Badge - alias Philomena.Repo - import Ecto.Query + alias Philomena.Badges - plug :verify_authorized - plug :load_resource, model: Badge, id_name: "badge_id", required: true + action_fallback PhilomenaWeb.FallbackController - def index(conn, _params) do - badge = conn.assigns.badge - - users = - User - |> join(:inner, [u], _ in assoc(u, :awards)) - |> where([_u, a], a.badge_id == ^badge.id) - |> order_by([u, _a], asc: u.name) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", title: "Users with badge #{badge.title}", users: users) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Badge) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + def index(conn, %{"badge_id" => id}) do + with {:ok, {badge, users}} <- + Badges.list_badge_users(conn.assigns.actor, id, conn.assigns.scrivener) do + render(conn, "index.html", + title: "Users with badge #{badge.title}", + badge: badge, + users: users + ) end end end diff --git a/lib/philomena_web/controllers/admin/badge_controller.ex b/lib/philomena_web/controllers/admin/badge_controller.ex index 76afd828c..2d19cffde 100644 --- a/lib/philomena_web/controllers/admin/badge_controller.ex +++ b/lib/philomena_web/controllers/admin/badge_controller.ex @@ -1,74 +1,57 @@ defmodule PhilomenaWeb.Admin.BadgeController do use PhilomenaWeb, :controller - alias Philomena.Badges.Badge alias Philomena.Badges - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - plug :load_resource, model: Badge, only: [:edit, :update] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - badges = - Badge - |> order_by(asc: :title) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", title: "Admin - Badges", badges: badges) + with {:ok, badges} <- Badges.list_badges(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", title: "Admin - Badges", badges: badges) + end end def new(conn, _params) do - changeset = Badges.change_badge(%Badge{}) - render(conn, "new.html", title: "New Badge", changeset: changeset) + with {:ok, changeset} <- Badges.new_badge(conn.assigns.actor) do + render(conn, "new.html", title: "New Badge", changeset: changeset) + end end def create(conn, %{"badge" => badge_params}) do - case Badges.create_badge(badge_params) do - {:ok, badge} -> + upload = PhilomenaMedia.Upload.cast(badge_params, "image") + + case Badges.create_badge(conn.assigns.actor, badge_params, upload) do + {:ok, _badge} -> conn |> put_flash(:info, "Badge created successfully.") - |> moderation_log(details: &log_details/2, data: badge) |> redirect(to: ~p"/admin/badges") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + {:error, reason} = error when reason in [:ban, :unauthorized] -> + error end end - def edit(conn, _params) do - changeset = Badges.change_badge(conn.assigns.badge) - render(conn, "edit.html", title: "Editing Badge", changeset: changeset) + def edit(conn, %{"id" => id}) do + with {:ok, {badge, changeset}} <- Badges.edit_badge(conn.assigns.actor, id) do + render(conn, "edit.html", title: "Editing Badge", badge: badge, changeset: changeset) + end end - def update(conn, %{"badge" => badge_params}) do - case Badges.update_badge(conn.assigns.badge, badge_params) do - {:ok, badge} -> + def update(conn, %{"id" => id, "badge" => badge_params}) do + case Badges.update_badge(conn.assigns.actor, id, badge_params) do + {:ok, _badge} -> conn |> put_flash(:info, "Badge updated successfully.") - |> moderation_log(details: &log_details/2, data: badge) |> redirect(to: ~p"/admin/badges") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", badge: changeset.data, changeset: changeset) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Badge) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + {:error, _} = error -> + error end end - - defp log_details(action, badge) do - body = - case action do - :create -> "Created badge '#{badge.title}'" - :update -> "Updated badge '#{badge.title}'" - end - - %{body: body, subject_path: ~p"/admin/badges"} - end end diff --git a/lib/philomena_web/controllers/admin/batch/tag_controller.ex b/lib/philomena_web/controllers/admin/batch/tag_controller.ex index 6d9ca1408..0f2a3c4f2 100644 --- a/lib/philomena_web/controllers/admin/batch/tag_controller.ex +++ b/lib/philomena_web/controllers/admin/batch/tag_controller.ex @@ -1,110 +1,23 @@ defmodule PhilomenaWeb.Admin.Batch.TagController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag alias Philomena.Images - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - plug PhilomenaWeb.UserAttributionPlug + action_fallback PhilomenaWeb.FallbackController - def update(conn, %{"tags" => tag_list, "image_ids" => image_ids}) - when is_binary(tag_list) and is_list(image_ids) do - tags = Tag.parse_tag_list(tag_list) - - added_tag_names = Enum.reject(tags, &String.starts_with?(&1, "-")) - - removed_tag_names = - tags - |> Enum.filter(&String.starts_with?(&1, "-")) - |> Enum.map(&String.replace_leading(&1, "-", "")) - - added_tags = - Tag - |> where([t], t.name in ^added_tag_names) - |> preload([:implied_tags, aliased_tag: :implied_tags]) - |> Repo.all() - |> Enum.map(&(&1.aliased_tag || &1)) - |> Enum.flat_map(&[&1 | &1.implied_tags]) - - removed_tags = - Tag - |> where([t], t.name in ^removed_tag_names) - |> Repo.all() - - attributes = conn.assigns.attributes - - attributes = %{ - ip: attributes[:ip], - fingerprint: attributes[:fingerprint], - user_id: attributes[:user].id - } - - {image_ids, unparsable_ids} = partition_ids(image_ids) - - case Images.batch_update(image_ids, added_tags, removed_tags, attributes) do - {:ok, matched_ids} -> - # Ids which parsed but matched no existing, non-hidden image were - # never touched by the batch, so they are reported as failed. - unmatched_ids = image_ids -- matched_ids - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:batch_tag_update", - %{ - image_ids: matched_ids, - added: Enum.map(added_tags, & &1.name), - removed: Enum.map(removed_tags, & &1.name) - } - ) + def update(conn, params) do + case Images.update_batch_tags(conn.assigns.actor, params) do + {:ok, result} -> + json(conn, %{succeeded: result.succeeded, failed: result.failed}) + {:error, %Ecto.Changeset{} = changeset} -> conn - |> moderation_log( - details: &log_details/2, - data: %{ - tag_list: tag_list, - image_count: Enum.count(matched_ids), - user: conn.assigns.current_user - } - ) - |> json(%{succeeded: matched_ids, failed: unmatched_ids ++ unparsable_ids}) + |> put_status(:bad_request) + |> put_view(PhilomenaWeb.Api.Json.ImageView) + |> render("error.json", changeset: changeset) - _error -> - json(conn, %{succeeded: [], failed: image_ids ++ unparsable_ids}) + error -> + error end end - - def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{succeeded: [], failed: []}) - end - - # An id that is not an integer cannot name an image, so it is reported as - # failed rather than crashing the whole batch. - defp partition_ids(image_ids) do - {parsed, unparsable} = - image_ids - |> Enum.map(&{&1, IntegerId.parse(&1)}) - |> Enum.split_with(&match?({_id, {:ok, _int}}, &1)) - - {Enum.map(parsed, fn {_id, {:ok, int}} -> int end), Enum.map(unparsable, &elem(&1, 0))} - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :batch_update, Tag) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end - - defp log_details(_action, data) do - %{ - body: "Batch tagged '#{data.tag_list}' on #{data.image_count} images", - subject_path: ~p"/profiles/#{data.user}" - } - end end diff --git a/lib/philomena_web/controllers/admin/dnp_entry/transition_controller.ex b/lib/philomena_web/controllers/admin/dnp_entry/transition_controller.ex index f3e373963..27008ca7a 100644 --- a/lib/philomena_web/controllers/admin/dnp_entry/transition_controller.ex +++ b/lib/philomena_web/controllers/admin/dnp_entry/transition_controller.ex @@ -1,50 +1,26 @@ defmodule PhilomenaWeb.Admin.DnpEntry.TransitionController do use PhilomenaWeb, :controller - alias Philomena.DnpEntries.DnpEntry alias Philomena.DnpEntries - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController - plug :load_resource, - model: DnpEntry, - only: [:create], - id_name: "dnp_entry_id", - preload: [:tag], - required: true + def create(conn, %{"dnp_entry_id" => dnp_entry_id} = params) do + new_state = params["state"] - def create(conn, %{"state" => new_state}) do - case DnpEntries.transition_dnp_entry( - conn.assigns.dnp_entry, - conn.assigns.current_user, - new_state - ) do + case DnpEntries.create_dnp_entry_transition(conn.assigns.actor, dnp_entry_id, new_state) do {:ok, dnp_entry} -> conn |> put_flash(:info, "Successfully updated DNP entry.") - |> moderation_log(details: &log_details/2, data: dnp_entry) |> redirect(to: ~p"/dnp/#{dnp_entry}") - {:error, _changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> conn |> put_flash(:error, "Failed to update DNP entry!") - |> redirect(to: ~p"/dnp/#{conn.assigns.dnp_entry}") - end - end + |> redirect(to: ~p"/dnp/#{changeset.data}") - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, DnpEntry) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + {:error, _} = error -> + error end end - - defp log_details(_action, dnp_entry) do - %{ - body: - "#{String.capitalize(dnp_entry.aasm_state)} DNP entry #{dnp_entry.id} on #{dnp_entry.tag.name}", - subject_path: ~p"/dnp/#{dnp_entry}" - } - end end diff --git a/lib/philomena_web/controllers/admin/dnp_entry_controller.ex b/lib/philomena_web/controllers/admin/dnp_entry_controller.ex index f55d5942d..b5c645652 100644 --- a/lib/philomena_web/controllers/admin/dnp_entry_controller.ex +++ b/lib/philomena_web/controllers/admin/dnp_entry_controller.ex @@ -2,67 +2,30 @@ defmodule PhilomenaWeb.Admin.DnpEntryController do use PhilomenaWeb, :controller alias PhilomenaWeb.MarkdownRenderer - alias Philomena.DnpEntries.DnpEntry - alias Philomena.Repo - import Ecto.Query - - plug :verify_authorized - plug :load_resource, model: DnpEntry, only: [:show, :edit, :update] - - def index(conn, %{"states" => states}) when is_list(states) do - DnpEntry - |> where([d], d.aasm_state in ^states) - |> load_entries(conn) - end - - def index(conn, %{"eq" => q}) when is_binary(q) do - q = to_ilike(q) - - DnpEntry - |> join(:inner, [d], _ in assoc(d, :tag)) - |> join(:inner, [d, _t], _ in assoc(d, :requesting_user)) - |> where( - [d, t, u], - ilike(u.name, ^q) or ilike(t.name, ^q) or ilike(d.reason, ^q) or ilike(d.conditions, ^q) or - ilike(d.instructions, ^q) - ) - |> load_entries(conn) - end - - def index(conn, _params) do - DnpEntry - |> where([d], d.aasm_state in ["requested", "claimed", "rescinded", "acknowledged"]) - |> load_entries(conn) - end - - defp load_entries(dnp_entries, conn) do - dnp_entries = - dnp_entries - |> preload([:tag, :requesting_user, :modifying_user]) - |> order_by(desc: :updated_at) - |> Repo.paginate(conn.assigns.scrivener) - - bodies = - dnp_entries - |> Enum.map(&%{body: &1.conditions}) - |> MarkdownRenderer.render_collection(conn) - - dnp_entries = %{dnp_entries | entries: Enum.zip(bodies, dnp_entries.entries)} - - render(conn, "index.html", - layout_class: "layout--wide", - title: "Admin - DNP Entries", - dnp_entries: dnp_entries - ) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, DnpEntry) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + alias Philomena.DnpEntries + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + with {:ok, dnp_entries, changeset} <- + DnpEntries.list_admin_dnp_entries( + conn.assigns.actor, + params["eq"] || %{}, + conn.assigns.scrivener + ) do + bodies = + dnp_entries + |> Enum.map(&%{body: &1.conditions}) + |> MarkdownRenderer.render_collection(conn) + + dnp_entries = %{dnp_entries | entries: Enum.zip(bodies, dnp_entries.entries)} + + render(conn, "index.html", + layout_class: "layout--wide", + title: "Admin - DNP Entries", + dnp_entries: dnp_entries, + changeset: changeset + ) end end - - defp to_ilike(query), do: "%" <> query <> "%" end diff --git a/lib/philomena_web/controllers/admin/donation/user_controller.ex b/lib/philomena_web/controllers/admin/donation/user_controller.ex index cb1d70bbd..82102b4b9 100644 --- a/lib/philomena_web/controllers/admin/donation/user_controller.ex +++ b/lib/philomena_web/controllers/admin/donation/user_controller.ex @@ -1,29 +1,19 @@ defmodule PhilomenaWeb.Admin.Donation.UserController do use PhilomenaWeb, :controller - alias Philomena.Users.User - alias Philomena.Donations.Donation alias Philomena.Donations - plug :verify_authorized - plug :load_resource, model: User, id_field: "slug", persisted: true, preload: [donations: :user] + action_fallback PhilomenaWeb.FallbackController - def show(conn, _params) do - user = conn.assigns.user - changeset = Donations.change_donation(%Donation{}) - - render(conn, "index.html", - title: "Donations for User `#{user.name}'", - donations: user.donations, - changeset: changeset - ) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Donation) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + def show(conn, %{"id" => slug}) do + with {:ok, {user, changeset}} <- + Donations.show_user_donations(conn.assigns.actor, slug) do + render(conn, "index.html", + title: "Donations for User `#{user.name}'", + user: user, + donations: user.donations, + changeset: changeset + ) end end end diff --git a/lib/philomena_web/controllers/admin/donation_controller.ex b/lib/philomena_web/controllers/admin/donation_controller.ex index 541ec2b2a..59463eeeb 100644 --- a/lib/philomena_web/controllers/admin/donation_controller.ex +++ b/lib/philomena_web/controllers/admin/donation_controller.ex @@ -1,42 +1,31 @@ defmodule PhilomenaWeb.Admin.DonationController do use PhilomenaWeb, :controller - alias Philomena.Donations.Donation alias Philomena.Donations - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - donations = - Donation - |> order_by(desc: :created_at, asc: :user_id) - |> preload(:user) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", title: "Admin - Donations", donations: donations) + with {:ok, donations} <- + Donations.list_donations(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", title: "Admin - Donations", donations: donations) + end end def create(conn, %{"donation" => donation_params}) do - case Donations.create_donation(donation_params) do + case Donations.create_donation(conn.assigns.actor, donation_params) do {:ok, _donation} -> conn |> put_flash(:info, "Donation successfully created.") |> redirect(to: ~p"/admin/donations") + {:error, :unauthorized} = error -> + error + _error -> conn |> put_flash(:error, "Error creating donation!") |> redirect(to: ~p"/admin/donations") end end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Donation) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end end diff --git a/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex b/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex index b73a51a18..58618f915 100644 --- a/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex +++ b/lib/philomena_web/controllers/admin/fingerprint_ban_controller.ex @@ -2,128 +2,86 @@ defmodule PhilomenaWeb.Admin.FingerprintBanController do use PhilomenaWeb, :controller alias Philomena.Bans - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - - plug :load_resource, - model: Bans.Fingerprint, - as: :fingerprint_ban, - only: [:edit, :update, :delete] - - plug :check_can_delete when action in [:delete] - - def index(conn, %{"bq" => q}) when is_binary(q) do - Bans.Fingerprint - |> where( - [fb], - ilike(fb.fingerprint, ^"%#{q}%") or - fb.generated_ban_id == ^q or - fragment("to_tsvector(?) @@ plainto_tsquery(?)", fb.reason, ^q) or - fragment("to_tsvector(?) @@ plainto_tsquery(?)", fb.note, ^q) - ) - |> load_bans(conn) - end - - def index(conn, %{"fingerprint" => fingerprint}) when is_binary(fingerprint) do - Bans.Fingerprint - |> where(fingerprint: ^fingerprint) - |> load_bans(conn) - end - - def index(conn, _params) do - load_bans(Bans.Fingerprint, conn) - end - - def new(conn, %{"fingerprint" => fingerprint}) do - changeset = Bans.change_fingerprint(%Bans.Fingerprint{fingerprint: fingerprint}) - render(conn, "new.html", title: "New Fingerprint Ban", changeset: changeset) + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + case Bans.list_fingerprint_bans(conn.assigns.actor, params, conn.assigns.scrivener) do + {:ok, fingerprint_bans, changeset} -> + render(conn, "index.html", + title: "Admin - Fingerprint Bans", + layout_class: "layout--wide", + fingerprint_bans: fingerprint_bans, + changeset: changeset + ) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "index.html", + title: "Admin - Fingerprint Bans", + layout_class: "layout--wide", + fingerprint_bans: nil, + changeset: changeset + ) + + error -> + error + end end - def new(conn, _params) do - changeset = Bans.change_fingerprint(%Bans.Fingerprint{}) - render(conn, "new.html", title: "New Fingerprint Ban", changeset: changeset) + def new(conn, params) do + with {:ok, changeset} <- + Bans.new_fingerprint_ban(conn.assigns.actor, params["fingerprint"]) do + render(conn, "new.html", title: "New Fingerprint Ban", changeset: changeset) + end end def create(conn, %{"fingerprint" => fingerprint_ban_params}) do - case Bans.create_fingerprint(conn.assigns.current_user, fingerprint_ban_params) do - {:ok, fingerprint_ban} -> + case Bans.create_fingerprint_ban(conn.assigns.actor, fingerprint_ban_params) do + {:ok, _fingerprint_ban} -> conn |> put_flash(:info, "Fingerprint was successfully banned.") - |> moderation_log(details: &log_details/2, data: fingerprint_ban) |> redirect(to: ~p"/admin/fingerprint_bans") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + error -> + error end end - def edit(conn, _params) do - changeset = Bans.change_fingerprint(conn.assigns.fingerprint_ban) - render(conn, "edit.html", title: "Editing Fingerprint Ban", changeset: changeset) + def edit(conn, params) do + with {:ok, {fingerprint_ban, changeset}} <- + Bans.edit_fingerprint_ban(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", + title: "Editing Fingerprint Ban", + fingerprint_ban: fingerprint_ban, + changeset: changeset + ) + end end - def update(conn, %{"fingerprint" => fingerprint_ban_params}) do - case Bans.update_fingerprint(conn.assigns.fingerprint_ban, fingerprint_ban_params) do - {:ok, fingerprint_ban} -> + def update(conn, %{"id" => id, "fingerprint" => fingerprint_ban_params}) do + case Bans.update_fingerprint_ban(conn.assigns.actor, id, fingerprint_ban_params) do + {:ok, _fingerprint_ban} -> conn |> put_flash(:info, "Fingerprint ban successfully updated.") - |> moderation_log(details: &log_details/2, data: fingerprint_ban) |> redirect(to: ~p"/admin/fingerprint_bans") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, fingerprint_ban} = Bans.delete_fingerprint(conn.assigns.fingerprint_ban) - - conn - |> put_flash(:info, "Fingerprint ban successfully deleted.") - |> moderation_log(details: &log_details/2, data: fingerprint_ban) - |> redirect(to: ~p"/admin/fingerprint_bans") - end - - defp load_bans(queryable, conn) do - fingerprint_bans = - queryable - |> order_by(desc: :created_at) - |> preload(:banning_user) - |> Repo.paginate(conn.assigns.scrivener) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", fingerprint_ban: changeset.data, changeset: changeset) - render(conn, "index.html", - layout_class: "layout--wide", - title: "Admin - Fingerprint Bans", - fingerprint_bans: fingerprint_bans - ) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Bans.Fingerprint) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - defp check_can_delete(conn, _opts) do - if conn.assigns.current_user.role == "admin" do + def delete(conn, params) do + with {:ok, _fingerprint_ban} <- + Bans.delete_fingerprint_ban(conn.assigns.actor, params["id"]) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Fingerprint ban successfully deleted.") + |> redirect(to: ~p"/admin/fingerprint_bans") end end - - defp log_details(action, ban) do - body = - case action do - :create -> "Created a fingerprint ban #{ban.generated_ban_id}" - :update -> "Updated a fingerprint ban #{ban.generated_ban_id}" - :delete -> "Deleted a fingerprint ban #{ban.generated_ban_id}" - end - - %{body: body, subject_path: ~p"/admin/fingerprint_bans"} - end end diff --git a/lib/philomena_web/controllers/admin/forum_controller.ex b/lib/philomena_web/controllers/admin/forum_controller.ex index 3bede257a..659e08bb4 100644 --- a/lib/philomena_web/controllers/admin/forum_controller.ex +++ b/lib/philomena_web/controllers/admin/forum_controller.ex @@ -1,55 +1,56 @@ defmodule PhilomenaWeb.Admin.ForumController do use PhilomenaWeb, :controller - alias Philomena.Forums.Forum alias Philomena.Forums - plug :verify_authorized - plug :load_resource, model: Forum, id_field: "short_name", only: [:edit, :update] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - render(conn, "index.html", title: "Admin - Forums") + with {:ok, forums} <- Forums.list_admin_forums(conn.assigns.actor) do + render(conn, "index.html", title: "Admin - Forums", forums: forums) + end end def new(conn, _params) do - changeset = Forums.change_forum(%Forum{}) - render(conn, "new.html", title: "New Forum", changeset: changeset) + with {:ok, changeset} <- Forums.new_forum(conn.assigns.actor) do + render(conn, "new.html", title: "New Forum", changeset: changeset) + end end def create(conn, %{"forum" => forum_params}) do - case Forums.create_forum(forum_params) do + case Forums.create_forum(conn.assigns.actor, forum_params) do {:ok, _forum} -> conn |> put_flash(:info, "Forum created successfully.") |> redirect(to: ~p"/admin/forums") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + {:error, :unauthorized} = error -> + error end end - def edit(conn, _params) do - changeset = Forums.change_forum(conn.assigns.forum) - render(conn, "edit.html", title: "Editing Forum", changeset: changeset) + def edit(conn, params) do + with {:ok, {forum, changeset}} <- + Forums.edit_forum(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", title: "Editing Forum", forum: forum, changeset: changeset) + end end - def update(conn, %{"forum" => forum_params}) do - case Forums.update_forum(conn.assigns.forum, forum_params) do + def update(conn, %{"id" => id, "forum" => forum_params}) do + case Forums.update_forum(conn.assigns.actor, id, forum_params) do {:ok, _forum} -> conn |> put_flash(:info, "Forum updated successfully.") |> redirect(to: ~p"/admin/forums") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", forum: changeset.data, changeset: changeset) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, Forum) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/admin/mod_note_controller.ex b/lib/philomena_web/controllers/admin/mod_note_controller.ex index c0569f61b..c810c7943 100644 --- a/lib/philomena_web/controllers/admin/mod_note_controller.ex +++ b/lib/philomena_web/controllers/admin/mod_note_controller.ex @@ -2,84 +2,76 @@ defmodule PhilomenaWeb.Admin.ModNoteController do use PhilomenaWeb, :controller alias PhilomenaWeb.MarkdownRenderer - alias Philomena.ModNotes.ModNote alias Philomena.ModNotes - plug :load_and_authorize_resource, model: ModNote - - # Whitelist of the target foreign key columns a note may be filed against. - @target_columns [:user_id, :report_id, :dnp_entry_id] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - pagination = conn.assigns.scrivener renderer = &MarkdownRenderer.render_collection(&1, conn) - mod_notes = - case target(params) do - [_] = target -> ModNotes.list_mod_notes_for_target(renderer, pagination, target) - [] -> ModNotes.list_mod_notes(renderer, pagination) - end - - render(conn, "index.html", title: "Admin - Mod Notes", mod_notes: mod_notes) + with {:ok, mod_notes} <- + ModNotes.list_mod_notes( + conn.assigns.actor, + params, + renderer, + conn.assigns.scrivener + ) do + render(conn, "index.html", title: "Admin - Mod Notes", mod_notes: mod_notes) + end end def new(conn, params) do - changeset = ModNotes.change_mod_note(struct(ModNote, target(params))) - - render(conn, "new.html", title: "New Mod Note", changeset: changeset) + with {:ok, changeset} <- ModNotes.new_mod_note(conn.assigns.actor, params) do + render(conn, "new.html", title: "New Mod Note", changeset: changeset) + end end def create(conn, %{"mod_note" => mod_note_params}) do - case ModNotes.create_mod_note( - conn.assigns.current_user, - mod_note_params, - target(mod_note_params) - ) do + case ModNotes.create_mod_note(conn.assigns.actor, mod_note_params) do {:ok, _mod_note} -> conn |> put_flash(:info, "Successfully created mod note.") |> redirect(to: ~p"/admin/mod_notes") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + {:error, _} = error -> + error end end - def edit(conn, _params) do - changeset = ModNotes.change_mod_note(conn.assigns.mod_note) - render(conn, "edit.html", title: "Editing Mod Note", changeset: changeset) + def edit(conn, %{"id" => id}) do + with {:ok, {mod_note, changeset}} <- + ModNotes.edit_mod_note(conn.assigns.actor, id) do + render(conn, "edit.html", + title: "Editing Mod Note", + mod_note: mod_note, + changeset: changeset + ) + end end - def update(conn, %{"mod_note" => mod_note_params}) do - case ModNotes.update_mod_note(conn.assigns.mod_note, mod_note_params) do + def update(conn, %{"id" => id, "mod_note" => mod_note_params}) do + case ModNotes.update_mod_note(conn.assigns.actor, id, mod_note_params) do {:ok, _mod_note} -> conn |> put_flash(:info, "Successfully updated mod note.") |> redirect(to: ~p"/admin/mod_notes") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, _mod_note} = ModNotes.delete_mod_note(conn.assigns.mod_note) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", mod_note: changeset.data, changeset: changeset) - conn - |> put_flash(:info, "Successfully deleted mod note.") - |> redirect(to: ~p"/admin/mod_notes") + {:error, _} = error -> + error + end end - # The one target foreign key column named in `params`, as a one-entry keyword - # list (e.g. `[user_id: 1]`), or an empty list when none is present. - defp target(params) do - Enum.find_value(@target_columns, [], fn column -> - with value when value not in [nil, ""] <- params[to_string(column)], - {id, ""} <- Integer.parse(to_string(value)) do - [{column, id}] - else - _ -> false - end - end) + def delete(conn, %{"id" => id}) do + with {:ok, _mod_note} <- ModNotes.delete_mod_note(conn.assigns.actor, id) do + conn + |> put_flash(:info, "Successfully deleted mod note.") + |> redirect(to: ~p"/admin/mod_notes") + end end end diff --git a/lib/philomena_web/controllers/admin/report/claim_controller.ex b/lib/philomena_web/controllers/admin/report/claim_controller.ex index 76702b2e3..03ed3df18 100644 --- a/lib/philomena_web/controllers/admin/report/claim_controller.ex +++ b/lib/philomena_web/controllers/admin/report/claim_controller.ex @@ -1,31 +1,41 @@ defmodule PhilomenaWeb.Admin.Report.ClaimController do use PhilomenaWeb, :controller - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit - plug :load_and_authorize_resource, model: Report, id_name: "report_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - case Reports.claim_report(conn.assigns.report, conn.assigns.current_user) do + def create(conn, %{"report_id" => report_id}) do + case Reports.create_report_claim(conn.assigns.actor, report_id) do {:ok, _report} -> conn |> put_flash(:info, "Successfully marked report as in progress") |> redirect(to: ~p"/admin/reports") - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: report}} -> conn |> put_flash(:error, "Couldn't claim that report!") - |> redirect(to: ~p"/admin/reports/#{conn.assigns.report}") + |> redirect(to: ~p"/admin/reports/#{report}") + + error -> + error end end - def delete(conn, _params) do - {:ok, report} = Reports.unclaim_report(conn.assigns.report) + def delete(conn, %{"report_id" => report_id}) do + case Reports.delete_report_claim(conn.assigns.actor, report_id) do + {:ok, report} -> + conn + |> put_flash(:info, "Successfully released report.") + |> redirect(to: ~p"/admin/reports/#{report}") - conn - |> put_flash(:info, "Successfully released report.") - |> redirect(to: ~p"/admin/reports/#{report}") + {:error, %Ecto.Changeset{data: report}} -> + conn + |> put_flash(:error, "Report was not claimed!") + |> redirect(to: ~p"/admin/reports/#{report}") + + error -> + error + end end end diff --git a/lib/philomena_web/controllers/admin/report/close_controller.ex b/lib/philomena_web/controllers/admin/report/close_controller.ex index 75f1e7ff5..3a75487a1 100644 --- a/lib/philomena_web/controllers/admin/report/close_controller.ex +++ b/lib/philomena_web/controllers/admin/report/close_controller.ex @@ -1,17 +1,24 @@ defmodule PhilomenaWeb.Admin.Report.CloseController do use PhilomenaWeb, :controller - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit - plug :load_and_authorize_resource, model: Report, id_name: "report_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - {:ok, _report} = Reports.close_report(conn.assigns.report, conn.assigns.current_user) + def create(conn, %{"report_id" => report_id}) do + case Reports.create_report_close(conn.assigns.actor, report_id) do + {:ok, _report} -> + conn + |> put_flash(:info, "Successfully closed report") + |> redirect(to: ~p"/admin/reports") - conn - |> put_flash(:info, "Successfully closed report") - |> redirect(to: ~p"/admin/reports") + {:error, %Ecto.Changeset{data: report}} -> + conn + |> put_flash(:error, "Failed to close report") + |> redirect(to: ~p"/admin/reports/#{report}") + + error -> + error + end end end diff --git a/lib/philomena_web/controllers/admin/report_controller.ex b/lib/philomena_web/controllers/admin/report_controller.ex index 094fba4e7..f79a9dbd2 100644 --- a/lib/philomena_web/controllers/admin/report_controller.ex +++ b/lib/philomena_web/controllers/admin/report_controller.ex @@ -1,136 +1,57 @@ defmodule PhilomenaWeb.Admin.ReportController do use PhilomenaWeb, :controller - alias PhilomenaQuery.Search alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Reports.Report - alias Philomena.Reports.Query alias Philomena.Reports - alias Philomena.ModNotes.ModNote - alias Philomena.ModNotes - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - - plug :load_and_authorize_resource, - model: Report, - only: [:show], - preload: [:admin, :rule, user: [:linked_tags, awards: :badge]] - - plug :set_mod_notes when action in [:show] - - def index(conn, %{"rq" => query_string}) do - {:ok, query} = Query.compile(query_string) - - reports = load_reports(conn, query) - - render(conn, "index.html", - title: "Admin - Reports", - layout_class: "layout--wide", - reports: reports, - my_reports: [], - system_reports: [] - ) - end - - def index(conn, _params) do - user = conn.assigns.current_user - - query = %{ - bool: %{ - should: [ - %{term: %{open: false}}, - %{ - bool: %{ - must: %{term: %{open: true}}, - must_not: [ - %{term: %{admin_id: user.id}}, - %{term: %{system: true}} - ] - } - } - ] - } - } - - reports = load_reports(conn, query) - - my_reports = - Report - |> where(open: true, admin_id: ^user.id) - |> preload([:admin, :rule, user: :linked_tags]) - |> order_by(desc: :created_at) - |> Repo.all() - |> Reports.preload_targets() - - system_reports = - Report - |> where(open: true, system: true) - |> preload([:admin, :rule, user: :linked_tags]) - |> order_by(desc: :created_at) - |> Repo.all() - |> Reports.preload_targets() - - render(conn, "index.html", - title: "Admin - Reports", - layout_class: "layout--wide", - reports: reports, - my_reports: my_reports, - system_reports: system_reports - ) + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + case Reports.list_reports( + conn.assigns.actor, + params["rq"] || %{}, + conn.assigns.pagination + ) do + {:ok, page, query_changeset} -> + render(conn, "index.html", + title: "Admin - Reports", + layout_class: "layout--wide", + reports: page.reports, + my_reports: page.my_reports, + system_reports: page.system_reports, + changeset: query_changeset + ) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "index.html", + title: "Admin - Reports", + layout_class: "layout--wide", + reports: nil, + my_reports: [], + system_reports: [], + changeset: changeset + ) + + error -> + error + end end - def show(conn, _params) do - report = Reports.preload_targets(conn.assigns.report) - - body = MarkdownRenderer.render_one(%{body: report.reason}, conn) - - render(conn, "show.html", title: "Showing Report", report: report, body: body) - end + def show(conn, %{"id" => report_id}) do + with {:ok, report} <- Reports.show_report(conn.assigns.actor, report_id) do + body = MarkdownRenderer.render_one(%{body: report.reason}, conn) - defp load_reports(conn, query) do - reports = - Report - |> Search.search_definition( - %{ - query: query, - sort: sorts() - }, - conn.assigns.pagination + render(conn, "show.html", + title: "Showing Report", + report: report, + body: body, + mod_notes: mod_notes(conn, report) ) - |> Search.search_records(preload(Report, [:admin, :rule, user: :linked_tags])) - - entries = Reports.preload_targets(reports) - - %{reports | entries: entries} - end - - defp sorts do - [ - %{open: :desc}, - %{state: :desc}, - %{created_at: :desc} - ] - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Report) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) end end - defp set_mod_notes(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, ModNote) do - report = conn.assigns.report - - renderer = &MarkdownRenderer.render_collection(&1, conn) - mod_notes = ModNotes.list_all_mod_notes_for_target(renderer, report_id: report.id) - assign(conn, :mod_notes, mod_notes) - else - conn - end + defp mod_notes(conn, report) do + renderer = &MarkdownRenderer.render_collection(&1, conn) + Reports.mod_notes(conn.assigns.actor, report, renderer) end end diff --git a/lib/philomena_web/controllers/admin/site_notice_controller.ex b/lib/philomena_web/controllers/admin/site_notice_controller.ex index 677f460b6..3dfe1c24a 100644 --- a/lib/philomena_web/controllers/admin/site_notice_controller.ex +++ b/lib/philomena_web/controllers/admin/site_notice_controller.ex @@ -1,70 +1,70 @@ defmodule PhilomenaWeb.Admin.SiteNoticeController do use PhilomenaWeb, :controller - alias Philomena.SiteNotices.SiteNotice alias Philomena.SiteNotices - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - plug :load_and_authorize_resource, model: SiteNotice, except: [:index] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - site_notices = - SiteNotice - |> order_by(desc: :start_date) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", title: "Admin - Site Notices", admin_site_notices: site_notices) + with {:ok, site_notices} <- + SiteNotices.list_site_notices(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", title: "Admin - Site Notices", admin_site_notices: site_notices) + end end def new(conn, _params) do - changeset = SiteNotices.change_site_notice(%SiteNotice{}) - render(conn, "new.html", title: "New Site Notice", changeset: changeset) + with {:ok, changeset} <- SiteNotices.new_site_notice(conn.assigns.actor) do + render(conn, "new.html", title: "New Site Notice", changeset: changeset) + end end def create(conn, %{"site_notice" => site_notice_params}) do - case SiteNotices.create_site_notice(conn.assigns.current_user, site_notice_params) do + case SiteNotices.create_site_notice(conn.assigns.actor, site_notice_params) do {:ok, _site_notice} -> conn |> put_flash(:info, "Successfully created site notice.") |> redirect(to: ~p"/admin/site_notices") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + {:error, :unauthorized} = error -> + error end end - def edit(conn, _params) do - changeset = SiteNotices.change_site_notice(conn.assigns.site_notice) - render(conn, "edit.html", title: "Editing Site Notices", changeset: changeset) + def edit(conn, params) do + with {:ok, {site_notice, changeset}} <- + SiteNotices.edit_site_notice(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", + title: "Editing Site Notices", + site_notice: site_notice, + changeset: changeset + ) + end end - def update(conn, %{"site_notice" => site_notice_params}) do - case SiteNotices.update_site_notice(conn.assigns.site_notice, site_notice_params) do + def update(conn, %{"id" => id, "site_notice" => site_notice_params}) do + case SiteNotices.update_site_notice(conn.assigns.actor, id, site_notice_params) do {:ok, _site_notice} -> conn |> put_flash(:info, "Successfully updated site notice.") |> redirect(to: ~p"/admin/site_notices") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", site_notice: changeset.data, changeset: changeset) - def delete(conn, _params) do - {:ok, _site_notice} = SiteNotices.delete_site_notice(conn.assigns.site_notice) - - conn - |> put_flash(:info, "Successfully deleted site notice.") - |> redirect(to: ~p"/admin/site_notices") + {:error, _} = error -> + error + end end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, SiteNotice) do + def delete(conn, params) do + with {:ok, _site_notice} <- + SiteNotices.delete_site_notice(conn.assigns.actor, params["id"]) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Successfully deleted site notice.") + |> redirect(to: ~p"/admin/site_notices") end end end diff --git a/lib/philomena_web/controllers/admin/subnet_ban_controller.ex b/lib/philomena_web/controllers/admin/subnet_ban_controller.ex index 5354aba97..acaf2dace 100644 --- a/lib/philomena_web/controllers/admin/subnet_ban_controller.ex +++ b/lib/philomena_web/controllers/admin/subnet_ban_controller.ex @@ -2,139 +2,88 @@ defmodule PhilomenaWeb.Admin.SubnetBanController do use PhilomenaWeb, :controller alias Philomena.Bans - alias Philomena.Repo - import Ecto.Query - - plug :verify_authorized - plug :load_resource, model: Bans.Subnet, only: [:edit, :update, :delete] - plug :check_can_delete when action in [:delete] - - def index(conn, %{"bq" => q}) when is_binary(q) do - Bans.Subnet - |> where( - [sb], - sb.generated_ban_id == ^q or - fragment("to_tsvector(?) @@ plainto_tsquery(?)", sb.reason, ^q) or - fragment("to_tsvector(?) @@ plainto_tsquery(?)", sb.note, ^q) - ) - |> load_bans(conn) - end - - def index(conn, %{"ip" => ip}) when is_binary(ip) do - case EctoNetwork.INET.cast(ip) do - {:ok, ip} -> - Bans.Subnet - |> where([sb], fragment("? >>= ?", sb.specification, ^ip)) - |> load_bans(conn) - _error -> - conn - |> put_flash(:error, "`#{ip}' is not a valid IP address or CIDR range.") - |> redirect(to: ~p"/admin/subnet_bans") + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + case Bans.list_subnet_bans(conn.assigns.actor, params, conn.assigns.scrivener) do + {:ok, subnet_bans, changeset} -> + render(conn, "index.html", + title: "Admin - Subnet Bans", + layout_class: "layout--wide", + subnet_bans: subnet_bans, + changeset: changeset + ) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "index.html", + title: "Admin - Subnet Bans", + layout_class: "layout--wide", + subnet_bans: nil, + changeset: changeset + ) + + error -> + error end end - def index(conn, _params) do - load_bans(Bans.Subnet, conn) - end - - def new(conn, %{"specification" => ip}) when is_binary(ip) do - case EctoNetwork.INET.cast(ip) do - {:ok, ip} -> - render_new(conn, %Bans.Subnet{specification: ip}) + def new(conn, params) do + case Bans.new_subnet_ban(conn.assigns.actor, params["specification"]) do + {:ok, changeset} -> + render_new(conn, changeset) - _error -> - conn - |> put_flash(:error, "`#{ip}' is not a valid IP address or CIDR range.") - |> render_new(%Bans.Subnet{}) + error -> + error end end - def new(conn, _params), do: render_new(conn, %Bans.Subnet{}) - - defp render_new(conn, subnet) do - changeset = Bans.change_subnet(subnet) + defp render_new(conn, changeset) do render(conn, "new.html", title: "New Subnet Ban", changeset: changeset) end def create(conn, %{"subnet" => subnet_ban_params}) do - case Bans.create_subnet(conn.assigns.current_user, subnet_ban_params) do - {:ok, subnet_ban} -> + case Bans.create_subnet_ban(conn.assigns.actor, subnet_ban_params) do + {:ok, _subnet_ban} -> conn |> put_flash(:info, "Subnet was successfully banned.") - |> moderation_log(details: &log_details/2, data: subnet_ban) |> redirect(to: ~p"/admin/subnet_bans") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + error -> + error end end - def edit(conn, _params) do - changeset = Bans.change_subnet(conn.assigns.subnet) - render(conn, "edit.html", title: "Editing Subnet Ban", changeset: changeset) + def edit(conn, params) do + with {:ok, {subnet, changeset}} <- + Bans.edit_subnet_ban(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", title: "Editing Subnet Ban", subnet: subnet, changeset: changeset) + end end - def update(conn, %{"subnet" => subnet_ban_params}) do - case Bans.update_subnet(conn.assigns.subnet, subnet_ban_params) do - {:ok, subnet_ban} -> + def update(conn, %{"id" => id, "subnet" => subnet_ban_params}) do + case Bans.update_subnet_ban(conn.assigns.actor, id, subnet_ban_params) do + {:ok, _subnet_ban} -> conn |> put_flash(:info, "Subnet ban successfully updated.") - |> moderation_log(details: &log_details/2, data: subnet_ban) |> redirect(to: ~p"/admin/subnet_bans") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, subnet_ban} = Bans.delete_subnet(conn.assigns.subnet) - - conn - |> put_flash(:info, "Subnet ban successfully deleted.") - |> moderation_log(details: &log_details/2, data: subnet_ban) - |> redirect(to: ~p"/admin/subnet_bans") - end - - defp load_bans(queryable, conn) do - subnet_bans = - queryable - |> order_by(desc: :created_at) - |> preload(:banning_user) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", - title: "Admin - Subnet Bans", - layout_class: "layout--wide", - subnet_bans: subnet_bans - ) - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", subnet: changeset.data, changeset: changeset) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Bans.Subnet) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - defp check_can_delete(conn, _opts) do - if conn.assigns.current_user.role == "admin" do + def delete(conn, params) do + with {:ok, _subnet_ban} <- Bans.delete_subnet_ban(conn.assigns.actor, params["id"]) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Subnet ban successfully deleted.") + |> redirect(to: ~p"/admin/subnet_bans") end end - - defp log_details(action, ban) do - body = - case action do - :create -> "Created a subnet ban #{ban.generated_ban_id}" - :update -> "Updated a subnet ban #{ban.generated_ban_id}" - :delete -> "Deleted a subnet ban #{ban.generated_ban_id}" - end - - %{body: body, subject_path: ~p"/admin/subnet_bans"} - end end diff --git a/lib/philomena_web/controllers/admin/user/activation_controller.ex b/lib/philomena_web/controllers/admin/user/activation_controller.ex index 3a26606e8..fffa668b9 100644 --- a/lib/philomena_web/controllers/admin/user/activation_controller.ex +++ b/lib/philomena_web/controllers/admin/user/activation_controller.ex @@ -1,45 +1,23 @@ defmodule PhilomenaWeb.Admin.User.ActivationController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - {:ok, user} = Users.reactivate_user(conn.assigns.user) - - conn - |> put_flash(:info, "User was reactivated.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - def delete(conn, _params) do - {:ok, user} = Users.deactivate_user(conn.assigns.current_user, conn.assigns.user) - - conn - |> put_flash(:info, "User was deactivated.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def create(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.create_user_activation(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "User was reactivated.") + |> redirect(to: ~p"/profiles/#{user}") end end - defp log_details(action, user) do - body = - case action do - :create -> "Reactivated #{user.name}" - :delete -> "Deactivated #{user.name}" - end - - %{body: body, subject_path: ~p"/profiles/#{user}"} + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_activation(conn.assigns.actor, slug) do + conn + |> put_flash(:info, "User was deactivated.") + |> redirect(to: ~p"/profiles/#{user}") + end end end diff --git a/lib/philomena_web/controllers/admin/user/api_key_controller.ex b/lib/philomena_web/controllers/admin/user/api_key_controller.ex index 029f9e4c8..c20e2c244 100644 --- a/lib/philomena_web/controllers/admin/user/api_key_controller.ex +++ b/lib/philomena_web/controllers/admin/user/api_key_controller.ex @@ -1,30 +1,15 @@ defmodule PhilomenaWeb.Admin.User.ApiKeyController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def delete(conn, _params) do - {:ok, user} = Users.reset_api_key(conn.assigns.user) - - conn - |> put_flash(:info, "API token successfully reset.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_api_key(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "API token successfully reset.") + |> redirect(to: ~p"/profiles/#{user}") end end - - defp log_details(_action, user) do - %{body: "Reset API key for #{user.name}", subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/avatar_controller.ex b/lib/philomena_web/controllers/admin/user/avatar_controller.ex index 2bca93b93..dd6204902 100644 --- a/lib/philomena_web/controllers/admin/user/avatar_controller.ex +++ b/lib/philomena_web/controllers/admin/user/avatar_controller.ex @@ -1,30 +1,15 @@ defmodule PhilomenaWeb.Admin.User.AvatarController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def delete(conn, _params) do - {:ok, user} = Users.remove_avatar(conn.assigns.user) - - conn - |> put_flash(:info, "Successfully removed avatar.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/admin/users/#{conn.assigns.user}/edit") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_avatar(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Successfully removed avatar.") + |> redirect(to: ~p"/admin/users/#{user}/edit") end end - - defp log_details(_action, user) do - %{body: "Removed avatar for #{user.name}", subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/downvote_controller.ex b/lib/philomena_web/controllers/admin/user/downvote_controller.ex index e6d49404d..01278074a 100644 --- a/lib/philomena_web/controllers/admin/user/downvote_controller.ex +++ b/lib/philomena_web/controllers/admin/user/downvote_controller.ex @@ -1,32 +1,15 @@ defmodule PhilomenaWeb.Admin.User.DownvoteController do use PhilomenaWeb, :controller - alias Philomena.UserUnvoteWorker - alias Philomena.Users.User + alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def delete(conn, _params) do - user = conn.assigns.user - - Exq.enqueue(Exq, "indexing", UserUnvoteWorker, [user.id, false]) - - conn - |> put_flash(:info, "Downvote wipe started.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_downvotes(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Downvote wipe started.") + |> redirect(to: ~p"/profiles/#{user}") end end - - defp log_details(_action, user) do - %{body: "Wiped downvotes for #{user.name}", subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/erase_controller.ex b/lib/philomena_web/controllers/admin/user/erase_controller.ex index 9c1166041..ff3cc7f11 100644 --- a/lib/philomena_web/controllers/admin/user/erase_controller.ex +++ b/lib/philomena_web/controllers/admin/user/erase_controller.ex @@ -1,77 +1,51 @@ defmodule PhilomenaWeb.Admin.User.EraseController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController - plug :load_resource, - model: User, - id_name: "user_id", - id_field: "slug", - persisted: true, - preload: [:roles] + def new(conn, %{"user_id" => slug}) do + case Users.new_user_erase(conn.assigns.actor, slug) do + {:ok, user} -> + render(conn, "new.html", title: "Erase user", user: user) - plug :prevent_deleting_nonexistent_users - plug :prevent_deleting_privileged_users - plug :prevent_deleting_verified_users - - def new(conn, _params) do - render(conn, "new.html", title: "Erase user") - end - - def create(conn, _params) do - {:ok, user} = Users.erase_user(conn.assigns.user, conn.assigns.current_user) - - conn - |> put_flash(:info, "User erase started") - |> moderation_log(details: &log_details/2, data: {conn.assigns.user, user}) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + render_erase_error(conn, error) end end - defp prevent_deleting_nonexistent_users(conn, _opts) do - if is_nil(conn.assigns.user) do - conn - |> put_flash(:error, "Couldn't find that username. Was it already erased?") - |> redirect(to: ~p"/admin/users") - |> Plug.Conn.halt() - else - conn - end - end + def create(conn, %{"user_id" => slug}) do + case Users.create_user_erase(conn.assigns.actor, slug) do + {:ok, user} -> + conn + |> put_flash(:info, "User erase started") + |> redirect(to: ~p"/profiles/#{user}") - defp prevent_deleting_privileged_users(conn, _opts) do - if conn.assigns.user.role != "user" do - conn - |> put_flash(:error, "Cannot erase a privileged user") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}") - |> Plug.Conn.halt() - else - conn + error -> + render_erase_error(conn, error) end end - defp prevent_deleting_verified_users(conn, _opts) do - if conn.assigns.user.verified do - conn - |> put_flash(:error, "Cannot erase a verified user") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}") - |> Plug.Conn.halt() - else - conn + defp render_erase_error(conn, error) do + case error do + {:error, :not_found} -> + conn + |> put_flash(:error, "Couldn't find that username. Was it already erased?") + |> redirect(to: ~p"/admin/users") + + {:error, {:privileged, user}} -> + conn + |> put_flash(:error, "Cannot erase a privileged user") + |> redirect(to: ~p"/profiles/#{user}") + + {:error, {:verified, user}} -> + conn + |> put_flash(:error, "Cannot erase a verified user") + |> redirect(to: ~p"/profiles/#{user}") + + {:error, :unauthorized} = err -> + err end end - - defp log_details(_action, {old_user, new_user}) do - %{body: "Erased #{old_user.name}", subject_path: ~p"/profiles/#{new_user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/force_filter_controller.ex b/lib/philomena_web/controllers/admin/user/force_filter_controller.ex index 37a8b1e62..499532db8 100644 --- a/lib/philomena_web/controllers/admin/user/force_filter_controller.ex +++ b/lib/philomena_web/controllers/admin/user/force_filter_controller.ex @@ -1,51 +1,45 @@ defmodule PhilomenaWeb.Admin.User.ForceFilterController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def new(conn, _params) do - changeset = Users.change_user(conn.assigns.user) - - render(conn, "new.html", changeset: changeset, title: "Forcing filter for user") - end - - def create(conn, %{"user" => user_params}) do - {:ok, user} = Users.force_filter(conn.assigns.user, user_params) - - conn - |> put_flash(:info, "Filter was forced.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") + def new(conn, %{"user_id" => slug}) do + with {:ok, %Ecto.Changeset{} = changeset} <- + Users.new_user_force_filter(conn.assigns.actor, slug) do + render(conn, "new.html", + title: "Forcing filter for user", + user: changeset.data, + changeset: changeset + ) + end end - def delete(conn, _params) do - {:ok, user} = Users.unforce_filter(conn.assigns.user) - - conn - |> put_flash(:info, "Forced filter was removed.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") + def create(conn, %{"user_id" => slug, "user" => user_params}) do + case Users.create_user_force_filter(conn.assigns.actor, slug, user_params) do + {:ok, user} -> + conn + |> put_flash(:info, "Filter was forced.") + |> redirect(to: ~p"/profiles/#{user}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", + title: "Forcing filter for user", + user: changeset.data, + changeset: changeset + ) + + {:error, _reason} = error -> + error + end end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_force_filter(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Forced filter was removed.") + |> redirect(to: ~p"/profiles/#{user}") end end - - defp log_details(action, user) do - body = - case action do - :create -> "Forced filter #{user.forced_filter_id} for #{user.name}" - :delete -> "Removed forced filter for #{user.name}" - end - - %{body: body, subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/unlock_controller.ex b/lib/philomena_web/controllers/admin/user/unlock_controller.ex index 61a6f5f06..e4a2ea18f 100644 --- a/lib/philomena_web/controllers/admin/user/unlock_controller.ex +++ b/lib/philomena_web/controllers/admin/user/unlock_controller.ex @@ -1,30 +1,15 @@ defmodule PhilomenaWeb.Admin.User.UnlockController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - {:ok, user} = Users.unlock_user(conn.assigns.user) - - conn - |> put_flash(:info, "User was unlocked.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def create(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.create_user_unlock(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "User was unlocked.") + |> redirect(to: ~p"/profiles/#{user}") end end - - defp log_details(_action, user) do - %{body: "Unlocked #{user.name}", subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/verification_controller.ex b/lib/philomena_web/controllers/admin/user/verification_controller.ex index 906e61e32..b61cbee5f 100644 --- a/lib/philomena_web/controllers/admin/user/verification_controller.ex +++ b/lib/philomena_web/controllers/admin/user/verification_controller.ex @@ -1,45 +1,23 @@ defmodule PhilomenaWeb.Admin.User.VerificationController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - {:ok, user} = Users.verify_user(conn.assigns.user) - - conn - |> put_flash(:info, "User verification granted.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - def delete(conn, _params) do - {:ok, user} = Users.unverify_user(conn.assigns.user) - - conn - |> put_flash(:info, "User verification revoked.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def create(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.create_user_verification(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "User verification granted.") + |> redirect(to: ~p"/profiles/#{user}") end end - defp log_details(action, user) do - body = - case action do - :create -> "Granted verification to #{user.name}" - :delete -> "Revoked verification from #{user.name}" - end - - %{body: body, subject_path: ~p"/profiles/#{user}"} + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_verification(conn.assigns.actor, slug) do + conn + |> put_flash(:info, "User verification revoked.") + |> redirect(to: ~p"/profiles/#{user}") + end end end diff --git a/lib/philomena_web/controllers/admin/user/vote_controller.ex b/lib/philomena_web/controllers/admin/user/vote_controller.ex index 4492ce16e..574acbd9b 100644 --- a/lib/philomena_web/controllers/admin/user/vote_controller.ex +++ b/lib/philomena_web/controllers/admin/user/vote_controller.ex @@ -1,32 +1,15 @@ defmodule PhilomenaWeb.Admin.User.VoteController do use PhilomenaWeb, :controller - alias Philomena.UserUnvoteWorker - alias Philomena.Users.User + alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def delete(conn, _params) do - user = conn.assigns.user - - Exq.enqueue(Exq, "indexing", UserUnvoteWorker, [user.id, true]) - - conn - |> put_flash(:info, "Vote and fave wipe started.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def delete(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.delete_user_votes(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Vote and fave wipe started.") + |> redirect(to: ~p"/profiles/#{user}") end end - - defp log_details(_action, user) do - %{body: "Wiped votes and faves for #{user.name}", subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user/wipe_controller.ex b/lib/philomena_web/controllers/admin/user/wipe_controller.ex index a8adaf5f5..b9736f234 100644 --- a/lib/philomena_web/controllers/admin/user/wipe_controller.ex +++ b/lib/philomena_web/controllers/admin/user/wipe_controller.ex @@ -1,35 +1,18 @@ defmodule PhilomenaWeb.Admin.User.WipeController do use PhilomenaWeb, :controller - alias Philomena.UserWipeWorker - alias Philomena.Users.User + alias Philomena.Users - plug :verify_authorized - plug :load_resource, model: User, id_name: "user_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - user = conn.assigns.user - - Exq.enqueue(Exq, "indexing", UserWipeWorker, [user.id]) - - conn - |> put_flash( - :info, - "PII wipe queued, please verify and then deactivate the account as necessary." - ) - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %User{}) do + def create(conn, %{"user_id" => slug}) do + with {:ok, user} <- Users.create_user_wipe(conn.assigns.actor, slug) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash( + :info, + "PII wipe queued, please verify and then deactivate the account as necessary." + ) + |> redirect(to: ~p"/profiles/#{user}") end end - - defp log_details(_action, user) do - %{body: "Wiped PII for #{user.name}", subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/admin/user_ban_controller.ex b/lib/philomena_web/controllers/admin/user_ban_controller.ex index c523c011d..0fb326540 100644 --- a/lib/philomena_web/controllers/admin/user_ban_controller.ex +++ b/lib/philomena_web/controllers/admin/user_ban_controller.ex @@ -1,156 +1,118 @@ defmodule PhilomenaWeb.Admin.UserBanController do use PhilomenaWeb, :controller - alias Philomena.Users alias Philomena.Bans - alias Philomena.Repo - import Ecto.Query - - plug :verify_authorized - plug :load_resource, model: Bans.User, only: [:edit, :update, :delete], preload: :user - plug :check_can_delete when action in [:delete] - - def index(conn, %{"bq" => q}) when is_binary(q) do - like_q = "%#{q}%" - - Bans.User - |> join(:inner, [ub], _ in assoc(ub, :user)) - |> where( - [ub, u], - ilike(u.name, ^like_q) or - ub.generated_ban_id == ^q or - fragment("to_tsvector(?) @@ plainto_tsquery(?)", ub.reason, ^q) or - fragment("to_tsvector(?) @@ plainto_tsquery(?)", ub.note, ^q) - ) - |> load_bans(conn) - end - def index(conn, %{"user_id" => user_id}) when is_binary(user_id) do - Bans.User - |> where(user_id: ^user_id) - |> load_bans(conn) + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + case Bans.list_user_bans(conn.assigns.actor, params, conn.assigns.scrivener) do + {:ok, user_bans, changeset} -> + render(conn, "index.html", + title: "Admin - User Bans", + layout_class: "layout--wide", + user_bans: user_bans, + changeset: changeset + ) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "index.html", + title: "Admin - User Bans", + layout_class: "layout--wide", + user_bans: nil, + changeset: changeset + ) + + error -> + error + end end - def index(conn, _params) do - load_bans(Bans.User, conn) - end + def new(conn, params) do + case Bans.new_user_ban(conn.assigns.actor, params["user_id"]) do + {:ok, {target_user, changeset}} -> + render_new(conn, target_user, changeset) - def new(conn, %{"user_id" => id}) do - case target_user(id) do - nil -> + {:error, :not_found} -> no_target_user(conn) - target_user -> - render_new(conn, target_user, Bans.change_user(Ecto.build_assoc(target_user, :bans))) + error -> + error end end - def new(conn, _params), do: no_target_user(conn) - def create(conn, %{"user" => user_ban_params}) do - case Bans.create_user(conn.assigns.current_user, user_ban_params) do - {:ok, user_ban} -> + case Bans.create_user_ban(conn.assigns.actor, user_ban_params["user_id"], user_ban_params) do + {:ok, _user_ban} -> conn |> put_flash(:info, "User was successfully banned.") - |> moderation_log(details: &log_details/2, data: user_ban) |> redirect(to: ~p"/admin/user_bans") - {:error, changeset} -> - # `new.html` names the user being banned; the form posts their id back - # in a hidden field. - case target_user(user_ban_params["user_id"]) do - nil -> no_target_user(conn) - target_user -> render_new(conn, target_user, changeset) - end - end - end + {:error, %Ecto.Changeset{} = changeset} -> + case Bans.new_user_ban( + conn.assigns.actor, + user_ban_params["user_id"], + user_ban_params + ) do + {:ok, {target_user, _rebuilt_changeset}} -> + render_new(conn, target_user, changeset) - defp render_new(conn, target_user, changeset) do - render(conn, "new.html", - title: "New User Ban", - target_user: target_user, - changeset: changeset - ) - end + {:error, :not_found} -> + no_target_user(conn) - defp no_target_user(conn) do - conn - |> put_flash(:error, "Must create ban on user.") - |> redirect(to: ~p"/admin/user_bans") - end + error -> + error + end + + {:error, :not_found} -> + no_target_user(conn) - defp target_user(id) do - case PhilomenaWeb.IntegerId.parse(id) do - {:ok, id} -> Repo.get(Users.User, id) - :error -> nil + error -> + error end end - def edit(conn, _params) do - changeset = Bans.change_user(conn.assigns.user) - render(conn, "edit.html", title: "Editing User Ban", changeset: changeset) + def edit(conn, params) do + with {:ok, {user_ban, changeset}} <- + Bans.edit_user_ban(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", title: "Editing User Ban", user: user_ban, changeset: changeset) + end end - def update(conn, %{"user" => user_ban_params}) do - case Bans.update_user(conn.assigns.user, user_ban_params) do - {:ok, user_ban} -> + def update(conn, %{"id" => id, "user" => user_ban_params}) do + case Bans.update_user_ban(conn.assigns.actor, id, user_ban_params) do + {:ok, _user_ban} -> conn |> put_flash(:info, "User ban successfully updated.") - |> moderation_log(details: &log_details/2, data: user_ban) |> redirect(to: ~p"/admin/user_bans") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, user_ban} = Bans.delete_user(conn.assigns.user) - - conn - |> put_flash(:info, "User ban successfully deleted.") - |> moderation_log(details: &log_details/2, data: user_ban) - |> redirect(to: ~p"/admin/user_bans") - end - - defp load_bans(queryable, conn) do - user_bans = - queryable - |> order_by(desc: :created_at) - |> preload([:user, :banning_user]) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", - title: "Admin - User Bans", - layout_class: "layout--wide", - user_bans: user_bans - ) - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", user: changeset.data, changeset: changeset) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, Bans.User) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - defp check_can_delete(conn, _opts) do - if conn.assigns.current_user.role == "admin" do + def delete(conn, params) do + with {:ok, _user_ban} <- Bans.delete_user_ban(conn.assigns.actor, params["id"]) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "User ban successfully deleted.") + |> redirect(to: ~p"/admin/user_bans") end end - defp log_details(action, ban) do - body = - case action do - :create -> "Created a user ban #{ban.generated_ban_id}" - :update -> "Updated a user ban #{ban.generated_ban_id}" - :delete -> "Deleted a user ban #{ban.generated_ban_id}" - end + defp render_new(conn, target_user, changeset) do + render(conn, "new.html", + title: "New User Ban", + target_user: target_user, + changeset: changeset + ) + end - %{body: body, subject_path: ~p"/admin/user_bans"} + defp no_target_user(conn) do + conn + |> put_flash(:error, "Must create ban on user.") + |> redirect(to: ~p"/admin/user_bans") end end diff --git a/lib/philomena_web/controllers/admin/user_controller.ex b/lib/philomena_web/controllers/admin/user_controller.ex index 277a24dd1..8caa41b49 100644 --- a/lib/philomena_web/controllers/admin/user_controller.ex +++ b/lib/philomena_web/controllers/admin/user_controller.ex @@ -1,85 +1,61 @@ defmodule PhilomenaWeb.Admin.UserController do use PhilomenaWeb, :controller - alias PhilomenaWeb.UserLoader - alias PhilomenaQuery.Search - alias Philomena.Roles.Role - alias Philomena.Users.User alias Philomena.Users - alias Philomena.Repo + alias Philomena.Users.AdminUserForm - plug :verify_authorized - - plug :load_and_authorize_resource, - model: User, - only: [:edit, :update], - id_field: "slug", - preload: [:roles] - - plug :load_roles when action in [:edit, :update] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - query_string = - case params["uq"] do - nil -> "*" - "" -> "*" - query_string -> query_string - end - - case Users.Query.compile(query_string) do - {:ok, query} -> - users = UserLoader.query(conn, query) |> Search.search_records(User) - + case Users.query_users(conn.assigns.actor, params["uq"] || %{}, conn.assigns.pagination) do + {:ok, users, changeset} -> render(conn, "index.html", title: "Admin - Users", layout_class: "layout--medium", - users: users + users: users, + changeset: changeset ) - {:error, msg} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "index.html", title: "Admin - Users", layout_class: "layout--medium", - users: [], - error: msg + users: nil, + changeset: changeset ) - end - end - def edit(conn, _params) do - changeset = Users.change_user(conn.assigns.user) - render(conn, "edit.html", title: "Editing User", changeset: changeset) + error -> + error + end end - def update(conn, %{"user" => user_params}) do - case Users.update_user(conn.assigns.user, user_params) do - {:ok, user} -> - conn - |> put_flash(:info, "User successfully updated.") - |> moderation_log(details: &log_details/2, data: user) - |> redirect(to: ~p"/profiles/#{user}") - - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) + def edit(conn, %{"id" => slug}) do + with {:ok, %AdminUserForm{} = form} <- + Users.edit_user(conn.assigns.actor, slug) do + render(conn, "edit.html", + title: "Editing User", + user: form.changeset.data, + changeset: form.changeset, + roles: form.roles + ) end end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, User) do + def update(conn, %{"id" => slug, "user" => user_params}) do + with {:ok, user} <- Users.update_user(conn.assigns.actor, slug, user_params) do conn + |> put_flash(:info, "User successfully updated.") + |> redirect(to: ~p"/profiles/#{user}") else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end - - defp load_roles(conn, _opts) do - assign(conn, :roles, Repo.all(Role)) - end + {:error, %AdminUserForm{} = form} -> + render(conn, "edit.html", + user: form.changeset.data, + changeset: form.changeset, + roles: form.roles + ) - defp log_details(_action, user) do - %{ - body: "Updated user details for #{user.name}", - subject_path: ~p"/profiles/#{user}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/advert_controller.ex b/lib/philomena_web/controllers/advert_controller.ex index 73c47482c..ddcbfd2fa 100644 --- a/lib/philomena_web/controllers/advert_controller.ex +++ b/lib/philomena_web/controllers/advert_controller.ex @@ -1,16 +1,13 @@ defmodule PhilomenaWeb.AdvertController do use PhilomenaWeb, :controller - alias Philomena.Adverts.Advert alias Philomena.Adverts - plug :load_resource, model: Advert + action_fallback PhilomenaWeb.FallbackController - def show(conn, _params) do - advert = conn.assigns.advert - - Adverts.record_click(advert) - - redirect(conn, external: advert.link) + def show(conn, %{"id" => id}) do + with {:ok, advert} <- Adverts.record_click(id) do + redirect(conn, external: advert.link) + end end end diff --git a/lib/philomena_web/controllers/api/json/comment_controller.ex b/lib/philomena_web/controllers/api/json/comment_controller.ex index aa7ead10f..d1395c5e8 100644 --- a/lib/philomena_web/controllers/api/json/comment_controller.ex +++ b/lib/philomena_web/controllers/api/json/comment_controller.ex @@ -1,29 +1,21 @@ defmodule PhilomenaWeb.Api.Json.CommentController do use PhilomenaWeb, :controller - alias Philomena.Comments.Comment - alias Philomena.Repo - import Ecto.Query + alias Philomena.Comments import PhilomenaWeb.Api.Json.NotFound def show(conn, %{"id" => id}) do - comment = - Comment - |> where(id: ^id) - |> preload([:image, :user]) - |> Repo.one() + case Comments.show_comment(conn.assigns.actor, id) do + {:ok, comment} -> + render(conn, "show.json", comment: comment) - cond do - is_nil(comment) or comment.destroyed_content -> + {:error, :not_found} -> not_found(conn) - comment.image.hidden_from_users -> + {:error, :unauthorized} -> conn |> put_status(:forbidden) |> text("") - - true -> - render(conn, "show.json", comment: comment) end end end diff --git a/lib/philomena_web/controllers/api/json/filter/system_filter_controller.ex b/lib/philomena_web/controllers/api/json/filter/system_filter_controller.ex index 6fbbfb30e..2233361c1 100755 --- a/lib/philomena_web/controllers/api/json/filter/system_filter_controller.ex +++ b/lib/philomena_web/controllers/api/json/filter/system_filter_controller.ex @@ -1,19 +1,14 @@ defmodule PhilomenaWeb.Api.Json.Filter.SystemFilterController do use PhilomenaWeb, :controller - alias Philomena.Filters.Filter - alias Philomena.Repo - import Ecto.Query + alias Philomena.Filters def index(conn, _params) do - system_filters = - Filter - |> where(system: true) - |> order_by(asc: :id) - |> Repo.paginate(conn.assigns.scrivener) - - conn - |> put_view(PhilomenaWeb.Api.Json.FilterView) - |> render("index.json", filters: system_filters, total: system_filters.total_entries) + with {:ok, system_filters} <- + Filters.system_filters(conn.assigns.actor, conn.assigns.scrivener) do + conn + |> put_view(PhilomenaWeb.Api.Json.FilterView) + |> render("index.json", filters: system_filters, total: system_filters.total_entries) + end end end diff --git a/lib/philomena_web/controllers/api/json/filter/user_filter_controller.ex b/lib/philomena_web/controllers/api/json/filter/user_filter_controller.ex index 5d7eee7be..6eb9a0a29 100755 --- a/lib/philomena_web/controllers/api/json/filter/user_filter_controller.ex +++ b/lib/philomena_web/controllers/api/json/filter/user_filter_controller.ex @@ -1,29 +1,22 @@ defmodule PhilomenaWeb.Api.Json.Filter.UserFilterController do use PhilomenaWeb, :controller - alias Philomena.Filters.Filter - alias Philomena.Repo - import Ecto.Query + alias Philomena.Filters def index(conn, _params) do - user = conn.assigns.current_user - - case user do + case conn.assigns.current_user do nil -> conn |> put_status(:forbidden) |> text("") - _ -> - user_filters = - Filter - |> where(user_id: ^user.id) - |> order_by(asc: :id) - |> Repo.paginate(conn.assigns.scrivener) - - conn - |> put_view(PhilomenaWeb.Api.Json.FilterView) - |> render("index.json", filters: user_filters, total: user_filters.total_entries) + _user -> + with {:ok, user_filters} <- + Filters.user_filters(conn.assigns.actor, conn.assigns.scrivener) do + conn + |> put_view(PhilomenaWeb.Api.Json.FilterView) + |> render("index.json", filters: user_filters, total: user_filters.total_entries) + end end end end diff --git a/lib/philomena_web/controllers/api/json/filter_controller.ex b/lib/philomena_web/controllers/api/json/filter_controller.ex index 5f37cd9c4..b88c6b2c5 100755 --- a/lib/philomena_web/controllers/api/json/filter_controller.ex +++ b/lib/philomena_web/controllers/api/json/filter_controller.ex @@ -1,33 +1,18 @@ defmodule PhilomenaWeb.Api.Json.FilterController do use PhilomenaWeb, :controller - alias Philomena.Filters.Filter - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - import Ecto.Query + alias Philomena.Filters import PhilomenaWeb.Api.Json.NotFound def show(conn, %{"id" => id}) do - user = conn.assigns.current_user - filter = load_filter(id) + case Filters.show_filter(conn.assigns.actor, id) do + {:ok, filter} -> + render(conn, "show.json", filter: filter) - if Canada.Can.can?(user, :show, filter) do - render(conn, "show.json", filter: filter) - else - not_found(conn) - end - end - - defp load_filter(id) do - case IntegerId.parse(id) do - {:ok, id} -> - Filter - |> where(id: ^id) - |> preload(:user) - |> Repo.one() - - :error -> - nil + # A filter the viewer may not see and a missing filter answer with the + # same uniform 404. + {:error, _not_found_or_unauthorized} -> + not_found(conn) end end end diff --git a/lib/philomena_web/controllers/api/json/forum/topic/post_controller.ex b/lib/philomena_web/controllers/api/json/forum/topic/post_controller.ex index 55616e2f1..15ffdd63b 100644 --- a/lib/philomena_web/controllers/api/json/forum/topic/post_controller.ex +++ b/lib/philomena_web/controllers/api/json/forum/topic/post_controller.ex @@ -1,70 +1,33 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostController do use PhilomenaWeb, :controller - alias Philomena.Topics.Topic - alias Philomena.Posts.Post - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - import Ecto.Query + alias Philomena.Topics + alias Philomena.Posts import PhilomenaWeb.Api.Json.NotFound - def index(conn, %{"forum_id" => forum_id, "topic_id" => topic_id}) do - case load_topic(forum_id, topic_id) do - nil -> + def index(conn, %{"forum_id" => forum_id, "topic_id" => topic_id} = params) do + case Topics.show_topic_page( + conn.assigns.actor, + forum_id, + topic_id, + params["post_id"], + conn.assigns.pagination + ) do + {:ok, page} -> + render(conn, "index.json", + posts: page.posts.entries, + total: page.posts.total_entries + ) + + {:error, reason} when reason in [:not_found, :unauthorized] -> not_found(conn) - - topic -> - %{page_number: page, page_size: page_size} = conn.assigns.pagination - - posts = - Post - |> where(topic_id: ^topic.id) - |> where(destroyed_content: false) - |> where( - [p], - p.topic_position >= ^(page_size * (page - 1)) and - p.topic_position < ^(page_size * page) - ) - |> order_by(asc: :topic_position) - |> preload(:user) - |> Repo.all() - |> Enum.map(&%{&1 | topic: topic}) - - render(conn, "index.json", posts: posts, total: topic.post_count) end end - def show(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "id" => post_id}) do - case IntegerId.parse(post_id) do - {:ok, post_id} -> show_post(conn, forum_id, topic_id, post_id) - :error -> not_found(conn) + def show(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "id" => id}) do + case Posts.show_topic_post(conn.assigns.actor, forum_id, topic_id, id) do + {:ok, post} -> render(conn, "show.json", post: post) + {:error, reason} when reason in [:not_found, :unauthorized] -> not_found(conn) end end - - defp show_post(conn, forum_id, topic_id, post_id) do - post = - Post - |> join(:inner, [p], _ in assoc(p, :topic)) - |> join(:inner, [_p, t], _ in assoc(t, :forum)) - |> where(id: ^post_id) - |> where(destroyed_content: false) - |> where([_p, t], t.hidden_from_users == false and t.slug == ^topic_id) - |> where([_p, _t, f], f.access_level == "normal" and f.short_name == ^forum_id) - |> preload([:user, :topic]) - |> Repo.one() - - if is_nil(post) do - not_found(conn) - else - render(conn, "show.json", post: post) - end - end - - defp load_topic(forum_id, topic_id) do - Topic - |> join(:inner, [t], _ in assoc(t, :forum)) - |> where([t], t.hidden_from_users == false and t.slug == ^topic_id) - |> where([_t, f], f.access_level == "normal" and f.short_name == ^forum_id) - |> Repo.one() - end end diff --git a/lib/philomena_web/controllers/api/json/forum/topic_controller.ex b/lib/philomena_web/controllers/api/json/forum/topic_controller.ex index ec5b3de92..3a3a7526e 100644 --- a/lib/philomena_web/controllers/api/json/forum/topic_controller.ex +++ b/lib/philomena_web/controllers/api/json/forum/topic_controller.ex @@ -1,39 +1,23 @@ defmodule PhilomenaWeb.Api.Json.Forum.TopicController do use PhilomenaWeb, :controller - alias Philomena.Topics.Topic - alias Philomena.Repo - import Ecto.Query + alias Philomena.{Forums, Topics} import PhilomenaWeb.Api.Json.NotFound - def index(conn, %{"forum_id" => id}) do - topics = - Topic - |> join(:inner, [t], _ in assoc(t, :forum)) - |> where(hidden_from_users: false) - |> where([_t, f], f.access_level == "normal" and f.short_name == ^id) - |> order_by(desc: :sticky, desc: :last_replied_to_at) - |> preload([:user]) - |> Repo.paginate(conn.assigns.scrivener) + def index(conn, %{"forum_id" => forum_id}) do + case Forums.show_forum_page(conn.assigns.actor, forum_id, conn.assigns.scrivener) do + {:ok, page} -> + render(conn, "index.json", topics: page.topics, total: page.topics.total_entries) - render(conn, "index.json", topics: topics, total: topics.total_entries) + {:error, reason} when reason in [:not_found, :unauthorized] -> + not_found(conn) + end end def show(conn, %{"forum_id" => forum_id, "id" => id}) do - topic = - Topic - |> join(:inner, [t], _ in assoc(t, :forum)) - |> where(slug: ^id) - |> where(hidden_from_users: false) - |> where([_t, f], f.access_level == "normal" and f.short_name == ^forum_id) - |> order_by(desc: :sticky, desc: :last_replied_to_at) - |> preload([:user]) - |> Repo.one() - - if is_nil(topic) do - not_found(conn) - else - render(conn, "show.json", topic: topic) + case Topics.show_topic(conn.assigns.actor, forum_id, id) do + {:ok, topic} -> render(conn, "show.json", topic: topic) + {:error, reason} when reason in [:not_found, :unauthorized] -> not_found(conn) end end end diff --git a/lib/philomena_web/controllers/api/json/forum_controller.ex b/lib/philomena_web/controllers/api/json/forum_controller.ex index 884402064..64d359c02 100644 --- a/lib/philomena_web/controllers/api/json/forum_controller.ex +++ b/lib/philomena_web/controllers/api/json/forum_controller.ex @@ -1,32 +1,19 @@ defmodule PhilomenaWeb.Api.Json.ForumController do use PhilomenaWeb, :controller - alias Philomena.Forums.Forum - alias Philomena.Repo - import Ecto.Query + alias Philomena.Forums import PhilomenaWeb.Api.Json.NotFound def index(conn, _params) do - forums = - Forum - |> where(access_level: "normal") - |> order_by(asc: :name) - |> Repo.paginate(conn.assigns.scrivener) + forum_index = Forums.list_forums(conn.assigns.actor, conn.assigns.scrivener) - render(conn, forums: forums, total: forums.total_entries) + render(conn, forums: forum_index.forums, total: forum_index.forums.total_entries) end def show(conn, %{"id" => id}) do - forum = - Forum - |> where(short_name: ^id) - |> where(access_level: "normal") - |> Repo.one() - - if is_nil(forum) do - not_found(conn) - else - render(conn, forum: forum) + case Forums.show_forum(conn.assigns.actor, id) do + {:ok, forum} -> render(conn, forum: forum) + {:error, reason} when reason in [:not_found, :unauthorized] -> not_found(conn) end end end diff --git a/lib/philomena_web/controllers/api/json/image/featured_controller.ex b/lib/philomena_web/controllers/api/json/image/featured_controller.ex index 1a72ba3d1..aa9486e96 100644 --- a/lib/philomena_web/controllers/api/json/image/featured_controller.ex +++ b/lib/philomena_web/controllers/api/json/image/featured_controller.ex @@ -1,37 +1,21 @@ defmodule PhilomenaWeb.Api.Json.Image.FeaturedController do use PhilomenaWeb, :controller - alias Philomena.ImageFeatures.ImageFeature - alias Philomena.Images.Image + alias Philomena.Images alias Philomena.Interactions - alias Philomena.Repo - import Ecto.Query import PhilomenaWeb.Api.Json.NotFound def show(conn, _params) do - user = conn.assigns.current_user - - featured_image = - Image - |> join(:inner, [i], f in ImageFeature, on: [image_id: i.id]) - |> where([i], i.hidden_from_users == false) - |> order_by([_i, f], desc: f.created_at) - |> limit(1) - |> preload([:user, :intensity, :sources, tags: :aliases]) - |> Repo.one() - - case featured_image do - nil -> - conn - |> not_found() - |> halt() - - _ -> - interactions = Interactions.user_interactions([featured_image], user) + case Images.show_featured_image(conn.assigns.actor, false) do + {:ok, image} -> + interactions = Interactions.user_interactions(conn.assigns.actor, [image]) conn |> put_view(PhilomenaWeb.Api.Json.ImageView) - |> render("show.json", image: featured_image, interactions: interactions) + |> render("show.json", image: image, interactions: interactions) + + {:error, :not_found} -> + not_found(conn) end end end diff --git a/lib/philomena_web/controllers/api/json/image_controller.ex b/lib/philomena_web/controllers/api/json/image_controller.ex index 03b867d01..e508ebada 100644 --- a/lib/philomena_web/controllers/api/json/image_controller.ex +++ b/lib/philomena_web/controllers/api/json/image_controller.ex @@ -1,56 +1,36 @@ defmodule PhilomenaWeb.Api.Json.ImageController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images alias Philomena.Interactions - alias Philomena.Repo - import Ecto.Query import PhilomenaWeb.Api.Json.NotFound plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ApiRequireAuthorizationPlug when action in [:create] - plug PhilomenaWeb.UserAttributionPlug when action in [:create] plug PhilomenaWeb.ScraperPlug, [params_name: "image", params_key: "image"] when action in [:create] def show(conn, %{"id" => id}) do - user = conn.assigns.current_user + case Images.show_api_image(conn.assigns.actor, id) do + {:ok, image} -> + interactions = Interactions.user_interactions(conn.assigns.actor, [image]) - image = - Image - |> where(id: ^id) - |> preload([:user, :intensity, :sources, tags: :aliases]) - |> Repo.one() + render(conn, "show.json", image: image, interactions: interactions) - case image do - nil -> + {:error, _not_visible_or_missing} -> not_found(conn) - - _ -> - interactions = Interactions.user_interactions([image], user) - - render(conn, "show.json", image: image, interactions: interactions) end end def create(conn, %{"image" => image_params}) do - attributes = conn.assigns.attributes + upload = PhilomenaMedia.Upload.cast(image_params, "image") - case Images.create_image(attributes, image_params) do + case Images.create_image(conn.assigns.actor, image_params, upload) do {:ok, %{image: image}} -> - image = Repo.preload(image, tags: :aliases) - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:create", - PhilomenaWeb.Api.Json.ImageView.render("show.json", %{image: image, interactions: []}) - ) - render(conn, "show.json", image: image, interactions: []) - {:error, :image, changeset, _} -> + {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:bad_request) |> render("error.json", changeset: changeset) diff --git a/lib/philomena_web/controllers/api/json/oembed_controller.ex b/lib/philomena_web/controllers/api/json/oembed_controller.ex index 215bd496d..14cf80f3a 100644 --- a/lib/philomena_web/controllers/api/json/oembed_controller.ex +++ b/lib/philomena_web/controllers/api/json/oembed_controller.ex @@ -1,10 +1,7 @@ defmodule PhilomenaWeb.Api.Json.OembedController do use PhilomenaWeb, :controller - alias Philomena.Images.Image - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - import Ecto.Query + alias Philomena.Images import PhilomenaWeb.Api.Json.NotFound # CDN image URLs always embed the image id directly after a @@ -30,10 +27,10 @@ defmodule PhilomenaWeb.Api.Json.OembedController do # A URL with no path at all (e.g. `https://example.com`) parses to a nil path. defp try_oembed(%{path: path}, conn) when is_binary(path) do - path - |> extract_image_id() - |> load_image() - |> oembed_image(conn) + case Images.show_api_image(conn.assigns.actor, extract_image_id(path)) do + {:ok, %{hidden_from_users: false} = image} -> render(conn, "show.json", image: image) + _error -> not_found(conn) + end end defp try_oembed(_parsed, conn), do: not_found(conn) @@ -57,22 +54,4 @@ defmodule PhilomenaWeb.Api.Json.OembedController do end end end - - defp load_image(nil), do: nil - - defp load_image(id) do - case IntegerId.parse(id) do - {:ok, id} -> - Image - |> where(id: ^id, hidden_from_users: false) - |> preload([:user, :sources, tags: :aliases]) - |> Repo.one() - - :error -> - nil - end - end - - defp oembed_image(nil, conn), do: not_found(conn) - defp oembed_image(image, conn), do: render(conn, "show.json", image: image) end diff --git a/lib/philomena_web/controllers/api/json/post_controller.ex b/lib/philomena_web/controllers/api/json/post_controller.ex index f8573e9de..0c93e3961 100644 --- a/lib/philomena_web/controllers/api/json/post_controller.ex +++ b/lib/philomena_web/controllers/api/json/post_controller.ex @@ -1,29 +1,18 @@ defmodule PhilomenaWeb.Api.Json.PostController do use PhilomenaWeb, :controller - alias Philomena.Posts.Post - alias Philomena.Repo - import Ecto.Query + alias Philomena.Posts import PhilomenaWeb.Api.Json.NotFound def show(conn, %{"id" => post_id}) do - post = - Post - |> join(:inner, [p], _ in assoc(p, :topic)) - |> join(:inner, [_p, t], _ in assoc(t, :forum)) - |> where(id: ^post_id) - |> where(destroyed_content: false) - |> where([_p, t], t.hidden_from_users == false) - |> where([_p, _t, f], f.access_level == "normal") - |> preload([:user, :topic]) - |> Repo.one() + case Posts.show_post(conn.assigns.actor, post_id) do + {:ok, post} -> + conn + |> put_view(PhilomenaWeb.Api.Json.Forum.Topic.PostView) + |> render("show.json", post: post) - if is_nil(post) do - not_found(conn) - else - conn - |> put_view(PhilomenaWeb.Api.Json.Forum.Topic.PostView) - |> render("show.json", post: post) + {:error, reason} when reason in [:not_found, :unauthorized] -> + not_found(conn) end end end diff --git a/lib/philomena_web/controllers/api/json/profile_controller.ex b/lib/philomena_web/controllers/api/json/profile_controller.ex index 8b41c3623..eae9cb51e 100755 --- a/lib/philomena_web/controllers/api/json/profile_controller.ex +++ b/lib/philomena_web/controllers/api/json/profile_controller.ex @@ -1,22 +1,16 @@ defmodule PhilomenaWeb.Api.Json.ProfileController do use PhilomenaWeb, :controller - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query + alias Philomena.Users import PhilomenaWeb.Api.Json.NotFound def show(conn, %{"id" => id}) do - user = - User - |> where(id: ^id) - |> preload(public_links: :tag, awards: :badge) - |> Repo.one() + case Users.show_profile(conn.assigns.actor, id) do + {:ok, user} -> + render(conn, "show.json", user: user) - if is_nil(user) or user.deleted_at do - not_found(conn) - else - render(conn, "show.json", user: user) + {:error, :not_found} -> + not_found(conn) end end end diff --git a/lib/philomena_web/controllers/api/json/search/comment_controller.ex b/lib/philomena_web/controllers/api/json/search/comment_controller.ex index 58d3e3b74..5f6cfeb2c 100644 --- a/lib/philomena_web/controllers/api/json/search/comment_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/comment_controller.ex @@ -1,21 +1,14 @@ defmodule PhilomenaWeb.Api.Json.Search.CommentController do use PhilomenaWeb, :controller - alias PhilomenaWeb.CommentLoader - alias PhilomenaQuery.Search - alias Philomena.Comments.Comment - alias Philomena.Comments.Query - import Ecto.Query + alias Philomena.Comments def index(conn, params) do - user = conn.assigns.current_user - - case Query.compile(params["q"], user: user) do - {:ok, query} -> - comments = - CommentLoader.query(conn, query) - |> Search.search_records(preload(Comment, [:image, :user])) + actor = conn.assigns.actor + filter = conn.assigns.current_filter + case Comments.query_comments(actor, filter, params["q"], conn.assigns.pagination) do + {:ok, comments} -> conn |> put_view(PhilomenaWeb.Api.Json.CommentView) |> render("index.json", comments: comments, total: comments.total_entries) diff --git a/lib/philomena_web/controllers/api/json/search/filter_controller.ex b/lib/philomena_web/controllers/api/json/search/filter_controller.ex index 7c4f81b5e..9e01e674d 100644 --- a/lib/philomena_web/controllers/api/json/search/filter_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/filter_controller.ex @@ -1,43 +1,11 @@ defmodule PhilomenaWeb.Api.Json.Search.FilterController do use PhilomenaWeb, :controller - alias PhilomenaQuery.Search - alias Philomena.Filters.Filter - alias Philomena.Filters.Query - import Ecto.Query + alias Philomena.Filters def index(conn, params) do - user = conn.assigns.current_user - - case Query.compile(params["q"], user: user) do - {:ok, query} -> - filters = - Filter - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: [ - query, - %{ - bool: %{ - should: - [%{term: %{public: true}}, %{term: %{system: true}}] ++ - user_should(user) - } - } - ] - } - }, - sort: [ - %{name: :asc}, - %{id: :desc} - ] - }, - conn.assigns.pagination - ) - |> Search.search_records(preload(Filter, [:user])) - + case Filters.query_filters(conn.assigns.actor, params["q"], conn.assigns.pagination) do + {:ok, filters} -> conn |> put_view(PhilomenaWeb.Api.Json.FilterView) |> render("index.json", filters: filters, total: filters.total_entries) @@ -48,14 +16,4 @@ defmodule PhilomenaWeb.Api.Json.Search.FilterController do |> json(%{error: msg}) end end - - defp user_should(user) do - case user do - nil -> - [] - - user -> - [%{term: %{user_id: user.id}}] - end - end end diff --git a/lib/philomena_web/controllers/api/json/search/gallery_controller.ex b/lib/philomena_web/controllers/api/json/search/gallery_controller.ex index 4a1d4e299..ab31c075a 100644 --- a/lib/philomena_web/controllers/api/json/search/gallery_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/gallery_controller.ex @@ -1,27 +1,11 @@ defmodule PhilomenaWeb.Api.Json.Search.GalleryController do use PhilomenaWeb, :controller - alias PhilomenaQuery.Search - alias Philomena.Galleries.Gallery - alias Philomena.Galleries.Query - import Ecto.Query + alias Philomena.Galleries def index(conn, params) do - user = conn.assigns.current_user - - case Query.compile(params["q"], user: user) do - {:ok, query} -> - galleries = - Gallery - |> Search.search_definition( - %{ - query: query, - sort: %{created_at: :desc} - }, - conn.assigns.pagination - ) - |> Search.search_records(preload(Gallery, [:user])) - + case Galleries.query_galleries(conn.assigns.actor, params["q"], conn.assigns.pagination) do + {:ok, galleries} -> conn |> put_view(PhilomenaWeb.Api.Json.GalleryView) |> render("index.json", galleries: galleries, total: galleries.total_entries) diff --git a/lib/philomena_web/controllers/api/json/search/image_controller.ex b/lib/philomena_web/controllers/api/json/search/image_controller.ex index 109e7abe7..a1b974f39 100644 --- a/lib/philomena_web/controllers/api/json/search/image_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/image_controller.ex @@ -1,20 +1,18 @@ defmodule PhilomenaWeb.Api.Json.Search.ImageController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias PhilomenaQuery.Search + alias Philomena.Images alias Philomena.Interactions - alias Philomena.Images.Image - import Ecto.Query - def index(conn, params) do - queryable = Image |> preload([:user, :intensity, :sources, tags: :aliases]) - user = conn.assigns.current_user + def index(conn, _params) do + scope = PhilomenaWeb.ImageScope.search_scope(conn) - case ImageLoader.search_string(conn, params["q"]) do - {:ok, {images, _tags}} -> - images = Search.search_records(images, queryable) - interactions = Interactions.user_interactions(images, user) + case Images.query_images(conn.assigns.actor, scope, + preload: [:user, :intensity, :sources, tags: :aliases], + hits: false + ) do + {:ok, %{images: images}} -> + interactions = Interactions.user_interactions(conn.assigns.actor, images) conn |> put_view(PhilomenaWeb.Api.Json.ImageView) @@ -26,7 +24,7 @@ defmodule PhilomenaWeb.Api.Json.Search.ImageController do {:error, msg} -> conn - |> Plug.Conn.put_status(:bad_request) + |> put_status(:bad_request) |> json(%{error: msg}) end end diff --git a/lib/philomena_web/controllers/api/json/search/post_controller.ex b/lib/philomena_web/controllers/api/json/search/post_controller.ex index 31f28ed7a..3193f8da0 100644 --- a/lib/philomena_web/controllers/api/json/search/post_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/post_controller.ex @@ -1,35 +1,15 @@ defmodule PhilomenaWeb.Api.Json.Search.PostController do use PhilomenaWeb, :controller - alias PhilomenaQuery.Search - alias Philomena.Posts.Post - alias Philomena.Posts.Query - import Ecto.Query + alias Philomena.Posts def index(conn, params) do - user = conn.assigns.current_user - - case Query.compile(params["q"], user: user) do - {:ok, query} -> - posts = - Post - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: [ - query, - %{term: %{hidden_from_users: false}}, - %{term: %{access_level: "normal"}} - ] - } - }, - sort: %{created_at: :desc} - }, - conn.assigns.pagination - ) - |> Search.search_records(preload(Post, [:user, :topic])) - + case Posts.query_posts( + conn.assigns.actor, + params["q"], + conn.assigns.pagination + ) do + {:ok, posts} -> conn |> put_view(PhilomenaWeb.Api.Json.Forum.Topic.PostView) |> render("index.json", posts: posts, total: posts.total_entries) diff --git a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex index 4abe75602..821a3a31c 100644 --- a/lib/philomena_web/controllers/api/json/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/reverse_controller.ex @@ -2,28 +2,29 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseController do use PhilomenaWeb, :controller alias Philomena.DuplicateReports + alias Philomena.DuplicateReports.SearchResult alias Philomena.Interactions plug PhilomenaWeb.ScraperCachePlug plug PhilomenaWeb.ScraperPlug, params_key: "image", params_name: "image" def create(conn, %{"image" => image_params}) do - user = conn.assigns.current_user + upload = PhilomenaMedia.Upload.cast(image_params, "image") {images, total} = image_params |> Map.put("distance", conn.params["distance"]) |> Map.put("limit", conn.params["limit"]) - |> DuplicateReports.execute_search_query() + |> then(&DuplicateReports.create_reverse_search(conn.assigns.actor, &1, upload)) |> case do - {:ok, images} -> + {:ok, %SearchResult{images: images}} -> {images, images.total_entries} {:error, _changeset} -> {[], 0} end - interactions = Interactions.user_interactions(images, user) + interactions = Interactions.user_interactions(conn.assigns.actor, images) conn |> put_view(PhilomenaWeb.Api.Json.ImageView) diff --git a/lib/philomena_web/controllers/api/json/search/tag_controller.ex b/lib/philomena_web/controllers/api/json/search/tag_controller.ex index b860cf460..142f7afd2 100644 --- a/lib/philomena_web/controllers/api/json/search/tag_controller.ex +++ b/lib/philomena_web/controllers/api/json/search/tag_controller.ex @@ -1,32 +1,28 @@ defmodule PhilomenaWeb.Api.Json.Search.TagController do use PhilomenaWeb, :controller - alias PhilomenaQuery.Search - alias Philomena.Tags.Tag - alias Philomena.Tags.Query - import Ecto.Query + alias Philomena.Tags def index(conn, params) do - case Query.compile(params["q"]) do - {:ok, query} -> - tags = - Tag - |> Search.search_definition( - %{query: query, sort: %{images: :desc}}, - conn.assigns.pagination - ) - |> Search.search_records( - preload(Tag, [:aliased_tag, :aliases, :implied_tags, :implied_by_tags, :dnp_entries]) - ) - + case Tags.query_tags( + conn.assigns.actor, + %{"query" => params["q"]}, + conn.assigns.pagination + ) do + {:ok, tags, _changeset} -> conn |> put_view(PhilomenaWeb.Api.Json.TagView) |> render("index.json", tags: tags, total: tags.total_entries) - {:error, msg} -> + {:error, %Ecto.Changeset{} = changeset} -> + {message, _options} = Keyword.fetch!(changeset.errors, :query) + conn |> put_status(:bad_request) - |> json(%{error: msg}) + |> json(%{error: message}) + + error -> + error end end end diff --git a/lib/philomena_web/controllers/api/json/tag_controller.ex b/lib/philomena_web/controllers/api/json/tag_controller.ex index 48d8a2f45..5cbf321fe 100644 --- a/lib/philomena_web/controllers/api/json/tag_controller.ex +++ b/lib/philomena_web/controllers/api/json/tag_controller.ex @@ -1,24 +1,19 @@ defmodule PhilomenaWeb.Api.Json.TagController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag - alias Philomena.Repo - import Ecto.Query + alias Philomena.Tags import PhilomenaWeb.Api.Json.NotFound def show(conn, %{"id" => slug}) do - tag = - Tag - |> where(slug: ^slug) - |> preload([:aliased_tag, :aliases, :implied_tags, :implied_by_tags, :dnp_entries]) - |> Repo.one() + case Tags.show_tag(conn.assigns.actor, slug) do + {:ok, tag} -> + render(conn, "show.json", tag: tag) - case tag do - nil -> + {:error, :not_found} -> not_found(conn) - _ -> - render(conn, "show.json", tag: tag) + error -> + error end end end diff --git a/lib/philomena_web/controllers/api/rss/watched_controller.ex b/lib/philomena_web/controllers/api/rss/watched_controller.ex index b2f00f0c4..cbe36925d 100644 --- a/lib/philomena_web/controllers/api/rss/watched_controller.ex +++ b/lib/philomena_web/controllers/api/rss/watched_controller.ex @@ -1,20 +1,19 @@ defmodule PhilomenaWeb.Api.Rss.WatchedController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias Philomena.Images.Image - alias PhilomenaQuery.Search + alias Philomena.Images - import Ecto.Query + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - {:ok, {images, _tags}} = ImageLoader.search_string(conn, "my:watched") - images = Search.search_records(images, preload(Image, [:sources, tags: :aliases])) + scope = PhilomenaWeb.ImageScope.search_scope(conn) - # NB: this is RSS, but using the RSS format causes Phoenix not to - # escape HTML - conn - |> put_resp_header("content-type", "application/rss+xml") - |> render("index.html", layout: false, images: images) + with {:ok, images} <- Images.list_watched_images(conn.assigns.actor, scope) do + # NB: this is RSS, but using the RSS format causes Phoenix not to + # escape HTML + conn + |> put_resp_header("content-type", "application/rss+xml") + |> render("index.html", layout: false, images: images) + end end end diff --git a/lib/philomena_web/controllers/autocomplete/compiled_controller.ex b/lib/philomena_web/controllers/autocomplete/compiled_controller.ex index 7571e03e6..605661fef 100644 --- a/lib/philomena_web/controllers/autocomplete/compiled_controller.ex +++ b/lib/philomena_web/controllers/autocomplete/compiled_controller.ex @@ -4,16 +4,14 @@ defmodule PhilomenaWeb.Autocomplete.CompiledController do alias Philomena.Autocomplete def show(conn, _params) do - autocomplete = Autocomplete.get_autocomplete() - - case autocomplete do - nil -> + case Autocomplete.show_compiled_autocomplete() do + {:error, :not_found} -> conn |> put_status(:not_found) |> configure_session(drop: true) |> text("") - %{content: content} -> + {:ok, %{content: content}} -> conn |> put_resp_header("cache-control", "public, max-age=86400") |> configure_session(drop: true) diff --git a/lib/philomena_web/controllers/autocomplete/tag_controller.ex b/lib/philomena_web/controllers/autocomplete/tag_controller.ex index c9f52bf4d..636286b0a 100644 --- a/lib/philomena_web/controllers/autocomplete/tag_controller.ex +++ b/lib/philomena_web/controllers/autocomplete/tag_controller.ex @@ -1,9 +1,7 @@ defmodule PhilomenaWeb.Autocomplete.TagController do use PhilomenaWeb, :controller - alias PhilomenaQuery.Search - alias Philomena.Tags.Tag - import Ecto.Query + alias Philomena.Tags def show(conn, %{"vsn" => "2"} = params), do: show_v2(conn, params) def show(conn, params), do: show_v1(conn, params) @@ -16,7 +14,11 @@ defmodule PhilomenaWeb.Autocomplete.TagController do defp show_v2(conn, params) do with {:ok, term} <- extract_term(params), {:ok, limit} <- extract_limit(params) do - suggestions = search(term, limit) + suggestions = + term + |> Tags.autocomplete_tags(limit) + |> Enum.map(&Map.from_struct/1) + json(conn, %{suggestions: suggestions}) else {:error, message} -> @@ -55,39 +57,6 @@ defmodule PhilomenaWeb.Autocomplete.TagController do end end - @spec search(String.t(), integer()) :: [map()] - defp search(term, limit) do - Tag - |> Search.search_definition( - %{ - query: %{ - bool: %{ - should: [ - %{prefix: %{name: term}}, - %{prefix: %{name_in_namespace: term}} - ] - } - }, - sort: %{images: :desc} - }, - %{page_size: 10} - ) - |> Search.search_records(preload(Tag, :aliased_tag)) - |> Enum.map( - &%{ - :alias => if(is_nil(&1.aliased_tag), do: nil, else: &1.name), - canonical: if(is_nil(&1.aliased_tag), do: &1.name, else: &1.aliased_tag.name), - images: if(is_nil(&1.aliased_tag), do: &1.images_count, else: &1.aliased_tag.images_count) - } - ) - |> Enum.filter(&(&1.images > 0)) - # Sometimes we have data desynchronization between OpenSearch and Postgres due - # to bugs. This client-side sort serves as a patch to make sure we have correct - # ranking of the autocomplete results even in the case of desynchronization. - |> Enum.sort_by(& &1.images, :desc) - |> Enum.take(limit) - end - # Version 1 is kept for backwards compatibility with the older versions of # the frontend application that may still be cached in user's browsers. Don't # change this code! All the new development should be done in the `v2` version. @@ -103,28 +72,16 @@ defmodule PhilomenaWeb.Autocomplete.TagController do [] {:ok, term} -> - Tag - |> Search.search_definition( - %{ - query: %{ - bool: %{ - should: [ - %{prefix: %{name: term}}, - %{prefix: %{name_in_namespace: term}} - ] - } - }, - sort: %{images: :desc} - }, - %{page_size: 10} - ) - |> Search.search_records(preload(Tag, :aliased_tag)) - |> Enum.map(&(&1.aliased_tag || &1)) - |> Enum.uniq_by(& &1.id) - |> Enum.filter(&(&1.images_count > 0)) - |> Enum.sort_by(&(-&1.images_count)) + term + |> Tags.autocomplete_tags() + |> Enum.uniq_by(& &1.canonical) |> Enum.take(5) - |> Enum.map(&%{label: "#{&1.name} (#{&1.images_count})", value: &1.name}) + |> Enum.map( + &%{ + label: "#{&1.canonical} (#{&1.images})", + value: &1.canonical + } + ) end conn diff --git a/lib/philomena_web/controllers/avatar_controller.ex b/lib/philomena_web/controllers/avatar_controller.ex index 9b6e45441..b17058d72 100644 --- a/lib/philomena_web/controllers/avatar_controller.ex +++ b/lib/philomena_web/controllers/avatar_controller.ex @@ -3,33 +3,40 @@ defmodule PhilomenaWeb.AvatarController do alias Philomena.Users - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.ScraperPlug, [params_name: "user", params_key: "avatar"] when action in [:update] + action_fallback PhilomenaWeb.FallbackController + def edit(conn, _params) do - changeset = Users.change_user(conn.assigns.current_user) - render(conn, "edit.html", title: "Editing Avatar", changeset: changeset) + with {:ok, %Ecto.Changeset{} = changeset} <- + Users.edit_avatar(conn.assigns.actor) do + render(conn, "edit.html", title: "Editing Avatar", changeset: changeset) + end end def update(conn, %{"user" => user_params}) do - case Users.update_avatar(conn.assigns.current_user, user_params) do + upload = PhilomenaMedia.Upload.cast(user_params, "avatar") + + case Users.update_avatar(conn.assigns.actor, upload) do {:ok, _user} -> conn |> put_flash(:info, "Successfully updated avatar.") |> redirect(to: ~p"/avatar/edit") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "edit.html", changeset: changeset) + + {:error, _} = error -> + error end end def delete(conn, _params) do - {:ok, _user} = Users.remove_avatar(conn.assigns.current_user) - - conn - |> put_flash(:info, "Successfully removed avatar.") - |> redirect(to: ~p"/avatar/edit") + with {:ok, _user} <- Users.delete_avatar(conn.assigns.actor) do + conn + |> put_flash(:info, "Successfully removed avatar.") + |> redirect(to: ~p"/avatar/edit") + end end end diff --git a/lib/philomena_web/controllers/channel/read_controller.ex b/lib/philomena_web/controllers/channel/read_controller.ex index ff075308c..ffb40e687 100644 --- a/lib/philomena_web/controllers/channel/read_controller.ex +++ b/lib/philomena_web/controllers/channel/read_controller.ex @@ -1,18 +1,14 @@ defmodule PhilomenaWeb.Channel.ReadController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Channels.Channel alias Philomena.Channels - plug :load_resource, model: Channel, id_name: "channel_id", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - channel = conn.assigns.channel - user = conn.assigns.current_user - - Channels.clear_channel_notification(channel, user) - - send_resp(conn, :ok, "") + def create(conn, params) do + with {:ok, _channel} <- + Channels.create_channel_read(conn.assigns.actor, params["channel_id"]) do + send_resp(conn, :ok, "") + end end end diff --git a/lib/philomena_web/controllers/channel/subscription_controller.ex b/lib/philomena_web/controllers/channel/subscription_controller.ex index 6c3ccc404..b273df60a 100644 --- a/lib/philomena_web/controllers/channel/subscription_controller.ex +++ b/lib/philomena_web/controllers/channel/subscription_controller.ex @@ -1,31 +1,27 @@ defmodule PhilomenaWeb.Channel.SubscriptionController do use PhilomenaWeb, :controller - alias Philomena.Channels.Channel alias Philomena.Channels - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show - plug :load_and_authorize_resource, model: Channel, id_name: "channel_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - channel = conn.assigns.channel - user = conn.assigns.current_user - - case Channels.create_subscription(channel, user) do - {:ok, _subscription} -> + def create(conn, params) do + case Channels.create_channel_subscription(conn.assigns.actor, params["channel_id"]) do + {:ok, channel} -> render(conn, "_subscription.html", channel: channel, watching: true, layout: false) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> render(conn, "_error.html", layout: false) + + {:error, _} = error -> + error end end - def delete(conn, _params) do - channel = conn.assigns.channel - user = conn.assigns.current_user - - {:ok, _subscription} = Channels.delete_subscription(channel, user) - - render(conn, "_subscription.html", channel: channel, watching: false, layout: false) + def delete(conn, params) do + with {:ok, channel} <- + Channels.delete_channel_subscription(conn.assigns.actor, params["channel_id"]) do + render(conn, "_subscription.html", channel: channel, watching: false, layout: false) + end end end diff --git a/lib/philomena_web/controllers/channel_controller.ex b/lib/philomena_web/controllers/channel_controller.ex index a548dda93..0923e98d7 100644 --- a/lib/philomena_web/controllers/channel_controller.ex +++ b/lib/philomena_web/controllers/channel_controller.ex @@ -2,105 +2,90 @@ defmodule PhilomenaWeb.ChannelController do use PhilomenaWeb, :controller alias Philomena.Channels - alias Philomena.Channels.Channel - alias Philomena.Repo - import Ecto.Query - plug :load_and_authorize_resource, - model: Channel, - only: [:show, :new, :create, :edit, :update, :delete] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do show_nsfw? = conn.cookies["chan_nsfw"] == "true" - channels = - Channel - |> maybe_show_nsfw(show_nsfw?) - |> where([c], not is_nil(c.last_fetched_at)) - |> order_by(desc: :is_live, asc: :title) - |> join(:left, [c], _ in assoc(c, :associated_artist_tag)) - |> preload([_c, t], associated_artist_tag: t) - |> maybe_search(params) - |> Repo.paginate(conn.assigns.scrivener) - - subscriptions = Channels.subscriptions(channels, conn.assigns.current_user) - - render(conn, "index.html", - title: "Livestreams", - layout_class: "layout--wide", - channels: channels, - subscriptions: subscriptions - ) + case Channels.list_channels(conn.assigns.actor, show_nsfw?, params, conn.assigns.scrivener) do + {:ok, channels, subscriptions, changeset} -> + render(conn, "index.html", + title: "Livestreams", + layout_class: "layout--wide", + channels: channels, + subscriptions: subscriptions, + changeset: changeset + ) + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "index.html", + title: "Livestreams", + layout_class: "layout--wide", + channels: nil, + subscriptions: %{}, + changeset: changeset + ) + end end - def show(conn, _params) do - channel = conn.assigns.channel - user = conn.assigns.current_user - - Channels.clear_channel_notification(channel, user) - - redirect(conn, external: channel_url(channel)) + def show(conn, params) do + with {:ok, channel} <- Channels.show_channel(conn.assigns.actor, params["id"]) do + redirect(conn, external: channel_url(channel)) + end end def new(conn, _params) do - changeset = Channels.change_channel(%Channel{}) - render(conn, "new.html", title: "New Channel", changeset: changeset) + with {:ok, changeset} <- Channels.new_channel(conn.assigns.actor) do + render(conn, "new.html", title: "New Channel", changeset: changeset) + end end def create(conn, %{"channel" => channel_params}) do - case Channels.create_channel(channel_params) do + case Channels.create_channel(conn.assigns.actor, channel_params) do {:ok, _channel} -> conn |> put_flash(:info, "Channel created successfully.") |> redirect(to: ~p"/channels") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + {:error, _} = error -> + error end end - def edit(conn, _params) do - changeset = Channels.change_channel(conn.assigns.channel) - render(conn, "edit.html", title: "Editing Channel", changeset: changeset) + def edit(conn, params) do + with {:ok, {channel, changeset}} <- + Channels.edit_channel(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", title: "Editing Channel", channel: channel, changeset: changeset) + end end - def update(conn, %{"channel" => channel_params}) do - case Channels.update_channel(conn.assigns.channel, channel_params) do + def update(conn, %{"id" => id, "channel" => channel_params}) do + case Channels.update_channel(conn.assigns.actor, id, channel_params) do {:ok, _channel} -> conn |> put_flash(:info, "Channel updated successfully.") |> redirect(to: ~p"/channels") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, _channel} = Channels.delete_channel(conn.assigns.channel) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", channel: changeset.data, changeset: changeset) - conn - |> put_flash(:info, "Channel destroyed successfully.") - |> redirect(to: ~p"/channels") + {:error, _} = error -> + error + end end - defp maybe_search(query, %{"cq" => cq}) when is_binary(cq) and cq != "" do - title_query = "#{cq}%" - tag_query = "%#{cq}%" - - where( - query, - [c, t], - ilike(c.title, ^title_query) or ilike(c.short_name, ^title_query) or - ilike(t.name, ^tag_query) - ) + def delete(conn, params) do + with {:ok, _channel} <- Channels.delete_channel(conn.assigns.actor, params["id"]) do + conn + |> put_flash(:info, "Channel destroyed successfully.") + |> redirect(to: ~p"/channels") + end end - defp maybe_search(query, _params), do: query - - defp maybe_show_nsfw(query, true), do: query - defp maybe_show_nsfw(query, _falsy), do: where(query, [c], c.nsfw == false) - defp channel_url(%{type: "LivestreamChannel", short_name: short_name}), do: "http://www.livestream.com/#{short_name}" diff --git a/lib/philomena_web/controllers/comment_controller.ex b/lib/philomena_web/controllers/comment_controller.ex index 654cebbc3..c1a32a17e 100644 --- a/lib/philomena_web/controllers/comment_controller.ex +++ b/lib/philomena_web/controllers/comment_controller.ex @@ -1,39 +1,29 @@ defmodule PhilomenaWeb.CommentController do use PhilomenaWeb, :controller - alias PhilomenaWeb.CommentLoader alias PhilomenaWeb.MarkdownRenderer - alias PhilomenaQuery.Search - alias Philomena.{Comments.Query, Comments.Comment} - import Ecto.Query + alias Philomena.Comments def index(conn, params) do cq = params["cq"] || "created_at.gte:1 week ago" + # The template reads the effective query back out of conn.params["cq"], + # so the default must be written there when the parameter is absent. params = Map.put(conn.params, "cq", cq) conn = Map.put(conn, :params, params) - user = conn.assigns.current_user - cq - |> Query.compile(user: user) - |> render_index(conn) - end - - defp render_index({:ok, query}, conn) do - comments = - CommentLoader.query(conn, query) - |> Search.search_records( - preload(Comment, [:deleted_by, image: [:sources, tags: :aliases], user: [awards: :badge]]) - ) + actor = conn.assigns.actor + filter = conn.assigns.current_filter - rendered = MarkdownRenderer.render_collection(comments.entries, conn) + case Comments.query_comments(actor, filter, cq, conn.assigns.pagination) do + {:ok, comments} -> + rendered = MarkdownRenderer.render_collection(comments.entries, conn) + comments = %{comments | entries: Enum.zip(rendered, comments.entries)} - comments = %{comments | entries: Enum.zip(rendered, comments.entries)} - - render(conn, "index.html", title: "Comments", comments: comments) - end + render(conn, "index.html", title: "Comments", comments: comments) - defp render_index({:error, msg}, conn) do - render(conn, "index.html", title: "Comments", error: msg, comments: []) + {:error, msg} -> + render(conn, "index.html", title: "Comments", error: msg, comments: []) + end end end diff --git a/lib/philomena_web/controllers/commission_controller.ex b/lib/philomena_web/controllers/commission_controller.ex index 4752191e1..2547a7c1b 100644 --- a/lib/philomena_web/controllers/commission_controller.ex +++ b/lib/philomena_web/controllers/commission_controller.ex @@ -1,60 +1,25 @@ defmodule PhilomenaWeb.CommissionController do use PhilomenaWeb, :controller - alias Philomena.Commissions.SearchQuery alias Philomena.Commissions - alias Philomena.Repo - plug PhilomenaWeb.MapParameterPlug, [param: "commission"] when action in [:index] - plug :preload_commission + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - commission_params = Map.get(params, "commission", %{}) - - {commissions, changeset} = - case Commissions.execute_search_query(commission_params) do - {:ok, commissions} -> - commissions = Repo.paginate(commissions, conn.assigns.scrivener) - changeset = Commissions.change_search_query(%SearchQuery{}) - {commissions, changeset} - - {:error, changeset} -> - {empty_page(conn), changeset} - end - - render(conn, "index.html", - title: "Commissions", - commissions: commissions, - changeset: changeset, - layout_class: "layout--wide" - ) - end - - # The results partial paginates whatever it is given, so a rejected search - # renders an empty page rather than a bare list. - defp empty_page(conn) do - pagination = conn.assigns.scrivener - - %Scrivener.Page{ - entries: [], - page_number: Keyword.get(pagination, :page, 1), - page_size: Keyword.get(pagination, :page_size, 25), - total_entries: 0, - total_pages: 1 - } - end - - defp preload_commission(conn, _opts) do - user = conn.assigns.current_user - - case user do - nil -> - conn - - user -> - user = Repo.preload(user, :commission) - - assign(conn, :current_user, user) + with {:ok, directory} <- + Commissions.list_commissions( + conn.assigns.actor, + params["commission"] || %{}, + conn.assigns.scrivener + ) do + conn + |> assign(:current_user, directory.current_user) + |> render("index.html", + title: "Commissions", + commissions: directory.commissions, + changeset: directory.changeset, + layout_class: "layout--wide" + ) end end end diff --git a/lib/philomena_web/controllers/confirmation_controller.ex b/lib/philomena_web/controllers/confirmation_controller.ex index c22aa9edb..88920758b 100644 --- a/lib/philomena_web/controllers/confirmation_controller.ex +++ b/lib/philomena_web/controllers/confirmation_controller.ex @@ -28,19 +28,29 @@ defmodule PhilomenaWeb.ConfirmationController do |> redirect(to: "/") end + def show(conn, %{"id" => token}) do + render(conn, "edit.html", token: token) + end + # Do not log in the user after confirmation to avoid a # leaked token giving the user access to the account. - def show(conn, %{"id" => token}) do - case Users.confirm_user(token) do + def update(conn, %{"id" => token}) do + case Users.update_confirmation(token) do {:ok, _} -> conn |> put_flash(:info, "Account confirmed successfully.") - |> redirect(to: "/") + |> redirect(to: ~p"/") :error -> - conn - |> put_flash(:error, "Confirmation link is invalid or it has expired.") - |> redirect(to: "/") + case conn.assigns do + %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> + redirect(conn, to: ~p"/") + + _ -> + conn + |> put_flash(:error, "Confirmation link is invalid or it has expired.") + |> redirect(to: ~p"/") + end end end end diff --git a/lib/philomena_web/controllers/conversation/hide_controller.ex b/lib/philomena_web/controllers/conversation/hide_controller.ex index 71480de62..13bb7efbf 100644 --- a/lib/philomena_web/controllers/conversation/hide_controller.ex +++ b/lib/philomena_web/controllers/conversation/hide_controller.ex @@ -1,36 +1,29 @@ defmodule PhilomenaWeb.Conversation.HideController do use PhilomenaWeb, :controller - alias Philomena.Conversations.Conversation alias Philomena.Conversations - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Conversation, - id_field: "slug", - id_name: "conversation_id", - persisted: true - - def create(conn, _params) do - conversation = conn.assigns.conversation - user = conn.assigns.current_user - - {:ok, _conversation} = Conversations.mark_conversation_hidden(conversation, user) - - conn - |> put_flash(:info, "Conversation hidden.") - |> redirect(to: ~p"/conversations") + def create(conn, %{"conversation_id" => conversation_id}) do + with {:ok, _conversation} <- + Conversations.update_conversation_hide(conn.assigns.actor, conversation_id) do + conn + |> put_flash(:info, "Conversation hidden.") + |> redirect(to: ~p"/conversations") + end end - def delete(conn, _params) do - conversation = conn.assigns.conversation - user = conn.assigns.current_user - - {:ok, _conversation} = Conversations.mark_conversation_hidden(conversation, user, false) - - conn - |> put_flash(:info, "Conversation restored.") - |> redirect(to: ~p"/conversations/#{conversation}") + def delete(conn, %{"conversation_id" => conversation_id}) do + with {:ok, conversation} <- + Conversations.update_conversation_hide( + conn.assigns.actor, + conversation_id, + false + ) do + conn + |> put_flash(:info, "Conversation restored.") + |> redirect(to: ~p"/conversations/#{conversation}") + end end end diff --git a/lib/philomena_web/controllers/conversation/message/approve_controller.ex b/lib/philomena_web/controllers/conversation/message/approve_controller.ex index fde13b1a8..32df49877 100644 --- a/lib/philomena_web/controllers/conversation/message/approve_controller.ex +++ b/lib/philomena_web/controllers/conversation/message/approve_controller.ex @@ -1,33 +1,24 @@ defmodule PhilomenaWeb.Conversation.Message.ApproveController do use PhilomenaWeb, :controller - alias Philomena.Conversations.Message alias Philomena.Conversations - plug PhilomenaWeb.CanaryMapPlug, create: :approve + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Message, - id_name: "message_id", - persisted: true, - preload: [:conversation] + def create(conn, %{"conversation_id" => conversation_id, "message_id" => message_id}) do + case Conversations.create_message_approve(conn.assigns.actor, conversation_id, message_id) do + {:ok, _message} -> + conn + |> put_flash(:info, "Conversation message approved.") + |> redirect(to: "/") - def create(conn, _params) do - message = conn.assigns.message + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:info, "Conversation message has already been approved.") + |> redirect(to: "/") - {:ok, _message} = - Conversations.approve_message(message, conn.assigns.current_user) - - conn - |> put_flash(:info, "Conversation message approved.") - |> moderation_log(details: &log_details/2, data: message) - |> redirect(to: "/") - end - - defp log_details(_action, message) do - %{ - body: "Approved private message in conversation ##{message.conversation_id}", - subject_path: "/" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/conversation/message_controller.ex b/lib/philomena_web/controllers/conversation/message_controller.ex index f6f7fdd13..ae738f673 100644 --- a/lib/philomena_web/controllers/conversation/message_controller.ex +++ b/lib/philomena_web/controllers/conversation/message_controller.ex @@ -1,37 +1,50 @@ defmodule PhilomenaWeb.Conversation.MessageController do use PhilomenaWeb, :controller - alias Philomena.Conversations.Conversation alias Philomena.Conversations + alias PhilomenaWeb.ConversationView + alias PhilomenaWeb.MarkdownRenderer - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.CanaryMapPlug, create: :show - - plug :load_and_authorize_resource, - model: Conversation, - id_name: "conversation_id", - id_field: "slug", - persisted: true + action_fallback PhilomenaWeb.FallbackController @page_size 25 - def create(conn, %{"message" => message_params}) do - conversation = conn.assigns.conversation - user = conn.assigns.current_user - - case Conversations.create_message(conversation, user, message_params) do - {:ok, _message} -> - count = Conversations.count_messages(conversation) - page = div(count + @page_size - 1, @page_size) + def create(conn, %{"conversation_id" => conversation_id} = params) do + case Conversations.create_message(conn.assigns.actor, conversation_id, params["message"]) do + {:ok, %{conversation: conversation}} -> + page = div(conversation.message_count + @page_size - 1, @page_size) conn |> put_flash(:info, "Message successfully sent.") |> redirect(to: ~p"/conversations/#{conversation}?#{[page: page]}") - _error -> - conn - |> put_flash(:error, "There was an error posting your message") - |> redirect(to: ~p"/conversations/#{conversation}") + {:error, %Ecto.Changeset{} = changeset} -> + render_message_error(conn, conversation_id, changeset) + + error -> + error + end + end + + defp render_message_error(conn, conversation_id, changeset) do + with {:ok, page} <- + Conversations.show_conversation( + conn.assigns.actor, + conversation_id, + conn.assigns.scrivener + ) do + rendered = MarkdownRenderer.render_collection(page.messages.entries, conn) + messages = %{page.messages | entries: Enum.zip(page.messages.entries, rendered)} + + conn + |> put_view(ConversationView) + |> render("show.html", + title: "Showing Conversation", + conversation: page.conversation, + messages: messages, + changeset: changeset, + trusted?: Conversations.trusted_sender?(conn.assigns.actor) + ) end end end diff --git a/lib/philomena_web/controllers/conversation/read_controller.ex b/lib/philomena_web/controllers/conversation/read_controller.ex index aa7772741..9ebabbd58 100644 --- a/lib/philomena_web/controllers/conversation/read_controller.ex +++ b/lib/philomena_web/controllers/conversation/read_controller.ex @@ -1,36 +1,25 @@ defmodule PhilomenaWeb.Conversation.ReadController do use PhilomenaWeb, :controller - alias Philomena.Conversations.Conversation alias Philomena.Conversations - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Conversation, - id_field: "slug", - id_name: "conversation_id", - persisted: true - - def create(conn, _params) do - conversation = conn.assigns.conversation - user = conn.assigns.current_user - - {:ok, _conversation} = Conversations.mark_conversation_read(conversation, user) - - conn - |> put_flash(:info, "Conversation marked as read.") - |> redirect(to: ~p"/conversations/#{conversation}") + def create(conn, %{"conversation_id" => conversation_id}) do + with {:ok, conversation} <- + Conversations.update_conversation_read(conn.assigns.actor, conversation_id) do + conn + |> put_flash(:info, "Conversation marked as read.") + |> redirect(to: ~p"/conversations/#{conversation}") + end end - def delete(conn, _params) do - conversation = conn.assigns.conversation - user = conn.assigns.current_user - - {:ok, _conversation} = Conversations.mark_conversation_read(conversation, user, false) - - conn - |> put_flash(:info, "Conversation marked as unread.") - |> redirect(to: ~p"/conversations") + def delete(conn, %{"conversation_id" => conversation_id}) do + with {:ok, _conversation} <- + Conversations.update_conversation_read(conn.assigns.actor, conversation_id, false) do + conn + |> put_flash(:info, "Conversation marked as unread.") + |> redirect(to: ~p"/conversations") + end end end diff --git a/lib/philomena_web/controllers/conversation/report_controller.ex b/lib/philomena_web/controllers/conversation/report_controller.ex index 3d2d52a74..7b7e4ad90 100644 --- a/lib/philomena_web/controllers/conversation/report_controller.ex +++ b/lib/philomena_web/controllers/conversation/report_controller.ex @@ -3,50 +3,36 @@ defmodule PhilomenaWeb.Conversation.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Conversations.Conversation - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - plug :load_and_authorize_resource, - model: Conversation, - id_name: "conversation_id", - id_field: "slug", - persisted: true, - preload: [:from, :to] - - def new(conn, _params) do - conversation = conn.assigns.conversation - action = ~p"/conversations/#{conversation}/reports" - - changeset = - %Report{conversation_id: conversation.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", - title: "Reporting Conversation", - subject: conversation, - changeset: changeset, - action: action - ) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"conversation_id" => conversation_id}) do + with {:ok, form} <- + Reports.new_report(conn.assigns.actor, {:conversation, conversation_id}) do + conversation = form.target + action = ~p"/conversations/#{conversation}/reports" + + conn + |> put_view(ReportView) + |> render("new.html", + title: "Reporting Conversation", + subject: conversation, + changeset: form.changeset, + rules: form.rules, + action: action + ) + end end - def create(conn, params) do - conversation = conn.assigns.conversation - action = ~p"/conversations/#{conversation}/reports" - + def create(conn, %{"conversation_id" => conversation_id} = params) do ReportController.create( conn, - action, - conversation, - [conversation_id: conversation.id], + {:conversation, conversation_id}, + fn conversation -> ~p"/conversations/#{conversation}/reports" end, params ) end diff --git a/lib/philomena_web/controllers/conversation_controller.ex b/lib/philomena_web/controllers/conversation_controller.ex index 0e4abdf0b..fc2178dcb 100644 --- a/lib/philomena_web/controllers/conversation_controller.ex +++ b/lib/philomena_web/controllers/conversation_controller.ex @@ -2,82 +2,80 @@ defmodule PhilomenaWeb.ConversationController do use PhilomenaWeb, :controller alias PhilomenaWeb.NotificationCountPlug - alias Philomena.{Conversations, Conversations.Conversation, Conversations.Message} + alias PhilomenaWeb.RateLimitedResponse + alias Philomena.Conversations + alias Philomena.Conversations.ConversationIndex alias PhilomenaWeb.MarkdownRenderer - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] - - plug PhilomenaWeb.LimitPlug, - [time: 60, error: "You may only create a conversation once every minute."] - when action in [:create] - - plug :load_and_authorize_resource, - model: Conversation, - id_field: "slug", - only: :show, - preload: [:to, :from] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - user = conn.assigns.current_user - - conversations = - case params do - %{"with" => partner_id} -> - Conversations.list_conversations_with(partner_id, user, conn.assigns.scrivener) - - _ -> - Conversations.list_conversations(user, conn.assigns.scrivener) - end - - render(conn, "index.html", title: "Conversations", conversations: conversations) + with {:ok, %ConversationIndex{} = index} <- + Conversations.list_conversations( + conn.assigns.actor, + params, + conn.assigns.scrivener + ) do + render(conn, "index.html", + title: "Conversations", + conversations: index.conversations, + changeset: index.changeset + ) + end end - def show(conn, _params) do - conversation = conn.assigns.conversation - user = conn.assigns.current_user - - messages = - Conversations.list_messages( - conversation, - user, - &MarkdownRenderer.render_collection(&1, conn), - conn.assigns.scrivener + def show(conn, %{"id" => id}) do + with {:ok, page} <- + Conversations.show_conversation( + conn.assigns.actor, + id, + conn.assigns.scrivener + ) do + # The page load marked the conversation read; refresh the header + # notification ticker afterwards so it reflects the cleared state. + conn = NotificationCountPlug.call(conn) + + rendered = MarkdownRenderer.render_collection(page.messages.entries, conn) + messages = %{page.messages | entries: Enum.zip(page.messages.entries, rendered)} + + render(conn, "show.html", + title: "Showing Conversation", + conversation: page.conversation, + messages: messages, + changeset: page.changeset, + trusted?: Conversations.trusted_sender?(conn.assigns.actor) ) - - changeset = Conversations.change_message(%Message{}) - Conversations.mark_conversation_read(conversation, user) - - # Update the conversation ticker in the header - conn = NotificationCountPlug.call(conn) - - render(conn, "show.html", - title: "Showing Conversation", - conversation: conversation, - messages: messages, - changeset: changeset - ) + end end def new(conn, params) do - conversation = - %Conversation{recipient: params["recipient"], messages: [%Message{}]} - - changeset = Conversations.change_conversation(conversation) - - render(conn, "new.html", title: "New Conversation", changeset: changeset) + with {:ok, changeset} <- Conversations.new_conversation(conn.assigns.actor, params) do + render(conn, "new.html", + title: "New Conversation", + changeset: changeset, + trusted?: Conversations.trusted_sender?(conn.assigns.actor) + ) + end end - def create(conn, %{"conversation" => conversation_params}) do - user = conn.assigns.current_user - - case Conversations.create_conversation(user, conversation_params) do + def create(conn, params) do + case Conversations.create_conversation(conn.assigns.actor, params["conversation"]) do {:ok, conversation} -> conn |> put_flash(:info, "Conversation successfully created.") |> redirect(to: ~p"/conversations/#{conversation}") - {:error, changeset} -> - render(conn, "new.html", changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", + changeset: changeset, + trusted?: Conversations.trusted_sender?(conn.assigns.actor) + ) + + {:error, :rate_limited} -> + RateLimitedResponse.call(conn, "You may only create a conversation once every minute.") + + error -> + error end end end diff --git a/lib/philomena_web/controllers/deactivation_controller.ex b/lib/philomena_web/controllers/deactivation_controller.ex index 88dc9fa0d..4372a7a91 100644 --- a/lib/philomena_web/controllers/deactivation_controller.ex +++ b/lib/philomena_web/controllers/deactivation_controller.ex @@ -8,12 +8,9 @@ defmodule PhilomenaWeb.DeactivationController do end def delete(conn, _params) do - user = conn.assigns.current_user - - Users.deactivate_user(user) - Users.deliver_user_reactivation_instructions(user, &url(~p"/reactivations/#{&1}")) - UserAuth.log_out_user(conn) - - conn |> redirect(to: "/") + with {:ok, _user} <- + Users.delete_deactivation(conn.assigns.actor, &url(~p"/reactivations/#{&1}")) do + UserAuth.log_out_user(conn) + end end end diff --git a/lib/philomena_web/controllers/dnp_entry_controller.ex b/lib/philomena_web/controllers/dnp_entry_controller.ex index 93bf82172..144462daf 100644 --- a/lib/philomena_web/controllers/dnp_entry_controller.ex +++ b/lib/philomena_web/controllers/dnp_entry_controller.ex @@ -1,176 +1,125 @@ defmodule PhilomenaWeb.DnpEntryController do use PhilomenaWeb, :controller - alias Philomena.DnpEntries.DnpEntry alias PhilomenaWeb.MarkdownRenderer alias Philomena.DnpEntries - alias Philomena.Tags.Tag - alias Philomena.ModNotes.ModNote - alias Philomena.ModNotes - alias Philomena.Repo - import Ecto.Query - - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] - plug :set_tags when action in [:new, :create, :edit, :update, :create] - - plug :load_and_authorize_resource, - model: DnpEntry, - only: [:show, :edit, :update], - preload: [:tag] - - plug :set_mod_notes when action in [:show] - - def index(%{assigns: %{current_user: user}} = conn, %{"mine" => _mine}) when not is_nil(user) do - DnpEntry - |> where(requesting_user_id: ^user.id) - |> preload(:tag) - |> order_by(asc: :created_at) - |> load_entries(conn, true) - end + alias Philomena.DnpEntries.{DnpEntryForm, DnpEntryPage} - def index(conn, _params) do - DnpEntry - |> where(aasm_state: "listed") - |> join(:inner, [d], t in Tag, on: d.tag_id == t.id) - |> preload(:tag) - |> order_by([_d, t], asc: t.name_in_namespace) - |> load_entries(conn, false) - end + action_fallback PhilomenaWeb.FallbackController - defp load_entries(dnp_entries, conn, status) do - dnp_entries = Repo.paginate(dnp_entries, conn.assigns.scrivener) - linked_tags = linked_tags(conn) + def index(conn, params) do + listing = + DnpEntries.list_dnp_entries(conn.assigns.actor, params, conn.assigns.scrivener) bodies = - dnp_entries + listing.dnp_entries |> Enum.map(&%{body: &1.conditions || "-"}) |> MarkdownRenderer.render_collection(conn) - dnp_entries = %{dnp_entries | entries: Enum.zip(bodies, dnp_entries.entries)} + dnp_entries = %{listing.dnp_entries | entries: Enum.zip(bodies, listing.dnp_entries.entries)} render(conn, "index.html", title: "Do-Not-Post List", layout_class: "layout--medium", dnp_entries: dnp_entries, - status_column: status, - linked_tags: linked_tags + status_column: listing.status_column, + linked_tags: listing.linked_tags ) end - def show(conn, _params) do - dnp_entry = conn.assigns.dnp_entry - - [conditions, reason, instructions] = - MarkdownRenderer.render_collection( - [ - %{body: dnp_entry.conditions || "-"}, - %{body: dnp_entry.reason || "-"}, - %{body: dnp_entry.instructions || "-"} - ], - conn - ) - - render(conn, "show.html", - title: "Showing DNP Listing", - dnp_entry: dnp_entry, - conditions: conditions, - reason: reason, - instructions: instructions - ) + def show(conn, %{"id" => id}) do + renderer = &MarkdownRenderer.render_collection(&1, conn) + + with {:ok, %DnpEntryPage{dnp_entry: dnp_entry, mod_notes: mod_notes}} <- + DnpEntries.show_dnp_entry(conn.assigns.actor, id, renderer) do + [conditions, reason, instructions] = + MarkdownRenderer.render_collection( + [ + %{body: dnp_entry.conditions || "-"}, + %{body: dnp_entry.reason || "-"}, + %{body: dnp_entry.instructions || "-"} + ], + conn + ) + + assigns = [ + title: "Showing DNP Listing", + dnp_entry: dnp_entry, + conditions: conditions, + reason: reason, + instructions: instructions + ] + + assigns = if is_nil(mod_notes), do: assigns, else: [{:mod_notes, mod_notes} | assigns] + + render(conn, "show.html", assigns) + end end - def new(conn, _params) do - changeset = DnpEntries.change_dnp_entry(%DnpEntry{}) - - render(conn, "new.html", - title: "New DNP Listing", - changeset: changeset - ) + def new(conn, params) do + with {:ok, %DnpEntryForm{changeset: changeset, selectable_tags: selectable_tags}} <- + DnpEntries.new_dnp_entry(conn.assigns.actor, params) do + render(conn, "new.html", + title: "New DNP Listing", + changeset: changeset, + selectable_tags: selectable_tags + ) + end end - def create(conn, %{"dnp_entry" => dnp_entry_params}) do - case DnpEntries.create_dnp_entry( - conn.assigns.current_user, - conn.assigns.selectable_tags, - dnp_entry_params - ) do + def create(conn, params) do + case DnpEntries.create_dnp_entry(conn.assigns.actor, params["dnp_entry"]) do {:ok, dnp_entry} -> conn |> put_flash(:info, "Successfully submitted DNP request.") |> redirect(to: ~p"/dnp/#{dnp_entry}") - {:error, changeset} -> - render(conn, "new.html", changeset: changeset) + {:error, %DnpEntryForm{changeset: changeset, selectable_tags: selectable_tags}} -> + render(conn, "new.html", changeset: changeset, selectable_tags: selectable_tags) + + {:error, _} = error -> + error end end - def edit(conn, _params) do - changeset = DnpEntries.change_dnp_entry(conn.assigns.dnp_entry) - - render(conn, "edit.html", - title: "Editing DNP Listing", - changeset: changeset - ) + def edit(conn, %{"id" => id}) do + with {:ok, + %DnpEntryForm{ + dnp_entry: dnp_entry, + changeset: changeset, + selectable_tags: selectable_tags + }} <- + DnpEntries.edit_dnp_entry(conn.assigns.actor, id) do + render(conn, "edit.html", + title: "Editing DNP Listing", + dnp_entry: dnp_entry, + changeset: changeset, + selectable_tags: selectable_tags + ) + end end - def update(conn, %{"dnp_entry" => dnp_entry_params}) do - case DnpEntries.update_dnp_entry( - conn.assigns.dnp_entry, - conn.assigns.selectable_tags, - dnp_entry_params - ) do + def update(conn, %{"id" => id} = params) do + case DnpEntries.update_dnp_entry(conn.assigns.actor, id, params["dnp_entry"]) do {:ok, dnp_entry} -> conn |> put_flash(:info, "Successfully updated DNP request.") |> redirect(to: ~p"/dnp/#{dnp_entry}") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - defp selectable_tags(conn) do - if present?(conn.params["tag_id"]) and - Canada.Can.can?(conn.assigns.current_user, :index, DnpEntry) do - [Repo.get!(Tag, conn.params["tag_id"])] - else - linked_tags(conn) - end - end - - defp linked_tags(%{assigns: %{current_user: user}}) when not is_nil(user) do - user - |> Repo.preload(:linked_tags) - |> Map.get(:linked_tags) - end - - defp linked_tags(_), do: [] - - defp present?(nil), do: false - defp present?(""), do: false - defp present?(_), do: true - - defp set_mod_notes(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, ModNote) do - dnp_entry = conn.assigns.dnp_entry - - renderer = &MarkdownRenderer.render_collection(&1, conn) - mod_notes = ModNotes.list_all_mod_notes_for_target(renderer, dnp_entry_id: dnp_entry.id) - assign(conn, :mod_notes, mod_notes) - else - conn - end - end - - defp set_tags(conn, _opts) do - tags = selectable_tags(conn) - - case tags do - [] -> - PhilomenaWeb.NotAuthorizedPlug.call(conn) - - _ -> - assign(conn, :selectable_tags, tags) + {:error, + %DnpEntryForm{ + dnp_entry: dnp_entry, + changeset: changeset, + selectable_tags: selectable_tags + }} -> + render(conn, "edit.html", + dnp_entry: dnp_entry, + changeset: changeset, + selectable_tags: selectable_tags + ) + + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/duplicate_report/accept_controller.ex b/lib/philomena_web/controllers/duplicate_report/accept_controller.ex index 3c57af230..35747036e 100644 --- a/lib/philomena_web/controllers/duplicate_report/accept_controller.ex +++ b/lib/philomena_web/controllers/duplicate_report/accept_controller.ex @@ -1,40 +1,24 @@ defmodule PhilomenaWeb.DuplicateReport.AcceptController do use PhilomenaWeb, :controller - alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DuplicateReports - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: DuplicateReport, - id_name: "duplicate_report_id", - persisted: true, - preload: [:image, :duplicate_of_image] - - def create(conn, _params) do - report = conn.assigns.duplicate_report - user = conn.assigns.current_user - - case DuplicateReports.accept_duplicate_report(report, user) do - {:ok, report} -> + def create(conn, %{"duplicate_report_id" => id}) do + case DuplicateReports.create_duplicate_report_accept(conn.assigns.actor, id) do + {:ok, _duplicate_report} -> conn |> put_flash(:info, "Successfully accepted report.") - |> moderation_log(details: &log_details/2, data: report.duplicate_report) |> redirect(to: ~p"/duplicate_reports") - _error -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Failed to accept report! Maybe someone else already accepted it.") |> redirect(to: ~p"/duplicate_reports") - end - end - defp log_details(_action, report) do - %{ - body: - "Accepted duplicate report, merged #{report.image.id} into #{report.duplicate_of_image.id}", - subject_path: ~p"/images/#{report.image}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/duplicate_report/accept_reverse_controller.ex b/lib/philomena_web/controllers/duplicate_report/accept_reverse_controller.ex index 5e4ae80ed..f99d27e31 100644 --- a/lib/philomena_web/controllers/duplicate_report/accept_reverse_controller.ex +++ b/lib/philomena_web/controllers/duplicate_report/accept_reverse_controller.ex @@ -1,40 +1,24 @@ defmodule PhilomenaWeb.DuplicateReport.AcceptReverseController do use PhilomenaWeb, :controller - alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DuplicateReports - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: DuplicateReport, - id_name: "duplicate_report_id", - persisted: true, - preload: [:image, :duplicate_of_image] - - def create(conn, _params) do - report = conn.assigns.duplicate_report - user = conn.assigns.current_user - - case DuplicateReports.accept_reverse_duplicate_report(report, user) do - {:ok, report} -> + def create(conn, %{"duplicate_report_id" => id}) do + case DuplicateReports.create_duplicate_report_accept_reverse(conn.assigns.actor, id) do + {:ok, _duplicate_report} -> conn |> put_flash(:info, "Successfully accepted report in reverse.") - |> moderation_log(details: &log_details/2, data: report.duplicate_report) |> redirect(to: ~p"/duplicate_reports") - _error -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Failed to accept report! Maybe someone else already accepted it.") |> redirect(to: ~p"/duplicate_reports") - end - end - defp log_details(_action, report) do - %{ - body: - "Reverse-accepted duplicate report, merged #{report.image.id} into #{report.duplicate_of_image.id}", - subject_path: ~p"/images/#{report.image}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/duplicate_report/claim_controller.ex b/lib/philomena_web/controllers/duplicate_report/claim_controller.ex index 2abec4b30..ae0b821f0 100644 --- a/lib/philomena_web/controllers/duplicate_report/claim_controller.ex +++ b/lib/philomena_web/controllers/duplicate_report/claim_controller.ex @@ -1,48 +1,41 @@ defmodule PhilomenaWeb.DuplicateReport.ClaimController do use PhilomenaWeb, :controller - alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DuplicateReports - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: DuplicateReport, - id_name: "duplicate_report_id", - persisted: true + def create(conn, %{"duplicate_report_id" => id}) do + case DuplicateReports.create_duplicate_report_claim(conn.assigns.actor, id) do + {:ok, _report} -> + conn + |> put_flash(:info, "Successfully claimed report.") + |> redirect(to: ~p"/duplicate_reports") - def create(conn, _params) do - {:ok, report} = - DuplicateReports.claim_duplicate_report( - conn.assigns.duplicate_report, - conn.assigns.current_user - ) + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Failed to claim report.") + |> redirect(to: ~p"/duplicate_reports") - conn - |> put_flash(:info, "Successfully claimed report.") - |> moderation_log(details: &log_details/2, data: report) - |> redirect(to: ~p"/duplicate_reports") + error -> + error + end end - def delete(conn, _params) do - {:ok, _report} = DuplicateReports.unclaim_duplicate_report(conn.assigns.duplicate_report) - - conn - |> put_flash(:info, "Successfully released report.") - |> moderation_log(details: &log_details/2) - |> redirect(to: ~p"/duplicate_reports") - end - - defp log_details(action, _) do - body = - case action do - :create -> "Claimed a duplicate report" - :delete -> "Released a duplicate report" - end - - %{ - body: body, - subject_path: ~p"/duplicate_reports" - } + def delete(conn, %{"duplicate_report_id" => id}) do + case DuplicateReports.delete_duplicate_report_claim(conn.assigns.actor, id) do + {:ok, _report} -> + conn + |> put_flash(:info, "Successfully released report.") + |> redirect(to: ~p"/duplicate_reports") + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Failed to release report.") + |> redirect(to: ~p"/duplicate_reports") + + error -> + error + end end end diff --git a/lib/philomena_web/controllers/duplicate_report/reject_controller.ex b/lib/philomena_web/controllers/duplicate_report/reject_controller.ex index 957a92c85..35336a205 100644 --- a/lib/philomena_web/controllers/duplicate_report/reject_controller.ex +++ b/lib/philomena_web/controllers/duplicate_report/reject_controller.ex @@ -1,34 +1,24 @@ defmodule PhilomenaWeb.DuplicateReport.RejectController do use PhilomenaWeb, :controller - alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DuplicateReports - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: DuplicateReport, - id_name: "duplicate_report_id", - persisted: true, - preload: [:image, :duplicate_of_image] + def create(conn, %{"duplicate_report_id" => id}) do + case DuplicateReports.create_duplicate_report_reject(conn.assigns.actor, id) do + {:ok, _report} -> + conn + |> put_flash(:info, "Successfully rejected report.") + |> redirect(to: ~p"/duplicate_reports") - def create(conn, _params) do - {:ok, report} = - DuplicateReports.reject_duplicate_report( - conn.assigns.duplicate_report, - conn.assigns.current_user - ) + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Failed to reject report.") + |> redirect(to: ~p"/duplicate_reports") - conn - |> put_flash(:info, "Successfully rejected report.") - |> moderation_log(details: &log_details/2, data: report) - |> redirect(to: ~p"/duplicate_reports") - end - - defp log_details(_action, report) do - %{ - body: "Rejected duplicate report (#{report.image.id} -> #{report.duplicate_of_image.id})", - subject_path: ~p"/duplicate_reports" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/duplicate_report_controller.ex b/lib/philomena_web/controllers/duplicate_report_controller.ex index b3c17f136..0002b666c 100644 --- a/lib/philomena_web/controllers/duplicate_report_controller.ex +++ b/lib/philomena_web/controllers/duplicate_report_controller.ex @@ -2,107 +2,57 @@ defmodule PhilomenaWeb.DuplicateReportController do use PhilomenaWeb, :controller alias Philomena.DuplicateReports - alias Philomena.DuplicateReports.DuplicateReport - alias Philomena.Images.Image - alias Philomena.Repo - import Ecto.Query - @valid_states ~W(open rejected accepted claimed) - - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:create] - plug PhilomenaWeb.UserAttributionPlug when action in [:create] - - plug :load_resource, - model: DuplicateReport, - only: [:show], - preload: [:image, :duplicate_of_image] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - states = - (presence(params["states"]) || ~W(open claimed)) - |> wrap() - |> Enum.filter(&Enum.member?(@valid_states, &1)) - - duplicate_reports = - DuplicateReport - |> where([d], d.state in ^states) - |> preload([ - :user, - :modifier, - image: [:user, :sources, tags: :aliases], - duplicate_of_image: [:user, :sources, tags: :aliases] - ]) - |> order_by(desc: :created_at) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", - title: "Duplicate Reports", - duplicate_reports: duplicate_reports, - layout_class: "layout--wide" - ) + with {:ok, duplicate_reports, changeset} <- + DuplicateReports.list_duplicate_reports( + conn.assigns.actor, + params, + conn.assigns.scrivener + ) do + render(conn, "index.html", + title: "Duplicate Reports", + duplicate_reports: duplicate_reports, + changeset: changeset, + layout_class: "layout--wide" + ) + end end - def create(conn, %{"duplicate_report" => duplicate_report_params}) - when is_map(duplicate_report_params) do - source = load_image(duplicate_report_params["image_id"]) - target = load_image(duplicate_report_params["duplicate_of_image_id"]) - - create_report(conn, source, target, duplicate_report_params) + def show(conn, %{"id" => id}) do + with {:ok, duplicate_report} <- + DuplicateReports.show_duplicate_report(conn.assigns.actor, id) do + render(conn, "show.html", + title: "Showing Duplicate Report", + duplicate_report: duplicate_report, + layout_class: "layout--wide" + ) + end end - def create(conn, _params), do: PhilomenaWeb.NotFoundPlug.call(conn) - - # Without a source image there is nowhere to redirect back to. - defp create_report(conn, nil, _target, _params), - do: PhilomenaWeb.NotFoundPlug.call(conn) - - defp create_report(conn, source, nil, _params), - do: report_failed(conn, source) - - defp create_report(conn, source, target, duplicate_report_params) do - attributes = conn.assigns.attributes - + def create(conn, %{"duplicate_report" => attrs}) when is_map(attrs) do case DuplicateReports.create_duplicate_report( - source, - target, - attributes, - duplicate_report_params + conn.assigns.actor, + attrs["image_id"], + attrs["duplicate_of_image_id"], + attrs ) do {:ok, duplicate_report} -> conn |> put_flash(:info, "Duplicate report created successfully.") |> redirect(to: ~p"/images/#{duplicate_report.image_id}") - {:error, _changeset} -> - report_failed(conn, source) - end - end - - defp report_failed(conn, source) do - conn - |> put_flash(:error, "Failed to submit duplicate report") - |> redirect(to: ~p"/images/#{source}") - end + {:error, %Ecto.Changeset{data: %{image: source}}} when not is_nil(source) -> + conn + |> put_flash(:error, "Failed to submit duplicate report") + |> redirect(to: ~p"/images/#{source}") - defp load_image(id) do - case PhilomenaWeb.IntegerId.parse(id) do - {:ok, id} -> Repo.get(Image, id) - :error -> nil + error -> + error end end - def show(conn, _params) do - dr = conn.assigns.duplicate_report - - render(conn, "show.html", - title: "Showing Duplicate Report", - duplicate_report: dr, - layout_class: "layout--wide" - ) - end - - defp wrap(list) when is_list(list), do: list - defp wrap(not_a_list), do: [not_a_list] - defp presence(""), do: nil - defp presence(x), do: x + def create(_conn, _params), do: {:error, :not_found} end diff --git a/lib/philomena_web/controllers/fallback_controller.ex b/lib/philomena_web/controllers/fallback_controller.ex index eaeb47af9..f7a3e931c 100644 --- a/lib/philomena_web/controllers/fallback_controller.ex +++ b/lib/philomena_web/controllers/fallback_controller.ex @@ -1,22 +1,32 @@ defmodule PhilomenaWeb.FallbackController do @moduledoc """ - Translates the two global context error shapes into the exact HTTP responses + Translates global context error shapes into the exact HTTP responses the web layer expects. Used with Phoenix `action_fallback` (which applies to HTML controllers too): - when a controller action returns a bare `{:error, :unauthorized}` or - `{:error, :not_found}` instead of a `Plug.Conn`, Phoenix invokes this - controller to finish the response. + when a controller action returns a bare `{:error, :unauthorized}`, + `{:error, :not_found}`, `{:error, :ban}`, or `{:error, :forced_filter}` instead + of a `Plug.Conn`, Phoenix invokes this controller to finish the response. - This fallback handles only these two global error - shapes. Any action whose failure path is bespoke - a redirect to a specific + Any action whose failure path is bespoke - a redirect to a specific resource, a different flash, an action-specific status - keeps a visible `case`/`with else` clause in the controller instead of routing through here. """ use Phoenix.Controller, formats: [json: "View", html: "View"] - @spec call(Plug.Conn.t(), {:error, :unauthorized} | {:error, :not_found}) :: Plug.Conn.t() + @spec call( + Plug.Conn.t(), + {:error, :unauthorized | :not_found | :ban | :forced_filter} + ) :: + Plug.Conn.t() def call(conn, {:error, :unauthorized}), do: PhilomenaWeb.NotAuthorizedPlug.call(conn) def call(conn, {:error, :not_found}), do: PhilomenaWeb.NotFoundPlug.call(conn) + def call(conn, {:error, :ban}), do: PhilomenaWeb.FilterBannedUsersPlug.ban_response(conn) + + def call(conn, {:error, :forced_filter}) do + conn + |> put_flash(:error, "You have been blocked from performing this action on this image.") + |> redirect(external: conn.assigns.referrer) + end end diff --git a/lib/philomena_web/controllers/fetch/tag_controller.ex b/lib/philomena_web/controllers/fetch/tag_controller.ex index e0e38f999..835d3c388 100644 --- a/lib/philomena_web/controllers/fetch/tag_controller.ex +++ b/lib/philomena_web/controllers/fetch/tag_controller.ex @@ -1,10 +1,8 @@ defmodule PhilomenaWeb.Fetch.TagController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - import Ecto.Query + alias Philomena.IntegerId + alias Philomena.Tags def index(conn, %{"ids" => ids}) when is_list(ids) do ids = @@ -14,9 +12,8 @@ defmodule PhilomenaWeb.Fetch.TagController do |> Enum.flat_map(&parse_id/1) tags = - Tag - |> where([t], t.id in ^ids) - |> Repo.all() + ids + |> Tags.list_tags_by_ids() |> Enum.map(&tag_json/1) conn diff --git a/lib/philomena_web/controllers/filter/clear_recent.ex b/lib/philomena_web/controllers/filter/clear_recent.ex deleted file mode 100644 index 24f321d32..000000000 --- a/lib/philomena_web/controllers/filter/clear_recent.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule PhilomenaWeb.Filter.ClearRecentController do - use PhilomenaWeb, :controller - - alias Philomena.Users - - plug PhilomenaWeb.RequireUserPlug - - def delete(conn, _params) do - {:ok, _user} = Users.clear_recent_filters(conn.assigns.current_user) - - conn - |> put_flash(:info, "Cleared recent filters.") - |> redirect(to: ~p"/filters") - end -end diff --git a/lib/philomena_web/controllers/filter/clear_recent_controller.ex b/lib/philomena_web/controllers/filter/clear_recent_controller.ex new file mode 100644 index 000000000..b8f933fea --- /dev/null +++ b/lib/philomena_web/controllers/filter/clear_recent_controller.ex @@ -0,0 +1,15 @@ +defmodule PhilomenaWeb.Filter.ClearRecentController do + use PhilomenaWeb, :controller + + alias Philomena.Users + + action_fallback PhilomenaWeb.FallbackController + + def delete(conn, _params) do + with {:ok, _user} <- Users.delete_recent_filters(conn.assigns.actor) do + conn + |> put_flash(:info, "Cleared recent filters.") + |> redirect(to: ~p"/filters") + end + end +end diff --git a/lib/philomena_web/controllers/filter/current_controller.ex b/lib/philomena_web/controllers/filter/current_controller.ex index d51bbf177..f2209011f 100644 --- a/lib/philomena_web/controllers/filter/current_controller.ex +++ b/lib/philomena_web/controllers/filter/current_controller.ex @@ -3,35 +3,23 @@ defmodule PhilomenaWeb.Filter.CurrentController do @cookie_opts [max_age: 788_923_800, same_site: "Lax"] - alias Philomena.Users - alias Philomena.{Filters, Filters.Filter} + alias Philomena.Filters - plug :load_resource, model: Filter + action_fallback PhilomenaWeb.FallbackController - def update(conn, _params) do - filter = conn.assigns.filter + def update(conn, params) do user = conn.assigns.current_user - filter = - if Canada.Can.can?(user, :show, filter) do - filter - else - Filters.default_filter() - end - - conn - |> update_filter(user, filter) - |> put_flash(:info, "Switched to filter #{filter.name}") - |> redirect(external: conn.assigns.referrer) - end - - defp update_filter(conn, nil, filter) do - put_resp_cookie(conn, "filter_id", Integer.to_string(filter.id), @cookie_opts) + with {:ok, filter} <- Filters.update_current_filter(conn.assigns.actor, params["id"]) do + conn + |> put_filter_cookie(user, filter) + |> put_flash(:info, "Switched to filter #{filter.name}") + |> redirect(external: conn.assigns.referrer) + end end - defp update_filter(conn, user, filter) do - {:ok, _user} = Users.update_filter(user, filter) + defp put_filter_cookie(conn, nil, filter), + do: put_resp_cookie(conn, "filter_id", Integer.to_string(filter.id), @cookie_opts) - conn - end + defp put_filter_cookie(conn, _user, _filter), do: conn end diff --git a/lib/philomena_web/controllers/filter/hide_controller.ex b/lib/philomena_web/controllers/filter/hide_controller.ex index 6a1744aac..1d72c2233 100644 --- a/lib/philomena_web/controllers/filter/hide_controller.ex +++ b/lib/philomena_web/controllers/filter/hide_controller.ex @@ -2,49 +2,41 @@ defmodule PhilomenaWeb.Filter.HideController do use PhilomenaWeb, :controller alias Philomena.Filters - alias Philomena.Tags.Tag - plug PhilomenaWeb.FilterBannedUsersPlug - plug :authorize_filter + action_fallback PhilomenaWeb.FallbackController - plug :load_resource, model: Tag, id_field: "slug", id_name: "tag", required: true + def create(conn, params) do + render_result( + conn, + Filters.create_filter_hide(actor(conn), current_filter(conn), params["tag"]) + ) + end - def create(conn, _params) do - case Filters.hide_tag(conn.assigns.current_filter, conn.assigns.tag) do - {:ok, _filter} -> - conn - |> put_status(:ok) - |> text("") - - {:error, _changeset} -> - conn - |> put_status(:internal_server_error) - |> text("") - end + def delete(conn, params) do + render_result( + conn, + Filters.delete_filter_hide(actor(conn), current_filter(conn), params["tag"]) + ) end - def delete(conn, _params) do - case Filters.unhide_tag(conn.assigns.current_filter, conn.assigns.tag) do + defp actor(conn), do: conn.assigns.actor + defp current_filter(conn), do: conn.assigns.current_filter + + # A denied filter edit is answered with an empty 403 and an update failure with + # an empty 500; the ban and not-found shapes redirect through the fallback. + defp render_result(conn, result) do + case result do {:ok, _filter} -> - conn - |> put_status(:ok) - |> text("") - - {:error, _changeset} -> - conn - |> put_status(:internal_server_error) - |> text("") - end - end + conn |> put_status(:ok) |> text("") + + {:error, :unauthorized} -> + conn |> put_status(:forbidden) |> text("") + + {:error, %Ecto.Changeset{}} -> + conn |> put_status(:internal_server_error) |> text("") - defp authorize_filter(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, conn.assigns.current_filter) do - conn - else - conn - |> put_status(:forbidden) - |> text("") - |> halt() + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/filter/public_controller.ex b/lib/philomena_web/controllers/filter/public_controller.ex index e31a82194..0ecf82574 100644 --- a/lib/philomena_web/controllers/filter/public_controller.ex +++ b/lib/philomena_web/controllers/filter/public_controller.ex @@ -2,22 +2,23 @@ defmodule PhilomenaWeb.Filter.PublicController do use PhilomenaWeb, :controller alias Philomena.Filters - alias Philomena.Filters.Filter - plug PhilomenaWeb.CanaryMapPlug, create: :edit - plug :load_and_authorize_resource, model: Filter, id_name: "filter_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - case Filters.make_filter_public(conn.assigns.filter) do + def create(conn, %{"filter_id" => id}) do + case Filters.create_filter_public(conn.assigns.actor, id) do {:ok, filter} -> conn |> put_flash(:info, "Successfully made filter public.") |> redirect(to: ~p"/filters/#{filter}") - _error -> + {:error, %Ecto.Changeset{} = changeset} -> conn |> put_flash(:error, "Couldn't make filter public!") - |> redirect(to: ~p"/filters/#{conn.assigns.filter}") + |> redirect(to: ~p"/filters/#{changeset.data}") + + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/filter/spoiler_controller.ex b/lib/philomena_web/controllers/filter/spoiler_controller.ex index d18b52952..09ca35245 100644 --- a/lib/philomena_web/controllers/filter/spoiler_controller.ex +++ b/lib/philomena_web/controllers/filter/spoiler_controller.ex @@ -2,49 +2,41 @@ defmodule PhilomenaWeb.Filter.SpoilerController do use PhilomenaWeb, :controller alias Philomena.Filters - alias Philomena.Tags.Tag - plug PhilomenaWeb.FilterBannedUsersPlug - plug :authorize_filter + action_fallback PhilomenaWeb.FallbackController - plug :load_resource, model: Tag, id_field: "slug", id_name: "tag", required: true + def create(conn, params) do + render_result( + conn, + Filters.create_filter_spoiler(actor(conn), current_filter(conn), params["tag"]) + ) + end - def create(conn, _params) do - case Filters.spoiler_tag(conn.assigns.current_filter, conn.assigns.tag) do - {:ok, _filter} -> - conn - |> put_status(:ok) - |> text("") - - {:error, _changeset} -> - conn - |> put_status(:internal_server_error) - |> text("") - end + def delete(conn, params) do + render_result( + conn, + Filters.delete_filter_spoiler(actor(conn), current_filter(conn), params["tag"]) + ) end - def delete(conn, _params) do - case Filters.unspoiler_tag(conn.assigns.current_filter, conn.assigns.tag) do + defp actor(conn), do: conn.assigns.actor + defp current_filter(conn), do: conn.assigns.current_filter + + # A denied filter edit is answered with an empty 403 and an update failure with + # an empty 500; the ban and not-found shapes redirect through the fallback. + defp render_result(conn, result) do + case result do {:ok, _filter} -> - conn - |> put_status(:ok) - |> text("") - - {:error, _changeset} -> - conn - |> put_status(:internal_server_error) - |> text("") - end - end + conn |> put_status(:ok) |> text("") + + {:error, :unauthorized} -> + conn |> put_status(:forbidden) |> text("") + + {:error, %Ecto.Changeset{}} -> + conn |> put_status(:internal_server_error) |> text("") - defp authorize_filter(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, conn.assigns.current_filter) do - conn - else - conn - |> put_status(:forbidden) - |> text("") - |> halt() + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/filter/spoiler_type_controller.ex b/lib/philomena_web/controllers/filter/spoiler_type_controller.ex index 2541e4e98..09db1a03d 100644 --- a/lib/philomena_web/controllers/filter/spoiler_type_controller.ex +++ b/lib/philomena_web/controllers/filter/spoiler_type_controller.ex @@ -3,17 +3,20 @@ defmodule PhilomenaWeb.Filter.SpoilerTypeController do alias Philomena.Users - plug PhilomenaWeb.RequireUserPlug + action_fallback PhilomenaWeb.FallbackController def update(conn, %{"settings" => settings_params}) when is_map(settings_params) do - case Users.update_spoiler_type(conn.assigns.current_user, settings_params) do + case Users.update_spoiler_type(conn.assigns.actor, settings_params) do {:ok, settings} -> conn |> put_flash(:info, "Changed spoiler type to #{settings.spoiler_type}") |> redirect(external: conn.assigns.referrer) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> update_failed(conn) + + error -> + error end end diff --git a/lib/philomena_web/controllers/filter_controller.ex b/lib/philomena_web/controllers/filter_controller.ex index ad047b85a..df70056d9 100644 --- a/lib/philomena_web/controllers/filter_controller.ex +++ b/lib/philomena_web/controllers/filter_controller.ex @@ -1,148 +1,50 @@ defmodule PhilomenaWeb.FilterController do use PhilomenaWeb, :controller - alias Philomena.{Filters, Filters.Filter, Filters.Query, Tags.Tag} - alias PhilomenaQuery.Search - alias Philomena.Schema.TagList - alias Philomena.Repo - import Ecto.Query + alias Philomena.Filters - plug PhilomenaWeb.RequireUserPlug when action not in [:index, :show] - plug :load_and_authorize_resource, model: Filter, except: [:index], preload: :user + action_fallback PhilomenaWeb.FallbackController def index(conn, %{"fq" => fq}) do - user = conn.assigns.current_user + case Filters.query_filters(conn.assigns.actor, fq, conn.assigns.pagination) do + {:ok, filters} -> + render(conn, "index.html", title: "Filters", filters: filters) - fq - |> Query.compile(user: user) - |> render_index(conn, user) + {:error, msg} -> + render(conn, "index.html", title: "Filters", error: msg, filters: []) + end end def index(conn, _params) do - user = conn.assigns.current_user - - my_filters = - if user do - Filter - |> where(user_id: ^user.id) - |> preload(:user) - |> Repo.all() - else - [] - end - - system_filters = - Filter - |> where(system: true) - |> preload(:user) - |> Repo.all() - - render(conn, "index.html", - title: "Filters", - my_filters: my_filters, - system_filters: system_filters - ) - end - - defp render_index({:ok, query}, conn, user) do - filters = - Filter - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: [query | filters(user)] - } - }, - sort: [ - %{name: :asc}, - %{id: :desc} - ] - }, - conn.assigns.pagination + with {:ok, {my_filters, system_filters}} <- + Filters.list_filters(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", + title: "Filters", + my_filters: my_filters, + system_filters: system_filters ) - |> Search.search_records(preload(Filter, [:user])) - - render(conn, "index.html", title: "Filters", filters: filters) - end - - defp render_index({:error, msg}, conn, _user) do - render(conn, "index.html", title: "Filters", error: msg, filters: []) - end - - defp filters(user), - do: [%{bool: %{should: shoulds(user)}}] - - defp shoulds(user) do - case user do - nil -> - anonymous_should() - - user -> - user_should(user) end end - defp user_should(user), - do: anonymous_should() ++ [%{term: %{user_id: user.id}}] - - defp anonymous_should(), - do: [%{term: %{public: true}}, %{term: %{system: true}}] - - def show(conn, _params) do - filter = conn.assigns.filter - - spoilered_tags = - Tag - |> where([t], t.id in ^filter.spoilered_tag_ids) - |> order_by(asc: :name) - |> Repo.all() - - hidden_tags = - Tag - |> where([t], t.id in ^filter.hidden_tag_ids) - |> order_by(asc: :name) - |> Repo.all() - - render(conn, "show.html", - title: "Showing Filter", - filter: filter, - spoilered_tags: spoilered_tags, - hidden_tags: hidden_tags - ) - end - - def new(conn, %{"based_on" => filter_id}) do - # The last line here is a hack to get Ecto to save a new - # model instead of trying to update the existing one. - filter = - Filter - |> where(id: ^filter_id) - |> where( - [f], - f.system == true or f.public == true or f.user_id == ^conn.assigns.current_user.id + def show(conn, %{"id" => id}) do + with {:ok, page} <- Filters.show_filter_page(conn.assigns.actor, id) do + render(conn, "show.html", + title: "Showing Filter", + filter: page.filter, + spoilered_tags: page.spoilered_tags, + hidden_tags: page.hidden_tags ) - |> Repo.one() - |> Kernel.||(%Filter{}) - |> TagList.assign_tag_list(:spoilered_tag_ids, :spoilered_tag_list) - |> TagList.assign_tag_list(:hidden_tag_ids, :hidden_tag_list) - |> Map.put(:__meta__, %Ecto.Schema.Metadata{ - state: :built, - source: "filters", - schema: Filter - }) - - changeset = Filters.change_filter(filter) - render(conn, "new.html", title: "New Filter", changeset: %{changeset | action: nil}) + end end - def new(conn, _params) do - changeset = Filters.change_filter(%Filter{}) - render(conn, "new.html", title: "New Filter", changeset: changeset) + def new(conn, params) do + with {:ok, changeset} <- Filters.new_filter(conn.assigns.actor, params["based_on"]) do + render(conn, "new.html", title: "New Filter", changeset: changeset) + end end def create(conn, %{"filter" => filter_params}) do - case Filters.create_filter(conn.assigns.current_user, filter_params) do + case Filters.create_filter(conn.assigns.actor, filter_params) do {:ok, filter} -> conn |> put_flash(:info, "Filter created successfully.") @@ -150,47 +52,47 @@ defmodule PhilomenaWeb.FilterController do {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + error -> + error end end - def edit(conn, _params) do - filter = - conn.assigns.filter - |> TagList.assign_tag_list(:spoilered_tag_ids, :spoilered_tag_list) - |> TagList.assign_tag_list(:hidden_tag_ids, :hidden_tag_list) - - changeset = Filters.change_filter(filter) - - render(conn, "edit.html", title: "Editing Filter", filter: filter, changeset: changeset) + def edit(conn, %{"id" => id}) do + with {:ok, {filter, changeset}} <- Filters.edit_filter(conn.assigns.actor, id) do + render(conn, "edit.html", title: "Editing Filter", filter: filter, changeset: changeset) + end end - def update(conn, %{"filter" => filter_params}) do - filter = conn.assigns.filter - - case Filters.update_filter(filter, filter_params) do + def update(conn, %{"id" => id, "filter" => filter_params}) do + case Filters.update_filter(conn.assigns.actor, id, filter_params) do {:ok, filter} -> conn |> put_flash(:info, "Filter updated successfully.") |> redirect(to: ~p"/filters/#{filter}") {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "edit.html", filter: filter, changeset: changeset) + render(conn, "edit.html", filter: changeset.data, changeset: changeset) + + error -> + error end end - def delete(conn, _params) do - filter = conn.assigns.filter - - case Filters.delete_filter(filter) do + def delete(conn, %{"id" => id}) do + case Filters.delete_filter(conn.assigns.actor, id) do {:ok, _filter} -> conn |> put_flash(:info, "Filter deleted successfully.") |> redirect(to: ~p"/filters") - _error -> + {:error, %Ecto.Changeset{} = changeset} -> conn |> put_flash(:error, "Filter is still in use, not deleted.") - |> redirect(to: ~p"/filters/#{filter}") + |> redirect(to: ~p"/filters/#{changeset.data}") + + error -> + error end end end diff --git a/lib/philomena_web/controllers/fingerprint_profile/source_change_controller.ex b/lib/philomena_web/controllers/fingerprint_profile/source_change_controller.ex index b8ca84d1a..d46e13354 100644 --- a/lib/philomena_web/controllers/fingerprint_profile/source_change_controller.ex +++ b/lib/philomena_web/controllers/fingerprint_profile/source_change_controller.ex @@ -1,42 +1,33 @@ defmodule PhilomenaWeb.FingerprintProfile.SourceChangeController do use PhilomenaWeb, :controller - alias Philomena.SourceChanges.SourceChange - alias Philomena.Repo - import Ecto.Query + alias Philomena.SourceChanges + alias Philomena.SourceChanges.SourceChangePage - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController def index(conn, %{"fingerprint_profile_id" => fingerprint} = params) do - source_changes = - SourceChange - |> where(fingerprint: ^fingerprint) - |> added_filter(params) - |> order_by(desc: :id) - |> preload([:user, image: [:user, :sources, tags: :aliases]]) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", - title: "Source Changes for Fingerprint `#{fingerprint}'", - fingerprint: fingerprint, - source_changes: source_changes - ) - end - - defp added_filter(query, %{"added" => "1"}), - do: where(query, added: true) - - defp added_filter(query, %{"added" => "0"}), - do: where(query, added: false) - - defp added_filter(query, _params), - do: query - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :show, :ip_address) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + case SourceChanges.list_fingerprint_source_changes( + conn.assigns.actor, + fingerprint, + params, + conn.assigns.scrivener + ) do + {:ok, %SourceChangePage{target: fingerprint, source_changes: source_changes}, changeset} -> + render(conn, "index.html", + title: "Source Changes for Fingerprint `#{fingerprint}'", + fingerprint: fingerprint, + source_changes: source_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid source change filter.") + |> redirect(to: "/") + + error -> + error end end end diff --git a/lib/philomena_web/controllers/fingerprint_profile/tag_change/revert_controller.ex b/lib/philomena_web/controllers/fingerprint_profile/tag_change/revert_controller.ex new file mode 100644 index 000000000..b95471c3e --- /dev/null +++ b/lib/philomena_web/controllers/fingerprint_profile/tag_change/revert_controller.ex @@ -0,0 +1,16 @@ +defmodule PhilomenaWeb.FingerprintProfile.TagChange.RevertController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + + action_fallback PhilomenaWeb.FallbackController + + def create(conn, %{"fingerprint_profile_id" => fingerprint}) do + with {:ok, _target} <- + TagChanges.create_fingerprint_tag_change_revert(conn.assigns.actor, fingerprint) do + conn + |> put_flash(:info, "Reversion of tag changes enqueued.") + |> redirect(external: conn.assigns.referrer) + end + end +end diff --git a/lib/philomena_web/controllers/fingerprint_profile/tag_change_controller.ex b/lib/philomena_web/controllers/fingerprint_profile/tag_change_controller.ex new file mode 100644 index 000000000..2a03e961a --- /dev/null +++ b/lib/philomena_web/controllers/fingerprint_profile/tag_change_controller.ex @@ -0,0 +1,38 @@ +defmodule PhilomenaWeb.FingerprintProfile.TagChangeController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChangePage + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"fingerprint_profile_id" => fingerprint} = params) do + case TagChanges.list_fingerprint_tag_changes( + conn.assigns.actor, + fingerprint, + params, + conn.assigns.pagination + ) do + {:ok, %TagChangePage{target: fingerprint, tag_changes: tag_changes}, changeset} -> + path = ~p"/fingerprint_profiles/#{fingerprint}/tag_changes" + + conn + |> put_view(PhilomenaWeb.TagChangeView) + |> render("index.html", + title: "Tag Changes for Fingerprint `#{fingerprint}'", + path: path, + pagination_route: fn query -> "#{path}?#{Plug.Conn.Query.encode(query)}" end, + tag_changes: tag_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid tag change query.") + |> redirect(to: "/tag_changes") + + error -> + error + end + end +end diff --git a/lib/philomena_web/controllers/fingerprint_profile_controller.ex b/lib/philomena_web/controllers/fingerprint_profile_controller.ex index 4b2189a5f..c20bcfaae 100644 --- a/lib/philomena_web/controllers/fingerprint_profile_controller.ex +++ b/lib/philomena_web/controllers/fingerprint_profile_controller.ex @@ -1,40 +1,19 @@ defmodule PhilomenaWeb.FingerprintProfileController do use PhilomenaWeb, :controller - alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.Bans.Fingerprint - alias Philomena.Repo - import Ecto.Query + alias Philomena.UserFingerprints - plug :authorize_ip + action_fallback PhilomenaWeb.FallbackController def show(conn, %{"id" => fingerprint}) do - user_fps = - UserFingerprint - |> where(fingerprint: ^fingerprint) - |> order_by(desc: :updated_at) - |> preload(:user) - |> Repo.all() - - fp_bans = - Fingerprint - |> where(fingerprint: ^fingerprint) - |> order_by(desc: :created_at) - |> Repo.all() - - render(conn, "show.html", - title: "#{fingerprint}'s fingerprint profile", - fingerprint: fingerprint, - user_fps: user_fps, - fingerprint_bans: fp_bans - ) - end - - defp authorize_ip(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :show, :ip_address) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + with {:ok, profile} <- + UserFingerprints.show_fingerprint_profile(conn.assigns.actor, fingerprint) do + render(conn, "show.html", + title: "#{profile.fingerprint}'s fingerprint profile", + fingerprint: profile.fingerprint, + user_fps: profile.user_fingerprints, + fingerprint_bans: profile.fingerprint_bans + ) end end end diff --git a/lib/philomena_web/controllers/forum/subscription_controller.ex b/lib/philomena_web/controllers/forum/subscription_controller.ex index 5aac82e0a..e9c1080b7 100644 --- a/lib/philomena_web/controllers/forum/subscription_controller.ex +++ b/lib/philomena_web/controllers/forum/subscription_controller.ex @@ -1,36 +1,26 @@ defmodule PhilomenaWeb.Forum.SubscriptionController do use PhilomenaWeb, :controller - alias Philomena.Forums.Forum alias Philomena.Forums - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - def create(conn, _params) do - forum = conn.assigns.forum - user = conn.assigns.current_user - - case Forums.create_subscription(forum, user) do - {:ok, _subscription} -> + def create(conn, params) do + case Forums.create_forum_subscription(conn.assigns.actor, params["forum_id"]) do + {:ok, forum} -> render(conn, "_subscription.html", forum: forum, watching: true, layout: false) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> render(conn, "_error.html", layout: false) + + {:error, _} = error -> + error end end - def delete(conn, _params) do - forum = conn.assigns.forum - user = conn.assigns.current_user - - {:ok, _subscription} = Forums.delete_subscription(forum, user) - - render(conn, "_subscription.html", forum: forum, watching: false, layout: false) + def delete(conn, params) do + with {:ok, forum} <- Forums.delete_forum_subscription(conn.assigns.actor, params["forum_id"]) do + render(conn, "_subscription.html", forum: forum, watching: false, layout: false) + end end end diff --git a/lib/philomena_web/controllers/forum_controller.ex b/lib/philomena_web/controllers/forum_controller.ex index 05ce73d4c..036fdea70 100644 --- a/lib/philomena_web/controllers/forum_controller.ex +++ b/lib/philomena_web/controllers/forum_controller.ex @@ -1,46 +1,29 @@ defmodule PhilomenaWeb.ForumController do use PhilomenaWeb, :controller - alias Philomena.{Forums, Forums.Forum, Topics.Topic} - alias Philomena.Repo - import Ecto.Query + alias Philomena.Forums - plug :load_and_authorize_resource, model: Forum, id_field: "short_name", only: [:show] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - user = conn.assigns.current_user + index = Forums.list_forums(conn.assigns.actor, conn.assigns.scrivener) - forums = - Forum - |> order_by(asc: :name) - |> preload(last_post: [:user, topic: :forum]) - |> Repo.all() - |> Enum.filter(&Canada.Can.can?(user, :show, &1)) - - topic_count = Repo.aggregate(Forum, :sum, :topic_count) - - render(conn, "index.html", title: "Forums", forums: forums, topic_count: topic_count) + render(conn, "index.html", + title: "Forums", + forums: index.forums, + topic_count: index.topic_count + ) end - def show(conn, %{"id" => _id}) do - forum = conn.assigns.forum - user = conn.assigns.current_user - - topics = - Topic - |> where(forum_id: ^forum.id) - |> where(hidden_from_users: false) - |> order_by(desc: :sticky, desc: :last_replied_to_at) - |> preload([:poll, :forum, :user, last_post: :user]) - |> Repo.paginate(conn.assigns.scrivener) - - watching = Forums.subscribed?(forum, user) - - render(conn, "show.html", - title: forum.name, - forum: conn.assigns.forum, - watching: watching, - topics: topics - ) + def show(conn, %{"id" => short_name}) do + with {:ok, page} <- + Forums.show_forum_page(conn.assigns.actor, short_name, conn.assigns.scrivener) do + render(conn, "show.html", + title: page.forum.name, + forum: page.forum, + watching: page.watching, + topics: page.topics + ) + end end end diff --git a/lib/philomena_web/controllers/gallery/image_controller.ex b/lib/philomena_web/controllers/gallery/image_controller.ex index 33569725f..e2769b4d0 100644 --- a/lib/philomena_web/controllers/gallery/image_controller.ex +++ b/lib/philomena_web/controllers/gallery/image_controller.ex @@ -1,39 +1,29 @@ defmodule PhilomenaWeb.Gallery.ImageController do use PhilomenaWeb, :controller - alias Philomena.Galleries.Gallery alias Philomena.Galleries - alias Philomena.Images.Image - plug PhilomenaWeb.FilterBannedUsersPlug + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.CanaryMapPlug, create: :edit, delete: :edit - plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true - - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - - def create(conn, _params) do - case Galleries.add_image_to_gallery(conn.assigns.gallery, conn.assigns.image) do + def create(conn, %{"gallery_id" => gallery_id} = params) do + case Galleries.create_gallery_image(conn.assigns.actor, gallery_id, params["image_id"]) do {:ok, _gallery} -> json(conn, %{}) - _error -> + {:error, %Ecto.Changeset{}} -> conn - |> put_status(:bad_request) + |> put_status(:conflict) |> json(%{}) + + error -> + error end end - def delete(conn, _params) do - case Galleries.remove_image_from_gallery(conn.assigns.gallery, conn.assigns.image) do - {:ok, _gallery} -> - json(conn, %{}) - - _error -> - conn - |> put_status(:bad_request) - |> json(%{}) + def delete(conn, %{"gallery_id" => gallery_id} = params) do + with {:ok, _gallery} <- + Galleries.delete_gallery_image(conn.assigns.actor, gallery_id, params["image_id"]) do + json(conn, %{}) end end end diff --git a/lib/philomena_web/controllers/gallery/order_controller.ex b/lib/philomena_web/controllers/gallery/order_controller.ex index 301d4c57b..5fc97e19c 100644 --- a/lib/philomena_web/controllers/gallery/order_controller.ex +++ b/lib/philomena_web/controllers/gallery/order_controller.ex @@ -1,19 +1,22 @@ defmodule PhilomenaWeb.Gallery.OrderController do use PhilomenaWeb, :controller - alias Philomena.Galleries.Gallery alias Philomena.Galleries - plug PhilomenaWeb.FilterBannedUsersPlug + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.CanaryMapPlug, update: :edit - plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true + def update(conn, %{"gallery_id" => gallery_id} = params) do + case Galleries.update_gallery_order(conn.assigns.actor, gallery_id, params) do + {:ok, _reorder_form} -> + json(conn, %{}) - def update(conn, %{"image_ids" => image_ids}) when is_list(image_ids) do - gallery = conn.assigns.gallery + {:error, %Ecto.Changeset{}} -> + conn + |> put_status(:bad_request) + |> json(%{error: "image_ids must be a non-empty subset of the gallery's images"}) - Galleries.reorder_gallery(gallery, image_ids) - - json(conn, %{}) + error -> + error + end end end diff --git a/lib/philomena_web/controllers/gallery/read_controller.ex b/lib/philomena_web/controllers/gallery/read_controller.ex index 2ffaa559f..6ff8e02c2 100644 --- a/lib/philomena_web/controllers/gallery/read_controller.ex +++ b/lib/philomena_web/controllers/gallery/read_controller.ex @@ -1,18 +1,14 @@ defmodule PhilomenaWeb.Gallery.ReadController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Galleries.Gallery alias Philomena.Galleries - plug :load_resource, model: Gallery, id_name: "gallery_id", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - gallery = conn.assigns.gallery - user = conn.assigns.current_user - - Galleries.clear_gallery_notification(gallery, user) - - send_resp(conn, :ok, "") + def create(conn, params) do + with {:ok, _gallery} <- + Galleries.create_gallery_read(conn.assigns.actor, params["gallery_id"]) do + send_resp(conn, :ok, "") + end end end diff --git a/lib/philomena_web/controllers/gallery/report_controller.ex b/lib/philomena_web/controllers/gallery/report_controller.ex index b5ef0ed27..596ca3a6e 100644 --- a/lib/philomena_web/controllers/gallery/report_controller.ex +++ b/lib/philomena_web/controllers/gallery/report_controller.ex @@ -3,43 +3,36 @@ defmodule PhilomenaWeb.Gallery.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Galleries.Gallery - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - plug :load_and_authorize_resource, - model: Gallery, - id_name: "gallery_id", - persisted: true - - def new(conn, _params) do - gallery = conn.assigns.gallery - action = ~p"/galleries/#{gallery}/reports" - - changeset = - %Report{gallery_id: gallery.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", - title: "Reporting Gallery", - subject: gallery, - changeset: changeset, - action: action - ) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"gallery_id" => gallery_id}) do + with {:ok, form} <- Reports.new_report(conn.assigns.actor, {:gallery, gallery_id}) do + gallery = form.target + action = ~p"/galleries/#{gallery}/reports" + + conn + |> put_view(ReportView) + |> render("new.html", + title: "Reporting Gallery", + subject: gallery, + changeset: form.changeset, + rules: form.rules, + action: action + ) + end end - def create(conn, params) do - gallery = conn.assigns.gallery - action = ~p"/galleries/#{gallery}/reports" - - ReportController.create(conn, action, gallery, [gallery_id: gallery.id], params) + def create(conn, %{"gallery_id" => gallery_id} = params) do + ReportController.create( + conn, + {:gallery, gallery_id}, + fn gallery -> ~p"/galleries/#{gallery}/reports" end, + params + ) end end diff --git a/lib/philomena_web/controllers/gallery/subscription_controller.ex b/lib/philomena_web/controllers/gallery/subscription_controller.ex index 4c5643640..a0733c6ef 100644 --- a/lib/philomena_web/controllers/gallery/subscription_controller.ex +++ b/lib/philomena_web/controllers/gallery/subscription_controller.ex @@ -1,31 +1,27 @@ defmodule PhilomenaWeb.Gallery.SubscriptionController do use PhilomenaWeb, :controller - alias Philomena.Galleries.Gallery alias Philomena.Galleries - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show - plug :load_and_authorize_resource, model: Gallery, id_name: "gallery_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - gallery = conn.assigns.gallery - user = conn.assigns.current_user - - case Galleries.create_subscription(gallery, user) do - {:ok, _subscription} -> + def create(conn, params) do + case Galleries.create_gallery_subscription(conn.assigns.actor, params["gallery_id"]) do + {:ok, gallery} -> render(conn, "_subscription.html", gallery: gallery, watching: true, layout: false) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> render(conn, "_error.html", layout: false) + + {:error, _} = error -> + error end end - def delete(conn, _params) do - gallery = conn.assigns.gallery - user = conn.assigns.current_user - - {:ok, _subscription} = Galleries.delete_subscription(gallery, user) - - render(conn, "_subscription.html", gallery: gallery, watching: false, layout: false) + def delete(conn, params) do + with {:ok, gallery} <- + Galleries.delete_gallery_subscription(conn.assigns.actor, params["gallery_id"]) do + render(conn, "_subscription.html", gallery: gallery, watching: false, layout: false) + end end end diff --git a/lib/philomena_web/controllers/gallery_controller.ex b/lib/philomena_web/controllers/gallery_controller.ex index 0b6a37a34..9efe4a151 100644 --- a/lib/philomena_web/controllers/gallery_controller.ex +++ b/lib/philomena_web/controllers/gallery_controller.ex @@ -1,230 +1,122 @@ defmodule PhilomenaWeb.GalleryController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader + alias PhilomenaWeb.ImageScope alias PhilomenaWeb.NotificationCountPlug - alias PhilomenaQuery.Search - alias Philomena.Interactions - alias Philomena.Galleries.Gallery alias Philomena.Galleries - alias Philomena.Images.Image - import Ecto.Query - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create, :edit, :update, :delete] - plug PhilomenaWeb.MapParameterPlug, [param: "gallery"] when action in [:index] - - plug :load_and_authorize_resource, - model: Gallery, - except: [:index], - preload: [:user, thumbnail: [:sources, tags: :aliases]] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - galleries = - Gallery - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: parse_search(params) - } - }, - sort: parse_sort(params) - }, - conn.assigns.pagination - ) - |> Search.search_records(preload(Gallery, thumbnail: [:sources, tags: :aliases])) - - render(conn, "index.html", - title: "Galleries", - galleries: galleries, - layout_class: "layout--wide" - ) + case Galleries.list_galleries( + conn.assigns.actor, + params["gallery"] || %{}, + conn.assigns.pagination + ) do + {:ok, galleries, changeset} -> + render( + conn, + "index.html", + title: "Galleries", + galleries: galleries, + changeset: changeset, + layout_class: "layout--wide" + ) + + {:error, %Ecto.Changeset{} = changeset} -> + render( + conn, + "index.html", + title: "Galleries", + galleries: nil, + changeset: changeset, + layout_class: "layout--wide" + ) + + error -> + error + end end - def show(conn, _params) do - gallery = conn.assigns.gallery - user = conn.assigns.current_user - query = "gallery_id:#{gallery.id}" - - conn = - update_in( - conn.params, - &Map.merge(&1, %{ - "q" => query, - "sf" => "gallery_id:#{gallery.id}", - "sd" => position_order(gallery) - }) - ) - - {:ok, {images, _tags}} = ImageLoader.search_string(conn, query) - {gallery_prev, gallery_next} = prev_next_page_images(conn, query) - - [images, gallery_prev, gallery_next] = - Search.msearch_records_with_hits( - [images, gallery_prev, gallery_next], - [ - preload(Image, [:sources, tags: :aliases]), - preload(Image, [:sources, tags: :aliases]), - preload(Image, [:sources, tags: :aliases]) - ] - ) - - interactions = Interactions.user_interactions([images, gallery_prev, gallery_next], user) - - watching = Galleries.subscribed?(gallery, user) - - gallery_images = - Enum.to_list(gallery_prev) ++ Enum.to_list(images) ++ Enum.to_list(gallery_next) - - gallery_json = JSON.encode!(Enum.map(gallery_images, &elem(&1, 0).id)) - - Galleries.clear_gallery_notification(gallery, user) - - conn - |> NotificationCountPlug.call([]) - |> assign(:clientside_data, gallery_images: gallery_json) - |> render("show.html", - title: "Showing Gallery", - layout_class: "layout--wide", - watching: watching, - gallery: gallery, - gallery_prev: Enum.any?(gallery_prev), - gallery_next: Enum.any?(gallery_next), - gallery_images: gallery_images, - images: images, - interactions: interactions - ) + def show(conn, params) do + case Galleries.show_gallery( + conn.assigns.actor, + ImageScope.search_scope(conn), + params["id"] + ) do + {:ok, page} -> + gallery_json = JSON.encode!(Enum.map(page.gallery_images, &elem(&1, 0).id)) + + # The page load clears the gallery notification, so the header ticker + # must be re-read afterwards. + conn + |> NotificationCountPlug.call([]) + |> assign(:clientside_data, gallery_images: gallery_json) + |> render("show.html", + title: "Showing Gallery", + layout_class: "layout--wide", + watching: page.watching, + gallery: page.gallery, + gallery_prev: page.gallery_prev, + gallery_next: page.gallery_next, + gallery_images: page.gallery_images, + images: page.images, + interactions: page.interactions + ) + + {:error, _} = error -> + error + end end def new(conn, _params) do - changeset = Galleries.change_gallery(%Gallery{}) - render(conn, "new.html", title: "New Gallery", changeset: changeset) + with {:ok, changeset} <- Galleries.new_gallery(conn.assigns.actor) do + render(conn, "new.html", title: "New Gallery", changeset: changeset) + end end - def create(conn, %{"gallery" => gallery_params}) do - user = conn.assigns.current_user - - case Galleries.create_gallery(user, gallery_params) do + def create(conn, params) do + case Galleries.create_gallery(conn.assigns.actor, params["gallery"]) do {:ok, gallery} -> conn |> put_flash(:info, "Gallery successfully created.") |> redirect(to: ~p"/galleries/#{gallery}") - {:error, changeset} -> - conn - |> render("new.html", changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) + + {:error, _} = error -> + error end end - def edit(conn, _params) do - gallery = conn.assigns.gallery - changeset = Galleries.change_gallery(gallery) - - render(conn, "edit.html", title: "Editing Gallery", gallery: gallery, changeset: changeset) + def edit(conn, params) do + with {:ok, {gallery, changeset}} <- + Galleries.edit_gallery(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", title: "Editing Gallery", gallery: gallery, changeset: changeset) + end end - def update(conn, %{"gallery" => gallery_params}) do - gallery = conn.assigns.gallery - - case Galleries.update_gallery(gallery, gallery_params) do + def update(conn, params) do + case Galleries.update_gallery(conn.assigns.actor, params["id"], params["gallery"]) do {:ok, gallery} -> conn |> put_flash(:info, "Gallery successfully updated.") |> redirect(to: ~p"/galleries/#{gallery}") - {:error, changeset} -> - conn - |> render("edit.html", gallery: gallery, changeset: changeset) - end - end - - def delete(conn, _params) do - gallery = conn.assigns.gallery - - {:ok, _gallery} = Galleries.delete_gallery(gallery, conn.assigns.current_user) - - conn - |> put_flash(:info, "Gallery successfully destroyed.") - |> redirect(to: ~p"/galleries") - end - - defp prev_next_page_images(conn, query) do - limit = conn.assigns.image_pagination.page_size - offset = (conn.assigns.image_pagination.page_number - 1) * limit - - # Inconsistency: OpenSearch doesn't allow requesting offsets which are less than 0, - # but it does allow requesting offsets which are beyond the total number of results. - - prev_image = gallery_image(offset - 1, conn, query) - next_image = gallery_image(offset + limit, conn, query) - - {prev_image, next_image} - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", gallery: changeset.data, changeset: changeset) - defp gallery_image(offset, _conn, _query) when offset < 0 do - Search.search_definition(Image, %{query: %{match_none: %{}}}) - end - - defp gallery_image(offset, conn, query) do - pagination_params = %{page_number: offset + 1, page_size: 1} - - {:ok, {image, _tags}} = ImageLoader.search_string(conn, query, pagination: pagination_params) - - image - end - - defp parse_search(%{"gallery" => gallery_params}) do - parse_title(gallery_params) ++ - parse_creator(gallery_params) ++ - parse_included_image(gallery_params) ++ - parse_description(gallery_params) - end - - defp parse_search(_params), do: [%{match_all: %{}}] - - defp parse_title(%{"title" => title}) when is_binary(title) and title not in [nil, ""], - do: [%{wildcard: %{title: "*" <> String.downcase(title) <> "*"}}] - - defp parse_title(_params), do: [] - - defp parse_creator(%{"creator" => creator}) - when is_binary(creator) and creator not in [nil, ""], - do: [%{term: %{creator: String.downcase(creator)}}] - - defp parse_creator(_params), do: [] - - defp parse_included_image(%{"include_image" => image_id}) - when is_binary(image_id) and image_id not in [nil, ""] do - with {image_id, _rest} <- Integer.parse(image_id) do - [%{term: %{image_ids: image_id}}] - else - _ -> - [] + {:error, _} = error -> + error end end - defp parse_included_image(_params), do: [] - - defp parse_description(%{"description" => description}) - when is_binary(description) and description not in [nil, ""], - do: [%{match_phrase: %{description: description}}] - - defp parse_description(_params), do: [] - - defp parse_sort(%{"gallery" => %{"sf" => "created_at", "sd" => sd}}) when sd in ~w(desc asc) do - [%{created_at: sd}, %{id: sd}] - end - - defp parse_sort(%{"gallery" => %{"sf" => sf, "sd" => sd}}) - when sf in ~w(updated_at image_count subscriber_count _score) and - sd in ~w(desc asc) do - [%{sf => sd}, %{created_at: sd}, %{id: sd}] - end - - defp parse_sort(_params) do - [%{created_at: :desc}, %{id: :desc}] + def delete(conn, params) do + with {:ok, _gallery} <- Galleries.delete_gallery(conn.assigns.actor, params["id"]) do + conn + |> put_flash(:info, "Gallery successfully destroyed.") + |> redirect(to: ~p"/galleries") + end end - - defp position_order(%{order_position_asc: true}), do: "asc" - defp position_order(_gallery), do: "desc" end diff --git a/lib/philomena_web/controllers/image/anonymous_controller.ex b/lib/philomena_web/controllers/image/anonymous_controller.ex index f39c0a667..e91686498 100644 --- a/lib/philomena_web/controllers/image/anonymous_controller.ex +++ b/lib/philomena_web/controllers/image/anonymous_controller.ex @@ -1,43 +1,25 @@ defmodule PhilomenaWeb.Image.AnonymousController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug :verify_authorized - plug :load_resource, model: Image, id_name: "image_id", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - Images.update_anonymous(conn.assigns.image, %{"anonymous" => true}) - |> process_request(conn) - end - - def delete(conn, _params) do - Images.update_anonymous(conn.assigns.image, %{"anonymous" => false}) - |> process_request(conn) - end - - defp process_request({:ok, image}, conn) do - Images.reindex_image(image) - - conn - |> put_flash(:info, "Successfully updated anonymity.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :show, :ip_address) do + def create(conn, params) do + with {:ok, image} <- + Images.update_anonymous(conn.assigns.actor, params["image_id"], true) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Successfully updated anonymity.") + |> redirect(to: ~p"/images/#{image}") end end - defp log_details(_action, image) do - %{ - body: "Updated anonymity of image #{image.id}", - subject_path: ~p"/images/#{image}" - } + def delete(conn, params) do + with {:ok, image} <- + Images.update_anonymous(conn.assigns.actor, params["image_id"], false) do + conn + |> put_flash(:info, "Successfully updated anonymity.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/approve_controller.ex b/lib/philomena_web/controllers/image/approve_controller.ex index 52bfdbd77..ec8144103 100644 --- a/lib/philomena_web/controllers/image/approve_controller.ex +++ b/lib/philomena_web/controllers/image/approve_controller.ex @@ -1,36 +1,24 @@ defmodule PhilomenaWeb.Image.ApproveController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :approve - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - plug :verify_not_approved + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - image = conn.assigns.image + def create(conn, params) do + case Images.create_image_approve(conn.assigns.actor, params["image_id"]) do + {:ok, _image} -> + conn + |> put_flash(:info, "Image has been approved.") + |> redirect(to: ~p"/admin/approvals") - {:ok, _comment} = Images.approve_image(image) + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Someone else already approved this image.") + |> redirect(to: ~p"/admin/approvals") - conn - |> put_flash(:info, "Image has been approved.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/admin/approvals") - end - - defp verify_not_approved(conn, _opts) do - if conn.assigns.image.approved do - conn - |> put_flash(:error, "Someone else already approved this image.") - |> redirect(to: ~p"/admin/approvals") - |> halt() - else - conn + error -> + error end end - - defp log_details(_action, image) do - %{body: "Approved image #{image.id}", subject_path: ~p"/images/#{image}"} - end end diff --git a/lib/philomena_web/controllers/image/comment/approve_controller.ex b/lib/philomena_web/controllers/image/comment/approve_controller.ex index de7a2918a..9615dee2a 100644 --- a/lib/philomena_web/controllers/image/comment/approve_controller.ex +++ b/lib/philomena_web/controllers/image/comment/approve_controller.ex @@ -1,31 +1,24 @@ defmodule PhilomenaWeb.Image.Comment.ApproveController do use PhilomenaWeb, :controller - alias Philomena.Comments.Comment alias Philomena.Comments - plug PhilomenaWeb.CanaryMapPlug, create: :approve + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Comment, - id_name: "comment_id", - persisted: true + def create(conn, %{"image_id" => image_id, "comment_id" => comment_id}) do + case Comments.create_comment_approve(conn.assigns.actor, image_id, comment_id) do + {:ok, comment} -> + conn + |> put_flash(:info, "Comment has been approved.") + |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - def create(conn, _params) do - comment = conn.assigns.comment + {:error, %{data: comment} = _changeset} -> + conn + |> put_flash(:info, "Comment has already been approved.") + |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - {:ok, _comment} = Comments.approve_comment(comment, conn.assigns.current_user) - - conn - |> put_flash(:info, "Comment has been approved.") - |> moderation_log(details: &log_details/2, data: comment) - |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - end - - defp log_details(_action, comment) do - %{ - body: "Approved comment on image #{comment.image_id}", - subject_path: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/image/comment/delete_controller.ex b/lib/philomena_web/controllers/image/comment/delete_controller.ex index ac5125d55..cda710c7c 100644 --- a/lib/philomena_web/controllers/image/comment/delete_controller.ex +++ b/lib/philomena_web/controllers/image/comment/delete_controller.ex @@ -1,35 +1,25 @@ defmodule PhilomenaWeb.Image.Comment.DeleteController do use PhilomenaWeb, :controller - alias Philomena.Comments.Comment alias Philomena.Comments + alias Philomena.Comments.Comment - plug PhilomenaWeb.CanaryMapPlug, create: :hide - plug :load_and_authorize_resource, model: Comment, id_name: "comment_id", persisted: true - - def create(conn, _params) do - comment = conn.assigns.comment + action_fallback PhilomenaWeb.FallbackController - case Comments.destroy_comment(comment) do + def create(conn, %{"image_id" => image_id, "comment_id" => comment_id}) do + case Comments.create_comment_delete(conn.assigns.actor, image_id, comment_id) do {:ok, comment} -> - Comments.reindex_comment(comment) - conn |> put_flash(:info, "Comment successfully destroyed!") - |> moderation_log(details: &log_details/2, data: comment) |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: %Comment{} = comment}} -> conn |> put_flash(:error, "Unable to destroy comment!") |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - end - end - defp log_details(_action, comment) do - %{ - body: "Destroyed comment on image #{comment.image_id}", - subject_path: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}" - } + {:error, _} = error -> + error + end end end diff --git a/lib/philomena_web/controllers/image/comment/hide_controller.ex b/lib/philomena_web/controllers/image/comment/hide_controller.ex index f21f6c69f..245e15098 100644 --- a/lib/philomena_web/controllers/image/comment/hide_controller.ex +++ b/lib/philomena_web/controllers/image/comment/hide_controller.ex @@ -1,57 +1,46 @@ defmodule PhilomenaWeb.Image.Comment.HideController do use PhilomenaWeb, :controller - alias Philomena.Comments.Comment alias Philomena.Comments + alias Philomena.Comments.Comment - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :load_and_authorize_resource, model: Comment, id_name: "comment_id", persisted: true - - def create(conn, %{"comment" => comment_params}) do - comment = conn.assigns.comment - user = conn.assigns.current_user + action_fallback PhilomenaWeb.FallbackController - case Comments.hide_comment(comment, comment_params, user) do + def create(conn, %{ + "image_id" => image_id, + "comment_id" => comment_id, + "comment" => comment_params + }) do + case Comments.create_comment_hide(conn.assigns.actor, image_id, comment_id, comment_params) do {:ok, comment} -> conn |> put_flash(:info, "Comment successfully deleted!") - |> moderation_log(details: &log_details/2, data: comment) |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - _error -> + {:error, %Ecto.Changeset{data: %Comment{} = comment}} -> conn |> put_flash(:error, "Unable to delete comment!") |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") + + {:error, _} = error -> + error end end - def delete(conn, _params) do - comment = conn.assigns.comment - - case Comments.unhide_comment(comment) do + def delete(conn, %{"image_id" => image_id, "comment_id" => comment_id}) do + case Comments.delete_comment_hide(conn.assigns.actor, image_id, comment_id) do {:ok, comment} -> conn |> put_flash(:info, "Comment successfully restored!") - |> moderation_log(details: &log_details/2, data: comment) |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: %Comment{} = comment}} -> conn |> put_flash(:error, "Unable to restore comment!") |> redirect(to: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}") - end - end - defp log_details(action, comment) do - body = - case action do - :create -> "Deleted comment on image #{comment.image_id} (#{comment.deletion_reason})" - :delete -> "Restored comment on image #{comment.image_id}" - end - - %{ - body: body, - subject_path: ~p"/images/#{comment.image_id}" <> "#comment_#{comment.id}" - } + {:error, _} = error -> + error + end end end diff --git a/lib/philomena_web/controllers/image/comment/history_controller.ex b/lib/philomena_web/controllers/image/comment/history_controller.ex index 32514d8ab..ea487351b 100644 --- a/lib/philomena_web/controllers/image/comment/history_controller.ex +++ b/lib/philomena_web/controllers/image/comment/history_controller.ex @@ -1,27 +1,19 @@ defmodule PhilomenaWeb.Image.Comment.HistoryController do use PhilomenaWeb, :controller - alias Philomena.Versions - alias Philomena.Images.Image alias PhilomenaWeb.MarkdownRenderer + alias Philomena.Comments - plug PhilomenaWeb.CanaryMapPlug, index: :show - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.LoadCommentPlug - - def index(conn, _params) do - image = conn.assigns.image - comment = conn.assigns.comment - - versions = - comment - |> Versions.load_comment_versions() - |> MarkdownRenderer.render_version_diffs() - - render(conn, "index.html", - title: "Comment History for comment #{comment.id} on image #{image.id}", - versions: versions - ) + def index(conn, %{"image_id" => image_id, "comment_id" => comment_id}) do + with {:ok, history} <- + Comments.list_comment_history(conn.assigns.actor, image_id, comment_id) do + render(conn, "index.html", + title: "Comment History for comment #{history.comment.id} on image #{history.image.id}", + comment: history.comment, + versions: MarkdownRenderer.render_version_diffs(history.versions) + ) + end end end diff --git a/lib/philomena_web/controllers/image/comment/report_controller.ex b/lib/philomena_web/controllers/image/comment/report_controller.ex index 54b9bb1f2..768a99727 100644 --- a/lib/philomena_web/controllers/image/comment/report_controller.ex +++ b/lib/philomena_web/controllers/image/comment/report_controller.ex @@ -3,47 +3,38 @@ defmodule PhilomenaWeb.Image.Comment.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Images.Image - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:sources, tags: :aliases] - - plug PhilomenaWeb.LoadCommentPlug - - def new(conn, _params) do - comment = conn.assigns.comment - action = ~p"/images/#{comment.image}/comments/#{comment}/reports" - - changeset = - %Report{comment_id: comment.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", - title: "Reporting Comment", - subject: comment, - changeset: changeset, - action: action - ) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"image_id" => image_id, "comment_id" => comment_id}) do + locator = {:comment, image_id, comment_id} + + with {:ok, form} <- Reports.new_report(conn.assigns.actor, locator) do + comment = form.target + action = ~p"/images/#{comment.image}/comments/#{comment}/reports" + + conn + |> put_view(ReportView) + |> render("new.html", + title: "Reporting Comment", + subject: comment, + changeset: form.changeset, + rules: form.rules, + action: action + ) + end end - def create(conn, params) do - comment = conn.assigns.comment - action = ~p"/images/#{comment.image}/comments/#{comment}/reports" - - ReportController.create(conn, action, comment, [comment_id: comment.id], params) + def create(conn, %{"image_id" => image_id, "comment_id" => comment_id} = params) do + ReportController.create( + conn, + {:comment, image_id, comment_id}, + fn comment -> ~p"/images/#{comment.image}/comments/#{comment}/reports" end, + params + ) end end diff --git a/lib/philomena_web/controllers/image/comment_controller.ex b/lib/philomena_web/controllers/image/comment_controller.ex index c88eb59b4..1bce328d8 100644 --- a/lib/philomena_web/controllers/image/comment_controller.ex +++ b/lib/philomena_web/controllers/image/comment_controller.ex @@ -1,153 +1,106 @@ defmodule PhilomenaWeb.Image.CommentController do use PhilomenaWeb, :controller - alias PhilomenaWeb.CommentLoader alias PhilomenaWeb.MarkdownRenderer - alias Philomena.{Images.Image, Comments.Comment} - alias Philomena.UserStatistics + alias PhilomenaWeb.RateLimitedResponse alias Philomena.Comments - alias Philomena.Images - plug PhilomenaWeb.LimitPlug, - [time: 15, error: "You may only create a comment once every 15 seconds."] - when action in [:create] - - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:create, :edit, :update] - plug PhilomenaWeb.UserAttributionPlug when action in [:create] - - plug PhilomenaWeb.CanaryMapPlug, - create: :create_comment, - edit: :create_comment, - update: :create_comment - - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:sources, tags: :aliases] - - plug :verify_authorized when action in [:show] - plug PhilomenaWeb.FilterForcedUsersPlug when action in [:create, :edit, :update] - - # Undo the previous private parameter screwery - plug PhilomenaWeb.LoadCommentPlug, [param: "id", show_hidden: true] when action in [:show] - plug PhilomenaWeb.LoadCommentPlug, [param: "id"] when action in [:edit, :update] - plug PhilomenaWeb.CanaryMapPlug, create: :create, edit: :edit, update: :edit - - plug :authorize_resource, - model: Comment, - only: [:edit, :update], - preload: [:image, user: [awards: :badge]] - - def index(conn, %{"comment_id" => comment_id}) do - page = CommentLoader.find_page(conn, conn.assigns.image, comment_id) - - redirect(conn, to: ~p"/images/#{conn.assigns.image}/comments?#{[page: page]}") + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"comment_id" => comment_id, "image_id" => image_id}) do + with {:ok, {image, page}} <- + Comments.list_comment_page( + conn.assigns.actor, + image_id, + comment_id, + conn.assigns.comment_scrivener + ) do + redirect(conn, to: ~p"/images/#{image}/comments?#{[page: page]}") + end end - def index(conn, _params) do - comments = CommentLoader.load_comments(conn, conn.assigns.image) + def index(conn, %{"image_id" => image_id}) do + with {:ok, image} <- Comments.load_image(conn.assigns.actor, image_id, :index) do + comments = + Comments.list_image_comments( + conn.assigns.actor, + image, + conn.assigns.comment_scrivener + ) - rendered = MarkdownRenderer.render_collection(comments.entries, conn) + rendered = MarkdownRenderer.render_collection(comments.entries, conn) - comments = %{comments | entries: Enum.zip(comments.entries, rendered)} + comments = %{comments | entries: Enum.zip(comments.entries, rendered)} - render(conn, "index.html", layout: false, image: conn.assigns.image, comments: comments) + render(conn, "index.html", layout: false, image: image, comments: comments) + end end - def show(conn, _params) do - rendered = MarkdownRenderer.render_one(conn.assigns.comment, conn) - - render(conn, "show.html", - layout: false, - image: conn.assigns.image, - comment: conn.assigns.comment, - body: rendered - ) + def show(conn, %{"id" => comment_id, "image_id" => image_id}) do + with {:ok, {image, comment}} <- + Comments.show_comment(conn.assigns.actor, image_id, comment_id) do + rendered = MarkdownRenderer.render_one(comment, conn) + + render(conn, "show.html", + layout: false, + image: image, + comment: comment, + body: rendered + ) + end end - def create(conn, %{"comment" => comment_params}) do - attributes = conn.assigns.attributes - image = conn.assigns.image - - case Comments.create_comment(image, attributes, comment_params) do - {:ok, %{comment: comment}} -> - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "comment:create", - PhilomenaWeb.Api.Json.CommentView.render("show.json", %{comment: comment}) - ) - - Comments.reindex_comment(comment) - Images.reindex_image(conn.assigns.image) + def create(conn, %{"comment" => comment_params, "image_id" => image_id}) do + case Comments.create_comment(conn.assigns.actor, image_id, comment_params) do + {:ok, comment} -> + index(conn, %{"comment_id" => comment.id, "image_id" => comment.image_id}) - if comment.approved do - UserStatistics.inc_stat(conn.assigns.current_user, :comments_count) - else - Comments.report_non_approved(comment) - end - - index(conn, %{"comment_id" => comment.id}) - - _error -> + {:error, {image, %Ecto.Changeset{}}} -> conn |> put_flash(:error, "There was an error posting your comment") |> redirect(to: ~p"/images/#{image}") - end - end - def edit(conn, _params) do - changeset = - conn.assigns.comment - |> Comments.change_comment() + {:error, :rate_limited} -> + RateLimitedResponse.call(conn, "You may only create a comment once every 15 seconds.") - render(conn, "edit.html", - title: "Editing Comment", - comment: conn.assigns.comment, - changeset: changeset - ) + error -> + error + end end - def update(conn, %{"comment" => comment_params}) do - case Comments.update_comment(conn.assigns.comment, conn.assigns.current_user, comment_params) do - {:ok, %{comment: comment}} -> - if not comment.approved do - Comments.report_non_approved(comment) - end - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "comment:update", - PhilomenaWeb.Api.Json.CommentView.render("show.json", %{comment: comment}) - ) - - Comments.reindex_comment(comment) - - conn - |> put_flash(:info, "Comment updated successfully.") - |> redirect(to: ~p"/images/#{conn.assigns.image}" <> "#comment_#{comment.id}") - - {:error, :comment, changeset, _changes} -> - render(conn, "edit.html", comment: conn.assigns.comment, changeset: changeset) + def edit(conn, %{"id" => comment_id, "image_id" => image_id}) do + with {:ok, form} <- + Comments.edit_comment(conn.assigns.actor, image_id, comment_id) do + render(conn, "edit.html", + title: "Editing Comment", + image: form.data.image, + comment: form.data, + changeset: form + ) end end - defp verify_authorized(conn, _params) do - image = conn.assigns.image - - image = - if is_nil(image.duplicate_id) do - image - else - Images.get_image!(image.duplicate_id) - end + def update(conn, %{"id" => comment_id, "comment" => comment_params, "image_id" => image_id}) do + case Comments.update_comment( + conn.assigns.actor, + image_id, + comment_id, + comment_params + ) do + {:ok, {image, comment}} -> + conn + |> put_flash(:info, "Comment updated successfully.") + |> redirect(to: ~p"/images/#{image}" <> "#comment_#{comment.id}") - conn = assign(conn, :image, image) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", + image: changeset.data.image, + comment: changeset.data, + changeset: changeset + ) - if Canada.Can.can?(conn.assigns.current_user, :show, image) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/image/comment_lock_controller.ex b/lib/philomena_web/controllers/image/comment_lock_controller.ex index 303f9f424..ed8ac4795 100644 --- a/lib/philomena_web/controllers/image/comment_lock_controller.ex +++ b/lib/philomena_web/controllers/image/comment_lock_controller.ex @@ -1,40 +1,25 @@ defmodule PhilomenaWeb.Image.CommentLockController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - {:ok, image} = Images.lock_comments(conn.assigns.image, true) - - conn - |> put_flash(:info, "Successfully locked comments.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + def create(conn, params) do + with {:ok, image} <- + Images.update_image_comment_lock(conn.assigns.actor, params["image_id"], true) do + conn + |> put_flash(:info, "Successfully locked comments.") + |> redirect(to: ~p"/images/#{image}") + end end - def delete(conn, _params) do - {:ok, image} = Images.lock_comments(conn.assigns.image, false) - - conn - |> put_flash(:info, "Successfully unlocked comments.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end - - defp log_details(action, image) do - body = - case action do - :create -> "Locked comments on image #{image.id}" - :delete -> "Unlocked comments on image #{image.id}" - end - - %{ - body: body, - subject_path: ~p"/images/#{image}" - } + def delete(conn, params) do + with {:ok, image} <- + Images.update_image_comment_lock(conn.assigns.actor, params["image_id"], false) do + conn + |> put_flash(:info, "Successfully unlocked comments.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/delete_controller.ex b/lib/philomena_web/controllers/image/delete_controller.ex index 43dbb6652..afbbb73bd 100644 --- a/lib/philomena_web/controllers/image/delete_controller.ex +++ b/lib/philomena_web/controllers/image/delete_controller.ex @@ -4,81 +4,58 @@ defmodule PhilomenaWeb.Image.DeleteController do # N.B.: this would be Image.Hide, because it hides the image, but that is # taken by the user action - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :hide, update: :hide, delete: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - plug :verify_deleted when action in [:update] + action_fallback PhilomenaWeb.FallbackController - def create(conn, %{"image" => image_params}) do - image = conn.assigns.image - user = conn.assigns.current_user - - case Images.hide_image(image, user, image_params) do - {:ok, result} -> + def create(conn, %{"image" => image_params} = params) do + case Images.create_image_hide(conn.assigns.actor, params["image_id"], image_params) do + {:ok, _image} -> conn |> put_flash(:info, "Image successfully deleted.") - |> moderation_log(details: &log_details/2, data: result.image) - |> redirect(to: ~p"/images/#{image}") + |> redirect(to: ~p"/images/#{params["image_id"]}") - _error -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Failed to delete image.") - |> redirect(to: ~p"/images/#{image}") + |> redirect(to: ~p"/images/#{params["image_id"]}") + + error -> + error end end - def update(conn, %{"image" => image_params}) do - image = conn.assigns.image - - case Images.update_hide_reason(image, image_params) do - {:ok, image} -> + def update(conn, %{"image" => image_params} = params) do + case Images.update_image_hide(conn.assigns.actor, params["image_id"], image_params) do + {:ok, _image} -> conn |> put_flash(:info, "Deletion reason updated.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + |> redirect(to: ~p"/images/#{params["image_id"]}") - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Couldn't update deletion reason.") - |> redirect(to: ~p"/images/#{image}") - end - end + |> redirect(to: ~p"/images/#{params["image_id"]}") - defp verify_deleted(conn, _opts) do - if conn.assigns.image.hidden_from_users do - conn - else - conn - |> put_flash(:error, "Cannot change deletion reason on a non-deleted image!") - |> redirect(to: ~p"/images/#{conn.assigns.image}") - |> halt() + error -> + error end end - def delete(conn, _params) do - image = conn.assigns.image - - {:ok, image} = Images.unhide_image(image) - - conn - |> put_flash(:info, "Image successfully restored.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end + def delete(conn, params) do + case Images.delete_image_hide(conn.assigns.actor, params["image_id"]) do + {:ok, _image} -> + conn + |> put_flash(:info, "Image successfully restored.") + |> redirect(to: ~p"/images/#{params["image_id"]}") - defp log_details(action, image) do - body = - case action do - :create -> "Deleted image #{image.id} (#{image.deletion_reason})" - :update -> "Changed deletion reason of #{image.id} (#{image.deletion_reason})" - :delete -> "Restored image #{image.id}" - end + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Failed to restore image.") + |> redirect(to: ~p"/images/#{params["image_id"]}") - %{ - body: body, - subject_path: ~p"/images/#{image}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/image/description_controller.ex b/lib/philomena_web/controllers/image/description_controller.ex index 903eb2839..fe632a1df 100644 --- a/lib/philomena_web/controllers/image/description_controller.ex +++ b/lib/philomena_web/controllers/image/description_controller.ex @@ -2,47 +2,29 @@ defmodule PhilomenaWeb.Image.DescriptionController do use PhilomenaWeb, :controller alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.CanaryMapPlug, update: :edit_description - - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:user, :sources, tags: :aliases] - - def update(conn, %{"image" => image_params}) do - image = conn.assigns.image - old_description = image.description - - case Images.update_description(image, image_params) do - {:ok, image} -> - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:description_update", - %{image_id: image.id, added: image.description, removed: old_description} - ) - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:update", - PhilomenaWeb.Api.Json.ImageView.render("show.json", %{image: image, interactions: []}) - ) - - Images.reindex_image(image) + action_fallback PhilomenaWeb.FallbackController + def update(conn, %{"image" => image_params} = params) do + case Images.update_image_description(conn.assigns.actor, params["image_id"], image_params) do + {:ok, {image, _old_description}} -> body = MarkdownRenderer.render_one(%{body: image.description}, conn) conn |> put_view(PhilomenaWeb.ImageView) - |> render("_description.html", layout: false, image: image, body: body) + |> render("_description.html", + layout: false, + image: image, + body: body, + changeset: Images.change_image(image) + ) - {:error, changeset} -> - conn - |> render("_form.html", layout: false, image: image, changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "_form.html", layout: false, image: changeset.data, changeset: changeset) + + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/image/description_lock_controller.ex b/lib/philomena_web/controllers/image/description_lock_controller.ex index 599d0aa20..0de661f3b 100644 --- a/lib/philomena_web/controllers/image/description_lock_controller.ex +++ b/lib/philomena_web/controllers/image/description_lock_controller.ex @@ -1,40 +1,25 @@ defmodule PhilomenaWeb.Image.DescriptionLockController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - {:ok, image} = Images.lock_description(conn.assigns.image, true) - - conn - |> put_flash(:info, "Successfully locked description.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + def create(conn, params) do + with {:ok, image} <- + Images.update_image_description_lock(conn.assigns.actor, params["image_id"], true) do + conn + |> put_flash(:info, "Successfully locked description.") + |> redirect(to: ~p"/images/#{image}") + end end - def delete(conn, _params) do - {:ok, image} = Images.lock_description(conn.assigns.image, false) - - conn - |> put_flash(:info, "Successfully unlocked description.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end - - defp log_details(action, image) do - body = - case action do - :create -> "Locked description editing on image #{image.id}" - :delete -> "Unlocked description editing on image #{image.id}" - end - - %{ - body: body, - subject_path: ~p"/images/#{image}" - } + def delete(conn, params) do + with {:ok, image} <- + Images.update_image_description_lock(conn.assigns.actor, params["image_id"], false) do + conn + |> put_flash(:info, "Successfully unlocked description.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/destroy_controller.ex b/lib/philomena_web/controllers/image/destroy_controller.ex index ec0642e42..28a9b0113 100644 --- a/lib/philomena_web/controllers/image/destroy_controller.ex +++ b/lib/philomena_web/controllers/image/destroy_controller.ex @@ -1,45 +1,24 @@ defmodule PhilomenaWeb.Image.DestroyController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :destroy - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - plug :verify_deleted when action in [:create] + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - image = conn.assigns.image - - case Images.destroy_image(image) do + def create(conn, %{"image_id" => image_id}) do + case Images.create_image_destroy(conn.assigns.actor, image_id) do {:ok, image} -> conn |> put_flash(:info, "Image contents destroyed.") - |> moderation_log(details: &log_details/2, data: image) |> redirect(to: ~p"/images/#{image}") - _error -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Failed to destroy image.") - |> redirect(to: ~p"/images/#{image}") - end - end + |> redirect(to: ~p"/images/#{image_id}") - defp verify_deleted(conn, _opts) do - if conn.assigns.image.hidden_from_users do - conn - else - conn - |> put_flash(:error, "Cannot destroy a non-deleted image!") - |> redirect(to: ~p"/images/#{conn.assigns.image}") - |> halt() + error -> + error end end - - defp log_details(_action, image) do - %{ - body: "Hard-deleted image #{image.id}", - subject_path: ~p"/images/#{image}" - } - end end diff --git a/lib/philomena_web/controllers/image/fave_controller.ex b/lib/philomena_web/controllers/image/fave_controller.ex index 2a546fac8..6729c7ca9 100644 --- a/lib/philomena_web/controllers/image/fave_controller.ex +++ b/lib/philomena_web/controllers/image/fave_controller.ex @@ -1,68 +1,20 @@ defmodule PhilomenaWeb.Image.FaveController do use PhilomenaWeb, :controller - alias Philomena.{Images, Images.Image} - alias Philomena.{ImageFaves, ImageVotes} - alias Philomena.Repo - alias Ecto.Multi + alias Philomena.Images.Image + alias Philomena.Images - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.CanaryMapPlug, create: :vote, delete: :vote + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:sources, tags: :aliases] - - plug PhilomenaWeb.FilterForcedUsersPlug - - def create(conn, _params) do - user = conn.assigns.current_user - image = conn.assigns.image - - Multi.append( - ImageFaves.delete_fave_transaction(image, user), - ImageFaves.create_fave_transaction(image, user) - ) - |> Multi.append(ImageVotes.delete_vote_transaction(image, user)) - |> Multi.append(ImageVotes.create_vote_transaction(image, user, true)) - |> Repo.transaction() - |> case do - {:ok, _result} -> - image = - Images.get_image!(image.id) - |> Images.reindex_image() - - conn - |> json(Image.interaction_data(image)) - - _error -> - conn - |> Plug.Conn.put_status(409) - |> json(%{}) + def create(conn, %{"image_id" => image_id}) do + with {:ok, image} <- Images.create_image_fave(conn.assigns.actor, image_id) do + json(conn, Image.interaction_data(image)) end end - def delete(conn, _params) do - user = conn.assigns.current_user - image = conn.assigns.image - - ImageFaves.delete_fave_transaction(image, user) - |> Repo.transaction() - |> case do - {:ok, _result} -> - image = - Images.get_image!(image.id) - |> Images.reindex_image() - - conn - |> json(Image.interaction_data(image)) - - _error -> - conn - |> Plug.Conn.put_status(409) - |> json(%{}) + def delete(conn, %{"image_id" => image_id}) do + with {:ok, image} <- Images.delete_image_fave(conn.assigns.actor, image_id) do + json(conn, Image.interaction_data(image)) end end end diff --git a/lib/philomena_web/controllers/image/favorite_controller.ex b/lib/philomena_web/controllers/image/favorite_controller.ex index f3e24df32..2037bc43d 100644 --- a/lib/philomena_web/controllers/image/favorite_controller.ex +++ b/lib/philomena_web/controllers/image/favorite_controller.ex @@ -1,30 +1,14 @@ defmodule PhilomenaWeb.Image.FavoriteController do use PhilomenaWeb, :controller - alias Philomena.Images.Image - alias Philomena.Repo + alias Philomena.Images - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [faves: :user] + action_fallback PhilomenaWeb.FallbackController - plug :load_votes_if_authorized - - def index(conn, _params) do - render(conn, "index.html", layout: false) - end - - defp load_votes_if_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :tamper, conn.assigns.image) do - image = Repo.preload(conn.assigns.image, upvotes: :user, downvotes: :user, hides: :user) - - conn - |> assign(:image, image) - |> assign(:has_votes, true) - else - assign(conn, :has_votes, false) + def index(conn, params) do + with {:ok, {image, has_votes}} <- + Images.list_image_faves(conn.assigns.actor, params["image_id"]) do + render(conn, "index.html", layout: false, image: image, has_votes: has_votes) end end end diff --git a/lib/philomena_web/controllers/image/feature_controller.ex b/lib/philomena_web/controllers/image/feature_controller.ex index 019ace0bb..323e0a809 100644 --- a/lib/philomena_web/controllers/image/feature_controller.ex +++ b/lib/philomena_web/controllers/image/feature_controller.ex @@ -1,40 +1,15 @@ defmodule PhilomenaWeb.Image.FeatureController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - plug :verify_not_deleted + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - user = conn.assigns.current_user - image = conn.assigns.image - - {:ok, _feature} = Images.feature_image(user, image) - - conn - |> put_flash(:info, "Image marked as featured image.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end - - defp verify_not_deleted(conn, _opts) do - if conn.assigns.image.hidden_from_users do - conn - |> put_flash(:error, "Cannot feature a deleted image.") - |> redirect(to: ~p"/images/#{conn.assigns.image}") - |> halt() - else + def create(conn, %{"image_id" => image_id}) do + with {:ok, _feature} <- Images.create_image_feature(conn.assigns.actor, image_id) do conn + |> put_flash(:info, "Image marked as featured image.") + |> redirect(to: ~p"/images/#{image_id}") end end - - defp log_details(_action, image) do - %{ - body: "Featured image #{image.id}", - subject_path: ~p"/images/#{image}" - } - end end diff --git a/lib/philomena_web/controllers/image/file_controller.ex b/lib/philomena_web/controllers/image/file_controller.ex index 646c2ec57..8e2f9a486 100644 --- a/lib/philomena_web/controllers/image/file_controller.ex +++ b/lib/philomena_web/controllers/image/file_controller.ex @@ -1,41 +1,28 @@ defmodule PhilomenaWeb.Image.FileController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, update: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - plug :verify_not_deleted + action_fallback PhilomenaWeb.FallbackController + plug PhilomenaWeb.ScraperPlug, params_name: "image", params_key: "image" - def update(conn, %{"image" => image_params}) do - case Images.update_file(conn.assigns.image, image_params) do + def update(conn, %{"image" => image_params} = params) do + upload = PhilomenaMedia.Upload.cast(image_params, "image") + + case Images.update_image_file(conn.assigns.actor, params["image_id"], upload) do {:ok, image} -> conn |> put_flash(:info, "Successfully updated file.") - |> moderation_log(details: &log_details/2, data: image) |> redirect(to: ~p"/images/#{image}") - _error -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Failed to update file!") - |> redirect(to: ~p"/images/#{conn.assigns.image}") - end - end + |> redirect(to: ~p"/images/#{params["image_id"]}") - defp verify_not_deleted(conn, _opts) do - if conn.assigns.image.hidden_from_users do - conn - |> put_flash(:error, "Cannot replace a deleted image.") - |> redirect(to: ~p"/images/#{conn.assigns.image}") - |> halt() - else - conn + {:error, _} = error -> + error end end - - defp log_details(_action, image) do - %{body: "Updated file of image #{image.id}", subject_path: ~p"/images/#{image}"} - end end diff --git a/lib/philomena_web/controllers/image/hash_controller.ex b/lib/philomena_web/controllers/image/hash_controller.ex index 1e8191ae4..f14ac40bc 100644 --- a/lib/philomena_web/controllers/image/hash_controller.ex +++ b/lib/philomena_web/controllers/image/hash_controller.ex @@ -1,25 +1,15 @@ defmodule PhilomenaWeb.Image.HashController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, delete: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def delete(conn, _params) do - {:ok, image} = Images.remove_hash(conn.assigns.image) - - conn - |> put_flash(:info, "Successfully cleared hash.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end - - defp log_details(_action, image) do - %{ - body: "Cleared hash of image #{image.id}", - subject_path: ~p"/images/#{image}" - } + def delete(conn, params) do + with {:ok, image} <- Images.delete_image_hash(conn.assigns.actor, params["image_id"]) do + conn + |> put_flash(:info, "Successfully cleared hash.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/hide_controller.ex b/lib/philomena_web/controllers/image/hide_controller.ex index 875ee1295..c3c74a648 100644 --- a/lib/philomena_web/controllers/image/hide_controller.ex +++ b/lib/philomena_web/controllers/image/hide_controller.ex @@ -1,59 +1,20 @@ defmodule PhilomenaWeb.Image.HideController do use PhilomenaWeb, :controller - alias Philomena.{Images, Images.Image} - alias Philomena.ImageHides - alias Philomena.Repo - alias Ecto.Multi + alias Philomena.Images.Image + alias Philomena.Images - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.CanaryMapPlug, create: :vote, delete: :vote - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - user = conn.assigns.current_user - image = conn.assigns.image - - Multi.append( - ImageHides.delete_hide_transaction(image, user), - ImageHides.create_hide_transaction(image, user) - ) - |> Repo.transaction() - |> case do - {:ok, _result} -> - image = - Images.get_image!(image.id) - |> Images.reindex_image() - - conn - |> json(Image.interaction_data(image)) - - _error -> - conn - |> Plug.Conn.put_status(409) - |> json(%{}) + def create(conn, params) do + with {:ok, image} <- Images.create_image_user_hide(conn.assigns.actor, params["image_id"]) do + json(conn, Image.interaction_data(image)) end end - def delete(conn, _params) do - user = conn.assigns.current_user - image = conn.assigns.image - - ImageHides.delete_hide_transaction(image, user) - |> Repo.transaction() - |> case do - {:ok, _result} -> - image = - Images.get_image!(image.id) - |> Images.reindex_image() - - conn - |> json(Image.interaction_data(image)) - - _error -> - conn - |> Plug.Conn.put_status(409) - |> json(%{}) + def delete(conn, params) do + with {:ok, image} <- Images.delete_image_user_hide(conn.assigns.actor, params["image_id"]) do + json(conn, Image.interaction_data(image)) end end end diff --git a/lib/philomena_web/controllers/image/navigate_controller.ex b/lib/philomena_web/controllers/image/navigate_controller.ex index 4f4f46135..20ea8fb1d 100644 --- a/lib/philomena_web/controllers/image/navigate_controller.ex +++ b/lib/philomena_web/controllers/image/navigate_controller.ex @@ -1,74 +1,40 @@ defmodule PhilomenaWeb.Image.NavigateController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias PhilomenaWeb.ImageNavigator alias PhilomenaWeb.ImageScope - alias PhilomenaQuery.Search - alias Philomena.Images.Image - alias Philomena.Images.Query + alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, index: :show - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def index(conn, %{"rel" => rel}) when rel in ~W(prev next) do - image = conn.assigns.image - filter = conn.assigns.compiled_filter + def index(conn, %{"rel" => rel} = params) when rel in ~W(prev next) do scope = ImageScope.scope(conn) - conn - |> ImageNavigator.find_consecutive(image, compile_query(conn), filter) - |> case do - {next_image, hit} -> - redirect(conn, - to: ~p"/images/#{next_image}?#{Keyword.put(scope, :sort, hit["sort"])}" - ) - - nil -> - redirect(conn, to: ~p"/images/#{image}?#{scope}") + with {:ok, {image, result}} <- + Images.list_image_navigation( + conn.assigns.actor, + ImageScope.search_scope(conn), + params["image_id"] + ) do + case result do + {next_image, hit} -> + redirect(conn, + to: ~p"/images/#{next_image}?#{Keyword.put(scope, :sort, hit["sort"])}" + ) + + nil -> + redirect(conn, to: ~p"/images/#{image}?#{scope}") + end end end - def index(conn, %{"rel" => "find"}) do - pagination = %{conn.assigns.image_pagination | page_number: 1} - - # Find does not use the current search scope - # (although it probably should). - body = %{range: %{id: %{gt: conn.assigns.image.id}}} - - {images, _tags} = ImageLoader.query(conn, body, pagination: pagination) - images = Search.search_records(images, Image) - - page_num = page_for_offset(pagination.page_size, images.total_entries) - - redirect(conn, to: ~p"/search?#{[q: "*", page: page_num, sf: "id"]}") - end - - defp page_for_offset(per_page, offset) do - offset - |> div(per_page) - |> Kernel.+(1) - |> to_string() - end - - defp compile_query(conn) do - user = conn.assigns.current_user - - {:ok, query} = - conn.params["q"] - |> match_all_if_blank() - |> Query.compile(user: user) - - query - end - - defp match_all_if_blank(nil), do: "*" - - defp match_all_if_blank(input) do - if String.trim(input) == "" do - "*" - else - input + def index(conn, %{"rel" => "find"} = params) do + with {:ok, page_num} <- + Images.list_image_index_page( + conn.assigns.actor, + ImageScope.search_scope(conn), + params["image_id"] + ) do + redirect(conn, to: ~p"/search?#{[q: "*", page: to_string(page_num), sf: "id"]}") end end end diff --git a/lib/philomena_web/controllers/image/random_controller.ex b/lib/philomena_web/controllers/image/random_controller.ex index e104ee403..0f0782f91 100644 --- a/lib/philomena_web/controllers/image/random_controller.ex +++ b/lib/philomena_web/controllers/image/random_controller.ex @@ -1,44 +1,21 @@ defmodule PhilomenaWeb.Image.RandomController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageSorter alias PhilomenaWeb.ImageScope - alias PhilomenaWeb.ImageLoader - alias PhilomenaQuery.Search - alias Philomena.Images.Image + alias Philomena.Images - def index(conn, params) do + def index(conn, _params) do scope = ImageScope.scope(conn) - search_definition = - ImageLoader.search_string( - conn, - query_string(params), - pagination: %{page_size: 1}, - sorts: &ImageSorter.parse_sort(%{"sf" => "random"}, &1) - ) - - case unwrap_random_result(search_definition) do - nil -> + case Images.list_random_images(conn.assigns.actor, ImageScope.search_scope(conn)) do + {:ok, nil} -> redirect(conn, to: ~p"/images") - random_id -> + {:ok, random_id} -> redirect(conn, to: ~p"/images/#{random_id}?#{scope}") - end - end - - defp query_string(%{"q" => query}), do: query - defp query_string(_params), do: "*" - defp unwrap_random_result({:ok, {definition, _tags}}) do - definition - |> Search.search_records(Image) - |> Enum.to_list() - |> unwrap() + {:error, _invalid_query} -> + redirect(conn, to: ~p"/images") + end end - - defp unwrap_random_result(_definition), do: nil - - defp unwrap([image]), do: image.id - defp unwrap([]), do: nil end diff --git a/lib/philomena_web/controllers/image/read_controller.ex b/lib/philomena_web/controllers/image/read_controller.ex index ff05b9974..4e626eeea 100644 --- a/lib/philomena_web/controllers/image/read_controller.ex +++ b/lib/philomena_web/controllers/image/read_controller.ex @@ -1,18 +1,13 @@ defmodule PhilomenaWeb.Image.ReadController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug :load_resource, model: Image, id_name: "image_id", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - image = conn.assigns.image - user = conn.assigns.current_user - - Images.clear_image_notification(image, user) - - send_resp(conn, :ok, "") + def create(conn, params) do + with {:ok, _image} <- Images.create_image_read(conn.assigns.actor, params["image_id"]) do + send_resp(conn, :ok, "") + end end end diff --git a/lib/philomena_web/controllers/image/related_controller.ex b/lib/philomena_web/controllers/image/related_controller.ex index 138d97a9d..ef3d7bb5d 100644 --- a/lib/philomena_web/controllers/image/related_controller.ex +++ b/lib/philomena_web/controllers/image/related_controller.ex @@ -1,74 +1,28 @@ defmodule PhilomenaWeb.Image.RelatedController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader + alias PhilomenaWeb.ImageScope + alias Philomena.Images alias Philomena.Interactions - alias Philomena.Images.Image - alias PhilomenaQuery.Search - import Ecto.Query - plug PhilomenaWeb.CanaryMapPlug, index: :show - - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:faves, :sources, tags: :aliases] - - def index(conn, _params) do - image = conn.assigns.image - user = conn.assigns.current_user - - tags_to_match = - image.tags - |> Enum.reject(&(&1.category == "rating")) - |> Enum.sort_by(& &1.images_count) - |> Enum.take(10) - |> Enum.map(& &1.id) - - low_count_tags = - tags_to_match - |> Enum.take(5) - |> Enum.map(&%{term: %{tag_ids: &1}}) - - high_count_tags = - tags_to_match - |> Enum.take(-5) - |> Enum.map(&%{term: %{tag_ids: &1}}) - - favs_to_match = - image.faves - |> Enum.take(11) - |> Enum.map(&%{term: %{favourited_by_user_ids: &1.user_id}}) - - query = %{ - bool: %{ - must: [ - %{bool: %{should: low_count_tags, boost: 2}}, - %{bool: %{should: high_count_tags, boost: 3, minimum_should_match: "5%"}}, - %{bool: %{should: favs_to_match, boost: 0.2, minimum_should_match: "5%"}} - ], - must_not: %{term: %{id: image.id}} - } - } - - {images, _tags} = - ImageLoader.query( - conn, - query, - sorts: &%{query: &1, sorts: [%{_score: :desc}]}, - pagination: %{conn.assigns.image_pagination | page_number: 1} + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + with {:ok, {image, images}} <- + Images.list_related_images( + conn.assigns.actor, + ImageScope.search_scope(conn), + params["image_id"] + ) do + interactions = Interactions.user_interactions(conn.assigns.actor, images) + + render(conn, "index.html", + title: "##{image.id} - Related Images", + layout_class: "layout--wide", + image: image, + images: images, + interactions: interactions ) - - images = Search.search_records(images, preload(Image, [:sources, tags: :aliases])) - - interactions = Interactions.user_interactions(images, user) - - render(conn, "index.html", - title: "##{image.id} - Related Images", - layout_class: "layout--wide", - images: images, - interactions: interactions - ) + end end end diff --git a/lib/philomena_web/controllers/image/repair_controller.ex b/lib/philomena_web/controllers/image/repair_controller.ex index 9dfb0c4ec..a2c4e74a7 100644 --- a/lib/philomena_web/controllers/image/repair_controller.ex +++ b/lib/philomena_web/controllers/image/repair_controller.ex @@ -1,26 +1,15 @@ defmodule PhilomenaWeb.Image.RepairController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - Images.repair_image(conn.assigns.image) - Images.purge_files(conn.assigns.image, conn.assigns.image.hidden_image_key) - - conn - |> put_flash(:info, "Repair job enqueued.") - |> moderation_log(details: &log_details/2, data: conn.assigns.image) - |> redirect(to: ~p"/images/#{conn.assigns.image}") - end - - defp log_details(_action, image) do - %{ - body: "Repaired image #{image.id}", - subject_path: ~p"/images/#{image}" - } + def create(conn, params) do + with {:ok, image} <- Images.create_image_repair(conn.assigns.actor, params["image_id"]) do + conn + |> put_flash(:info, "Repair job enqueued.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/report_controller.ex b/lib/philomena_web/controllers/image/report_controller.ex index 0071ce22b..5197d94c0 100644 --- a/lib/philomena_web/controllers/image/report_controller.ex +++ b/lib/philomena_web/controllers/image/report_controller.ex @@ -3,44 +3,36 @@ defmodule PhilomenaWeb.Image.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Images.Image - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:sources, tags: :aliases] - - def new(conn, _params) do - image = conn.assigns.image - action = ~p"/images/#{image}/reports" - - changeset = - %Report{image_id: image.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", - title: "Reporting Image", - subject: image, - changeset: changeset, - action: action - ) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"image_id" => image_id}) do + with {:ok, form} <- Reports.new_report(conn.assigns.actor, {:image, image_id}) do + image = form.target + action = ~p"/images/#{image}/reports" + + conn + |> put_view(ReportView) + |> render("new.html", + title: "Reporting Image", + subject: image, + changeset: form.changeset, + rules: form.rules, + action: action + ) + end end - def create(conn, params) do - image = conn.assigns.image - action = ~p"/images/#{image}/reports" - - ReportController.create(conn, action, image, [image_id: image.id], params) + def create(conn, %{"image_id" => image_id} = params) do + ReportController.create( + conn, + {:image, image_id}, + fn image -> ~p"/images/#{image}/reports" end, + params + ) end end diff --git a/lib/philomena_web/controllers/image/reporting_controller.ex b/lib/philomena_web/controllers/image/reporting_controller.ex index e03191c09..73a8573ee 100644 --- a/lib/philomena_web/controllers/image/reporting_controller.ex +++ b/lib/philomena_web/controllers/image/reporting_controller.ex @@ -1,41 +1,19 @@ defmodule PhilomenaWeb.Image.ReportingController do use PhilomenaWeb, :controller - alias Philomena.Images.Image - alias Philomena.DuplicateReports.DuplicateReport alias Philomena.DuplicateReports - alias Philomena.Repo - import Ecto.Query - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:sources, tags: :aliases] + action_fallback PhilomenaWeb.FallbackController - def show(conn, _params) do - image = conn.assigns.image - - dupe_reports = - DuplicateReport - |> preload([ - :user, - :modifier, - image: [:user, :sources, tags: :aliases], - duplicate_of_image: [:user, :sources, tags: :aliases] - ]) - |> where([d], d.image_id == ^image.id or d.duplicate_of_image_id == ^image.id) - |> Repo.all() - - changeset = - %DuplicateReport{} - |> DuplicateReports.change_duplicate_report() - - render(conn, "show.html", - layout: false, - image: image, - dupe_reports: dupe_reports, - changeset: changeset - ) + def show(conn, params) do + with {:ok, {image, dupe_reports, changeset}} <- + DuplicateReports.new_duplicate_report(conn.assigns.actor, params["image_id"]) do + render(conn, "show.html", + layout: false, + image: image, + dupe_reports: dupe_reports, + changeset: changeset + ) + end end end diff --git a/lib/philomena_web/controllers/image/scratchpad_controller.ex b/lib/philomena_web/controllers/image/scratchpad_controller.ex index 3a00b645d..752e6a591 100644 --- a/lib/philomena_web/controllers/image/scratchpad_controller.ex +++ b/lib/philomena_web/controllers/image/scratchpad_controller.ex @@ -1,30 +1,29 @@ defmodule PhilomenaWeb.Image.ScratchpadController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, edit: :hide, update: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def edit(conn, _params) do - changeset = Images.change_image(conn.assigns.image) - render(conn, "edit.html", title: "Editing Moderation Notes", changeset: changeset) - end - - def update(conn, %{"image" => image_params}) do - {:ok, image} = Images.update_scratchpad(conn.assigns.image, image_params) + def edit(conn, params) do + with {:ok, image} <- + Images.load_hidable_image(conn.assigns.actor, params["image_id"]) do + changeset = Images.change_image(image) - conn - |> put_flash(:info, "Successfully updated moderation notes.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + render(conn, "edit.html", + title: "Editing Moderation Notes", + image: image, + changeset: changeset + ) + end end - defp log_details(_action, image) do - %{ - body: "Updated mod notes on image #{image.id} (#{image.scratchpad})", - subject_path: ~p"/images/#{image}" - } + def update(conn, %{"image" => image_params} = params) do + with {:ok, image} <- + Images.update_image_scratchpad(conn.assigns.actor, params["image_id"], image_params) do + conn + |> put_flash(:info, "Successfully updated moderation notes.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/source_change_controller.ex b/lib/philomena_web/controllers/image/source_change_controller.ex index 9d3531e9c..3af77f90e 100644 --- a/lib/philomena_web/controllers/image/source_change_controller.ex +++ b/lib/philomena_web/controllers/image/source_change_controller.ex @@ -1,28 +1,33 @@ defmodule PhilomenaWeb.Image.SourceChangeController do use PhilomenaWeb, :controller - alias Philomena.Images.Image - alias Philomena.SourceChanges.SourceChange - alias Philomena.Repo - import Ecto.Query + alias Philomena.SourceChanges + alias Philomena.SourceChanges.SourceChangePage - plug PhilomenaWeb.CanaryMapPlug, index: :show - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def index(conn, _params) do - image = conn.assigns.image + def index(conn, %{"image_id" => image_id} = params) do + case SourceChanges.list_image_source_changes( + conn.assigns.actor, + image_id, + params, + conn.assigns.scrivener + ) do + {:ok, %SourceChangePage{target: image, source_changes: source_changes}, changeset} -> + render(conn, "index.html", + title: "Source Changes on Image #{image.id}", + image: image, + source_changes: source_changes, + changeset: changeset + ) - source_changes = - SourceChange - |> where(image_id: ^image.id) - |> preload([:user, image: [:user, :sources, tags: :aliases]]) - |> order_by(desc: :id) - |> Repo.paginate(conn.assigns.scrivener) + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid source change filter.") + |> redirect(to: "/") - render(conn, "index.html", - title: "Source Changes on Image #{image.id}", - image: image, - source_changes: source_changes - ) + error -> + error + end end end diff --git a/lib/philomena_web/controllers/image/source_controller.ex b/lib/philomena_web/controllers/image/source_controller.ex index f24fd8716..b0c641433 100644 --- a/lib/philomena_web/controllers/image/source_controller.ex +++ b/lib/philomena_web/controllers/image/source_controller.ex @@ -1,84 +1,49 @@ defmodule PhilomenaWeb.Image.SourceController do use PhilomenaWeb, :controller - alias Philomena.SourceChanges.SourceChange - alias Philomena.UserStatistics - alias Philomena.Images.Image alias Philomena.Images.Source alias Philomena.Images - alias Philomena.Repo - import Ecto.Query + alias PhilomenaWeb.RateLimitedResponse - plug PhilomenaWeb.LimitPlug, - [time: 5, error: "You may only update metadata once every 5 seconds."] - when action in [:update] + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.FilterBannedUsersPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug - plug PhilomenaWeb.UserAttributionPlug - plug PhilomenaWeb.CanaryMapPlug, update: :edit_metadata - - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - preload: [:user, :sources, tags: :aliases] - - def update(conn, %{"image" => image_params}) do - attributes = conn.assigns.attributes - image = conn.assigns.image - - case Images.update_sources(image, attributes, image_params) do - {:ok, %{image: {image, added_sources, removed_sources}}} -> - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:source_update", - %{image_id: image.id, added: [added_sources], removed: [removed_sources]} - ) - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:update", - PhilomenaWeb.Api.Json.ImageView.render("show.json", %{image: image, interactions: []}) - ) + def update(conn, %{"image" => image_params} = params) do + case Images.update_image_sources(conn.assigns.actor, params["image_id"], image_params) do + {:ok, %{image: image, source_change_count: count}} -> changeset = %{image | sources: sources_for_edit(image.sources)} |> Images.change_image() - source_change_count = - SourceChange - |> where(image_id: ^image.id) - |> Repo.aggregate(:count, :id) - - if Enum.any?(added_sources) or Enum.any?(removed_sources) do - UserStatistics.inc_stat(conn.assigns.current_user, :metadata_updates_count) - end - - Images.reindex_image(image) - conn |> put_view(PhilomenaWeb.ImageView) |> render("_source.html", layout: false, - source_change_count: source_change_count, + source_change_count: count, image: image, changeset: changeset ) - {:error, :image, changeset, _} -> + {:error, %Ecto.Changeset{} = changeset} -> conn |> put_view(PhilomenaWeb.ImageView) |> render("_source.html", layout: false, source_change_count: 0, - image: image, + image: changeset.data, changeset: changeset ) + + {:error, :rate_limited} -> + RateLimitedResponse.call(conn, "You may only update metadata once every 5 seconds.") + + {:error, _} = error -> + error end end - # TODO: this is duplicated in ImageController defp sources_for_edit(), do: [%Source{}] defp sources_for_edit([]), do: sources_for_edit() defp sources_for_edit(sources), do: sources diff --git a/lib/philomena_web/controllers/image/source_history_controller.ex b/lib/philomena_web/controllers/image/source_history_controller.ex index 58964080a..73d8f2053 100644 --- a/lib/philomena_web/controllers/image/source_history_controller.ex +++ b/lib/philomena_web/controllers/image/source_history_controller.ex @@ -1,27 +1,16 @@ defmodule PhilomenaWeb.Image.SourceHistoryController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, delete: :hide - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def delete(conn, _params) do - {:ok, image} = Images.remove_source_history(conn.assigns.image) - - Images.reindex_image(image) - - conn - |> put_flash(:info, "Successfully deleted source history.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") - end - - defp log_details(_action, image) do - %{ - body: "Deleted source history for image #{image.id}", - subject_path: ~p"/images/#{image}" - } + def delete(conn, params) do + with {:ok, image} <- + Images.delete_image_source_history(conn.assigns.actor, params["image_id"]) do + conn + |> put_flash(:info, "Successfully deleted source history.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/subscription_controller.ex b/lib/philomena_web/controllers/image/subscription_controller.ex index 192bb29ac..8ab52daa0 100644 --- a/lib/philomena_web/controllers/image/subscription_controller.ex +++ b/lib/philomena_web/controllers/image/subscription_controller.ex @@ -1,31 +1,26 @@ defmodule PhilomenaWeb.Image.SubscriptionController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - image = conn.assigns.image - user = conn.assigns.current_user - - case Images.create_subscription(image, user) do - {:ok, _subscription} -> + def create(conn, params) do + case Images.create_image_subscription(conn.assigns.actor, params["image_id"]) do + {:ok, image} -> render(conn, "_subscription.html", image: image, watching: true, layout: false) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> render(conn, "_error.html", layout: false) + + {:error, _} = error -> + error end end - def delete(conn, _params) do - image = conn.assigns.image - user = conn.assigns.current_user - - {:ok, _subscription} = Images.delete_subscription(image, user) - - render(conn, "_subscription.html", image: image, watching: false, layout: false) + def delete(conn, params) do + with {:ok, image} <- Images.delete_image_subscription(conn.assigns.actor, params["image_id"]) do + render(conn, "_subscription.html", image: image, watching: false, layout: false) + end end end diff --git a/lib/philomena_web/controllers/image/tag_change_controller.ex b/lib/philomena_web/controllers/image/tag_change_controller.ex new file mode 100644 index 000000000..941d4c426 --- /dev/null +++ b/lib/philomena_web/controllers/image/tag_change_controller.ex @@ -0,0 +1,38 @@ +defmodule PhilomenaWeb.Image.TagChangeController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChangePage + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"image_id" => image_id} = params) do + case TagChanges.list_image_tag_changes( + conn.assigns.actor, + image_id, + params, + conn.assigns.pagination + ) do + {:ok, %TagChangePage{target: image, tag_changes: tag_changes}, changeset} -> + path = ~p"/images/#{image}/tag_changes" + + conn + |> put_view(PhilomenaWeb.TagChangeView) + |> render("index.html", + title: "Tag Changes for Image ##{image.id}", + path: path, + pagination_route: fn query -> "#{path}?#{Plug.Conn.Query.encode(query)}" end, + tag_changes: tag_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid tag change query.") + |> redirect(to: "/tag_changes") + + error -> + error + end + end +end diff --git a/lib/philomena_web/controllers/image/tag_controller.ex b/lib/philomena_web/controllers/image/tag_controller.ex index ca3c25b0f..cccbce259 100644 --- a/lib/philomena_web/controllers/image/tag_controller.ex +++ b/lib/philomena_web/controllers/image/tag_controller.ex @@ -1,67 +1,22 @@ defmodule PhilomenaWeb.Image.TagController do use PhilomenaWeb, :controller - alias Philomena.TagChanges - alias Philomena.UserStatistics - alias Philomena.Comments - alias Philomena.Images.Image alias Philomena.Images - alias Philomena.Tags - alias Philomena.Repo - alias Plug.Conn + alias PhilomenaWeb.RateLimitedResponse - plug PhilomenaWeb.LimitPlug, - [time: 5, error: "You may only update metadata once every 5 seconds."] - when action in [:update] + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.FilterBannedUsersPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug - plug PhilomenaWeb.UserAttributionPlug - plug PhilomenaWeb.CanaryMapPlug, update: :edit_metadata - - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - preload: [:user, :locked_tags, :sources, tags: :aliases] - - def update(conn, %{"image" => image_params}) do - attributes = conn.assigns.attributes - image = conn.assigns.image - - case Images.update_tags(image, attributes, image_params) do - {:ok, %{image: {image, added_tags, removed_tags}}} -> - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:tag_update", - %{ - image_id: image.id, - added: Enum.map(added_tags, & &1.name), - removed: Enum.map(removed_tags, & &1.name) - } - ) - - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:update", - PhilomenaWeb.Api.Json.ImageView.render("show.json", %{image: image, interactions: []}) - ) - - Comments.reindex_comments_on_image(image) - Images.reindex_image(image) - Tags.reindex_tags(added_tags ++ removed_tags) - - if Enum.any?(added_tags ++ removed_tags) do - UserStatistics.inc_stat(conn.assigns.current_user, :metadata_updates_count) - end - - {tag_change_count, tag_change_tag_count} = - TagChanges.count_tag_changes(:image_id, image.id) - - image = - image - |> Repo.preload([:sources, tags: :aliases], force: true) + def update(conn, %{"image" => image_params} = params) do + case Images.update_image_tags(conn.assigns.actor, params["image_id"], image_params) do + {:ok, + %{ + image: image, + tag_change_count: tag_change_count, + tag_change_tag_count: tag_change_tag_count + }} -> changeset = Images.change_image(image) conn @@ -74,43 +29,25 @@ defmodule PhilomenaWeb.Image.TagController do changeset: changeset ) - {:error, :image, changeset, _} -> - image = - image - |> Repo.preload([:sources, tags: :aliases], force: true) - + {:error, %Ecto.Changeset{} = changeset} -> conn |> put_view(PhilomenaWeb.ImageView) |> render("_tags.html", layout: false, tag_change_count: 0, tag_change_tag_count: 0, - image: image, + image: changeset.data, changeset: changeset ) - {:error, :check_limits, _error, _} -> - error_response(conn, "Too many tags changed. Change fewer tags or try again later.") - - _err -> - error_response(conn, "Failed to update tags!") - end - end - - # Matches the behavior of PhilomenaWeb.LimitPlug: AJAX requests get an - # empty 300 response (ujs.ts reloads the page so the flash renders), - # everything else is redirected back to the referrer. - defp error_response(conn, message) do - conn = put_flash(conn, :error, message) + {:error, :rate_limited} -> + RateLimitedResponse.call( + conn, + "Too many tags changed. Change fewer tags or try again later." + ) - if conn.assigns.ajax? do - conn - |> Conn.send_resp(:multiple_choices, "") - |> Conn.halt() - else - conn - |> redirect(external: conn.assigns.referrer) - |> Conn.halt() + error -> + error end end end diff --git a/lib/philomena_web/controllers/image/tag_lock_controller.ex b/lib/philomena_web/controllers/image/tag_lock_controller.ex index 01846a2fa..9fccbedd0 100644 --- a/lib/philomena_web/controllers/image/tag_lock_controller.ex +++ b/lib/philomena_web/controllers/image/tag_lock_controller.ex @@ -1,61 +1,44 @@ defmodule PhilomenaWeb.Image.TagLockController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - plug PhilomenaWeb.CanaryMapPlug, show: :hide, update: :hide, create: :hide, delete: :hide + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:locked_tags] - - def show(conn, _params) do - changeset = Images.change_image(conn.assigns.image) - - render(conn, "show.html", title: "Locking image tags", changeset: changeset) - end - - def update(conn, %{"image" => image_attrs}) do - {:ok, image} = Images.update_locked_tags(conn.assigns.image, image_attrs) - - conn - |> put_flash(:info, "Successfully updated list of locked tags.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + def show(conn, params) do + with {:ok, image} <- + Images.load_hidable_image(conn.assigns.actor, params["image_id"], + preload: :locked_tags + ) do + changeset = Images.change_image(image) + render(conn, "show.html", title: "Locking image tags", image: image, changeset: changeset) + end end - def create(conn, _params) do - {:ok, image} = Images.lock_tags(conn.assigns.image, true) - - conn - |> put_flash(:info, "Successfully locked tags.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + def update(conn, %{"image" => image_attrs} = params) do + with {:ok, image} <- + Images.update_image_locked_tags(conn.assigns.actor, params["image_id"], image_attrs) do + conn + |> put_flash(:info, "Successfully updated list of locked tags.") + |> redirect(to: ~p"/images/#{image}") + end end - def delete(conn, _params) do - {:ok, image} = Images.lock_tags(conn.assigns.image, false) - - conn - |> put_flash(:info, "Successfully unlocked tags.") - |> moderation_log(details: &log_details/2, data: image) - |> redirect(to: ~p"/images/#{image}") + def create(conn, params) do + with {:ok, image} <- + Images.update_image_tag_lock(conn.assigns.actor, params["image_id"], true) do + conn + |> put_flash(:info, "Successfully locked tags.") + |> redirect(to: ~p"/images/#{image}") + end end - defp log_details(action, image) do - body = - case action do - :create -> "Locked tags on image #{image.id}" - :update -> "Updated list of locked tags on image #{image.id}" - :delete -> "Unlocked tags on image #{image.id}" - end - - %{ - body: body, - subject_path: ~p"/images/#{image}" - } + def delete(conn, params) do + with {:ok, image} <- + Images.update_image_tag_lock(conn.assigns.actor, params["image_id"], false) do + conn + |> put_flash(:info, "Successfully unlocked tags.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/tamper_controller.ex b/lib/philomena_web/controllers/image/tamper_controller.ex index 120cadc17..e4b323394 100644 --- a/lib/philomena_web/controllers/image/tamper_controller.ex +++ b/lib/philomena_web/controllers/image/tamper_controller.ex @@ -1,49 +1,20 @@ defmodule PhilomenaWeb.Image.TamperController do use PhilomenaWeb, :controller - alias Philomena.Users.User - alias Philomena.Images.Image alias Philomena.Images - alias Philomena.ImageVotes - alias Philomena.Repo - - plug PhilomenaWeb.CanaryMapPlug, create: :tamper - plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - plug :load_resource, model: User, id_name: "user_id", required: true - - def create(conn, _params) do - image = conn.assigns.image - user = conn.assigns.user - - {:ok, result} = - ImageVotes.delete_vote_transaction(image, user) - |> Repo.transaction() - - Images.reindex_image(image) - - conn - |> put_flash(:info, "Vote removed.") - |> moderation_log( - details: &log_details/2, - data: %{vote: result, image: image, username: conn.assigns.user.name} - ) - |> redirect(to: ~p"/images/#{conn.assigns.image}") - end - - defp log_details(_action, data) do - image = data.image - - vote_type = - case data.vote do - %{undownvote: {1, _}} -> "downvote" - %{unupvote: {1, _}} -> "upvote" - _ -> "vote" - end - - %{ - body: "Deleted #{vote_type} by #{data.username} on image #{data.image.id}", - subject_path: ~p"/images/#{image}" - } + action_fallback PhilomenaWeb.FallbackController + + def create(conn, params) do + with {:ok, image} <- + Images.delete_user_vote( + conn.assigns.actor, + params["image_id"], + params["user_id"] + ) do + conn + |> put_flash(:info, "Vote removed.") + |> redirect(to: ~p"/images/#{image}") + end end end diff --git a/lib/philomena_web/controllers/image/uploader_controller.ex b/lib/philomena_web/controllers/image/uploader_controller.ex index 7d3c5f193..1981bd6ac 100644 --- a/lib/philomena_web/controllers/image/uploader_controller.ex +++ b/lib/philomena_web/controllers/image/uploader_controller.ex @@ -1,33 +1,27 @@ defmodule PhilomenaWeb.Image.UploaderController do use PhilomenaWeb, :controller - alias Philomena.Images.Image alias Philomena.Images - alias Philomena.Repo - plug :verify_authorized - plug :load_resource, model: Image, id_name: "image_id", required: true + action_fallback PhilomenaWeb.FallbackController - def update(conn, %{"image" => image_params}) when is_map(image_params) do - case Images.update_uploader(conn.assigns.image, image_params) do + def update(conn, params) do + case Images.update_image_uploader(conn.assigns.actor, params["image_id"], params["image"]) do {:ok, image} -> - Images.reindex_image(image) - - image = Repo.preload(image, user: [awards: :badge]) changeset = Images.change_image(image) conn |> put_view(PhilomenaWeb.ImageView) - |> moderation_log(details: &log_details/2, data: image) |> render("_uploader.html", layout: false, image: image, changeset: changeset) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> update_failed(conn) + + error -> + error end end - def update(conn, _params), do: update_failed(conn) - # The form is submitted over AJAX; a 300 makes `ujs.ts` reload the page so the # flash renders. defp update_failed(conn) do @@ -35,16 +29,4 @@ defmodule PhilomenaWeb.Image.UploaderController do |> put_flash(:error, "Failed to update uploader!") |> send_resp(:multiple_choices, "") end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :show, :ip_address) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end - - defp log_details(_action, image) do - %{body: "Changed uploader of image #{image.id}", subject_path: ~p"/images/#{image}"} - end end diff --git a/lib/philomena_web/controllers/image/vote_controller.ex b/lib/philomena_web/controllers/image/vote_controller.ex index 6074d1130..7b428d7aa 100644 --- a/lib/philomena_web/controllers/image/vote_controller.ex +++ b/lib/philomena_web/controllers/image/vote_controller.ex @@ -1,78 +1,31 @@ defmodule PhilomenaWeb.Image.VoteController do use PhilomenaWeb, :controller - alias Philomena.{Images, Images.Image} - alias Philomena.ImageVotes - alias Philomena.Repo - alias Ecto.Multi + alias Philomena.Images.Image + alias Philomena.Images + alias PhilomenaWeb.Api.Json.ImageView - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.CanaryMapPlug, create: :vote, delete: :vote + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Image, - id_name: "image_id", - persisted: true, - preload: [:sources, tags: :aliases] + def create(conn, %{"image_id" => image_id} = params) do + case Images.create_image_vote(conn.assigns.actor, image_id, params) do + {:ok, image} -> + json(conn, Image.interaction_data(image)) - plug PhilomenaWeb.FilterForcedUsersPlug - - def create(conn, params) do - user = conn.assigns.current_user - image = conn.assigns.image - - case parse_up(params["up"]) do - {:ok, up} -> - Multi.append( - ImageVotes.delete_vote_transaction(image, user), - ImageVotes.create_vote_transaction(image, user, up) - ) - |> Repo.transaction() - |> case do - {:ok, _result} -> - image = - Images.get_image!(image.id) - |> Images.reindex_image() - - conn - |> json(Image.interaction_data(image)) - - _error -> - conn - |> Plug.Conn.put_status(409) - |> json(%{}) - end - - :error -> + {:error, %Ecto.Changeset{} = changeset} -> conn - |> Plug.Conn.put_status(400) - |> json(%{}) + |> put_status(400) + |> put_view(ImageView) + |> render("error.json", changeset: changeset) + + error -> + error end end - def delete(conn, _params) do - user = conn.assigns.current_user - image = conn.assigns.image - - ImageVotes.delete_vote_transaction(image, user) - |> Repo.transaction() - |> case do - {:ok, _result} -> - image = - Images.get_image!(image.id) - |> Images.reindex_image() - - conn - |> json(Image.interaction_data(image)) - - _error -> - conn - |> Plug.Conn.put_status(409) - |> json(%{}) + def delete(conn, %{"image_id" => image_id}) do + with {:ok, image} <- Images.delete_image_vote(conn.assigns.actor, image_id) do + json(conn, Image.interaction_data(image)) end end - - defp parse_up(up) when up in [true, "true"], do: {:ok, true} - defp parse_up(up) when up in [false, "false"], do: {:ok, false} - defp parse_up(_up), do: :error end diff --git a/lib/philomena_web/controllers/image_controller.ex b/lib/philomena_web/controllers/image_controller.ex index da4fdfefe..ea01ff9c8 100644 --- a/lib/philomena_web/controllers/image_controller.ex +++ b/lib/philomena_web/controllers/image_controller.ex @@ -1,36 +1,17 @@ defmodule PhilomenaWeb.ImageController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias PhilomenaWeb.CommentLoader + alias PhilomenaWeb.ImageScope alias PhilomenaWeb.NotificationCountPlug alias PhilomenaWeb.MarkdownRenderer - alias PhilomenaWeb.ImageScope - - alias Philomena.{ - Images, - Images.Image, - Images.Source, - Comments.Comment, - Galleries.Gallery, - TagChanges.TagChange, - SourceChanges.SourceChange - } - - alias PhilomenaQuery.Search + alias PhilomenaWeb.RateLimitedResponse + alias Philomena.Images alias Philomena.Interactions - alias Philomena.Comments - alias Philomena.Repo - import Ecto.Query - plug PhilomenaWeb.LimitPlug, - [time: 5, error: "You may only upload images once every 5 seconds."] - when action in [:create] + action_fallback PhilomenaWeb.FallbackController plug :load_image when action in [:show] - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] - plug PhilomenaWeb.UserAttributionPlug when action in [:create] plug PhilomenaWeb.CaptchaPlug when action in [:new, :show, :create] plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] @@ -40,11 +21,9 @@ defmodule PhilomenaWeb.ImageController do plug PhilomenaWeb.AdvertPlug when action in [:show] def index(conn, _params) do - {images, _tags} = ImageLoader.default_query(conn) + images = Images.list_images(conn.assigns.actor, ImageScope.search_scope(conn)) - images = Search.search_records(images, preload(Image, [:sources, tags: :aliases])) - - interactions = Interactions.user_interactions(images, conn.assigns.current_user) + interactions = Interactions.user_interactions(conn.assigns.actor, images) render(conn, "index.html", title: "Images", @@ -57,48 +36,41 @@ defmodule PhilomenaWeb.ImageController do def show(conn, %{"id" => _id}) do image = conn.assigns.image - user = conn.assigns.current_user - Images.clear_image_notification(image, user) + page = + Images.show_image_page( + conn.assigns.actor, + image, + conn.assigns.comment_scrivener + ) - # Update the notification ticker in the header + # The page load clears the image notification, so the header ticker must + # be re-read afterwards. conn = NotificationCountPlug.call(conn) - conn = maybe_skip_to_last_comment_page(conn, image, user) - - comments = CommentLoader.load_comments(conn, image) - - rendered = MarkdownRenderer.render_collection(comments.entries, conn) - - comments = %{comments | entries: Enum.zip(comments.entries, rendered)} - - description = - %{body: image.description} - |> MarkdownRenderer.render_one(conn) + rendered = MarkdownRenderer.render_collection(page.comments.entries, conn) + comments = %{page.comments | entries: Enum.zip(page.comments.entries, rendered)} - interactions = Interactions.user_interactions([image], conn.assigns.current_user) - - comment_changeset = - %Comment{} - |> Comments.change_comment() - - image_changeset = - %{image | sources: sources_for_edit(image.sources)} - |> Images.change_image() - - watching = Images.subscribed?(image, conn.assigns.current_user) - - user_galleries = user_galleries(image, conn.assigns.current_user) + description = MarkdownRenderer.render_one(%{body: image.description}, conn) assigns = [ image: image, comments: comments, - image_changeset: image_changeset, - comment_changeset: comment_changeset, - user_galleries: user_galleries, + comment_changeset: page.comment_changeset, + description_changeset: page.description_changeset, + tag_changeset: page.tag_changeset, + source_changeset: page.source_changeset, + file_changeset: page.file_changeset, + hide_changeset: page.hide_changeset, + feature_changeset: page.feature_changeset, + repair_changeset: page.repair_changeset, + hash_changeset: page.hash_changeset, + uploader_changeset: page.uploader_changeset, + user_galleries: page.user_galleries, description: description, - interactions: interactions, - watching: watching, + interactions: page.interactions, + watching: page.watching, + can_interact: page.can_interact, layout_class: "layout--wide", title: "##{image.id} - #{Images.tag_list(image)}" ] @@ -111,140 +83,53 @@ defmodule PhilomenaWeb.ImageController do end def new(conn, _params) do - changeset = - %Image{sources: sources_for_edit()} - |> Images.change_image() - - render(conn, "new.html", title: "New Image", changeset: changeset) + with {:ok, changeset} <- Images.new_image(conn.assigns.actor) do + render(conn, "new.html", title: "New Image", changeset: changeset) + end end - def create(conn, %{"image" => image_params}) do - attributes = conn.assigns.attributes + def create(conn, params) do + upload = PhilomenaMedia.Upload.cast(params["image"], "image") - case Images.create_image(attributes, image_params) do + case Images.create_image(conn.assigns.actor, params["image"], upload) do {:ok, %{image: image}} -> - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "image:create", - PhilomenaWeb.Api.Json.ImageView.render("show.json", %{image: image, interactions: []}) - ) - conn |> put_flash(:info, "Image created successfully.") |> redirect(to: ~p"/images/#{image}") - {:error, :image, changeset, _} -> - conn - |> render("new.html", changeset: changeset) - end - end - - defp maybe_skip_to_last_comment_page(conn, image, %{ - settings: %{ - comments_newest_first: false, - comments_always_jump_to_last: true - } - }) do - page = CommentLoader.last_page(conn, image) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "new.html", changeset: changeset) - conn - |> assign(:comment_scrivener, Keyword.merge(conn.assigns.comment_scrivener, page: page)) - end - - defp maybe_skip_to_last_comment_page(conn, _image, _user), do: conn - - defp user_galleries(_image, nil), do: [] - - defp user_galleries(image, user) do - Gallery - |> where(user_id: ^user.id) - |> join( - :inner_lateral, - [g], - _ in fragment( - "SELECT EXISTS(SELECT 1 FROM gallery_interactions gi WHERE gi.image_id = ? AND gi.gallery_id = ?)", - ^image.id, - g.id - ), - on: true - ) - |> select([g, e], {g, e.exists}) - |> order_by(desc: :updated_at) - |> Repo.all() - end + {:error, :rate_limited} -> + RateLimitedResponse.call(conn, "You may only upload images once every 5 seconds.") - defp load_image(conn, opts) do - case PhilomenaWeb.IntegerId.parse(conn.params["id"]) do - {:ok, id} -> do_load_image(conn, id, opts) - :error -> PhilomenaWeb.NotFoundPlug.call(conn) + error -> + error end end - defp do_load_image(conn, id, _opts) do - {image, tag_changes, tag_changes_tags, source_changes} = - Image - |> from(as: :image) - |> where(id: ^id) - |> join( - :inner_lateral, - [], - subquery( - TagChange - |> where(image_id: parent_as(:image).id) - |> join(:left, [c], t in assoc(c, :tags)) - |> select([c, t], %{ - change_count: count(c, :distinct), - tag_count: count(t) - }) - ), - on: true - ) - |> join( - :inner_lateral, - [], - subquery( - SourceChange - |> where(image_id: parent_as(:image).id) - |> select(%{count: count()}) - ), - on: true - ) - |> preload([:deleter, :locked_tags, :sources, user: [awards: :badge], tags: :aliases]) - |> select([i, t, s], {i, t.change_count, t.tag_count, s.count}) - |> Repo.one() - |> case do - nil -> - {nil, nil, nil, nil} - - result -> - result - end - - cond do - is_nil(image) -> - PhilomenaWeb.NotFoundPlug.call(conn) - - not is_nil(image.duplicate_id) and - not Canada.Can.can?(conn.assigns.current_user, :show, image) -> + defp load_image(conn, _opts) do + case Images.show_image(conn.assigns.actor, conn.params["id"]) do + {:ok, image} -> + conn + |> assign(:image, image) + |> assign(:tag_change_count, image.tag_change_count) + |> assign(:tag_change_tag_count, image.tag_change_tag_count) + |> assign(:source_change_count, image.source_change_count) + + {:duplicate_of, target_image_id} -> conn |> put_flash( :info, "The image you were looking for has been marked a duplicate of the image below" ) - |> redirect(to: ~p"/images/#{image.duplicate_id}") + |> redirect(to: ~p"/images/#{target_image_id}") |> halt() - true -> + {:error, _not_visible_or_missing} = error -> conn - |> assign(:image, image) - |> assign(:tag_change_count, tag_changes) - |> assign(:tag_change_tag_count, tag_changes_tags) - |> assign(:source_change_count, source_changes) + |> PhilomenaWeb.FallbackController.call(error) + |> halt() end end - - # TODO: this is duplicated in Image.SourceController - defp sources_for_edit(), do: [%Source{}] - defp sources_for_edit([]), do: sources_for_edit() - defp sources_for_edit(sources), do: sources end diff --git a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex index 8fb671c4a..7ba3f1ff3 100644 --- a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex @@ -1,52 +1,34 @@ defmodule PhilomenaWeb.IpProfile.SourceChangeController do use PhilomenaWeb, :controller - alias PhilomenaQuery.IpMask - alias Philomena.SourceChanges.SourceChange - alias Philomena.Repo - import Ecto.Query + alias Philomena.SourceChanges + alias Philomena.SourceChanges.SourceChangePage - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController def index(conn, %{"ip_profile_id" => ip} = params) do - case EctoNetwork.INET.cast(ip) do - {:ok, ip} -> list_source_changes(conn, ip, params) - _error -> PhilomenaWeb.NotFoundPlug.call(conn) - end - end - - defp list_source_changes(conn, ip, params) do - range = IpMask.parse_mask(ip, params) - - source_changes = - SourceChange - |> where(fragment("? >>= ip", ^range)) - |> added_filter(params) - |> order_by(desc: :id) - |> preload([:user, image: [:user, :sources, tags: :aliases]]) - |> Repo.paginate(conn.assigns.scrivener) - - render(conn, "index.html", - title: "Source Changes for IP `#{ip}'", - ip: range, - source_changes: source_changes - ) - end - - defp added_filter(query, %{"added" => "1"}), - do: where(query, added: true) - - defp added_filter(query, %{"added" => "0"}), - do: where(query, added: false) - - defp added_filter(query, _params), - do: query - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :show, :ip_address) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + case SourceChanges.list_ip_source_changes( + conn.assigns.actor, + ip, + params, + conn.assigns.scrivener + ) do + {:ok, %SourceChangePage{target: ip, range: range, source_changes: source_changes}, + changeset} -> + render(conn, "index.html", + title: "Source Changes for IP `#{ip}'", + ip: range, + source_changes: source_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid source change filter.") + |> redirect(to: "/") + + error -> + error end end end diff --git a/lib/philomena_web/controllers/ip_profile/tag_change/revert_controller.ex b/lib/philomena_web/controllers/ip_profile/tag_change/revert_controller.ex new file mode 100644 index 000000000..d52241e17 --- /dev/null +++ b/lib/philomena_web/controllers/ip_profile/tag_change/revert_controller.ex @@ -0,0 +1,15 @@ +defmodule PhilomenaWeb.IpProfile.TagChange.RevertController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + + action_fallback PhilomenaWeb.FallbackController + + def create(conn, %{"ip_profile_id" => ip}) do + with {:ok, _target} <- TagChanges.create_ip_tag_change_revert(conn.assigns.actor, ip) do + conn + |> put_flash(:info, "Reversion of tag changes enqueued.") + |> redirect(external: conn.assigns.referrer) + end + end +end diff --git a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex new file mode 100644 index 000000000..fe27a246c --- /dev/null +++ b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex @@ -0,0 +1,34 @@ +defmodule PhilomenaWeb.IpProfile.TagChangeController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChangePage + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"ip_profile_id" => ip} = params) do + case TagChanges.list_ip_tag_changes(conn.assigns.actor, ip, params, conn.assigns.pagination) do + {:ok, %TagChangePage{target: normalized_ip, tag_changes: tag_changes}, changeset} -> + ip = to_string(normalized_ip) + path = ~p"/ip_profiles/#{ip}/tag_changes" + + conn + |> put_view(PhilomenaWeb.TagChangeView) + |> render("index.html", + title: "Tag Changes for IP `#{ip}'", + path: path, + pagination_route: fn query -> "#{path}?#{Plug.Conn.Query.encode(query)}" end, + tag_changes: tag_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid tag change query.") + |> redirect(to: "/tag_changes") + + error -> + error + end + end +end diff --git a/lib/philomena_web/controllers/ip_profile_controller.ex b/lib/philomena_web/controllers/ip_profile_controller.ex index 84e60e509..99571fe19 100644 --- a/lib/philomena_web/controllers/ip_profile_controller.ex +++ b/lib/philomena_web/controllers/ip_profile_controller.ex @@ -1,47 +1,18 @@ defmodule PhilomenaWeb.IpProfileController do use PhilomenaWeb, :controller - alias Philomena.UserIps.UserIp - alias Philomena.Bans.Subnet - alias Philomena.Repo - import Ecto.Query + alias Philomena.UserIps - plug :authorize_ip + action_fallback PhilomenaWeb.FallbackController def show(conn, %{"id" => ip}) do - case EctoNetwork.INET.cast(ip) do - {:ok, ip} -> show_profile(conn, ip) - _error -> PhilomenaWeb.NotFoundPlug.call(conn) - end - end - - defp show_profile(conn, ip) do - user_ips = - UserIp - |> where(fragment("? >>= ip", ^ip)) - |> order_by(desc: :updated_at) - |> preload(:user) - |> Repo.all() - - subnet_bans = - Subnet - |> where([s], fragment("? >>= ?", s.specification, ^ip)) - |> order_by(desc: :created_at) - |> Repo.all() - - render(conn, "show.html", - title: "#{ip}'s IP profile", - ip: ip, - user_ips: user_ips, - subnet_bans: subnet_bans - ) - end - - defp authorize_ip(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :show, :ip_address) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + with {:ok, profile} <- UserIps.show_ip_profile(conn.assigns.actor, ip) do + render(conn, "show.html", + title: "#{profile.ip}'s IP profile", + ip: profile.ip, + user_ips: profile.user_ips, + subnet_bans: profile.subnet_bans + ) end end end diff --git a/lib/philomena_web/controllers/moderation_log_controller.ex b/lib/philomena_web/controllers/moderation_log_controller.ex index a3f202b16..df6abc719 100644 --- a/lib/philomena_web/controllers/moderation_log_controller.ex +++ b/lib/philomena_web/controllers/moderation_log_controller.ex @@ -2,14 +2,13 @@ defmodule PhilomenaWeb.ModerationLogController do use PhilomenaWeb, :controller alias Philomena.ModerationLogs - alias Philomena.ModerationLogs.ModerationLog - plug :load_and_authorize_resource, - model: ModerationLog, - preload: [:user] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - moderation_logs = ModerationLogs.list_moderation_logs(conn.assigns.scrivener) - render(conn, "index.html", title: "Moderation Logs", moderation_logs: moderation_logs) + with {:ok, moderation_logs} <- + ModerationLogs.list_moderation_logs(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", title: "Moderation Logs", moderation_logs: moderation_logs) + end end end diff --git a/lib/philomena_web/controllers/notification/category_controller.ex b/lib/philomena_web/controllers/notification/category_controller.ex index c050c4e9a..df56ac12b 100644 --- a/lib/philomena_web/controllers/notification/category_controller.ex +++ b/lib/philomena_web/controllers/notification/category_controller.ex @@ -3,31 +3,20 @@ defmodule PhilomenaWeb.Notification.CategoryController do alias Philomena.Notifications - def show(conn, params) do - category_param = category(params) + action_fallback PhilomenaWeb.FallbackController - notifications = - Notifications.unread_notifications_for_user_and_category( - conn.assigns.current_user, - category_param, - conn.assigns.scrivener + def show(conn, params) do + with {:ok, {category, notifications}} <- + Notifications.show_unread_notification_category( + conn.assigns.actor, + params["id"], + conn.assigns.scrivener + ) do + render(conn, "show.html", + title: "Notification Area", + notifications: notifications, + category: category ) - - render(conn, "show.html", - title: "Notification Area", - notifications: notifications, - category: category_param - ) - end - - defp category(params) do - case params["id"] do - "channel_live" -> :channel_live - "gallery_image" -> :gallery_image - "image_comment" -> :image_comment - "image_merge" -> :image_merge - "forum_topic" -> :forum_topic - _ -> :forum_post end end end diff --git a/lib/philomena_web/controllers/notification_controller.ex b/lib/philomena_web/controllers/notification_controller.ex index 158fc5f35..7e1563dad 100644 --- a/lib/philomena_web/controllers/notification_controller.ex +++ b/lib/philomena_web/controllers/notification_controller.ex @@ -3,13 +3,12 @@ defmodule PhilomenaWeb.NotificationController do alias Philomena.Notifications - def index(conn, _params) do - notifications = - Notifications.unread_notifications_for_user( - conn.assigns.current_user, - page_size: 10 - ) + action_fallback PhilomenaWeb.FallbackController - render(conn, "index.html", title: "Notification Area", notifications: notifications) + def index(conn, _params) do + with {:ok, notifications} <- + Notifications.list_unread_notifications(conn.assigns.actor, page_size: 10) do + render(conn, "index.html", title: "Notification Area", notifications: notifications) + end end end diff --git a/lib/philomena_web/controllers/page/history_controller.ex b/lib/philomena_web/controllers/page/history_controller.ex index a89c8b64d..2ca7696a8 100644 --- a/lib/philomena_web/controllers/page/history_controller.ex +++ b/lib/philomena_web/controllers/page/history_controller.ex @@ -1,30 +1,21 @@ defmodule PhilomenaWeb.Page.HistoryController do use PhilomenaWeb, :controller - alias Philomena.StaticPages.StaticPage - alias Philomena.StaticPages.Version - alias Philomena.Repo alias PhilomenaWeb.MarkdownRenderer - import Ecto.Query + alias Philomena.StaticPages - plug :load_resource, model: StaticPage, id_name: "page_id", id_field: "slug", required: true + action_fallback PhilomenaWeb.FallbackController - def index(conn, _params) do - page = conn.assigns.static_page - - versions = - Version - |> where(static_page_id: ^page.id) - |> preload(:user) - |> order_by(desc: :created_at, desc: :id) - |> Repo.all() - |> generate_differences() - - render(conn, "index.html", - title: "Revision History for Page `#{page.title}'", - layout_class: "layout--wide", - versions: versions - ) + def index(conn, %{"page_id" => slug}) do + with {:ok, {page, versions}} <- + StaticPages.list_page_history(conn.assigns.actor, slug) do + render(conn, "index.html", + title: "Revision History for Page `#{page.title}'", + layout_class: "layout--wide", + static_page: page, + versions: generate_differences(versions) + ) + end end # Versions store the body as it was after each edit, so a version's diff is diff --git a/lib/philomena_web/controllers/page_controller.ex b/lib/philomena_web/controllers/page_controller.ex index d9ebae27c..87c995ba1 100644 --- a/lib/philomena_web/controllers/page_controller.ex +++ b/lib/philomena_web/controllers/page_controller.ex @@ -1,56 +1,73 @@ defmodule PhilomenaWeb.PageController do use PhilomenaWeb, :controller - alias Philomena.StaticPages.StaticPage alias Philomena.StaticPages alias PhilomenaWeb.MarkdownRenderer - plug :load_and_authorize_resource, model: StaticPage, id_field: "slug" + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do - render(conn, "index.html", title: "Pages") + with {:ok, static_pages} <- StaticPages.list_pages(conn.assigns.actor) do + render(conn, "index.html", title: "Pages", static_pages: static_pages) + end end - def show(conn, _params) do - rendered = MarkdownRenderer.render_unsafe(conn.assigns.static_page.body, conn) - render(conn, "show.html", title: conn.assigns.static_page.title, rendered: rendered) + def show(conn, %{"id" => slug}) do + with {:ok, static_page} <- StaticPages.show_page(conn.assigns.actor, slug) do + rendered = MarkdownRenderer.render_unsafe(static_page.body, conn) + + render(conn, "show.html", + title: static_page.title, + static_page: static_page, + rendered: rendered + ) + end end def new(conn, _params) do - changeset = StaticPages.change_static_page(%StaticPage{}) - render(conn, "new.html", title: "New Page", changeset: changeset) + with {:ok, changeset} <- StaticPages.new_page(conn.assigns.actor) do + render(conn, "new.html", title: "New Page", changeset: changeset) + end end def create(conn, %{"static_page" => static_page_params}) do - case StaticPages.create_static_page(conn.assigns.current_user, static_page_params) do - {:ok, %{static_page: static_page}} -> + case StaticPages.create_page(conn.assigns.actor, static_page_params) do + {:ok, static_page} -> conn |> put_flash(:info, "Static page successfully created.") |> redirect(to: ~p"/pages/#{static_page}") - {:error, :static_page, changeset, _changes} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + error -> + error end end - def edit(conn, _params) do - changeset = StaticPages.change_static_page(conn.assigns.static_page) - render(conn, "edit.html", title: "Editing Page", changeset: changeset) + def edit(conn, %{"id" => slug}) do + with {:ok, {static_page, changeset}} <- + StaticPages.edit_page(conn.assigns.actor, slug) do + render(conn, "edit.html", + title: "Editing Page", + static_page: static_page, + changeset: changeset + ) + end end - def update(conn, %{"static_page" => static_page_params}) do - case StaticPages.update_static_page( - conn.assigns.static_page, - conn.assigns.current_user, - static_page_params - ) do - {:ok, %{static_page: static_page}} -> + def update(conn, %{"id" => slug, "static_page" => static_page_params}) do + case StaticPages.update_page(conn.assigns.actor, slug, static_page_params) do + {:ok, static_page} -> conn |> put_flash(:info, "Static page successfully updated.") |> redirect(to: ~p"/pages/#{static_page}") - {:error, :static_page, changeset, _changes} -> - render(conn, "edit.html", changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", static_page: changeset.data, changeset: changeset) + + error -> + error end end end diff --git a/lib/philomena_web/controllers/password_controller.ex b/lib/philomena_web/controllers/password_controller.ex index 597f0af02..ec966d17f 100644 --- a/lib/philomena_web/controllers/password_controller.ex +++ b/lib/philomena_web/controllers/password_controller.ex @@ -5,7 +5,6 @@ defmodule PhilomenaWeb.PasswordController do plug PhilomenaWeb.CaptchaPlug when action in [:new, :create] plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CompromisedPasswordCheckPlug when action in [:update] plug :get_user_by_reset_password_token when action in [:edit, :update] def new(conn, _params) do @@ -30,13 +29,13 @@ defmodule PhilomenaWeb.PasswordController do end def edit(conn, _params) do - render(conn, "edit.html", changeset: Users.change_user_password(conn.assigns.user)) + render(conn, "edit.html", changeset: Users.edit_password(conn.assigns.user)) end # Do not log in the user after reset password to avoid a # leaked token giving the user access to the account. def update(conn, %{"user" => user_params}) do - case Users.reset_user_password(conn.assigns.user, user_params) do + case Users.update_password(conn.assigns.user, user_params) do {:ok, _} -> conn |> put_flash(:info, "Password reset successfully.") diff --git a/lib/philomena_web/controllers/post/preview_controller.ex b/lib/philomena_web/controllers/post/preview_controller.ex index b70bf5bd4..f75256f63 100644 --- a/lib/philomena_web/controllers/post/preview_controller.ex +++ b/lib/philomena_web/controllers/post/preview_controller.ex @@ -3,10 +3,10 @@ defmodule PhilomenaWeb.Post.PreviewController do alias PhilomenaWeb.MarkdownRenderer alias Philomena.Posts.Post - alias Philomena.Repo + alias Philomena.Users def create(conn, params) do - user = preload_awards(conn.assigns.current_user) + user = Users.preload_preview_awards(conn.assigns.current_user) body = to_string(params["body"]) anonymous = params["anonymous"] == true @@ -15,10 +15,4 @@ defmodule PhilomenaWeb.Post.PreviewController do render(conn, "create.html", layout: false, post: post, body: rendered) end - - defp preload_awards(nil), do: nil - - defp preload_awards(user) do - Repo.preload(user, awards: :badge) - end end diff --git a/lib/philomena_web/controllers/post_controller.ex b/lib/philomena_web/controllers/post_controller.ex index 3d711e09a..bfc9db473 100644 --- a/lib/philomena_web/controllers/post_controller.ex +++ b/lib/philomena_web/controllers/post_controller.ex @@ -1,41 +1,21 @@ defmodule PhilomenaWeb.PostController do use PhilomenaWeb, :controller + alias Philomena.Posts alias PhilomenaWeb.MarkdownRenderer - alias PhilomenaQuery.Search - alias Philomena.{Posts.Query, Posts.Post} - import Ecto.Query def index(conn, params) do pq = params["pq"] || "created_at.gte:1 week ago" params = Map.put(conn.params, "pq", pq) conn = Map.put(conn, :params, params) - user = conn.assigns.current_user - pq - |> Query.compile(user: user) - |> render_index(conn, user) + conn.assigns.actor + |> Posts.query_posts(pq, conn.assigns.pagination) + |> render_index(conn) end - defp render_index({:ok, query}, conn, user) do - posts = - Post - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: [query | filters(user)] - } - }, - sort: %{created_at: :desc} - }, - conn.assigns.pagination - ) - |> Search.search_records( - preload(Post, [:deleted_by, topic: :forum, user: [awards: :badge]]) - ) - + defp render_index({:ok, posts}, conn) do rendered = MarkdownRenderer.render_collection(posts.entries, conn) posts = %{posts | entries: Enum.zip(rendered, posts.entries)} @@ -43,22 +23,7 @@ defmodule PhilomenaWeb.PostController do render(conn, "index.html", title: "Posts", posts: posts) end - defp render_index({:error, msg}, conn, _user) do + defp render_index({:error, msg}, conn) do render(conn, "index.html", title: "Posts", error: msg, posts: []) end - - defp filters(%{role: role}) when role in ["moderator", "admin"], do: [] - - defp filters(%{role: "assistant"}) do - [ - %{terms: %{access_level: ["normal", "assistant"]}} - ] - end - - defp filters(_user) do - [ - %{term: %{access_level: "normal"}}, - %{term: %{hidden_from_users: false}} - ] - end end diff --git a/lib/philomena_web/controllers/profile/alias_controller.ex b/lib/philomena_web/controllers/profile/alias_controller.ex index 18cd7f4fe..7079c4ff2 100644 --- a/lib/philomena_web/controllers/profile/alias_controller.ex +++ b/lib/philomena_web/controllers/profile/alias_controller.ex @@ -1,64 +1,18 @@ defmodule PhilomenaWeb.Profile.AliasController do use PhilomenaWeb, :controller - alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.UserIps.UserIp - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query - - plug PhilomenaWeb.CanaryMapPlug, index: :show_details - - plug :load_and_authorize_resource, - model: User, - id_field: "slug", - id_name: "profile_id", - persisted: true - - def index(conn, _params) do - user = conn.assigns.user - - # N.B.: subquery runs faster and is easier to read - # than the equivalent join, but Ecto doesn't support - # that for some reason (and ActiveRecord does??) - - ip_matches = - User - |> join(:inner, [u], _ in assoc(u, :user_ips)) - |> join(:left, [u, ui1], ui2 in UserIp, on: ui1.ip == ui2.ip) - |> where([u, _ui1, ui2], u.id != ^user.id and ui2.user_id == ^user.id) - |> select([u, _ui1, _ui2], u) - |> preload(:bans) - |> Repo.all() - |> Map.new(&{&1.id, &1}) - - fp_matches = - User - |> join(:inner, [u], _ in assoc(u, :user_fingerprints)) - |> join(:left, [u, uf1], uf2 in UserFingerprint, on: uf1.fingerprint == uf2.fingerprint) - |> where([u, _uf1, uf2], u.id != ^user.id and uf2.user_id == ^user.id) - |> select([u, _uf1, _uf2], u) - |> preload(:bans) - |> Repo.all() - |> Map.new(&{&1.id, &1}) - - both_matches = Map.take(ip_matches, Map.keys(fp_matches)) - - ip_matches = Map.drop(ip_matches, Map.keys(both_matches)) - - fp_matches = Map.drop(fp_matches, Map.keys(both_matches)) - - both_matches = Map.values(both_matches) - ip_matches = Map.values(ip_matches) - fp_matches = Map.values(fp_matches) - - render( - conn, - "index.html", - title: "Potential Aliases for `#{user.name}'", - both_matches: both_matches, - ip_matches: ip_matches, - fp_matches: fp_matches - ) + alias Philomena.Users + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"profile_id" => slug}) do + with {:ok, matches} <- Users.list_profile_aliases(conn.assigns.actor, slug) do + render(conn, "index.html", + title: "Potential Aliases for `#{matches.user.name}'", + both_matches: matches.both_matches, + ip_matches: matches.ip_matches, + fp_matches: matches.fp_matches + ) + end end end diff --git a/lib/philomena_web/controllers/profile/artist_link_controller.ex b/lib/philomena_web/controllers/profile/artist_link_controller.ex index 9ef16b076..caae6c58c 100644 --- a/lib/philomena_web/controllers/profile/artist_link_controller.ex +++ b/lib/philomena_web/controllers/profile/artist_link_controller.ex @@ -1,84 +1,76 @@ defmodule PhilomenaWeb.Profile.ArtistLinkController do use PhilomenaWeb, :controller - alias Philomena.ArtistLinks.ArtistLink alias Philomena.ArtistLinks - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: ArtistLink, - only: [:show, :edit, :update], - preload: [:user, :tag, :contacted_by_user] - - plug PhilomenaWeb.CanaryMapPlug, - index: :create_links, - new: :create_links, - create: :create_links, - show: :create_links, - edit: :edit_links, - update: :edit_links - - plug :load_and_authorize_resource, - model: User, - id_field: "slug", - id_name: "profile_id", - persisted: true - - def index(conn, _params) do - user = conn.assigns.user - - artist_links = - ArtistLink - |> where(user_id: ^user.id) - |> Repo.all() - - render(conn, "index.html", title: "Artist Links", artist_links: artist_links) + def index(conn, %{"profile_id" => slug}) do + with {:ok, {user, artist_links}} <- + ArtistLinks.list_artist_links(conn.assigns.actor, slug) do + render(conn, "index.html", title: "Artist Links", user: user, artist_links: artist_links) + end end - def new(conn, _params) do - changeset = ArtistLinks.change_artist_link(%ArtistLink{}) - render(conn, "new.html", title: "New Artist Link", changeset: changeset) + def new(conn, %{"profile_id" => slug}) do + with {:ok, {user, changeset}} <- + ArtistLinks.new_artist_link(conn.assigns.actor, slug) do + render(conn, "new.html", title: "New Artist Link", user: user, changeset: changeset) + end end - def create(conn, %{"artist_link" => artist_link_params}) do - case ArtistLinks.create_artist_link(conn.assigns.user, artist_link_params) do - {:ok, artist_link} -> + def create(conn, %{"profile_id" => slug, "artist_link" => artist_link_params}) do + case ArtistLinks.create_artist_link(conn.assigns.actor, slug, artist_link_params) do + {:ok, {user, artist_link}} -> conn |> put_flash( :info, "Link submitted! Please put '#{artist_link.verification_code}' on your linked webpage now." ) - |> redirect(to: ~p"/profiles/#{conn.assigns.user}/artist_links/#{artist_link}") + |> redirect(to: ~p"/profiles/#{user}/artist_links/#{artist_link}") + + {:error, {user, changeset}} -> + render(conn, "new.html", user: user, changeset: changeset) - {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "new.html", changeset: changeset) + {:error, _} = error -> + error end end - def show(conn, _params) do - artist_link = conn.assigns.artist_link - render(conn, "show.html", title: "Showing Artist Link", artist_link: artist_link) + def show(conn, %{"profile_id" => slug, "id" => id}) do + with {:ok, {user, artist_link}} <- + ArtistLinks.show_artist_link(conn.assigns.actor, slug, id) do + render(conn, "show.html", + title: "Showing Artist Link", + user: user, + artist_link: artist_link + ) + end end - def edit(conn, _params) do - changeset = ArtistLinks.change_artist_link(conn.assigns.artist_link) - - render(conn, "edit.html", title: "Editing Artist Link", changeset: changeset) + def edit(conn, %{"profile_id" => slug, "id" => id}) do + with {:ok, {artist_link, changeset}} <- + ArtistLinks.edit_artist_link(conn.assigns.actor, slug, id) do + render(conn, "edit.html", + title: "Editing Artist Link", + artist_link: artist_link, + changeset: changeset + ) + end end - def update(conn, %{"artist_link" => artist_link_params}) do - case ArtistLinks.update_artist_link(conn.assigns.artist_link, artist_link_params) do - {:ok, artist_link} -> + def update(conn, %{"profile_id" => slug, "id" => id, "artist_link" => artist_link_params}) do + case ArtistLinks.update_artist_link(conn.assigns.actor, slug, id, artist_link_params) do + {:ok, {user, artist_link}} -> conn |> put_flash(:info, "Link successfully updated.") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}/artist_links/#{artist_link}") + |> redirect(to: ~p"/profiles/#{user}/artist_links/#{artist_link}") + + {:error, {artist_link, changeset}} -> + render(conn, "edit.html", artist_link: artist_link, changeset: changeset) - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/profile/award_controller.ex b/lib/philomena_web/controllers/profile/award_controller.ex index 05ffa71ce..be0e6b72a 100644 --- a/lib/philomena_web/controllers/profile/award_controller.ex +++ b/lib/philomena_web/controllers/profile/award_controller.ex @@ -1,96 +1,79 @@ defmodule PhilomenaWeb.Profile.AwardController do use PhilomenaWeb, :controller - alias Philomena.Badges.Award - alias Philomena.Badges.Badge - alias Philomena.Users.User alias Philomena.Badges - alias Philomena.Repo - import Ecto.Query - plug :verify_authorized - plug :load_resource, model: User, id_name: "profile_id", id_field: "slug", required: true - plug :load_resource, model: Award, only: [:edit, :update, :delete] - plug :load_badges when action in [:new, :create, :edit, :update] - - def new(conn, _params) do - changeset = Badges.change_badge_award(%Award{}) - render(conn, "new.html", title: "New Award", changeset: changeset) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"profile_id" => slug}) do + with {:ok, {user, changeset, badges}} <- + Badges.new_award(conn.assigns.actor, slug) do + render(conn, "new.html", + title: "New Award", + user: user, + changeset: changeset, + badges: badges + ) + end end - def create(conn, %{"award" => award_params}) do - user = conn.assigns.user - - case Badges.create_badge_award(conn.assigns.current_user, user, award_params) do - {:ok, award} -> + def create(conn, %{"profile_id" => slug, "award" => award_params}) do + case Badges.create_award(conn.assigns.actor, slug, award_params) do + {:ok, {user, _award}} -> conn |> put_flash(:info, "Award successfully created.") - |> moderation_log(details: &log_details/2, data: {user, award}) |> redirect(to: ~p"/profiles/#{user}") - {:error, changeset} -> - render(conn, "new.html", changeset: changeset) + {:error, {user, changeset, badges}} -> + render(conn, "new.html", + user: user, + changeset: changeset, + badges: badges + ) + + {:error, _} = error -> + error end end - def edit(conn, _params) do - changeset = Badges.change_badge_award(conn.assigns.award) - render(conn, "edit.html", title: "Editing Award", changeset: changeset) + def edit(conn, %{"profile_id" => slug, "id" => id}) do + with {:ok, {user, award, changeset, badges}} <- + Badges.edit_award(conn.assigns.actor, slug, id) do + render(conn, "edit.html", + title: "Editing Award", + user: user, + award: award, + changeset: changeset, + badges: badges + ) + end end - def update(conn, %{"award" => award_params}) do - case Badges.update_badge_award(conn.assigns.award, award_params) do - {:ok, award} -> - user = conn.assigns.user - + def update(conn, %{"profile_id" => slug, "id" => id, "award" => award_params}) do + case Badges.update_award(conn.assigns.actor, slug, id, award_params) do + {:ok, {user, _award}} -> conn |> put_flash(:info, "Award successfully updated.") - |> moderation_log(details: &log_details/2, data: {user, award}) |> redirect(to: ~p"/profiles/#{user}") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - user = conn.assigns.user - {:ok, award} = Badges.delete_badge_award(conn.assigns.award) + {:error, {user, award, changeset, badges}} -> + render(conn, "edit.html", + user: user, + award: award, + changeset: changeset, + badges: badges + ) - conn - |> put_flash(:info, "Award successfully destroyed. By cruel and unusual means.") - |> moderation_log(details: &log_details/2, data: {user, award}) - |> redirect(to: ~p"/profiles/#{user}") + {:error, _} = error -> + error + end end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :create, Award) do + def delete(conn, %{"profile_id" => slug, "id" => id}) do + with {:ok, {user, _award}} <- Badges.delete_award(conn.assigns.actor, slug, id) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Award successfully destroyed. By cruel and unusual means.") + |> redirect(to: ~p"/profiles/#{user}") end end - - defp load_badges(conn, _opts) do - badges = - Badge - |> where(disable_award: false) - |> order_by(asc: :title) - |> Repo.all() - - assign(conn, :badges, badges) - end - - defp log_details(action, {user, award}) do - award = Repo.preload(award, [:badge]) - - body = - case action do - :create -> "Awarded badge '#{award.badge.title}' to #{user.name}" - :update -> "Updated award of badge '#{award.badge.title}' on #{user.name}" - :delete -> "Removed badge '#{award.badge.title}' from #{user.name}" - end - - %{body: body, subject_path: ~p"/profiles/#{user}"} - end end diff --git a/lib/philomena_web/controllers/profile/commission/item_controller.ex b/lib/philomena_web/controllers/profile/commission/item_controller.ex index ae81f57b0..e4da99161 100644 --- a/lib/philomena_web/controllers/profile/commission/item_controller.ex +++ b/lib/philomena_web/controllers/profile/commission/item_controller.ex @@ -1,122 +1,79 @@ defmodule PhilomenaWeb.Profile.Commission.ItemController do use PhilomenaWeb, :controller - alias Philomena.Commissions.Item alias Philomena.Commissions - alias Philomena.Users.User - alias Philomena.Repo - plug PhilomenaWeb.FilterBannedUsersPlug - - plug :load_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - preload: [ - :verified_links, - commission: [ - sheet_image: [:sources, tags: :aliases], - user: [awards: :badge], - items: [example_image: [:sources, tags: :aliases]] - ] - ], - persisted: true - - plug :ensure_commission - plug :ensure_correct_user - - def new(conn, _params) do - user = conn.assigns.user - commission = user.commission - - changeset = Commissions.change_item(%Item{}) - - render(conn, "new.html", - title: "New Commission Item", - user: user, - commission: commission, - changeset: changeset - ) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"profile_id" => slug}) do + with {:ok, %Ecto.Changeset{data: item} = changeset} <- + Commissions.new_item(conn.assigns.actor, slug) do + render(conn, "new.html", + title: "New Commission Item", + user: item.commission.user, + commission: item.commission, + changeset: changeset + ) + end end - def create(conn, %{"item" => item_params}) do - user = conn.assigns.user - commission = user.commission - - case Commissions.create_item(commission, item_params) do - {:ok, _multi} -> + def create(conn, %{"profile_id" => slug, "item" => item_params}) do + case Commissions.create_item(conn.assigns.actor, slug, item_params) do + {:ok, item} -> conn |> put_flash(:info, "Item successfully created.") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}/commission") + |> redirect(to: ~p"/profiles/#{item.commission.user}/commission") + + {:error, %Ecto.Changeset{data: item} = changeset} -> + render(conn, "new.html", + user: item.commission.user, + commission: item.commission, + changeset: changeset + ) - {:error, changeset} -> - render(conn, "new.html", user: user, commission: commission, changeset: changeset) + error -> + error end end - def edit(conn, %{"id" => id}) do - user = conn.assigns.user - commission = user.commission - item = Repo.get_by!(Item, commission_id: commission.id, id: id) - - changeset = Commissions.change_item(item) - - render(conn, "edit.html", - title: "Editing Commission Item", - user: user, - commission: commission, - item: item, - changeset: changeset - ) + def edit(conn, %{"profile_id" => slug, "id" => id}) do + with {:ok, %Ecto.Changeset{data: item} = changeset} <- + Commissions.edit_item(conn.assigns.actor, slug, id) do + render(conn, "edit.html", + title: "Editing Commission Item", + user: item.commission.user, + commission: item.commission, + item: item, + changeset: changeset + ) + end end - def update(conn, %{"id" => id, "item" => item_params}) do - user = conn.assigns.user - commission = user.commission - item = Repo.get_by!(Item, commission_id: commission.id, id: id) - - case Commissions.update_item(item, item_params) do - {:ok, _commission} -> + def update(conn, %{"profile_id" => slug, "id" => id, "item" => item_params}) do + case Commissions.update_item(conn.assigns.actor, slug, id, item_params) do + {:ok, item} -> conn |> put_flash(:info, "Item successfully updated.") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}/commission") + |> redirect(to: ~p"/profiles/#{item.commission.user}/commission") - {:error, changeset} -> + {:error, %Ecto.Changeset{data: item} = changeset} -> render(conn, "edit.html", - user: user, - commission: commission, + user: item.commission.user, + commission: item.commission, item: item, changeset: changeset ) - end - end - - def delete(conn, %{"id" => id}) do - user = conn.assigns.user - commission = user.commission - item = Repo.get_by!(Item, commission_id: commission.id, id: id) - - {:ok, _multi} = Commissions.delete_item(item) - conn - |> put_flash(:info, "Item deleted successfully.") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}/commission") - end - - defp ensure_commission(conn, _opts) do - if is_nil(conn.assigns.user.commission) do - PhilomenaWeb.NotFoundPlug.call(conn) - else - conn + error -> + error end end - defp ensure_correct_user(conn, _opts) do - user_id = conn.assigns.user.id - - case conn.assigns.current_user do - %{id: ^user_id} -> conn - _other -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + def delete(conn, %{"profile_id" => slug, "id" => id}) do + with {:ok, item} <- Commissions.delete_item(conn.assigns.actor, slug, id) do + conn + |> put_flash(:info, "Item deleted successfully.") + |> redirect(to: ~p"/profiles/#{item.commission.user}/commission") end end end diff --git a/lib/philomena_web/controllers/profile/commission/report_controller.ex b/lib/philomena_web/controllers/profile/commission/report_controller.ex index 6f278396e..2fde9720a 100644 --- a/lib/philomena_web/controllers/profile/commission/report_controller.ex +++ b/lib/philomena_web/controllers/profile/commission/report_controller.ex @@ -3,64 +3,37 @@ defmodule PhilomenaWeb.Profile.Commission.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Users.User - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - plug :load_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - preload: [ - :verified_links, - commission: [ - sheet_image: [:sources, tags: :aliases], - user: [awards: :badge], - items: [example_image: [:sources, tags: :aliases]] - ] - ], - persisted: true + action_fallback PhilomenaWeb.FallbackController - plug :ensure_commission + def new(conn, %{"profile_id" => slug}) do + with {:ok, form} <- Reports.new_report(conn.assigns.actor, {:commission, slug}) do + commission = form.target + user = commission.user + action = ~p"/profiles/#{user}/commission/reports" - def new(conn, _params) do - user = conn.assigns.user - commission = conn.assigns.user.commission - action = ~p"/profiles/#{user}/commission/reports" - - changeset = - %Report{commission_id: commission.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", - title: "Reporting Commission", - subject: commission, - changeset: changeset, - action: action - ) - end - - def create(conn, params) do - user = conn.assigns.user - commission = conn.assigns.user.commission - action = ~p"/profiles/#{user}/commission/reports" - - ReportController.create(conn, action, commission, [commission_id: commission.id], params) - end - - defp ensure_commission(conn, _opts) do - if is_nil(conn.assigns.user.commission) do - PhilomenaWeb.NotFoundPlug.call(conn) - else conn + |> put_view(ReportView) + |> render("new.html", + title: "Reporting Commission", + subject: commission, + changeset: form.changeset, + rules: form.rules, + action: action + ) end end + + def create(conn, %{"profile_id" => slug} = params) do + ReportController.create( + conn, + {:commission, slug}, + fn commission -> ~p"/profiles/#{commission.user}/commission/reports" end, + params + ) + end end diff --git a/lib/philomena_web/controllers/profile/commission_controller.ex b/lib/philomena_web/controllers/profile/commission_controller.ex index 9a3292cf5..2f65beb69 100644 --- a/lib/philomena_web/controllers/profile/commission_controller.ex +++ b/lib/philomena_web/controllers/profile/commission_controller.ex @@ -1,163 +1,137 @@ defmodule PhilomenaWeb.Profile.CommissionController do use PhilomenaWeb, :controller - alias Philomena.Commissions.Commission alias Philomena.Commissions alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Users.User - - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create, :edit, :update, :delete] - - plug :load_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - preload: [ - :verified_links, - commission: [ - sheet_image: [:sources, tags: :aliases], - user: [awards: :badge], - items: [example_image: [:sources, tags: :aliases]] - ] - ], - persisted: true - - plug :ensure_commission when action in [:show, :edit, :update, :delete] - plug :ensure_no_commission when action in [:new, :create] - plug :ensure_correct_user when action in [:new, :create, :edit, :update, :delete] - plug :ensure_links_verified when action in [:new, :create, :edit, :update, :delete] - - def show(conn, _params) do - commission = conn.assigns.user.commission - - items = - commission.items - |> Enum.sort(&(Decimal.compare(&1.base_price, &2.base_price) != :gt)) - - item_descriptions = - items - |> Enum.map(&%{body: &1.description}) - |> MarkdownRenderer.render_collection(conn) - - item_add_ons = - items - |> Enum.map(&%{body: &1.add_ons}) - |> MarkdownRenderer.render_collection(conn) - - [information, contact, will_create, will_not_create] = - MarkdownRenderer.render_collection( - [ - %{body: commission.information || ""}, - %{body: commission.contact || ""}, - %{body: commission.will_create || ""}, - %{body: commission.will_not_create || ""} - ], - conn - ) - rendered = %{ - information: information, - contact: contact, - will_create: will_create, - will_not_create: will_not_create - } - - items = Enum.zip([item_descriptions, item_add_ons, items]) - - render(conn, "show.html", - title: "Showing Commission", - rendered: rendered, - commission: commission, - items: items, - layout_class: "layout--wide" - ) + action_fallback PhilomenaWeb.FallbackController + + def show(conn, %{"profile_id" => slug}) do + with {:ok, commission} <- + Commissions.show_commission(conn.assigns.actor, slug) do + item_descriptions = + commission.items + |> Enum.map(&%{body: &1.description}) + |> MarkdownRenderer.render_collection(conn) + + item_add_ons = + commission.items + |> Enum.map(&%{body: &1.add_ons}) + |> MarkdownRenderer.render_collection(conn) + + [information, contact, will_create, will_not_create] = + MarkdownRenderer.render_collection( + [ + %{body: commission.information || ""}, + %{body: commission.contact || ""}, + %{body: commission.will_create || ""}, + %{body: commission.will_not_create || ""} + ], + conn + ) + + rendered = %{ + information: information, + contact: contact, + will_create: will_create, + will_not_create: will_not_create + } + + items = Enum.zip([item_descriptions, item_add_ons, commission.items]) + + render(conn, "show.html", + title: "Showing Commission", + user: commission.user, + rendered: rendered, + commission: commission, + items: items, + layout_class: "layout--wide" + ) + end end - def new(conn, _params) do - changeset = Commissions.change_commission(%Commission{}) - render(conn, "new.html", title: "New Commission", changeset: changeset) - end + def new(conn, %{"profile_id" => slug}) do + case Commissions.new_commission(conn.assigns.actor, slug) do + {:ok, %Ecto.Changeset{data: commission} = changeset} -> + render(conn, "new.html", + title: "New Commission", + user: commission.user, + changeset: changeset + ) - def create(conn, %{"commission" => commission_params}) do - user = conn.assigns.user + {:error, :no_verified_links} -> + require_verified_link(conn) - case Commissions.create_commission(user, commission_params) do - {:ok, _commission} -> + error -> + error + end + end + + def create(conn, %{"profile_id" => slug, "commission" => commission_params}) do + case Commissions.create_commission(conn.assigns.actor, slug, commission_params) do + {:ok, %{user: user} = _commission} -> conn |> put_flash(:info, "Commission successfully created.") |> redirect(to: ~p"/profiles/#{user}/commission") - {:error, changeset} -> - render(conn, "new.html", changeset: changeset) + {:error, %Ecto.Changeset{data: commission} = changeset} -> + render(conn, "new.html", user: commission.user, changeset: changeset) + + {:error, :no_verified_links} -> + require_verified_link(conn) + + error -> + error end end - def edit(conn, _params) do - changeset = Commissions.change_commission(conn.assigns.user.commission) - render(conn, "edit.html", title: "Editing Commission", changeset: changeset) + def edit(conn, %{"profile_id" => slug}) do + case Commissions.edit_commission(conn.assigns.actor, slug) do + {:ok, %Ecto.Changeset{data: commission} = changeset} -> + render(conn, "edit.html", + title: "Editing Commission", + user: commission.user, + changeset: changeset + ) + + error -> + error + end end - def update(conn, %{"commission" => commission_params}) do - commission = conn.assigns.user.commission - - case Commissions.update_commission(commission, commission_params) do - {:ok, _commission} -> + def update(conn, %{"profile_id" => slug, "commission" => commission_params}) do + case Commissions.update_commission(conn.assigns.actor, slug, commission_params) do + {:ok, %{user: user} = _commission} -> conn |> put_flash(:info, "Commission successfully updated.") - |> redirect(to: ~p"/profiles/#{conn.assigns.user}/commission") - - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - commission = conn.assigns.user.commission + |> redirect(to: ~p"/profiles/#{user}/commission") - {:ok, _commission} = Commissions.delete_commission(commission, conn.assigns.current_user) + {:error, %Ecto.Changeset{data: commission} = changeset} -> + render(conn, "edit.html", user: commission.user, changeset: changeset) - conn - |> put_flash(:info, "Commission deleted successfully.") - |> redirect(to: ~p"/commissions") - end - - defp ensure_commission(conn, _opts) do - if is_nil(conn.assigns.user.commission) do - PhilomenaWeb.NotFoundPlug.call(conn) - else - conn + error -> + error end end - defp ensure_no_commission(conn, _opts) do - if is_nil(conn.assigns.user.commission) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end - - defp ensure_correct_user(conn, _opts) do - user_id = conn.assigns.user.id + def delete(conn, %{"profile_id" => slug}) do + case Commissions.delete_commission(conn.assigns.actor, slug) do + {:ok, _commission} -> + conn + |> put_flash(:info, "Commission deleted successfully.") + |> redirect(to: ~p"/commissions") - case conn.assigns.current_user do - %{id: ^user_id} -> conn - %{role: role} when role in ["admin", "moderator"] -> conn - _other -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - defp ensure_links_verified(conn, _opts) do - if Enum.any?(conn.assigns.user.verified_links) do - conn - else - conn - |> put_flash( - :error, - "You must have a verified artist link to create a commission listing." - ) - |> redirect(to: ~p"/commissions") - |> halt() - end + defp require_verified_link(conn) do + conn + |> put_flash( + :error, + "You must have a verified artist link to create a commission listing." + ) + |> redirect(to: ~p"/commissions") end end diff --git a/lib/philomena_web/controllers/profile/description_controller.ex b/lib/philomena_web/controllers/profile/description_controller.ex index 6dac8e889..eb5475015 100644 --- a/lib/philomena_web/controllers/profile/description_controller.ex +++ b/lib/philomena_web/controllers/profile/description_controller.ex @@ -1,39 +1,33 @@ defmodule PhilomenaWeb.Profile.DescriptionController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.CanaryMapPlug, edit: :edit_description, update: :edit_description + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - persisted: true - - def edit(conn, _params) do - changeset = Users.change_user(conn.assigns.user) - - render(conn, "edit.html", - title: "Editing Profile Description", - changeset: changeset, - user: conn.assigns.user - ) + def edit(conn, %{"profile_id" => slug}) do + with {:ok, %Ecto.Changeset{} = changeset} <- + Users.edit_profile_description(conn.assigns.actor, slug) do + render(conn, "edit.html", + title: "Editing Profile Description", + changeset: changeset, + user: changeset.data + ) + end end - def update(conn, %{"user" => user_params}) do - user = conn.assigns.user - - case Users.update_description(user, user_params) do - {:ok, _user} -> + def update(conn, %{"profile_id" => slug, "user" => user_params}) do + case Users.update_profile_description(conn.assigns.actor, slug, user_params) do + {:ok, user} -> conn |> put_flash(:info, "Description successfully updated.") |> redirect(to: ~p"/profiles/#{user}") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", changeset: changeset, user: changeset.data) + + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/profile/fp_history_controller.ex b/lib/philomena_web/controllers/profile/fp_history_controller.ex index c59f4f1c6..5304e919a 100644 --- a/lib/philomena_web/controllers/profile/fp_history_controller.ex +++ b/lib/philomena_web/controllers/profile/fp_history_controller.ex @@ -1,46 +1,23 @@ defmodule PhilomenaWeb.Profile.FpHistoryController do use PhilomenaWeb, :controller - alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query - - plug PhilomenaWeb.CanaryMapPlug, index: :show_details - - plug :load_and_authorize_resource, - model: User, - id_field: "slug", - id_name: "profile_id", - persisted: true - - def index(conn, _params) do - user = conn.assigns.user - - user_fps = - UserFingerprint - |> where(user_id: ^user.id) - |> preload(:user) - |> order_by(desc: :updated_at) - |> Repo.all() - - distinct_fps = - user_fps - |> Enum.map(& &1.fingerprint) - |> Enum.uniq() - - other_users = - UserFingerprint - |> where([u], u.fingerprint in ^distinct_fps) - |> preload(:user) - |> order_by(desc: :updated_at) - |> Repo.all() - |> Enum.group_by(& &1.fingerprint) - - render(conn, "index.html", - title: "FP History for `#{user.name}'", - user_fps: user_fps, - other_users: other_users - ) + alias Philomena.Profiles + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"profile_id" => slug}) do + with {:ok, history} <- + Profiles.list_profile_fingerprint_history( + conn.assigns.actor, + slug, + conn.assigns.scrivener + ) do + render(conn, "index.html", + title: "Fingerprint History for `#{history.user.name}'", + user: history.user, + user_fingerprints: history.user_fingerprints, + other_users: history.other_users + ) + end end end diff --git a/lib/philomena_web/controllers/profile/ip_history_controller.ex b/lib/philomena_web/controllers/profile/ip_history_controller.ex index 053e2b9a7..28ddca448 100644 --- a/lib/philomena_web/controllers/profile/ip_history_controller.ex +++ b/lib/philomena_web/controllers/profile/ip_history_controller.ex @@ -1,46 +1,19 @@ defmodule PhilomenaWeb.Profile.IpHistoryController do use PhilomenaWeb, :controller - alias Philomena.UserIps.UserIp - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query - - plug PhilomenaWeb.CanaryMapPlug, index: :show_details - - plug :load_and_authorize_resource, - model: User, - id_field: "slug", - id_name: "profile_id", - persisted: true - - def index(conn, _params) do - user = conn.assigns.user - - user_ips = - UserIp - |> where(user_id: ^user.id) - |> preload(:user) - |> order_by(desc: :updated_at) - |> Repo.all() - - distinct_ips = - user_ips - |> Enum.map(& &1.ip) - |> Enum.uniq() - - other_users = - UserIp - |> where([u], u.ip in ^distinct_ips) - |> preload(:user) - |> order_by(desc: :updated_at) - |> Repo.all() - |> Enum.group_by(& &1.ip) - - render(conn, "index.html", - title: "IP History for `#{user.name}'", - user_ips: user_ips, - other_users: other_users - ) + alias Philomena.Profiles + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"profile_id" => slug}) do + with {:ok, history} <- + Profiles.list_profile_ip_history(conn.assigns.actor, slug, conn.assigns.scrivener) do + render(conn, "index.html", + title: "IP History for `#{history.user.name}'", + user: history.user, + user_ips: history.user_ips, + other_users: history.other_users + ) + end end end diff --git a/lib/philomena_web/controllers/profile/report_controller.ex b/lib/philomena_web/controllers/profile/report_controller.ex index 4e94eb2b3..da4339100 100644 --- a/lib/philomena_web/controllers/profile/report_controller.ex +++ b/lib/philomena_web/controllers/profile/report_controller.ex @@ -3,44 +3,36 @@ defmodule PhilomenaWeb.Profile.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Users.User - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - plug :load_and_authorize_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - persisted: true - - def new(conn, _params) do - user = conn.assigns.user - action = ~p"/profiles/#{user}/reports" - - changeset = - %Report{reported_user_id: user.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", - title: "Reporting User", - subject: user, - changeset: changeset, - action: action - ) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"profile_id" => slug}) do + with {:ok, form} <- Reports.new_report(conn.assigns.actor, {:user, slug}) do + user = form.target + action = ~p"/profiles/#{user}/reports" + + conn + |> put_view(ReportView) + |> render("new.html", + title: "Reporting User", + subject: user, + changeset: form.changeset, + rules: form.rules, + action: action + ) + end end - def create(conn, params) do - user = conn.assigns.user - action = ~p"/profiles/#{user}/reports" - - ReportController.create(conn, action, user, [reported_user_id: user.id], params) + def create(conn, %{"profile_id" => slug} = params) do + ReportController.create( + conn, + {:user, slug}, + fn user -> ~p"/profiles/#{user}/reports" end, + params + ) end end diff --git a/lib/philomena_web/controllers/profile/scratchpad_controller.ex b/lib/philomena_web/controllers/profile/scratchpad_controller.ex index 5da2f637a..d8ea1bd96 100644 --- a/lib/philomena_web/controllers/profile/scratchpad_controller.ex +++ b/lib/philomena_web/controllers/profile/scratchpad_controller.ex @@ -1,49 +1,33 @@ defmodule PhilomenaWeb.Profile.ScratchpadController do use PhilomenaWeb, :controller - alias Philomena.Users.User alias Philomena.Users - alias Philomena.ModNotes.ModNote - plug PhilomenaWeb.FilterBannedUsersPlug + action_fallback PhilomenaWeb.FallbackController - plug :verify_authorized - - plug :load_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - persisted: true - - def edit(conn, _params) do - changeset = Users.change_user(conn.assigns.user) - - render(conn, "edit.html", - title: "Editing Moderation Scratchpad", - changeset: changeset, - user: conn.assigns.user - ) + def edit(conn, %{"profile_id" => slug}) do + with {:ok, %Ecto.Changeset{} = changeset} <- + Users.edit_profile_scratchpad(conn.assigns.actor, slug) do + render(conn, "edit.html", + title: "Editing Moderation Scratchpad", + changeset: changeset, + user: changeset.data + ) + end end - def update(conn, %{"user" => user_params}) do - user = conn.assigns.user - - case Users.update_scratchpad(user, user_params) do - {:ok, _user} -> + def update(conn, %{"profile_id" => slug, "user" => user_params}) do + case Users.update_profile_scratchpad(conn.assigns.actor, slug, user_params) do + {:ok, user} -> conn |> put_flash(:info, "Moderation scratchpad successfully updated.") |> redirect(to: ~p"/profiles/#{user}") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", changeset: changeset, user: changeset.data) - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, ModNote) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/profile/source_change_controller.ex b/lib/philomena_web/controllers/profile/source_change_controller.ex index 539dd88a7..78a580202 100644 --- a/lib/philomena_web/controllers/profile/source_change_controller.ex +++ b/lib/philomena_web/controllers/profile/source_change_controller.ex @@ -1,57 +1,39 @@ defmodule PhilomenaWeb.Profile.SourceChangeController do use PhilomenaWeb, :controller - alias Philomena.Users.User - alias Philomena.Images.Image - alias Philomena.SourceChanges.SourceChange - alias Philomena.Repo - import Ecto.Query - - plug PhilomenaWeb.CanaryMapPlug, index: :show - - plug :load_and_authorize_resource, - model: User, - id_name: "profile_id", - id_field: "slug", - persisted: true - - def index(conn, params) do - user = conn.assigns.user - - common_query = - SourceChange - |> join(:inner, [sc], i in Image, on: sc.image_id == i.id) - |> where( - [sc, i], - sc.user_id == ^user.id and not (i.user_id == ^user.id and i.anonymous == true) - ) - |> added_filter(params) - - source_changes = - common_query - |> preload([:user, image: [:user, :sources, tags: :aliases]]) - |> order_by(desc: :id) - |> Repo.paginate(conn.assigns.scrivener) - - image_count = - common_query - |> select([_, i], count(i.id, :distinct)) - |> Repo.one() - - render(conn, "index.html", - title: "Source Changes for User `#{user.name}'", - user: user, - source_changes: source_changes, - image_count: image_count - ) + alias Philomena.SourceChanges + alias Philomena.SourceChanges.SourceChangePage + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"profile_id" => slug} = params) do + case SourceChanges.list_user_source_changes( + conn.assigns.actor, + slug, + params, + conn.assigns.scrivener + ) do + {:ok, + %SourceChangePage{ + target: user, + source_changes: source_changes, + image_count: image_count + }, changeset} -> + render(conn, "index.html", + title: "Source Changes for User `#{user.name}'", + user: user, + source_changes: source_changes, + image_count: image_count, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid source change filter.") + |> redirect(to: "/") + + error -> + error + end end - - defp added_filter(query, %{"added" => "1"}), - do: where(query, added: true) - - defp added_filter(query, %{"added" => "0"}), - do: where(query, added: false) - - defp added_filter(query, _params), - do: query end diff --git a/lib/philomena_web/controllers/profile/tag_change/revert_controller.ex b/lib/philomena_web/controllers/profile/tag_change/revert_controller.ex new file mode 100644 index 000000000..22b10d10e --- /dev/null +++ b/lib/philomena_web/controllers/profile/tag_change/revert_controller.ex @@ -0,0 +1,15 @@ +defmodule PhilomenaWeb.Profile.TagChange.RevertController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + + action_fallback PhilomenaWeb.FallbackController + + def create(conn, %{"profile_id" => user_id}) do + with {:ok, _target} <- TagChanges.create_user_tag_change_revert(conn.assigns.actor, user_id) do + conn + |> put_flash(:info, "Reversion of tag changes enqueued.") + |> redirect(external: conn.assigns.referrer) + end + end +end diff --git a/lib/philomena_web/controllers/profile/tag_change_controller.ex b/lib/philomena_web/controllers/profile/tag_change_controller.ex new file mode 100644 index 000000000..ffd849876 --- /dev/null +++ b/lib/philomena_web/controllers/profile/tag_change_controller.ex @@ -0,0 +1,38 @@ +defmodule PhilomenaWeb.Profile.TagChangeController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChangePage + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"profile_id" => profile_id} = params) do + case TagChanges.list_user_tag_changes( + conn.assigns.actor, + profile_id, + params, + conn.assigns.pagination + ) do + {:ok, %TagChangePage{target: user, tag_changes: tag_changes}, changeset} -> + path = ~p"/profiles/#{user}/tag_changes" + + conn + |> put_view(PhilomenaWeb.TagChangeView) + |> render("index.html", + title: "Tag Changes for User `#{user.name}'", + path: path, + pagination_route: fn query -> "#{path}?#{Plug.Conn.Query.encode(query)}" end, + tag_changes: tag_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid tag change query.") + |> redirect(to: "/tag_changes") + + error -> + error + end + end +end diff --git a/lib/philomena_web/controllers/profile_controller.ex b/lib/philomena_web/controllers/profile_controller.ex index 8e5301aab..05479c918 100644 --- a/lib/philomena_web/controllers/profile_controller.ex +++ b/lib/philomena_web/controllers/profile_controller.ex @@ -1,292 +1,99 @@ defmodule PhilomenaWeb.ProfileController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias PhilomenaWeb.CommentLoader - alias PhilomenaQuery.Search + alias PhilomenaWeb.ImageScope alias PhilomenaWeb.MarkdownRenderer - alias Philomena.UserStatistics.UserStatistic - alias Philomena.Users.User - alias Philomena.Bans - alias Philomena.Galleries.Gallery - alias Philomena.Posts.Post - alias Philomena.Comments.Comment - alias Philomena.Interactions - alias Philomena.Tags.Tag - alias Philomena.UserIps.UserIp - alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.ModNotes.ModNote - alias Philomena.ModNotes - alias Philomena.UserNameChanges.UserNameChange - alias Philomena.Images.Image - alias Philomena.Repo - import Ecto.Query + alias Philomena.Profiles - plug :load_and_authorize_resource, - model: User, - only: :show, - id_field: "slug", - preload: [ - awards: [:badge, :awarded_by], - public_links: :tag, - verified_links: :tag, - commission: [ - sheet_image: [:sources, tags: :aliases], - items: [example_image: [:sources, tags: :aliases]] - ] - ] + action_fallback PhilomenaWeb.FallbackController - plug :set_admin_metadata - plug :set_mod_notes - plug :set_name_changes + def show(conn, %{"id" => slug}) do + with {:ok, page} <- + Profiles.show_profile( + conn.assigns.actor, + ImageScope.search_scope(conn), + conn.assigns.current_filter, + slug + ) do + user = page.user - def show(conn, _params) do - current_user = conn.assigns.current_user - user = Repo.preload(conn.assigns.user, [:forced_filter]) + rendered_comments = MarkdownRenderer.render_collection(page.recent_comments, conn) + recent_comments = Enum.zip(rendered_comments, page.recent_comments) - {:ok, {recent_uploads, _tags}} = - ImageLoader.search_string( - conn, - "uploader_id:#{user.id}", - pagination: %{page_number: 1, page_size: 4} - ) + about_me = MarkdownRenderer.render_one(%{body: user.description || ""}, conn) + scratchpad = MarkdownRenderer.render_one(%{body: user.scratchpad || ""}, conn) + commission_information = commission_info(user.commission, conn) - {:ok, {recent_faves, _tags}} = - ImageLoader.search_string( - conn, - "faved_by_id:#{user.id}", - pagination: %{page_number: 1, page_size: 4} - ) - - tags = tags(conn.assigns.user.public_links) - - all_tag_ids = - conn.assigns.user.verified_links - |> tags() - |> Enum.map(& &1.id) - - watcher_counts = - Tag - |> where([t], t.id in ^all_tag_ids) - |> join( - :inner_lateral, - [t], - _ in fragment("SELECT count(*) FROM users WHERE watched_tag_ids @> ARRAY[?]", t.id), - on: true - ) - |> select([t, c], {t.id, c.count}) - |> Repo.all() - |> Map.new() - - recent_artwork = recent_artwork(conn, tags) - - recent_comments = - CommentLoader.query( - conn, - [ - %{term: %{author_id: user.id}}, - %{term: %{hidden_from_users: false}} - ], - pagination: %{page_size: 3}, - show_hidden: false - ) - - recent_posts = - Search.search_definition( - Post, - %{ - query: %{ - bool: %{ - must: [ - %{term: %{author_id: user.id}}, - %{term: %{hidden_from_users: false}}, - %{term: %{access_level: "normal"}} - ] - } - }, - sort: %{created_at: :desc} - }, - %{page_size: 6} - ) - - [recent_uploads, recent_faves, recent_artwork, recent_comments, recent_posts] = - Search.msearch_records( - [recent_uploads, recent_faves, recent_artwork, recent_comments, recent_posts], + assigns = [ - preload(Image, [:sources, tags: :aliases]), - preload(Image, [:sources, tags: :aliases]), - preload(Image, [:sources, tags: :aliases]), - preload(Comment, [ - :deleted_by, - user: [awards: :badge], - image: [:sources, tags: :aliases] - ]), - preload(Post, [:deleted_by, user: [awards: :badge], topic: :forum]) - ] - ) - - recent_posts = Enum.filter(recent_posts, &Canada.Can.can?(current_user, :show, &1.topic)) - - recent_comments = - recent_comments - |> Enum.filter(&Canada.Can.can?(current_user, :show, &1.image)) - |> MarkdownRenderer.render_collection(conn) - |> Enum.zip(recent_comments) - - about_me = MarkdownRenderer.render_one(%{body: user.description || ""}, conn) - - scratchpad = MarkdownRenderer.render_one(%{body: user.scratchpad || ""}, conn) - - commission_information = commission_info(user.commission, conn) - - recent_galleries = - Gallery - |> where(user_id: ^user.id, anonymous: false) - |> preload(thumbnail: [:sources, tags: :aliases]) - |> limit(4) - |> Repo.all() - - statistics = calculate_statistics(user) - - interactions = - Interactions.user_interactions([recent_uploads, recent_faves, recent_artwork], current_user) - - forced = user.forced_filter - - bans = - Bans.User - |> where(user_id: ^user.id) - |> order_by(desc: :created_at) - |> Repo.all() - - render( - conn, - "show.html", - user: user, - interactions: interactions, - commission_information: commission_information, - recent_artwork: recent_artwork, - recent_uploads: recent_uploads, - recent_faves: recent_faves, - recent_comments: recent_comments, - recent_posts: recent_posts, - recent_galleries: recent_galleries, - statistics: statistics, - watcher_counts: watcher_counts, - about_me: about_me, - scratchpad: scratchpad, - tags: tags, - forced: forced, - bans: bans, - layout_class: "layout--medium", - title: "#{user.name}'s profile" - ) - end - - defp calculate_statistics(user) do - today = Date.utc_today() - - last_90 = - UserStatistic - |> where(user_id: ^user.id) - |> where([us], us.day >= ^Date.add(today, -89)) - |> Repo.all() - |> Map.new(&{Date.diff(today, &1.day), &1}) - - %{ - images_count: individual_stat(last_90, :images_count), - image_faves_count: individual_stat(last_90, :image_faves_count), - comments_count: individual_stat(last_90, :comments_count), - image_votes_count: individual_stat(last_90, :image_votes_count), - metadata_updates_count: individual_stat(last_90, :metadata_updates_count), - posts_count: individual_stat(last_90, :posts_count) - } - end - - defp individual_stat(mapping, stat_name) do - Enum.map(89..0//-1, &(map_fetch(mapping[&1], stat_name) || 0)) + user: user, + interactions: page.interactions, + commission_information: commission_information, + recent_artwork: page.recent_artwork, + recent_uploads: page.recent_uploads, + recent_faves: page.recent_faves, + recent_comments: recent_comments, + recent_posts: page.recent_posts, + recent_galleries: page.recent_galleries, + statistics: page.statistics, + watcher_counts: page.watcher_counts, + about_me: about_me, + scratchpad: scratchpad, + tags: page.tags, + forced: user.forced_filter, + bans: page.bans, + layout_class: "layout--medium", + title: "#{user.name}'s profile" + ] ++ admin_assigns(conn, user) + + render(conn, "show.html", assigns) + end end - defp map_fetch(nil, _field_name), do: nil - defp map_fetch(map, field_name), do: Map.get(map, field_name) - - defp commission_info(%{information: info}, conn) - when info not in [nil, ""], - do: MarkdownRenderer.render_one(%{body: info}, conn) - - defp commission_info(_commission, _conn), do: "" - - defp tags([]), do: [] - defp tags(links), do: Enum.map(links, & &1.tag) |> Enum.reject(&is_nil/1) - - defp recent_artwork(_conn, []) do - Search.search_definition(Image, %{query: %{match_none: %{}}}) + # Admin-only strips assemble their data behind the same permission checks the + # view uses to show them, so the assigns are present exactly when the view + # reads them. + defp admin_assigns(conn, user) do + viewer = conn.assigns.actor + renderer = &MarkdownRenderer.render_collection(&1, conn) + + [] + |> put_admin_metadata(viewer, user) + |> put_mod_notes(viewer, user, renderer) + |> put_name_changes(viewer, user) end - defp recent_artwork(conn, tags) do - {images, _tags} = - ImageLoader.query( - conn, - %{terms: %{tag_ids: Enum.map(tags, & &1.id)}}, - pagination: %{page_number: 1, page_size: 4} - ) + defp put_admin_metadata(assigns, viewer, user) do + case Profiles.load_admin_metadata(viewer, user) do + {:error, _reason} -> + assigns - images + {:ok, metadata} -> + [ + filter: metadata.filter, + last_ip: metadata.last_ip, + last_fingerprint: metadata.last_fingerprint + ] ++ assigns + end end - defp set_admin_metadata(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, User) do - user = Repo.preload(conn.assigns.user, [:current_filter]) - filter = user.current_filter - - last_ip = - UserIp - |> where(user_id: ^user.id) - |> order_by(desc: :updated_at) - |> limit(1) - |> Repo.one() - - last_fp = - UserFingerprint - |> where(user_id: ^user.id) - |> order_by(desc: :updated_at) - |> limit(1) - |> Repo.one() - - conn - |> assign(:filter, filter) - |> assign(:last_ip, last_ip) - |> assign(:last_fp, last_fp) - else - conn + defp put_mod_notes(assigns, viewer, user, renderer) do + case Profiles.load_mod_notes(viewer, user, renderer) do + {:ok, mod_notes} -> [{:mod_notes, mod_notes} | assigns] + {:error, _reason} -> assigns end end - defp set_mod_notes(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, ModNote) do - renderer = &MarkdownRenderer.render_collection(&1, conn) - user = conn.assigns.user - - mod_notes = ModNotes.list_all_mod_notes_for_target(renderer, user_id: user.id) - assign(conn, :mod_notes, mod_notes) - else - conn + defp put_name_changes(assigns, viewer, user) do + case Profiles.load_name_changes(viewer, user) do + {:ok, name_changes} -> [{:name_changes, name_changes} | assigns] + {:error, _reason} -> assigns end end - defp set_name_changes(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :index, UserNameChange) do - user = conn.assigns.user - - name_changes = - UserNameChange - |> where(user_id: ^user.id) - |> order_by(desc: :id) - |> Repo.all() + defp commission_info(%{information: info}, conn) + when info not in [nil, ""], + do: MarkdownRenderer.render_one(%{body: info}, conn) - assign(conn, :name_changes, name_changes) - else - conn - end - end + defp commission_info(_commission, _conn), do: "" end diff --git a/lib/philomena_web/controllers/reactivation_controller.ex b/lib/philomena_web/controllers/reactivation_controller.ex index 6e669bd38..b192cad49 100644 --- a/lib/philomena_web/controllers/reactivation_controller.ex +++ b/lib/philomena_web/controllers/reactivation_controller.ex @@ -1,6 +1,5 @@ defmodule PhilomenaWeb.ReactivationController do use PhilomenaWeb, :controller - alias Philomena.Users.{User} alias Philomena.Users def show(conn, %{"id" => _}) do @@ -8,12 +7,7 @@ defmodule PhilomenaWeb.ReactivationController do end def create(conn, %{"token" => token}) do - with user = %User{} <- Users.get_user_by_reactivation_token(token) do - Users.reactivate_user(user) - else - nil -> - nil - end + Users.create_reactivation(token) conn |> put_flash(:info, "If the token provided was valid, your account has been reactivated.") diff --git a/lib/philomena_web/controllers/registration/email_controller.ex b/lib/philomena_web/controllers/registration/email_controller.ex index 0c696906d..9eac88719 100644 --- a/lib/philomena_web/controllers/registration/email_controller.ex +++ b/lib/philomena_web/controllers/registration/email_controller.ex @@ -6,7 +6,7 @@ defmodule PhilomenaWeb.Registration.EmailController do def create(conn, %{"current_password" => password, "user" => user_params}) do user = conn.assigns.current_user - case Users.apply_user_email(user, password, user_params) do + case Users.create_email(user, password, user_params) do {:ok, applied_user} -> Users.deliver_update_email_instructions( applied_user, @@ -29,7 +29,7 @@ defmodule PhilomenaWeb.Registration.EmailController do end def show(conn, %{"id" => token}) do - case Users.update_user_email(conn.assigns.current_user, token) do + case Users.show_email(conn.assigns.current_user, token) do :ok -> conn |> put_flash(:info, "Email changed successfully.") diff --git a/lib/philomena_web/controllers/registration/name_controller.ex b/lib/philomena_web/controllers/registration/name_controller.ex index df748e93c..68d865159 100644 --- a/lib/philomena_web/controllers/registration/name_controller.ex +++ b/lib/philomena_web/controllers/registration/name_controller.ex @@ -3,32 +3,27 @@ defmodule PhilomenaWeb.Registration.NameController do alias Philomena.Users - plug PhilomenaWeb.FilterBannedUsersPlug - plug :verify_authorized + action_fallback PhilomenaWeb.FallbackController def edit(conn, _params) do - changeset = Users.change_user(conn.assigns.current_user) - - render(conn, "edit.html", title: "Editing Name", changeset: changeset) + with {:ok, %Ecto.Changeset{} = changeset} <- + Users.edit_name(conn.assigns.actor) do + render(conn, "edit.html", title: "Editing Name", changeset: changeset) + end end def update(conn, %{"user" => user_params}) do - case Users.update_name(conn.assigns.current_user, user_params) do + case Users.update_name(conn.assigns.actor, user_params) do {:ok, user} -> conn |> put_flash(:info, "Name successfully updated.") |> redirect(to: ~p"/profiles/#{user}") - {:error, changeset} -> + {:error, %Ecto.Changeset{} = changeset} -> render(conn, "edit.html", changeset: changeset) - end - end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :change_username, conn.assigns.current_user) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/registration/password_controller.ex b/lib/philomena_web/controllers/registration/password_controller.ex index 6e3ba04a8..924050d49 100644 --- a/lib/philomena_web/controllers/registration/password_controller.ex +++ b/lib/philomena_web/controllers/registration/password_controller.ex @@ -4,12 +4,10 @@ defmodule PhilomenaWeb.Registration.PasswordController do alias Philomena.Users alias PhilomenaWeb.UserAuth - plug PhilomenaWeb.CompromisedPasswordCheckPlug when action in [:update] - def update(conn, %{"current_password" => password, "user" => user_params}) do user = conn.assigns.current_user - case Users.update_user_password(user, password, user_params) do + case Users.update_password(user, password, user_params) do {:ok, user} -> conn |> put_flash(:info, "Password updated successfully.") diff --git a/lib/philomena_web/controllers/registration/totp_controller.ex b/lib/philomena_web/controllers/registration/totp_controller.ex index 6e0535470..40293c614 100644 --- a/lib/philomena_web/controllers/registration/totp_controller.ex +++ b/lib/philomena_web/controllers/registration/totp_controller.ex @@ -4,22 +4,19 @@ defmodule PhilomenaWeb.Registration.TotpController do alias PhilomenaWeb.UserAuth alias Philomena.Users.User alias Philomena.Users - alias Philomena.Repo def edit(conn, _params) do user = conn.assigns.current_user case user.encrypted_otp_secret do nil -> - user - |> User.create_totp_secret_changeset() - |> Repo.update() + {:ok, _user} = Users.edit_totp(user) # Redirect to have the conn pick up the changes redirect(conn, to: ~p"/registrations/totp/edit") _ -> - changeset = Users.change_user(user) + changeset = Users.totp_changeset(user) secret = User.totp_secret(user) qrcode = User.totp_qrcode(user) @@ -33,19 +30,13 @@ defmodule PhilomenaWeb.Registration.TotpController do end def update(conn, %{"user" => %{"current_password" => _password}} = params) do - backup_codes = User.random_backup_codes() user = conn.assigns.current_user - user - |> User.totp_changeset(params, backup_codes) - |> Repo.update() - |> case do + case Users.update_totp(user, params) do {:error, changeset} -> render_edit(conn, user, changeset) - {:ok, user} -> - Users.reindex_user(user) - + {:ok, user, backup_codes} -> conn |> put_flash(:totp_backup_codes, backup_codes) |> put_session(:user_return_to, ~p"/registrations/totp/edit") diff --git a/lib/philomena_web/controllers/registration_controller.ex b/lib/philomena_web/controllers/registration_controller.ex index 09a893853..10dac9ec7 100644 --- a/lib/philomena_web/controllers/registration_controller.ex +++ b/lib/philomena_web/controllers/registration_controller.ex @@ -5,18 +5,19 @@ defmodule PhilomenaWeb.RegistrationController do alias Philomena.Users alias Philomena.Users.User + action_fallback PhilomenaWeb.FallbackController + plug PhilomenaWeb.CaptchaPlug when action in [:new, :create] plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CompromisedPasswordCheckPlug when action in [:create] - plug :assign_email_and_password_changesets when action in [:edit] def new(conn, _params) do - changeset = Users.change_user_registration(%User{}) - render(conn, "new.html", changeset: changeset) + with {:ok, changeset} <- Users.new_registration(conn.assigns.actor, %User{}) do + render(conn, "new.html", changeset: changeset) + end end def create(conn, %{"user" => user_params}) do - case Users.register_user(user_params) do + case Users.create_registration(conn.assigns.actor, user_params) do {:ok, user} -> UserAuth.update_usages(conn, user) @@ -35,18 +36,21 @@ defmodule PhilomenaWeb.RegistrationController do {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) + + error -> + error end end def edit(conn, _params) do - render(conn, "edit.html", title: "Account Settings") - end - - defp assign_email_and_password_changesets(conn, _opts) do user = conn.assigns.current_user - conn - |> assign(:email_changeset, Users.change_user_email(user)) - |> assign(:password_changeset, Users.change_user_password(user)) + render( + conn, + "edit.html", + title: "Account Settings", + email_changeset: Users.change_user_email(user), + password_changeset: Users.edit_password(user) + ) end end diff --git a/lib/philomena_web/controllers/report_controller.ex b/lib/philomena_web/controllers/report_controller.ex index d3d0e74a4..1258263a5 100644 --- a/lib/philomena_web/controllers/report_controller.ex +++ b/lib/philomena_web/controllers/report_controller.ex @@ -1,24 +1,15 @@ defmodule PhilomenaWeb.ReportController do use PhilomenaWeb, :controller - alias Philomena.Reports.Report alias Philomena.Reports - alias Philomena.Repo - import Ecto.Query + alias Philomena.Reports.ReportForm - def index(conn, _params) do - user = conn.assigns.current_user - - reports = - Report - |> where(user_id: ^user.id) - |> order_by(desc: :created_at) - |> preload(:rule) - |> Repo.paginate(conn.assigns.scrivener) - - reports = %{reports | entries: Reports.preload_targets(reports)} + action_fallback PhilomenaWeb.FallbackController - render(conn, "index.html", title: "My Reports", reports: reports) + def index(conn, _params) do + with {:ok, reports} <- Reports.list_user_reports(conn.assigns.actor, conn.assigns.scrivener) do + render(conn, "index.html", title: "My Reports", reports: reports) + end end # Make sure that you load the resource in your controller: @@ -29,77 +20,42 @@ defmodule PhilomenaWeb.ReportController do # plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] # plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true - def create(conn, action, subject, target, %{"report" => report_params}) do - attribution = conn.assigns.attributes - - if too_many_reports?(conn) do - conn - |> put_flash( - :error, - "You may not have more than #{max_reports()} open reports at a time. Did you read the reporting tips?" - ) - |> redirect(to: "/") - else - case Reports.create_report(attribution, report_params, target) do - {:ok, _report} -> - conn - |> put_flash( - :info, - "Your report has been received and will be checked by staff shortly." - ) - |> redirect(to: redirect_path(conn.assigns.current_user)) - - {:error, changeset} -> - # The calling controllers are thin wrappers with no view of their own, - # so Phoenix's default view - derived from the caller's name - does - # not exist. Name the shared one explicitly. - conn - |> put_view(PhilomenaWeb.ReportView) - |> render("new.html", subject: subject, changeset: changeset, action: action) - end - end - end - - defp too_many_reports?(conn) do - user = conn.assigns.current_user - - case user do - %{role: role} when role != "user" -> - false - - _user -> - too_many_reports_user?(user) or too_many_reports_ip?(conn) + def create(conn, locator, action_for_target, params) do + case Reports.create_report(conn.assigns.actor, locator, params["report"]) do + {:ok, _report} -> + conn + |> put_flash( + :info, + "Your report has been received and will be checked by staff shortly." + ) + |> redirect(to: redirect_path(conn.assigns.current_user)) + + {:error, :too_many_reports} -> + conn + |> put_flash( + :error, + "You may not have more than #{Reports.max_open_reports()} open reports at a time. Did you read the reporting tips?" + ) + |> redirect(to: "/") + + {:error, %ReportForm{target: target, changeset: changeset, rules: rules}} -> + # The calling controllers are thin wrappers with no view of their own, + # so Phoenix's default view - derived from the caller's name - does + # not exist. Name the shared one explicitly. + conn + |> put_view(PhilomenaWeb.ReportView) + |> render("new.html", + subject: target, + changeset: changeset, + rules: rules, + action: action_for_target.(target) + ) + + {:error, _reason} = error -> + error end end - defp too_many_reports_user?(nil), do: false - - defp too_many_reports_user?(user) do - reports_open = - Report - |> where(user_id: ^user.id) - |> where([r], r.state in ["open", "in_progress"]) - |> Repo.aggregate(:count, :id) - - reports_open >= max_reports() - end - - defp too_many_reports_ip?(conn) do - attribution = conn.assigns.attributes - - reports_open = - Report - |> where(ip: ^attribution[:ip]) - |> where([r], r.state in ["open", "in_progress"]) - |> Repo.aggregate(:count, :id) - - reports_open >= max_reports() - end - defp redirect_path(nil), do: "/" defp redirect_path(_user), do: ~p"/reports" - - defp max_reports do - 5 - end end diff --git a/lib/philomena_web/controllers/rule_controller.ex b/lib/philomena_web/controllers/rule_controller.ex index 42a650551..5b2382d4b 100644 --- a/lib/philomena_web/controllers/rule_controller.ex +++ b/lib/philomena_web/controllers/rule_controller.ex @@ -2,23 +2,14 @@ defmodule PhilomenaWeb.RuleController do use PhilomenaWeb, :controller alias Philomena.Rules - alias Philomena.Rules.Rule alias PhilomenaWeb.MarkdownRenderer - plug :load_and_authorize_resource, - model: Rule, - id_field: "position", - except: [:index] - - plug :check_permission when action in [:show] + action_fallback PhilomenaWeb.FallbackController def index(conn, _params) do rules = - if Canada.Can.can?(conn.assigns.current_user, :edit, Rule) do - Rules.list_rules() - else - Rules.list_visible_rules() - end + conn.assigns.actor + |> Rules.list_rules_for() |> Enum.map(&render_rule(&1, conn)) last_updated_at = @@ -30,12 +21,13 @@ defmodule PhilomenaWeb.RuleController do end def new(conn, _params) do - changeset = Rules.change_rule(%Rule{}) - render(conn, :new, changeset: changeset) + with {:ok, changeset} <- Rules.new_rule(conn.assigns.actor) do + render(conn, :new, changeset: changeset) + end end def create(conn, %{"rule" => rule_params}) do - case Rules.create_rule_with_version(rule_params, conn.assigns.current_user) do + case Rules.create_rule(conn.assigns.actor, rule_params) do {:ok, [rule, _version]} -> conn |> put_flash(:info, "Rule created successfully.") @@ -43,40 +35,47 @@ defmodule PhilomenaWeb.RuleController do {:error, %Ecto.Changeset{} = changeset} -> render(conn, :new, changeset: changeset) + + {:error, _} = error -> + error end end def show(conn, %{"id" => id}) do - rule = - id - |> Rules.get_by_position!() - |> render_rule(conn) + case Rules.show_rule(conn.assigns.actor, id) do + {:ok, rule} -> + rule = render_rule(rule, conn) - versions = - rule - |> Rules.list_rule_versions() - |> generate_diff() + versions = + rule + |> Rules.list_rule_versions() + |> generate_diff() + + render(conn, :show, rule: rule, versions: versions) - render(conn, :show, rule: rule, versions: versions) + {:error, _} = error -> + error + end end def edit(conn, %{"id" => id}) do - rule = Rules.get_by_position!(id) - changeset = Rules.change_rule(rule) - render(conn, :edit, rule: rule, changeset: changeset) + with {:ok, {rule, changeset}} <- Rules.edit_rule(conn.assigns.actor, id) do + render(conn, :edit, rule: rule, changeset: changeset) + end end def update(conn, %{"id" => id, "rule" => rule_params}) do - rule = Rules.get_by_position!(id) - - case Rules.update_rule_with_version(rule, conn.assigns.current_user, rule_params) do + case Rules.update_rule(conn.assigns.actor, id, rule_params) do {:ok, [rule, _version]} -> conn |> put_flash(:info, "Rule updated successfully.") |> redirect(to: ~p"/rules/#{rule}") - {:error, %Ecto.Changeset{} = changeset} -> + {:error, {rule, %Ecto.Changeset{} = changeset}} -> render(conn, :edit, rule: rule, changeset: changeset) + + {:error, _} = error -> + error end end @@ -139,22 +138,4 @@ defmodule PhilomenaWeb.RuleController do # Reverse back to have newest first |> Enum.reverse() end - - defp check_permission(conn, _opts) do - id = conn.params["id"] - rule = Rules.get_by_position!(id) - - if rule.hidden or rule.internal do - if Canada.Can.can?(conn.assigns.current_user, :edit, rule) do - conn - else - conn - |> put_flash(:error, "You do not have permission to view that rule.") - |> redirect(to: ~p"/rules") - |> halt() - end - else - conn - end - end end diff --git a/lib/philomena_web/controllers/search/reverse_controller.ex b/lib/philomena_web/controllers/search/reverse_controller.ex index 0938642ab..21e56e41f 100644 --- a/lib/philomena_web/controllers/search/reverse_controller.ex +++ b/lib/philomena_web/controllers/search/reverse_controller.ex @@ -1,8 +1,8 @@ defmodule PhilomenaWeb.Search.ReverseController do use PhilomenaWeb, :controller - alias Philomena.DuplicateReports.SearchQuery alias Philomena.DuplicateReports + alias Philomena.DuplicateReports.SearchResult alias Philomena.Interactions plug PhilomenaWeb.ScraperCachePlug @@ -14,16 +14,17 @@ defmodule PhilomenaWeb.Search.ReverseController do def create(conn, %{"image" => image_params}) when is_map(image_params) and image_params != %{} do - case DuplicateReports.execute_search_query(image_params) do - {:ok, images} -> - changeset = DuplicateReports.change_search_query(%SearchQuery{}) - interactions = Interactions.user_interactions(images, conn.assigns.current_user) + upload = PhilomenaMedia.Upload.cast(image_params, "image") + + case DuplicateReports.create_reverse_search(conn.assigns.actor, image_params, upload) do + {:ok, %SearchResult{} = result} -> + interactions = Interactions.user_interactions(conn.assigns.actor, result.images) render(conn, "index.html", title: "Reverse Search", layout_class: "layout--wide", - images: images, - changeset: changeset, + images: result.images, + changeset: result.changeset, interactions: interactions ) @@ -38,13 +39,14 @@ defmodule PhilomenaWeb.Search.ReverseController do end def create(conn, _params) do - changeset = DuplicateReports.change_search_query(%SearchQuery{}) - - render(conn, "index.html", - title: "Reverse Search", - layout_class: "layout--wide", - images: nil, - changeset: changeset - ) + with {:ok, %SearchResult{} = result} <- + DuplicateReports.create_reverse_search(conn.assigns.actor) do + render(conn, "index.html", + title: "Reverse Search", + layout_class: "layout--wide", + images: result.images, + changeset: result.changeset + ) + end end end diff --git a/lib/philomena_web/controllers/search_controller.ex b/lib/philomena_web/controllers/search_controller.ex index cb82c7d61..dcdc556dc 100644 --- a/lib/philomena_web/controllers/search_controller.ex +++ b/lib/philomena_web/controllers/search_controller.ex @@ -1,30 +1,20 @@ defmodule PhilomenaWeb.SearchController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias Philomena.Images.Image - alias PhilomenaQuery.Search + alias PhilomenaWeb.ImageScope + alias PhilomenaWeb.TagInfoRenderer + alias Philomena.Images alias Philomena.Interactions - import Ecto.Query def index(conn, params) do - user = conn.assigns.current_user + case Images.query_images(conn.assigns.actor, ImageScope.search_scope(conn)) do + {:ok, %{images: images, tags: tags}} -> + interactions = Interactions.user_interactions(conn.assigns.actor, images) - case ImageLoader.search_string(conn, params["q"]) do - {:ok, {images, tags}} -> - images = - search_function(custom_ordering?(conn)).( - images, - preload(Image, [:sources, tags: :aliases]) - ) - - interactions = Interactions.user_interactions(images, user) - - conn - |> render("index.html", + render(conn, "index.html", title: "Searching for #{params["q"]}", images: images, - tags: tags, + tags: TagInfoRenderer.render_tag_info(tags, conn), search_query: params["q"], interactions: interactions, layout_class: "layout--wide" @@ -40,10 +30,4 @@ defmodule PhilomenaWeb.SearchController do ) end end - - defp search_function(true), do: &Search.search_records_with_hits/2 - defp search_function(_custom), do: &Search.search_records/2 - - defp custom_ordering?(%{params: %{"sf" => sf}}) when sf not in ~W(id first_seen_at), do: true - defp custom_ordering?(_conn), do: false end diff --git a/lib/philomena_web/controllers/session/totp_controller.ex b/lib/philomena_web/controllers/session/totp_controller.ex index 4185659d7..b14a10e3b 100644 --- a/lib/philomena_web/controllers/session/totp_controller.ex +++ b/lib/philomena_web/controllers/session/totp_controller.ex @@ -3,26 +3,21 @@ defmodule PhilomenaWeb.Session.TotpController do alias PhilomenaWeb.LayoutView alias PhilomenaWeb.UserAuth - alias Philomena.Users.User alias Philomena.Users - alias Philomena.Repo def new(conn, _params) do - changeset = Users.change_user(conn.assigns.current_user) + changeset = Users.totp_changeset(conn.assigns.current_user) render(conn, "new.html", layout: {LayoutView, "two_factor.html"}, changeset: changeset) end def create(conn, %{"user" => user_params} = params) when is_map(user_params) do - conn.assigns.current_user - |> User.consume_totp_token_changeset(params) - |> Repo.update() - |> case do - {:error, _changeset} -> - invalid_token(conn) - + case Users.create_session_totp(conn.assigns.current_user, params) do {:ok, user} -> UserAuth.totp_auth_user(conn, user, user_params) + + {:error, _changeset} -> + invalid_token(conn) end end diff --git a/lib/philomena_web/controllers/session_controller.ex b/lib/philomena_web/controllers/session_controller.ex index c9a3b02c6..8c3073ff7 100644 --- a/lib/philomena_web/controllers/session_controller.ex +++ b/lib/philomena_web/controllers/session_controller.ex @@ -3,7 +3,6 @@ defmodule PhilomenaWeb.SessionController do alias Philomena.Users alias PhilomenaWeb.UserAuth - alias PhilomenaWeb.CompromisedPasswordCheckPlug plug PhilomenaWeb.CaptchaPlug when action in [:new, :create] plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] @@ -15,17 +14,13 @@ defmodule PhilomenaWeb.SessionController do def create(conn, %{"user" => user_params}) do %{"email" => email, "password" => password} = user_params - user = - Users.get_user_by_email_and_password( - email, - password, - &url(~p"/unlocks/#{&1}") - ) - - cond do - not is_nil(user) and CompromisedPasswordCheckPlug.password_compromised?(password) -> - Users.delete_user_sessions(user) + case Users.fetch_user_by_email_and_password(email, password, &url(~p"/unlocks/#{&1}")) do + {:ok, user} -> + conn + |> put_flash(:info, "Successfully logged in.") + |> UserAuth.log_in_user(user, user_params) + {:error, :password_compromised} -> conn |> put_flash( :error, @@ -33,19 +28,14 @@ defmodule PhilomenaWeb.SessionController do ) |> redirect(to: ~p"/passwords/new") - not is_nil(user) and is_nil(user.confirmed_at) -> + {:error, :unconfirmed} -> render( conn, "new.html", error_message: "You must confirm your account before logging in." ) - not is_nil(user) -> - conn - |> put_flash(:info, "Successfully logged in.") - |> UserAuth.log_in_user(user, user_params) - - true -> + {:error, :not_found} -> render( conn, "new.html", diff --git a/lib/philomena_web/controllers/setting_controller.ex b/lib/philomena_web/controllers/setting_controller.ex index cdcc8f7f0..453e93b57 100644 --- a/lib/philomena_web/controllers/setting_controller.ex +++ b/lib/philomena_web/controllers/setting_controller.ex @@ -13,7 +13,7 @@ defmodule PhilomenaWeb.SettingController do changeset = %{user | settings: assign_theme(user.settings)} |> TagList.assign_tag_list(:watched_tag_ids, :watched_tag_list) - |> Users.change_user() + |> Users.settings_changeset() render(conn, "edit.html", title: "Editing Settings", changeset: changeset) end @@ -98,8 +98,8 @@ defmodule PhilomenaWeb.SettingController do defp maybe_update_user(conn, nil, _user_params), do: {:ok, conn} - defp maybe_update_user(conn, user, user_params) do - case Users.update_settings(user, determine_theme(user_params)) do + defp maybe_update_user(conn, _user, user_params) do + case Users.update_settings(conn.assigns.actor, determine_theme(user_params)) do {:ok, _user} -> {:ok, conn} diff --git a/lib/philomena_web/controllers/staff_controller.ex b/lib/philomena_web/controllers/staff_controller.ex index 2d363f84e..e27dd9250 100644 --- a/lib/philomena_web/controllers/staff_controller.ex +++ b/lib/philomena_web/controllers/staff_controller.ex @@ -1,47 +1,9 @@ defmodule PhilomenaWeb.StaffController do use PhilomenaWeb, :controller - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query + alias Philomena.Users def index(conn, _params) do - users = - User - |> where([u], u.role in ["admin", "moderator", "assistant"]) - |> order_by(asc: :name) - |> Repo.all() - - categories = [ - Administrators: Enum.filter(users, &(&1.role == "admin" and &1.hide_default_role == false)), - "Technical Team": - Enum.filter( - users, - &(&1.role != "admin" and &1.secondary_role in ["Site Developer", "Devops"]) - ), - "Public Relations": - Enum.filter(users, &(&1.role != "admin" and &1.secondary_role == "Public Relations")), - Moderators: - Enum.filter( - users, - &(&1.role == "moderator" and &1.secondary_role in [nil, ""] and - &1.hide_default_role == false) - ), - Assistants: - Enum.filter( - users, - &(&1.role == "assistant" and &1.secondary_role in [nil, ""] and - &1.hide_default_role == false) - ), - Others: - Enum.filter( - users, - &(&1.role != "user" and - &1.secondary_role not in [nil, "", "Site Developer", "Devops", "Public Relations"] and - &1.hide_default_role == true) - ) - ] - - render(conn, "index.html", title: "Site Staff", categories: categories) + render(conn, "index.html", title: "Site Staff", categories: Users.staff_categories()) end end diff --git a/lib/philomena_web/controllers/tag/alias_controller.ex b/lib/philomena_web/controllers/tag/alias_controller.ex index 934330f48..40b97c667 100644 --- a/lib/philomena_web/controllers/tag/alias_controller.ex +++ b/lib/philomena_web/controllers/tag/alias_controller.ex @@ -1,52 +1,37 @@ defmodule PhilomenaWeb.Tag.AliasController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag alias Philomena.Tags - plug PhilomenaWeb.CanaryMapPlug, edit: :alias, update: :alias, delete: :alias + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Tag, - id_name: "tag_id", - id_field: "slug", - preload: [:implied_tags, :aliased_tag], - persisted: true - - def edit(conn, _params) do - changeset = Tags.change_tag(conn.assigns.tag) - render(conn, "edit.html", title: "Editing Tag Alias", changeset: changeset) + def edit(conn, params) do + with {:ok, {tag, changeset}} <- + Tags.edit_tag_alias(conn.assigns.actor, params["tag_id"]) do + render(conn, "edit.html", title: "Editing Tag Alias", tag: tag, changeset: changeset) + end end - def update(conn, %{"tag" => tag_params}) do - case Tags.alias_tag(conn.assigns.tag, tag_params) do + def update(conn, %{"tag_id" => slug, "tag" => tag_params}) do + case Tags.update_tag_alias(conn.assigns.actor, slug, tag_params) do {:ok, tag} -> conn |> put_flash(:info, "Tag alias queued.") - |> moderation_log(details: &log_details/2, data: tag) |> redirect(to: ~p"/tags/#{tag}/alias/edit") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, tag} = Tags.unalias_tag(conn.assigns.tag) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", tag: changeset.data, changeset: changeset) - conn - |> put_flash(:info, "Tag dealias queued.") - |> moderation_log(details: &log_details/2, data: tag) - |> redirect(to: ~p"/tags/#{tag}") + {:error, _} = error -> + error + end end - defp log_details(action, tag) do - body = - case action do - :update -> "Aliased tag '#{tag.name}' into '#{tag.aliased_tag.name}'" - :delete -> "Dealiased tag '#{tag.name}'" - end - - %{body: body, subject_path: ~p"/tags/#{tag}"} + def delete(conn, params) do + with {:ok, tag} <- Tags.delete_tag_alias(conn.assigns.actor, params["tag_id"]) do + conn + |> put_flash(:info, "Tag dealias successful.") + |> redirect(to: ~p"/tags/#{tag}") + end end end diff --git a/lib/philomena_web/controllers/tag/detail_controller.ex b/lib/philomena_web/controllers/tag/detail_controller.ex index 06a5028d0..35fc16076 100644 --- a/lib/philomena_web/controllers/tag/detail_controller.ex +++ b/lib/philomena_web/controllers/tag/detail_controller.ex @@ -1,50 +1,21 @@ defmodule PhilomenaWeb.Tag.DetailController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag - alias Philomena.Filters.Filter - alias Philomena.Users.User - alias Philomena.Repo - import Ecto.Query - - plug :verify_authorized - plug :load_resource, model: Tag, id_name: "tag_id", id_field: "slug", required: true - - def index(conn, _params) do - tag = conn.assigns.tag - - filters_spoilering = - Filter - |> where([f], fragment("? @> ARRAY[?]::integer[]", f.spoilered_tag_ids, ^tag.id)) - |> preload(:user) - |> Repo.all() - - filters_hiding = - Filter - |> where([f], fragment("? @> ARRAY[?]::integer[]", f.hidden_tag_ids, ^tag.id)) - |> preload(:user) - |> Repo.all() - - users_watching = - User - |> where([u], fragment("? @> ARRAY[?]::integer[]", u.watched_tag_ids, ^tag.id)) - |> Repo.all() - - render( - conn, - "index.html", - title: "Tag Usage for Tag `#{tag.name}'", - filters_spoilering: filters_spoilering, - filters_hiding: filters_hiding, - users_watching: users_watching - ) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, %Tag{}) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + alias Philomena.Tags + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, params) do + with {:ok, detail} <- Tags.list_tag_details(conn.assigns.actor, params["tag_id"]) do + render( + conn, + "index.html", + title: "Tag Usage for Tag `#{detail.tag.name}'", + tag: detail.tag, + filters_spoilering: detail.filters_spoilering, + filters_hiding: detail.filters_hiding, + users_watching: detail.users_watching + ) end end end diff --git a/lib/philomena_web/controllers/tag/image_controller.ex b/lib/philomena_web/controllers/tag/image_controller.ex index 562667de2..77fc7b711 100644 --- a/lib/philomena_web/controllers/tag/image_controller.ex +++ b/lib/philomena_web/controllers/tag/image_controller.ex @@ -1,55 +1,43 @@ defmodule PhilomenaWeb.Tag.ImageController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag alias Philomena.Tags - plug PhilomenaWeb.CanaryMapPlug, update: :edit, delete: :edit + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Tag, - id_name: "tag_id", - id_field: "slug", - preload: [:implied_tags], - persisted: true - - def edit(conn, _params) do - changeset = Tags.change_tag(conn.assigns.tag) - render(conn, "edit.html", title: "Editing Tag Spoiler Image", changeset: changeset) + def edit(conn, params) do + with {:ok, {tag, changeset}} <- + Tags.edit_tag_image(conn.assigns.actor, params["tag_id"]) do + render(conn, "edit.html", + title: "Editing Tag Spoiler Image", + tag: tag, + changeset: changeset + ) + end end - def update(conn, %{"tag" => tag_params}) do - case Tags.update_tag_image(conn.assigns.tag, tag_params) do + def update(conn, %{"tag_id" => slug, "tag" => tag_params}) do + upload = PhilomenaMedia.Upload.cast(tag_params, "image") + + case Tags.update_tag_image(conn.assigns.actor, slug, upload) do {:ok, tag} -> conn |> put_flash(:info, "Tag image successfully updated.") - |> moderation_log(details: &log_details/2, data: tag) |> redirect(to: ~p"/tags/#{tag}") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, tag} = Tags.remove_tag_image(conn.assigns.tag) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", tag: changeset.data, changeset: changeset) - conn - |> put_flash(:info, "Tag image successfully removed.") - |> moderation_log(details: &log_details/2, data: tag) - |> redirect(to: ~p"/tags/#{conn.assigns.tag}") + {:error, _} = error -> + error + end end - defp log_details(action, tag) do - body = - case action do - :update -> "Updated image on tag '#{tag.name}'" - :delete -> "Removed image on tag '#{tag.name}'" - end - - %{ - body: body, - subject_path: ~p"/tags/#{tag}" - } + def delete(conn, params) do + with {:ok, tag} <- Tags.delete_tag_image(conn.assigns.actor, params["tag_id"]) do + conn + |> put_flash(:info, "Tag image successfully removed.") + |> redirect(to: ~p"/tags/#{tag}") + end end end diff --git a/lib/philomena_web/controllers/tag/reindex_controller.ex b/lib/philomena_web/controllers/tag/reindex_controller.ex index b7b277ed9..8e096e254 100644 --- a/lib/philomena_web/controllers/tag/reindex_controller.ex +++ b/lib/philomena_web/controllers/tag/reindex_controller.ex @@ -1,24 +1,15 @@ defmodule PhilomenaWeb.Tag.ReindexController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag alias Philomena.Tags - plug PhilomenaWeb.CanaryMapPlug, create: :alias + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Tag, - id_name: "tag_id", - id_field: "slug", - preload: [:implied_tags, :aliased_tag], - persisted: true - - def create(conn, _params) do - {:ok, tag} = Tags.reindex_tag_images(conn.assigns.tag) - Tags.reindex_tag(tag) - - conn - |> put_flash(:info, "Tag reindex started.") - |> redirect(to: ~p"/tags/#{tag}/edit") + def create(conn, params) do + with {:ok, tag} <- Tags.create_tag_reindex(conn.assigns.actor, params["tag_id"]) do + conn + |> put_flash(:info, "Tag reindex started.") + |> redirect(to: ~p"/tags/#{tag}/edit") + end end end diff --git a/lib/philomena_web/controllers/tag/tag_change_controller.ex b/lib/philomena_web/controllers/tag/tag_change_controller.ex new file mode 100644 index 000000000..b72be6afc --- /dev/null +++ b/lib/philomena_web/controllers/tag/tag_change_controller.ex @@ -0,0 +1,38 @@ +defmodule PhilomenaWeb.Tag.TagChangeController do + use PhilomenaWeb, :controller + + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChangePage + + action_fallback PhilomenaWeb.FallbackController + + def index(conn, %{"tag_id" => tag_id} = params) do + case TagChanges.list_tag_tag_changes( + conn.assigns.actor, + tag_id, + params, + conn.assigns.pagination + ) do + {:ok, %TagChangePage{target: tag, tag_changes: tag_changes}, changeset} -> + path = ~p"/tags/#{tag}/tag_changes" + + conn + |> put_view(PhilomenaWeb.TagChangeView) + |> render("index.html", + title: "Tag Changes for Tag `#{tag.name}'", + path: path, + pagination_route: fn query -> "#{path}?#{Plug.Conn.Query.encode(query)}" end, + tag_changes: tag_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid tag change query.") + |> redirect(to: "/tag_changes") + + error -> + error + end + end +end diff --git a/lib/philomena_web/controllers/tag/watch_controller.ex b/lib/philomena_web/controllers/tag/watch_controller.ex index ccdf020c6..2272ab7c3 100644 --- a/lib/philomena_web/controllers/tag/watch_controller.ex +++ b/lib/philomena_web/controllers/tag/watch_controller.ex @@ -1,36 +1,41 @@ defmodule PhilomenaWeb.Tag.WatchController do use PhilomenaWeb, :controller - alias Philomena.Tags.Tag - alias Philomena.Users + alias Philomena.Tags - plug :load_resource, model: Tag, id_field: "slug", id_name: "tag_id", required: true + action_fallback PhilomenaWeb.FallbackController - def create(conn, _params) do - case Users.watch_tag(conn.assigns.current_user, conn.assigns.tag) do + def create(conn, params) do + case Tags.create_tag_watch(conn.assigns.actor, params["tag_id"]) do {:ok, _user} -> conn |> put_status(:ok) |> text("") - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> conn |> put_status(:internal_server_error) |> text("") + + {:error, _} = error -> + error end end - def delete(conn, _params) do - case Users.unwatch_tag(conn.assigns.current_user, conn.assigns.tag) do + def delete(conn, params) do + case Tags.delete_tag_watch(conn.assigns.actor, params["tag_id"]) do {:ok, _user} -> conn |> put_status(:ok) |> text("") - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> conn |> put_status(:internal_server_error) |> text("") + + {:error, _} = error -> + error end end end diff --git a/lib/philomena_web/controllers/tag_change/full_revert_controller.ex b/lib/philomena_web/controllers/tag_change/full_revert_controller.ex deleted file mode 100644 index 71cca11b4..000000000 --- a/lib/philomena_web/controllers/tag_change/full_revert_controller.ex +++ /dev/null @@ -1,84 +0,0 @@ -defmodule PhilomenaWeb.TagChange.FullRevertController do - use PhilomenaWeb, :controller - - alias Philomena.Users.User - alias Philomena.TagChanges.TagChange - alias Philomena.TagChanges - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - - plug :verify_authorized - plug PhilomenaWeb.UserAttributionPlug - - def create(%{assigns: %{attributes: attributes}} = conn, params) do - attributes = %{ - ip: to_string(attributes[:ip]), - fingerprint: attributes[:fingerprint], - user_id: attributes[:user].id, - batch_size: attributes[:batch_size] || 100 - } - - case revert_target(params) do - nil -> - conn - |> put_flash(:error, "Couldn't revert those tag changes!") - |> redirect(external: conn.assigns.referrer) - - target -> - TagChanges.full_revert(Map.put(target, :attributes, attributes)) - - conn - |> put_flash(:info, "Reversion of tag changes enqueued.") - |> moderation_log( - details: &log_details/2, - data: %{user: conn.assigns.current_user, params: params} - ) - |> redirect(external: conn.assigns.referrer) - end - end - - defp revert_target(%{"user_id" => user_id}), do: %{user_id: user_id} - defp revert_target(%{"ip" => ip}), do: %{ip: ip} - defp revert_target(%{"fingerprint" => fp}), do: %{fingerprint: fp} - defp revert_target(_params), do: nil - - defp verify_authorized(conn, _params) do - if Canada.Can.can?(conn.assigns.current_user, :revert, TagChange) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end - - defp log_details(_action, data) do - {subject, subject_path} = - case data.params do - %{"user_id" => user_id} -> - log_user(user_id) - - %{"ip" => ip} -> - {"ip #{ip}", ~p"/ip_profiles/#{ip}"} - - %{"fingerprint" => fp} -> - {"fingerprint #{fp}", ~p"/fingerprint_profiles/#{fp}"} - end - - %{body: "Reverted all tag changes for #{subject}", subject_path: subject_path} - end - - # The revert is enqueued for whatever id was named, so the log entry has to - # survive an id that names no user. - defp log_user(user_id) do - case load_user(user_id) do - nil -> {"user #{user_id}", ~p"/tag_changes"} - user -> {"user #{user.name}", ~p"/profiles/#{user}"} - end - end - - defp load_user(user_id) do - case IntegerId.parse(user_id) do - {:ok, id} -> Repo.get(User, id) - :error -> nil - end - end -end diff --git a/lib/philomena_web/controllers/tag_change/revert_controller.ex b/lib/philomena_web/controllers/tag_change/revert_controller.ex index 6fbcd85e0..a093d8dd6 100644 --- a/lib/philomena_web/controllers/tag_change/revert_controller.ex +++ b/lib/philomena_web/controllers/tag_change/revert_controller.ex @@ -1,53 +1,24 @@ defmodule PhilomenaWeb.TagChange.RevertController do use PhilomenaWeb, :controller - alias Philomena.TagChanges.TagChange alias Philomena.TagChanges - plug :verify_authorized - plug PhilomenaWeb.UserAttributionPlug + action_fallback PhilomenaWeb.FallbackController - def create(conn, %{"ids" => ids}) when is_list(ids) do - attributes = conn.assigns.attributes - - attributes = %{ - ip: attributes[:ip], - fingerprint: attributes[:fingerprint], - user_id: attributes[:user].id - } - - case TagChanges.mass_revert(ids, attributes) do + def create(conn, params) do + case TagChanges.create_tag_change_revert(conn.assigns.actor, params) do {:ok, tag_changes} -> conn |> put_flash(:info, "Successfully reverted #{length(tag_changes)} tag changes.") - |> moderation_log( - details: &log_details/2, - data: %{user: conn.assigns.current_user, count: length(tag_changes)} - ) |> redirect(external: conn.assigns.referrer) - _error -> - revert_failed(conn) - end - end - - def create(conn, _params), do: revert_failed(conn) - - defp revert_failed(conn) do - conn - |> put_flash(:error, "Couldn't revert those tag changes!") - |> redirect(external: conn.assigns.referrer) - end + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Couldn't revert those tag changes!") + |> redirect(external: conn.assigns.referrer) - defp verify_authorized(conn, _params) do - if Canada.Can.can?(conn.assigns.current_user, :revert, TagChange) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end - - defp log_details(_action, data) do - %{body: "Reverted #{data.count} tag changes", subject_path: ~p"/profiles/#{data.user}"} - end end diff --git a/lib/philomena_web/controllers/tag_change_controller.ex b/lib/philomena_web/controllers/tag_change_controller.ex index 6f676af7e..03bcf2977 100644 --- a/lib/philomena_web/controllers/tag_change_controller.ex +++ b/lib/philomena_web/controllers/tag_change_controller.ex @@ -2,52 +2,45 @@ defmodule PhilomenaWeb.TagChangeController do use PhilomenaWeb, :controller alias Philomena.TagChanges - alias Philomena.TagChanges.TagChange + alias Philomena.TagChanges.TagChangePage - plug :load_and_authorize_resource, - model: TagChange, - only: [:delete], - preload: [:user, :image, tags: [:tag]] + action_fallback PhilomenaWeb.FallbackController def index(conn, params) do - tag_changes = - TagChanges.load( - conn.assigns.current_user, - params, - conn.assigns.pagination - ) - - render(conn, "index.html", - title: "Tag Changes", - tag_changes: tag_changes, - resource_type: params["resource_type"], - resource_id: params["resource_id"] - ) + case TagChanges.list_tag_changes(conn.assigns.actor, params, conn.assigns.pagination) do + {:ok, %TagChangePage{} = page, changeset} -> + render(conn, "index.html", + title: "Tag Changes", + path: ~p"/tag_changes", + pagination_route: fn query -> ~p"/tag_changes?#{query}" end, + tag_changes: page.tag_changes, + changeset: changeset + ) + + {:error, %Ecto.Changeset{}} -> + conn + |> put_flash(:error, "Invalid tag change query.") + |> redirect(to: "/tag_changes") + + error -> + error + end end def delete(conn, params) do - case TagChanges.delete_tag_change(conn.assigns.tag_change) do - {:ok, tag_change} -> + case TagChanges.delete_tag_change(conn.assigns.actor, params["id"]) do + {:ok, _tag_change} -> conn |> put_flash(:info, "Successfully deleted tag change from history.") - |> moderation_log( - details: &log_details/2, - data: tag_change - ) |> redirect(to: params["redirect"]) - _ -> + {:error, %Ecto.Changeset{}} -> conn |> put_flash(:error, "Failed to delete tag change from history.") |> redirect(to: params["redirect"]) - end - end - defp log_details(_action, %{user: %{name: name}, image: image, tags: tags}) do - %{ - body: - "Deleted tag change by #{name} containing #{length(tags)} tags on image #{image.id} from history", - subject_path: ~p"/images/#{image}" - } + {:error, _} = error -> + error + end end end diff --git a/lib/philomena_web/controllers/tag_controller.ex b/lib/philomena_web/controllers/tag_controller.ex index f404994fb..90df5265c 100644 --- a/lib/philomena_web/controllers/tag_controller.ex +++ b/lib/philomena_web/controllers/tag_controller.ex @@ -1,185 +1,97 @@ defmodule PhilomenaWeb.TagController do use PhilomenaWeb, :controller - alias PhilomenaWeb.ImageLoader - alias PhilomenaQuery.Search - alias Philomena.{Tags, Tags.Tag} - alias Philomena.{Images, Images.Image} + alias PhilomenaWeb.ImageScope alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Interactions - import Ecto.Query - - plug PhilomenaWeb.CanaryMapPlug, update: :edit - - plug :load_and_authorize_resource, - model: Tag, - id_field: "slug", - only: [:show, :edit, :update, :delete], - preload: [ - :aliases, - :aliased_tag, - :implied_tags, - :implied_by_tags, - :dnp_entries, - :channels, - public_links: :user, - hidden_links: :user - ] - - plug :redirect_alias when action in [:show] + alias Philomena.Tags - def index(conn, params) do - query_string = params["tq"] || "*" - - with {:ok, query} <- Tags.Query.compile(query_string) do - tags = - Tag - |> Search.search_definition( - %{ - query: query, - size: 250, - sort: [%{images: :desc}, %{name: :asc}] - }, - %{conn.assigns.pagination | page_size: 250} - ) - |> Search.search_records(Tag) - - render(conn, "index.html", title: "Tags", tags: tags) - else - {:error, msg} -> - render(conn, "index.html", title: "Tags", tags: [], error: msg) - end - end + action_fallback PhilomenaWeb.FallbackController - def show(conn, _params) do - user = conn.assigns.current_user - tag = conn.assigns.tag + def index(conn, params) do + pagination = Map.put(conn.assigns.pagination, :page_size, 250) - {images, _tags} = ImageLoader.query(conn, %{term: %{"tags" => tag.name}}) + case Tags.query_tags(conn.assigns.actor, %{"query" => params["tq"] || "*"}, pagination) do + {:ok, tags, _changeset} -> + render(conn, "index.html", title: "Tags", tags: tags) - images = Search.search_records(images, preload(Image, [:sources, tags: :aliases])) + {:error, %Ecto.Changeset{} = changeset} -> + {message, _options} = Keyword.fetch!(changeset.errors, :query) + render(conn, "index.html", title: "Tags", tags: [], error: message) - interactions = Interactions.user_interactions(images, user) + error -> + error + end + end - body = MarkdownRenderer.render_one(%{body: tag.description || ""}, conn) + def show(conn, params) do + case Tags.show_tag_page(conn.assigns.actor, ImageScope.search_scope(conn), params["id"]) do + {:ok, page} -> + tag = page.tag + body = MarkdownRenderer.render_one(%{body: tag.description || ""}, conn) + + dnp_bodies = + MarkdownRenderer.render_collection( + Enum.map(tag.dnp_entries, &%{body: &1.conditions || ""}), + conn + ) + + dnp_entries = Enum.zip(dnp_bodies, tag.dnp_entries) + + conn_params = Map.put(conn.params, "q", page.search_query) + conn = Map.put(conn, :params, conn_params) + + render( + conn, + "show.html", + tag: tag, + tags: [{tag, body, dnp_entries}], + search_query: page.search_query, + interactions: page.interactions, + images: page.images, + layout_class: "layout--wide", + title: "#{tag.name} - Tags" + ) - dnp_bodies = - MarkdownRenderer.render_collection( - Enum.map(tag.dnp_entries, &%{body: &1.conditions || ""}), + {:aliased_to, tag} -> conn - ) - - dnp_entries = Enum.zip(dnp_bodies, tag.dnp_entries) - - search_query = maybe_escape_name(tag) - params = Map.put(conn.params, "q", search_query) - conn = Map.put(conn, :params, params) - - render( - conn, - "show.html", - tags: [{tag, body, dnp_entries}], - search_query: search_query, - interactions: interactions, - images: images, - layout_class: "layout--wide", - title: "#{tag.name} - Tags" - ) + |> put_flash( + :info, + "This tag (\"#{tag.name}\") has been aliased into the tag \"#{tag.aliased_tag.name}\"." + ) + |> redirect(to: ~p"/tags/#{tag.aliased_tag}") + + {:error, _} = error -> + error + end end - def edit(conn, _params) do - changeset = Tags.change_tag(conn.assigns.tag) - render(conn, "edit.html", title: "Editing Tag", changeset: changeset) + def edit(conn, params) do + with {:ok, {tag, changeset}} <- + Tags.edit_tag(conn.assigns.actor, params["id"]) do + render(conn, "edit.html", title: "Editing Tag", tag: tag, changeset: changeset) + end end - def update(conn, %{"tag" => tag_params}) do - case Tags.update_tag(conn.assigns.tag, tag_params) do + def update(conn, %{"id" => slug, "tag" => tag_params}) do + case Tags.update_tag(conn.assigns.actor, slug, tag_params) do {:ok, tag} -> conn |> put_flash(:info, "Tag successfully updated.") - |> moderation_log(details: &log_details/2, data: tag) |> redirect(to: ~p"/tags/#{tag}") - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - def delete(conn, _params) do - {:ok, tag} = Tags.delete_tag(conn.assigns.tag) - - conn - |> put_flash(:info, "Tag queued for deletion.") - |> moderation_log(details: &log_details/2, data: tag) - |> redirect(to: "/") - end - - def maybe_escape_name(%{name: name}) do - name = - name - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> String.downcase() - - case Images.Query.compile(name) do - {:ok, %{term: %{"tags" => ^name}}} -> - name + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", tag: changeset.data, changeset: changeset) - _error -> - escape_name(name) + {:error, _} = error -> + error end end - defp escape_name(name) do - if String.contains?(name, "(") or String.contains?(name, ")") do - # \ * ? " should be escaped, wrap in quotes so parser doesn't - # choke on parens. - name = - name - |> String.replace("\\", "\\\\") - |> String.replace("*", "\\*") - |> String.replace("?", "\\?") - |> String.replace("\"", "\\\"") - - "\"#{name}\"" - else - # \ * ? - ! " all must be escaped. - name - |> String.replace(~r/\A-/, "\\-") - |> String.replace(~r/\A!/, "\\!") - |> String.replace("\\", "\\\\") - |> String.replace("*", "\\*") - |> String.replace("?", "\\?") - |> String.replace("\"", "\\\"") + def delete(conn, params) do + with {:ok, _tag} <- Tags.delete_tag(conn.assigns.actor, params["id"]) do + conn + |> put_flash(:info, "Tag queued for deletion.") + |> redirect(to: "/") end end - - defp redirect_alias(conn, _opts) do - case conn.assigns.tag do - %{aliased_tag: nil} -> - conn - - %{aliased_tag: tag} -> - conn - |> put_flash( - :info, - "This tag (\"#{conn.assigns.tag.name}\") has been aliased into the tag \"#{tag.name}\"." - ) - |> redirect(to: ~p"/tags/#{tag}") - |> halt() - end - end - - defp log_details(action, tag) do - body = - case action do - :update -> "Updated details on tag '#{tag.name}'" - :delete -> "Deleted tag '#{tag.name}'" - end - - %{ - body: body, - subject_path: ~p"/tags/#{tag}" - } - end end diff --git a/lib/philomena_web/controllers/topic/hide_controller.ex b/lib/philomena_web/controllers/topic/hide_controller.ex index 4a09f1d99..f22e3efaf 100644 --- a/lib/philomena_web/controllers/topic/hide_controller.ex +++ b/lib/philomena_web/controllers/topic/hide_controller.ex @@ -1,72 +1,50 @@ defmodule PhilomenaWeb.Topic.HideController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Forums.Forum - alias Philomena.Topics.Topic alias Philomena.Topics - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :authorize_resource, model: Topic, persisted: true - - def create(conn, %{"topic" => topic_params}) do - topic = conn.assigns.topic - deletion_reason = topic_params["deletion_reason"] - user = conn.assigns.current_user - - case Topics.hide_topic(topic, deletion_reason, user) do - {:ok, topic} -> + def create(conn, params) do + case Topics.create_topic_hide( + conn.assigns.actor, + params["forum_id"], + params["topic_id"], + params["topic"] || %{} + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully deleted!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "Unable to delete the topic!") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") + + error -> + error end end - def delete(conn, _opts) do - topic = conn.assigns.topic - - case Topics.unhide_topic(topic) do - {:ok, topic} -> + def delete(conn, params) do + case Topics.delete_topic_hide( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully restored!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "Unable to restore the topic!") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - end - end - - defp log_details(action, topic) do - body = - case action do - :create -> - "Deleted topic '#{topic.title}' (#{topic.deletion_reason}) in #{topic.forum.name}" + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - :delete -> - "Restored topic '#{topic.title}' in #{topic.forum.name}" - end - - %{ - body: body, - subject_path: ~p"/forums/#{topic.forum}/topics/#{topic}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/topic/lock_controller.ex b/lib/philomena_web/controllers/topic/lock_controller.ex index c9b9c21f9..b2e7380eb 100644 --- a/lib/philomena_web/controllers/topic/lock_controller.ex +++ b/lib/philomena_web/controllers/topic/lock_controller.ex @@ -1,68 +1,50 @@ defmodule PhilomenaWeb.Topic.LockController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Forums.Forum - alias Philomena.Topics.Topic alias Philomena.Topics - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :authorize_resource, model: Topic, persisted: true - - def create(conn, %{"topic" => topic_params}) do - topic = conn.assigns.topic - user = conn.assigns.current_user - - case Topics.lock_topic(topic, topic_params, user) do - {:ok, topic} -> + def create(conn, params) do + case Topics.create_topic_lock( + conn.assigns.actor, + params["forum_id"], + params["topic_id"], + params["topic"] || %{} + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully locked!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "Unable to lock the topic!") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") + + error -> + error end end - def delete(conn, _opts) do - topic = conn.assigns.topic - - case Topics.unlock_topic(topic) do - {:ok, topic} -> + def delete(conn, params) do + case Topics.delete_topic_lock( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully unlocked!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "Unable to unlock the topic!") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - end - end + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - defp log_details(action, topic) do - body = - case action do - :create -> "Locked topic '#{topic.title}' (#{topic.lock_reason}) in #{topic.forum.name}" - :delete -> "Unlocked topic '#{topic.title}' in #{topic.forum.name}" - end - - %{ - body: body, - subject_path: ~p"/forums/#{topic.forum}/topics/#{topic}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/topic/move_controller.ex b/lib/philomena_web/controllers/topic/move_controller.ex index 4b762102e..e6d4f7b74 100644 --- a/lib/philomena_web/controllers/topic/move_controller.ex +++ b/lib/philomena_web/controllers/topic/move_controller.ex @@ -1,61 +1,29 @@ defmodule PhilomenaWeb.Topic.MoveController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Forums.Forum - alias Philomena.Topics.Topic alias Philomena.Topics - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show - - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :authorize_resource, model: Topic, persisted: true - - def create(conn, %{"topic" => %{"target_forum_id" => target_id}}) do - case IntegerId.parse(target_id) do - {:ok, target_forum_id} -> move(conn, target_forum_id) - :error -> move_failed(conn) - end - end - - def create(conn, _params), do: move_failed(conn) - - defp move(conn, target_forum_id) do - topic = conn.assigns.topic - - case Topics.move_topic(topic, target_forum_id) do - {:ok, %{topic: topic}} -> - topic = Repo.preload(topic, :forum, force: true) + action_fallback PhilomenaWeb.FallbackController + def create(conn, params) do + case Topics.create_topic_move( + conn.assigns.actor, + params["forum_id"], + params["topic_id"], + params["topic"] || %{} + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully moved!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - - {:error, _changeset} -> - move_failed(conn) - end - end + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - defp move_failed(conn) do - conn - |> put_flash(:error, "Unable to move the topic!") - |> redirect(to: ~p"/forums/#{conn.assigns.forum}/topics/#{conn.assigns.topic}") - end + {:error, forum, topic} -> + conn + |> put_flash(:error, "Unable to move the topic!") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - defp log_details(_action, topic) do - %{ - body: "Topic '#{topic.title}' moved to #{topic.forum.name}", - subject_path: ~p"/forums/#{topic.forum}/topics/#{topic}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/topic/poll/vote_controller.ex b/lib/philomena_web/controllers/topic/poll/vote_controller.ex index 07fa98c96..33e2c67db 100644 --- a/lib/philomena_web/controllers/topic/poll/vote_controller.ex +++ b/lib/philomena_web/controllers/topic/poll/vote_controller.ex @@ -1,95 +1,42 @@ defmodule PhilomenaWeb.Topic.Poll.VoteController do use PhilomenaWeb, :controller - alias Philomena.Forums.Forum - alias Philomena.PollOptions.PollOption alias Philomena.PollVotes - alias PhilomenaWeb.IntegerId - alias Philomena.Repo - import Ecto.Query - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, index: :show, create: :show, delete: :show + # Builds the `:actor` struct (with the request's ban) that create's + # write-access check consumes; only create needs it. - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.LoadPollPlug - - plug :verify_authorized when action in [:index, :delete] - - def index(conn, _params) do - poll = conn.assigns.poll - - options = - PollOption - |> where(poll_id: ^poll.id) - |> preload(poll_votes: :user) - |> Repo.all() - |> Enum.filter(&(&1.vote_count > 0)) - - render(conn, "index.html", layout: false, options: options) + def index(conn, %{"forum_id" => forum_slug, "topic_id" => topic_slug}) do + with {:ok, options} <- + PollVotes.list_votes(conn.assigns.actor, forum_slug, topic_slug) do + render(conn, "index.html", layout: false, options: options) + end end - def create(conn, %{"poll" => poll_params}) do - poll = conn.assigns.poll - topic = conn.assigns.topic - - case PollVotes.create_poll_votes(conn.assigns.current_user, poll, poll_params) do - {:ok, _votes} -> + def create(conn, %{"forum_id" => forum_slug, "topic_id" => topic_slug} = params) do + case PollVotes.create_votes(conn.assigns.actor, forum_slug, topic_slug, params["poll"]) do + {:ok, ballot} -> conn |> put_flash(:info, "Your vote has been recorded.") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{ballot.poll.topic.forum}/topics/#{ballot.poll.topic}") - _error -> + {:error, %Ecto.Changeset{data: ballot}} -> conn |> put_flash(:error, "Your vote was not recorded.") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - end - end - - def create(conn, _params) do - topic = conn.assigns.topic - - conn - |> put_flash(:error, "Your vote was not recorded.") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - end - - def delete(conn, %{"id" => poll_vote_id}) do - topic = conn.assigns.topic - - case load_poll_vote(poll_vote_id) do - nil -> - conn - |> put_flash(:error, "Vote was not removed.") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - - poll_vote -> - {:ok, _poll_vote} = PollVotes.delete_poll_vote(poll_vote) - - conn - |> put_flash(:info, "Vote successfully removed.") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - end - end + |> redirect(to: ~p"/forums/#{ballot.poll.topic.forum}/topics/#{ballot.poll.topic}") - defp load_poll_vote(poll_vote_id) do - case IntegerId.parse(poll_vote_id) do - {:ok, id} -> PollVotes.get_poll_vote(id) - :error -> nil + error -> + error end end - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :hide, conn.assigns.topic) do + def delete(conn, %{"forum_id" => forum_slug, "topic_id" => topic_slug, "id" => vote_id}) do + with {:ok, poll} <- PollVotes.delete_vote(conn.assigns.actor, forum_slug, topic_slug, vote_id) do conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> put_flash(:info, "Vote successfully removed.") + |> redirect(to: ~p"/forums/#{poll.topic.forum}/topics/#{poll.topic}") end end end diff --git a/lib/philomena_web/controllers/topic/poll_controller.ex b/lib/philomena_web/controllers/topic/poll_controller.ex index eb1b27dc2..0b3ed20e1 100644 --- a/lib/philomena_web/controllers/topic/poll_controller.ex +++ b/lib/philomena_web/controllers/topic/poll_controller.ex @@ -1,52 +1,40 @@ defmodule PhilomenaWeb.Topic.PollController do use PhilomenaWeb, :controller - alias Philomena.Forums.Forum alias Philomena.Polls - alias Philomena.Repo - plug PhilomenaWeb.CanaryMapPlug, edit: :show, update: :show - - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.LoadPollPlug - - plug :verify_authorized - plug :preload_options - - def edit(conn, _params) do - changeset = Polls.change_poll(conn.assigns.poll) - render(conn, "edit.html", title: "Editing Poll", changeset: changeset) + action_fallback PhilomenaWeb.FallbackController + + def edit(conn, %{"forum_id" => forum_slug, "topic_id" => topic_slug}) do + with {:ok, %Ecto.Changeset{data: poll} = changeset} <- + Polls.edit_poll(conn.assigns.actor, forum_slug, topic_slug) do + render(conn, "edit.html", + title: "Editing Poll", + forum: poll.topic.forum, + topic: poll.topic, + poll: poll, + changeset: changeset + ) + end end - def update(conn, %{"poll" => poll_params}) do - case Polls.update_poll(conn.assigns.poll, poll_params) do - {:ok, _poll} -> + def update(conn, %{"forum_id" => forum_slug, "topic_id" => topic_slug, "poll" => poll_params}) do + case Polls.update_poll(conn.assigns.actor, forum_slug, topic_slug, poll_params) do + {:ok, poll} -> conn |> put_flash(:info, "Poll successfully updated.") - |> redirect(to: ~p"/forums/#{conn.assigns.forum}/topics/#{conn.assigns.topic}") - - {:error, changeset} -> - render(conn, "edit.html", changeset: changeset) - end - end - - defp preload_options(conn, _opts) do - poll = Repo.preload(conn.assigns.poll, :options) - - assign(conn, :poll, poll) - end - - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :hide, conn.assigns.topic) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + |> redirect(to: ~p"/forums/#{poll.topic.forum}/topics/#{poll.topic}") + + {:error, %Ecto.Changeset{data: poll} = changeset} -> + render(conn, "edit.html", + forum: poll.topic.forum, + topic: poll.topic, + poll: poll, + changeset: changeset + ) + + error -> + error end end end diff --git a/lib/philomena_web/controllers/topic/post/approve_controller.ex b/lib/philomena_web/controllers/topic/post/approve_controller.ex index e43416d68..059444db5 100644 --- a/lib/philomena_web/controllers/topic/post/approve_controller.ex +++ b/lib/philomena_web/controllers/topic/post/approve_controller.ex @@ -4,46 +4,27 @@ defmodule PhilomenaWeb.Topic.Post.ApproveController do alias Philomena.Posts.Post alias Philomena.Posts - plug PhilomenaWeb.CanaryMapPlug, create: :approve + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Post, - id_name: "post_id", - persisted: true, - preload: [:topic, topic: :forum] - - def create(conn, _params) do - post = conn.assigns.post - user = conn.assigns.current_user - - case Posts.approve_post(post, user) do + def create(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "post_id" => post_id}) do + case Posts.create_post_approve(conn.assigns.actor, forum_id, topic_id, post_id) do {:ok, post} -> conn |> put_flash(:info, "Post successfully approved.") - |> moderation_log(details: &log_details/2, data: post) - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: %Post{} = post}} -> conn - |> put_flash(:error, "Unable to approve post!") - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> put_flash(:info, "Post has already been approved.") + |> redirect(to: post_anchor(post)) + + error -> + error end end - defp log_details(_action, post) do - %{ - body: "Approved forum post ##{post.id} in topic '#{post.topic.title}'", - subject_path: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - } + defp post_anchor(post) do + ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> + "#post_#{post.id}" end end diff --git a/lib/philomena_web/controllers/topic/post/delete_controller.ex b/lib/philomena_web/controllers/topic/post/delete_controller.ex index 28aad707a..510306aa1 100644 --- a/lib/philomena_web/controllers/topic/post/delete_controller.ex +++ b/lib/philomena_web/controllers/topic/post/delete_controller.ex @@ -4,45 +4,27 @@ defmodule PhilomenaWeb.Topic.Post.DeleteController do alias Philomena.Posts.Post alias Philomena.Posts - plug PhilomenaWeb.CanaryMapPlug, create: :hide + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Post, - id_name: "post_id", - persisted: true, - preload: [:topic, topic: :forum] - - def create(conn, _params) do - post = conn.assigns.post - - case Posts.destroy_post(post) do + def create(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "post_id" => post_id}) do + case Posts.create_post_delete(conn.assigns.actor, forum_id, topic_id, post_id) do {:ok, post} -> conn |> put_flash(:info, "Post successfully destroyed!") - |> moderation_log(details: &log_details/2, data: post) - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: %Post{} = post}} -> conn |> put_flash(:error, "Unable to destroy post!") - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) + + error -> + error end end - defp log_details(_action, post) do - %{ - body: "Destroyed forum post ##{post.id} in topic '#{post.topic.title}'", - subject_path: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - } + defp post_anchor(post) do + ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> + "#post_#{post.id}" end end diff --git a/lib/philomena_web/controllers/topic/post/hide_controller.ex b/lib/philomena_web/controllers/topic/post/hide_controller.ex index ba8c28fe4..0602f36e5 100644 --- a/lib/philomena_web/controllers/topic/post/hide_controller.ex +++ b/lib/philomena_web/controllers/topic/post/hide_controller.ex @@ -4,80 +4,49 @@ defmodule PhilomenaWeb.Topic.Post.HideController do alias Philomena.Posts.Post alias Philomena.Posts - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - - plug :load_and_authorize_resource, - model: Post, - id_name: "post_id", - persisted: true, - preload: [:topic, topic: :forum] - - def create(conn, %{"post" => post_params}) do - post = conn.assigns.post - user = conn.assigns.current_user - - case Posts.hide_post(post, post_params, user) do + action_fallback PhilomenaWeb.FallbackController + + def create(conn, %{ + "forum_id" => forum_id, + "topic_id" => topic_id, + "post_id" => post_id, + "post" => post_params + }) do + case Posts.create_post_hide(conn.assigns.actor, forum_id, topic_id, post_id, post_params) do {:ok, post} -> conn |> put_flash(:info, "Post successfully deleted.") - |> moderation_log(details: &log_details/2, data: post) - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: %Post{} = post}} -> conn |> put_flash(:error, "Unable to delete post!") - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) + + error -> + error end end - def delete(conn, _params) do - post = conn.assigns.post - - case Posts.unhide_post(post) do + def delete(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "post_id" => post_id}) do + case Posts.delete_post_hide(conn.assigns.actor, forum_id, topic_id, post_id) do {:ok, post} -> conn |> put_flash(:info, "Post successfully restored.") - |> moderation_log(details: &log_details/2, data: post) - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) - {:error, _changeset} -> + {:error, %Ecto.Changeset{data: %Post{} = post}} -> conn |> put_flash(:error, "Unable to restore post!") - |> redirect( - to: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - ) + |> redirect(to: post_anchor(post)) + + error -> + error end end - defp log_details(action, post) do - body = - case action do - :create -> - "Deleted forum post ##{post.id} in topic '#{post.topic.title}' (#{post.deletion_reason})" - - :delete -> - "Restored forum post ##{post.id} in topic '#{post.topic.title}'" - end - - %{ - body: body, - subject_path: - ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> - "#post_#{post.id}" - } + defp post_anchor(post) do + ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> + "#post_#{post.id}" end end diff --git a/lib/philomena_web/controllers/topic/post/history_controller.ex b/lib/philomena_web/controllers/topic/post/history_controller.ex index 9b030d9e4..578ce6127 100644 --- a/lib/philomena_web/controllers/topic/post/history_controller.ex +++ b/lib/philomena_web/controllers/topic/post/history_controller.ex @@ -1,33 +1,19 @@ defmodule PhilomenaWeb.Topic.Post.HistoryController do use PhilomenaWeb, :controller - alias Philomena.Versions - alias Philomena.Forums.Forum alias PhilomenaWeb.MarkdownRenderer + alias Philomena.Posts - plug PhilomenaWeb.CanaryMapPlug, index: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.LoadPostPlug - - def index(conn, _params) do - topic = conn.assigns.topic - post = conn.assigns.post - - versions = - post - |> Versions.load_post_versions() - |> MarkdownRenderer.render_version_diffs() - - render(conn, "index.html", - title: "Post History for Post #{post.id} - #{topic.title} - Forums", - versions: versions - ) + def index(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "post_id" => post_id}) do + with {:ok, {topic, post, versions}} <- + Posts.list_post_history(conn.assigns.actor, forum_id, topic_id, post_id) do + render(conn, "index.html", + title: "Post History for Post #{post.id} - #{topic.title} - Forums", + post: post, + versions: MarkdownRenderer.render_version_diffs(versions) + ) + end end end diff --git a/lib/philomena_web/controllers/topic/post/report_controller.ex b/lib/philomena_web/controllers/topic/post/report_controller.ex index de0ccfce6..880dd765d 100644 --- a/lib/philomena_web/controllers/topic/post/report_controller.ex +++ b/lib/philomena_web/controllers/topic/post/report_controller.ex @@ -3,45 +3,43 @@ defmodule PhilomenaWeb.Topic.Post.ReportController do alias PhilomenaWeb.ReportController alias PhilomenaWeb.ReportView - alias Philomena.Forums.Forum - alias Philomena.Reports.Report alias Philomena.Reports - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CaptchaPlug plug PhilomenaWeb.CheckCaptchaPlug when action in [:create] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show - - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.LoadPostPlug - - def new(conn, _params) do - topic = conn.assigns.topic - post = conn.assigns.post - action = ~p"/forums/#{topic.forum}/topics/#{topic}/posts/#{post}/reports" - - changeset = - %Report{post_id: post.id} - |> Reports.change_report() - - conn - |> put_view(ReportView) - |> render("new.html", subject: post, changeset: changeset, action: action) + action_fallback PhilomenaWeb.FallbackController + + def new(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "post_id" => post_id}) do + locator = {:post, forum_id, topic_id, post_id} + + with {:ok, form} <- Reports.new_report(conn.assigns.actor, locator) do + post = form.target + topic = post.topic + action = ~p"/forums/#{topic.forum}/topics/#{topic}/posts/#{post}/reports" + + conn + |> put_view(ReportView) + |> render("new.html", + subject: post, + changeset: form.changeset, + rules: form.rules, + action: action + ) + end end - def create(conn, params) do - topic = conn.assigns.topic - post = conn.assigns.post - action = ~p"/forums/#{topic.forum}/topics/#{topic}/posts/#{post}/reports" - - ReportController.create(conn, action, post, [post_id: post.id], params) + def create( + conn, + %{"forum_id" => forum_id, "topic_id" => topic_id, "post_id" => post_id} = params + ) do + ReportController.create( + conn, + {:post, forum_id, topic_id, post_id}, + fn post -> + ~p"/forums/#{post.topic.forum}/topics/#{post.topic}/posts/#{post}/reports" + end, + params + ) end end diff --git a/lib/philomena_web/controllers/topic/post_controller.ex b/lib/philomena_web/controllers/topic/post_controller.ex index f50a0c5ed..54922c856 100644 --- a/lib/philomena_web/controllers/topic/post_controller.ex +++ b/lib/philomena_web/controllers/topic/post_controller.ex @@ -1,88 +1,47 @@ defmodule PhilomenaWeb.Topic.PostController do use PhilomenaWeb, :controller - alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post} alias Philomena.Posts - alias Philomena.UserStatistics + alias PhilomenaWeb.RateLimitedResponse - plug PhilomenaWeb.LimitPlug, - [time: 15, error: "You may only make a post once every 15 seconds."] - when action in [:create] - - plug PhilomenaWeb.FilterBannedUsersPlug - plug PhilomenaWeb.UserAttributionPlug - - plug PhilomenaWeb.CanaryMapPlug, create: :show, edit: :show, update: :show - - plug :load_and_authorize_resource, - model: Forum, - id_field: "short_name", - id_name: "forum_id", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.CanaryMapPlug, create: :create_post, edit: :create_post, update: :create_post - plug :authorize_resource, model: Topic, persisted: true - - plug PhilomenaWeb.LoadPostPlug, [param: "id"] when action in [:edit, :update] - plug PhilomenaWeb.CanaryMapPlug, edit: :edit, update: :edit - plug :authorize_resource, model: Post, only: [:edit, :update] - - def create(conn, %{"post" => post_params}) do - attributes = conn.assigns.attributes - forum = conn.assigns.forum - topic = conn.assigns.topic - - case Posts.create_post(topic, attributes, post_params) do - {:ok, %{post: post}} -> - if post.approved do - UserStatistics.inc_stat(conn.assigns.current_user, :posts_count) - else - Posts.report_non_approved(post) - end - - if forum.access_level == "normal" do - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "post:create", - PhilomenaWeb.Api.Json.Forum.Topic.PostView.render("firehose.json", %{ - post: post, - topic: topic, - forum: forum - }) - ) - end + action_fallback PhilomenaWeb.FallbackController + def create(conn, %{"forum_id" => forum_id, "topic_id" => topic_id} = params) do + case Posts.create_post(conn.assigns.actor, forum_id, topic_id, params["post"]) do + {:ok, post} -> conn |> put_flash(:info, "Post created successfully.") |> redirect( to: - ~p"/forums/#{forum}/topics/#{topic}?#{[post_id: post.id]}" <> + ~p"/forums/#{post.topic.forum}/topics/#{post.topic}?#{[post_id: post.id]}" <> "#post_#{post.id}" ) - _error -> + {:error, %Ecto.Changeset{} = changeset} -> + post = changeset.data + conn |> put_flash(:error, "There was an error creating the post") - |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{post.topic.forum}/topics/#{post.topic}") + + {:error, :rate_limited} -> + RateLimitedResponse.call(conn, "You may only make a post once every 15 seconds.") + + error -> + error end end - def edit(conn, _params) do - changeset = Posts.change_post(conn.assigns.post) - render(conn, "edit.html", title: "Editing Post", changeset: changeset) + def edit(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "id" => id}) do + with {:ok, changeset} <- + Posts.edit_post(conn.assigns.actor, forum_id, topic_id, id) do + render(conn, "edit.html", title: "Editing Post", post: changeset.data, changeset: changeset) + end end - def update(conn, %{"post" => post_params}) do - post = conn.assigns.post - user = conn.assigns.current_user - - case Posts.update_post(post, user, post_params) do - {:ok, %{post: post}} -> - if not post.approved do - Posts.report_non_approved(post) - end - + def update(conn, %{"forum_id" => forum_id, "topic_id" => topic_id, "id" => id} = params) do + case Posts.update_post(conn.assigns.actor, forum_id, topic_id, id, params["post"]) do + {:ok, post} -> conn |> put_flash(:info, "Post successfully edited.") |> redirect( @@ -91,8 +50,11 @@ defmodule PhilomenaWeb.Topic.PostController do "#post_#{post.id}" ) - {:error, :post, changeset, _changes} -> - render(conn, "edit.html", post: conn.assigns.post, changeset: changeset) + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, "edit.html", post: changeset.data, changeset: changeset) + + error -> + error end end end diff --git a/lib/philomena_web/controllers/topic/read_controller.ex b/lib/philomena_web/controllers/topic/read_controller.ex index 6a5f6980d..0cadd2bac 100644 --- a/lib/philomena_web/controllers/topic/read_controller.ex +++ b/lib/philomena_web/controllers/topic/read_controller.ex @@ -1,23 +1,18 @@ defmodule PhilomenaWeb.Topic.ReadController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Forums.Forum alias Philomena.Topics - plug :load_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - required: true + action_fallback PhilomenaWeb.FallbackController - plug PhilomenaWeb.LoadTopicPlug, show_hidden: true - - def create(conn, _params) do - user = conn.assigns.current_user - - Topics.clear_topic_notification(conn.assigns.topic, user) - - send_resp(conn, :ok, "") + def create(conn, params) do + with {:ok, _topic} <- + Topics.create_topic_read( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + send_resp(conn, :ok, "") + end end end diff --git a/lib/philomena_web/controllers/topic/stick_controller.ex b/lib/philomena_web/controllers/topic/stick_controller.ex index 39f0d562f..4b07d1645 100644 --- a/lib/philomena_web/controllers/topic/stick_controller.ex +++ b/lib/philomena_web/controllers/topic/stick_controller.ex @@ -1,67 +1,49 @@ defmodule PhilomenaWeb.Topic.StickController do - import Plug.Conn use PhilomenaWeb, :controller - alias Philomena.Forums.Forum - alias Philomena.Topics.Topic alias Philomena.Topics - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug - plug PhilomenaWeb.CanaryMapPlug, create: :hide, delete: :hide - plug :authorize_resource, model: Topic, persisted: true - - def create(conn, _opts) do - topic = conn.assigns.topic - - case Topics.stick_topic(topic) do - {:ok, topic} -> + def create(conn, params) do + case Topics.create_topic_stick( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully stickied!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "Unable to stick the topic!") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") + + error -> + error end end - def delete(conn, _opts) do - topic = conn.assigns.topic - - case Topics.unstick_topic(topic) do - {:ok, topic} -> + def delete(conn, params) do + case Topics.delete_topic_stick( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Topic successfully unstickied!") - |> moderation_log(details: &log_details/2, data: topic) - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "Unable to unstick the topic!") - |> redirect(to: ~p"/forums/#{topic.forum}/topics/#{topic}") - end - end + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - defp log_details(action, topic) do - body = - case action do - :create -> "Stickied topic '#{topic.title}' in #{topic.forum.name}" - :delete -> "Unstickied topic '#{topic.title}' in #{topic.forum.name}" - end - - %{ - body: body, - subject_path: ~p"/forums/#{topic.forum}/topics/#{topic}" - } + error -> + error + end end end diff --git a/lib/philomena_web/controllers/topic/subscription_controller.ex b/lib/philomena_web/controllers/topic/subscription_controller.ex index 664da82d8..1f0c92933 100644 --- a/lib/philomena_web/controllers/topic/subscription_controller.ex +++ b/lib/philomena_web/controllers/topic/subscription_controller.ex @@ -1,49 +1,45 @@ defmodule PhilomenaWeb.Topic.SubscriptionController do use PhilomenaWeb, :controller - alias Philomena.Forums.Forum alias Philomena.Topics - plug PhilomenaWeb.CanaryMapPlug, create: :show, delete: :show + action_fallback PhilomenaWeb.FallbackController - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug, [show_hidden: true] when action in [:delete] - plug PhilomenaWeb.LoadTopicPlug when action in [:create] - - def create(conn, _params) do - topic = conn.assigns.topic - user = conn.assigns.current_user - - case Topics.create_subscription(topic, user) do - {:ok, _subscription} -> + def create(conn, params) do + case Topics.create_topic_subscription( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + {:ok, {forum, topic}} -> render(conn, "_subscription.html", - forum: conn.assigns.forum, + forum: forum, topic: topic, watching: true, layout: false ) - {:error, _changeset} -> + {:error, %Ecto.Changeset{}} -> render(conn, "_error.html", layout: false) + + {:error, _} = error -> + error end end - def delete(conn, _params) do - topic = conn.assigns.topic - user = conn.assigns.current_user - - {:ok, _subscription} = Topics.delete_subscription(topic, user) - - render(conn, "_subscription.html", - forum: conn.assigns.forum, - topic: topic, - watching: false, - layout: false - ) + def delete(conn, params) do + with {:ok, {forum, topic}} <- + Topics.delete_topic_subscription( + conn.assigns.actor, + params["forum_id"], + params["topic_id"] + ) do + render(conn, "_subscription.html", + forum: forum, + topic: topic, + watching: false, + layout: false + ) + end end end diff --git a/lib/philomena_web/controllers/topic_controller.ex b/lib/philomena_web/controllers/topic_controller.ex index d217d846c..f4ceda168 100644 --- a/lib/philomena_web/controllers/topic_controller.ex +++ b/lib/philomena_web/controllers/topic_controller.ex @@ -2,162 +2,88 @@ defmodule PhilomenaWeb.TopicController do use PhilomenaWeb, :controller alias PhilomenaWeb.NotificationCountPlug - alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post, Polls.Poll, PollOptions.PollOption} - alias Philomena.{Topics, Polls, Posts} - alias Philomena.PollVotes alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Repo - import Ecto.Query + alias PhilomenaWeb.RateLimitedResponse + alias Philomena.Forums.Forum + alias Philomena.Topics - plug PhilomenaWeb.LimitPlug, - [time: 300, error: "You may only make a new topic once every 5 minutes."] - when action in [:create] - - plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] - plug PhilomenaWeb.UserAttributionPlug when action in [:new, :create] plug PhilomenaWeb.AdvertPlug when action in [:show] - plug PhilomenaWeb.CanaryMapPlug, new: :show, create: :show, update: :show - - plug :load_and_authorize_resource, - model: Forum, - id_name: "forum_id", - id_field: "short_name", - persisted: true - - plug PhilomenaWeb.LoadTopicPlug, [param: "id"] when action in [:show, :update] - plug :verify_authorized when action in [:update] - - def show(conn, params) do - forum = conn.assigns.forum - topic = conn.assigns.topic - - user = conn.assigns.current_user - - Topics.clear_topic_notification(topic, user) - - # Update the notification ticker in the header - conn = NotificationCountPlug.call(conn) - - conn = conn |> assign(:topic, topic) - %{page_number: page} = conn.assigns.pagination - - page = - with {post_id, _extra} <- Integer.parse(params["post_id"] || ""), - [post] <- Post |> where(id: ^post_id) |> Repo.all() do - div(post.topic_position, 25) + 1 - else - _ -> - page - end - - posts = - Post - |> where(topic_id: ^conn.assigns.topic.id) - |> where([p], p.topic_position >= ^(25 * (page - 1)) and p.topic_position < ^(25 * page)) - |> order_by(asc: :created_at) - |> preload([:deleted_by, :topic, topic: :forum, user: [awards: :badge]]) - |> Repo.all() - - rendered = MarkdownRenderer.render_collection(posts, conn) - - posts = Enum.zip(posts, rendered) - - posts = %Scrivener.Page{ - entries: posts, - page_number: page, - page_size: 25, - total_entries: topic.post_count, - total_pages: div(topic.post_count + 25 - 1, 25) - } - - watching = Topics.subscribed?(topic, conn.assigns.current_user) - - voted = PollVotes.voted?(topic.poll, conn.assigns.current_user) - - poll_active = Polls.active?(topic.poll) - - changeset = - %Post{} - |> Posts.change_post() + action_fallback PhilomenaWeb.FallbackController - topic_changeset = Topics.change_topic(conn.assigns.topic) + def show(conn, %{"forum_id" => forum_id, "id" => id} = params) do + with {:ok, page} <- + Topics.show_topic_page( + conn.assigns.actor, + forum_id, + id, + params["post_id"], + conn.assigns.pagination + ) do + # The page load cleared the topic's notifications; refresh the header + # notification ticker afterwards so it reflects the cleared state. + conn = NotificationCountPlug.call(conn) - title = "#{topic.title} - #{forum.name} - Forums" + rendered = MarkdownRenderer.render_collection(page.posts.entries, conn) + posts = %{page.posts | entries: Enum.zip(page.posts.entries, rendered)} - render(conn, "show.html", - title: title, - posts: posts, - changeset: changeset, - topic_changeset: topic_changeset, - watching: watching, - voted: voted, - poll_active: poll_active - ) + conn + |> assign(:forum, page.forum) + |> assign(:topic, page.topic) + |> render("show.html", + title: "#{page.topic.title} - #{page.forum.name} - Forums", + posts: posts, + changeset: page.post_changeset, + topic_changeset: page.topic_changeset, + watching: page.watching, + voted: page.voted, + poll_active: page.poll_active + ) + end end - def new(conn, _params) do - changeset = - %Topic{poll: %Poll{options: [%PollOption{}, %PollOption{}]}, posts: [%Post{}]} - |> Topics.change_topic() - - render(conn, "new.html", title: "New Topic", changeset: changeset) + def new(conn, %{"forum_id" => forum_id}) do + with {:ok, {forum, changeset}} <- Topics.new_topic(conn.assigns.actor, forum_id) do + conn + |> assign(:forum, forum) + |> render("new.html", title: "New Topic", changeset: changeset) + end end - def create(conn, %{"topic" => topic_params}) do - attributes = conn.assigns.attributes - forum = conn.assigns.forum - - case Topics.create_topic(forum, attributes, topic_params) do - {:ok, %{topic: topic}} -> - post = hd(topic.posts) - - if forum.access_level == "normal" do - PhilomenaWeb.Endpoint.broadcast!( - "firehose", - "post:create", - PhilomenaWeb.Api.Json.Forum.Topic.PostView.render("firehose.json", %{ - post: post, - topic: topic, - forum: forum - }) - ) - end - + def create(conn, %{"forum_id" => forum_id} = params) do + case Topics.create_topic(conn.assigns.actor, forum_id, params["topic"]) do + {:ok, %{topic: topic, forum: forum}} -> conn |> put_flash(:info, "Successfully posted topic.") |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, :topic, changeset, _} -> + {:error, %Forum{} = forum, changeset} -> conn + |> assign(:forum, forum) |> render("new.html", changeset: changeset) - _error -> - conn - |> put_flash(:error, "There was an error with your submission. Please try again.") - |> redirect(to: ~p"/forums/#{forum}/topics/new") + {:error, :rate_limited} -> + RateLimitedResponse.call(conn, "You may only make a new topic once every 5 minutes.") + + error -> + error end end - def update(conn, %{"topic" => topic_params}) do - case Topics.update_topic_title(conn.assigns.topic, topic_params) do - {:ok, topic} -> + def update(conn, %{"forum_id" => forum_id, "id" => id, "topic" => topic_params}) do + case Topics.update_topic(conn.assigns.actor, forum_id, id, topic_params) do + {:ok, {forum, topic}} -> conn |> put_flash(:info, "Successfully updated topic.") - |> redirect(to: ~p"/forums/#{conn.assigns.forum}/topics/#{topic}") + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - {:error, _changeset} -> + {:error, forum, topic} -> conn |> put_flash(:error, "There was an error with your submission. Please try again.") - |> redirect(to: ~p"/forums/#{conn.assigns.forum}/topics/#{conn.assigns.topic}") - end - end + |> redirect(to: ~p"/forums/#{forum}/topics/#{topic}") - defp verify_authorized(conn, _opts) do - if Canada.Can.can?(conn.assigns.current_user, :edit, conn.assigns.topic) do - conn - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) + error -> + error end end end diff --git a/lib/philomena_web/controllers/unlock_controller.ex b/lib/philomena_web/controllers/unlock_controller.ex index 62e8dc4f5..6f918cf33 100644 --- a/lib/philomena_web/controllers/unlock_controller.ex +++ b/lib/philomena_web/controllers/unlock_controller.ex @@ -31,7 +31,7 @@ defmodule PhilomenaWeb.UnlockController do # Do not log in the user after unlocking to avoid a # leaked token giving the user access to the account. def show(conn, %{"id" => token}) do - case Users.unlock_user_by_token(token) do + case Users.show_unlock(token) do {:ok, _} -> conn |> put_flash(:info, "Account unlocked successfully. You may now log in.") diff --git a/lib/philomena_web/fingerprint.ex b/lib/philomena_web/fingerprint.ex index 248b17e10..78c8316c0 100644 --- a/lib/philomena_web/fingerprint.ex +++ b/lib/philomena_web/fingerprint.ex @@ -1,6 +1,8 @@ defmodule PhilomenaWeb.Fingerprint do import Plug.Conn + alias Philomena.UserFingerprints + @type t :: String.t() @name "_ses" @@ -18,17 +20,17 @@ defmodule PhilomenaWeb.Fingerprint do fingerprint = upgrade(get_session(conn, @name), conn.cookies[@name]) # If the fingerprint is valid, persist to session. - if valid_format?(fingerprint) do + if UserFingerprints.valid_format?(fingerprint) do conn |> put_session(@name, fingerprint) |> assign(:fingerprint, fingerprint) else - assign(conn, :fingerprint, nil) + maybe_assign_api_fingerprint(conn, conn.path_info) end end defp upgrade(<<"c", _::binary>> = session_value, <<"d", _::binary>> = cookie_value) do - if valid_format?(cookie_value) do + if UserFingerprints.valid_format?(cookie_value) do # When both fingerprint values are valid and the session value # is an old version, use the cookie value. cookie_value @@ -43,41 +45,20 @@ defmodule PhilomenaWeb.Fingerprint do session_value || cookie_value end - @doc """ - Determine whether the fingerprint corresponds to a valid format. - - Valid formats start with `c` or `d` (for the version). The `c` format is a legacy format - corresponding to an integer-valued hash from the frontend. The `d` format is the current - format corresponding to a hex-valued hash from the frontend. By design, it is not - possible to infer anything else about these values from the server. - - See assets/js/fp.ts for additional information on the generation of the `d` format. - - ## Examples - - iex> valid_format?("b2502085657") - false - - iex> valid_format?("c637334158") - true - - iex> valid_format?("d63c4581f8cf58d") - true - - iex> valid_format?("5162549b16e8448") - false - - """ - @spec valid_format?(any()) :: boolean() - def valid_format?(fingerprint) - - def valid_format?(<<"c", rest::binary>>) when byte_size(rest) <= 12 do - match?({_result, ""}, Integer.parse(rest)) + defp user_agent(conn) do + case get_req_header(conn, "user-agent") do + [user_agent | _] -> user_agent + _ -> "" + end end - def valid_format?(<<"d", rest::binary>>) when byte_size(rest) == 14 do - match?({:ok, _result}, Base.decode16(rest, case: :lower)) + defp maybe_assign_api_fingerprint(conn, ["api" | _]) do + # Cookieless API requests receive a fingerprint based solely on the + # provided user-agent string. + assign(conn, :fingerprint, "a#{:erlang.crc32(user_agent(conn))}") end - def valid_format?(_fingerprint), do: false + defp maybe_assign_api_fingerprint(conn, _path_info) do + assign(conn, :fingerprint, nil) + end end diff --git a/lib/philomena_web/image_loader.ex b/lib/philomena_web/image_loader.ex deleted file mode 100644 index 7a020dd51..000000000 --- a/lib/philomena_web/image_loader.ex +++ /dev/null @@ -1,175 +0,0 @@ -defmodule PhilomenaWeb.ImageLoader do - alias PhilomenaWeb.ImageSorter - alias PhilomenaQuery.Search - alias Philomena.Images.{Image, Query} - alias PhilomenaWeb.MarkdownRenderer - alias Philomena.Tags.Tag - alias Philomena.Repo - import Ecto.Query - - defp delay_home_images?(nil), do: true - - defp delay_home_images?(user) when user.role != "user", - do: user.settings.staff_delay_home_images - - defp delay_home_images?(user), do: user.settings.delay_home_images - - # sobelow_skip ["SQL.Query"] - def default_query(conn, options \\ []) do - body = - if delay_home_images?(conn.assigns.current_user), - do: %{ - bool: %{ - must: [%{range: %{created_at: %{lte: "now-3m"}}}], - must_not: [%{term: %{thumbnails_generated: false}}] - } - }, - else: %{match_all: %{}} - - query(conn, body, options) - end - - # sobelow_skip ["SQL.Query"] - def search_string(conn, search_string, options \\ []) do - user = conn.assigns.current_user - - with {:ok, tree} <- Query.compile(search_string, user: user) do - {:ok, query(conn, tree, options)} - else - error -> - error - end - end - - def query(conn, body, options \\ []) do - pagination = Keyword.get(options, :pagination, conn.assigns.image_pagination) - sorts = Keyword.get(options, :sorts, &ImageSorter.parse_sort(conn.params, &1)) - - tags = - body - |> search_tag_names() - |> load_tags() - |> render_bodies(conn) - - user = conn.assigns.current_user - filter = conn.assigns.compiled_filter - filters = create_filters(conn, user, filter) - - %{query: query, sorts: sort} = sorts.(body) - - definition = - Search.search_definition( - Image, - %{ - query: %{ - bool: %{ - must: query, - must_not: filters - } - }, - sort: sort - }, - pagination - ) - - {definition, tags} - end - - defp create_filters(conn, user, filter) do - show_hidden? = Canada.Can.can?(user, :hide, %Image{}) - del = conn.params["del"] - hidden = conn.params["hidden"] - - [ - filter - ] - |> maybe_show_deleted(show_hidden?, del) - |> maybe_custom_hide(user, hidden) - |> hide_non_approved() - end - - # Allow moderators to index hidden images - - defp maybe_show_deleted(filters, _show_hidden?, "1"), - do: filters - - defp maybe_show_deleted(filters, false, _param), - do: [%{term: %{hidden_from_users: true}} | filters] - - defp maybe_show_deleted(filters, true, "only"), - do: [%{term: %{hidden_from_users: false}} | filters] - - defp maybe_show_deleted(filters, true, "deleted"), - do: [%{term: %{hidden_from_users: false}}, %{exists: %{field: :duplicate_id}} | filters] - - defp maybe_show_deleted(filters, true, _param), - do: [%{term: %{hidden_from_users: true}} | filters] - - # Allow users to reverse the effect of hiding images, - # if desired - - defp maybe_custom_hide(filters, %{id: _id}, "1"), - do: filters - - defp maybe_custom_hide(filters, %{id: id}, _param), - do: [%{term: %{hidden_by_user_ids: id}} | filters] - - defp maybe_custom_hide(filters, _user, _param), - do: filters - - # Hide all images that aren't approved from all search queries. - defp hide_non_approved(filters), - do: [%{term: %{approved: false}} | filters] - - # TODO: the search parser should try to optimize queries - defp search_tag_name(%{term: %{"tags" => tag_name}}), do: [tag_name] - defp search_tag_name(_other_query), do: [] - - defp search_tag_names(%{bool: %{must: musts}}), do: Enum.flat_map(musts, &search_tag_name(&1)) - - defp search_tag_names(%{bool: %{should: shoulds}}), - do: Enum.flat_map(shoulds, &search_tag_name(&1)) - - defp search_tag_names(%{term: %{"tags" => tag_name}}), do: [tag_name] - defp search_tag_names(_other_query), do: [] - - defp load_tags([]), do: [] - - defp load_tags(tags) do - Tag - |> join(:left, [t], at in Tag, on: t.id == at.aliased_tag_id) - |> where([t, at], t.name in ^tags or at.name in ^tags) - |> preload([ - :aliases, - :aliased_tag, - :implied_tags, - :implied_by_tags, - :dnp_entries, - :channels, - public_links: :user, - hidden_links: :user - ]) - |> Repo.all() - |> Enum.uniq_by(& &1.id) - |> Enum.filter(&is_nil(&1.aliased_tag)) - |> Tag.display_order() - end - - defp render_bodies([], _conn), do: [] - - defp render_bodies([tag], conn) do - dnp_bodies = - MarkdownRenderer.render_collection( - Enum.map(tag.dnp_entries, &%{body: &1.conditions || ""}), - conn - ) - - dnp_entries = Enum.zip(dnp_bodies, tag.dnp_entries) - - description = MarkdownRenderer.render_one(%{body: tag.description || ""}, conn) - - [{tag, description, dnp_entries}] - end - - defp render_bodies(tags, _conn), do: tags -end diff --git a/lib/philomena_web/image_navigator.ex b/lib/philomena_web/image_navigator.ex deleted file mode 100644 index 01b782e0e..000000000 --- a/lib/philomena_web/image_navigator.ex +++ /dev/null @@ -1,90 +0,0 @@ -defmodule PhilomenaWeb.ImageNavigator do - alias PhilomenaWeb.ImageSorter - alias Philomena.Images.Image - alias PhilomenaQuery.Search - - @order_for_dir %{ - "next" => %{"asc" => "asc", "desc" => "desc"}, - "prev" => %{"asc" => "desc", "desc" => "asc"} - } - - def find_consecutive(conn, image, compiled_query, compiled_filter) do - conn = update_in(conn.params, &Map.put_new(&1, "sf", "first_seen_at")) - - %{query: compiled_query, sorts: sorts} = ImageSorter.parse_sort(conn.params, compiled_query) - - sorts = - sorts - |> Enum.flat_map(&Enum.to_list/1) - |> Enum.map(&apply_direction(&1, conn.params["rel"])) - - search_after = - conn.params["sort"] - |> permit_list() - |> Enum.flat_map(&permit_value/1) - |> default_cursors(conn.params["sf"], image) - - maybe_search_after( - Image, - %{ - query: %{ - bool: %{ - must: compiled_query, - must_not: [ - compiled_filter, - %{term: %{hidden_from_users: true}}, - %{term: %{id: image.id}}, - hidden_filter(conn.assigns.current_user, conn.params["hidden"]) - ] - } - }, - sort: sorts, - search_after: search_after - }, - %{page_size: 1}, - Image, - length(sorts) == length(search_after) - ) - |> Enum.to_list() - |> case do - [] -> nil - [next_image] -> next_image - end - end - - defp maybe_search_after(module, body, options, queryable, true) do - module - |> Search.search_definition(body, options) - |> Search.search_records_with_hits(queryable) - end - - defp maybe_search_after(_module, _body, _options, _queryable, _false) do - [] - end - - defp default_cursors([], "id", image), do: [image.id] - - defp default_cursors([], "first_seen_at", image), - do: [image.first_seen_at |> DateTime.to_unix(:millisecond), image.id] - - defp default_cursors(list, _sf, _image), do: list - - defp apply_direction({"galleries.position", sort_body}, rel) do - sort_body = update_in(sort_body.order, fn direction -> @order_for_dir[rel][direction] end) - - %{"galleries.position" => sort_body} - end - - defp apply_direction({field, direction}, rel) do - %{field => @order_for_dir[rel][direction]} - end - - defp permit_list(value) when is_list(value), do: value - defp permit_list(_value), do: [] - - defp permit_value(value) when is_binary(value) or is_number(value), do: [value] - defp permit_value(_value), do: [] - - defp hidden_filter(%{id: id}, param) when param != "1", do: %{term: %{hidden_by_user_ids: id}} - defp hidden_filter(_user, _param), do: %{match_none: %{}} -end diff --git a/lib/philomena_web/image_scope.ex b/lib/philomena_web/image_scope.ex index dae148be5..56065d5cb 100644 --- a/lib/philomena_web/image_scope.ex +++ b/lib/philomena_web/image_scope.ex @@ -1,4 +1,10 @@ defmodule PhilomenaWeb.ImageScope do + alias Philomena.Images.Search.Scope + + @doc """ + Extracts the image listing parameters worth carrying across page + transitions into a keyword list for URL building. + """ def scope(conn) do [] |> scope(conn, "q", :q) @@ -9,6 +15,18 @@ defmodule PhilomenaWeb.ImageScope do |> scope(conn, "hidden", :hidden) end + @doc """ + Builds the viewer's `Philomena.Images.Search.Scope` from the request: + compiled filter, raw params, and the image pagination window. + """ + def search_scope(conn) do + Scope.new( + conn.assigns.image_filter.query, + conn.assigns.image_pagination, + conn.params + ) + end + defp scope(list, conn, key, key_atom) do case conn.params[key] do nil -> list diff --git a/lib/philomena_web/image_sorter.ex b/lib/philomena_web/image_sorter.ex deleted file mode 100644 index c05db5122..000000000 --- a/lib/philomena_web/image_sorter.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule PhilomenaWeb.ImageSorter do - @allowed_fields ~W( - id - updated_at - first_seen_at - aspect_ratio - faves - downvotes - upvotes - width - height - score - comment_count - tag_count - wilson_score - pixels - size - duration - hides - ) - - def parse_sort(params, query) do - sd = parse_sd(params) - - parse_sf(params, sd, query) - end - - defp parse_sd(%{"sd" => sd}) when sd in ~W(asc desc), do: sd - defp parse_sd(_params), do: "desc" - - defp parse_sf(%{"sf" => sf}, sd, query) when sf == "id" do - %{query: query, sorts: [%{"id" => sd}]} - end - - defp parse_sf(%{"sf" => sf}, sd, query) when sf in @allowed_fields do - %{query: query, sorts: [%{sf => sd}, %{"id" => sd}]} - end - - defp parse_sf(%{"sf" => "_score"}, sd, query) do - %{query: query, sorts: [%{"_score" => sd}, %{"id" => sd}]} - end - - defp parse_sf(%{"sf" => "random"}, sd, query) do - random_query(:rand.uniform(4_294_967_296), sd, query) - end - - defp parse_sf(%{"sf" => <<"random:", seed::binary>>}, sd, query) do - case Integer.parse(seed) do - {seed, _rest} -> - random_query(seed, sd, query) - - _ -> - random_query(:rand.uniform(4_294_967_296), sd, query) - end - end - - defp parse_sf(%{"sf" => <<"gallery_id:", gallery::binary>>}, sd, query) do - case Integer.parse(gallery) do - {gallery, _rest} -> - %{ - query: query, - sorts: [ - %{ - "galleries.position" => %{ - order: sd, - nested: %{ - path: :galleries, - filter: %{ - term: %{"galleries.id" => gallery} - } - } - } - }, - %{"id" => "desc"} - ] - } - - _ -> - %{query: query, sorts: []} - end - end - - defp parse_sf(_params, sd, query) do - %{query: query, sorts: [%{"first_seen_at" => sd}, %{"id" => sd}]} - end - - defp random_query(seed, sd, query) do - %{ - query: %{ - function_score: %{ - query: query, - random_score: %{seed: seed, field: :id}, - boost_mode: :replace - } - }, - sorts: [%{"_score" => sd}, %{"id" => sd}] - } - end -end diff --git a/lib/philomena_web/markdown_renderer.ex b/lib/philomena_web/markdown_renderer.ex index 3dcb19f8f..0edaf494e 100644 --- a/lib/philomena_web/markdown_renderer.ex +++ b/lib/philomena_web/markdown_renderer.ex @@ -1,10 +1,11 @@ defmodule PhilomenaWeb.MarkdownRenderer do alias Philomena.Markdown - alias Philomena.Images.Image - alias Philomena.Repo + alias Philomena.Images alias PhilomenaWeb.ImageView import Phoenix.HTML.Link - import Ecto.Query + + # TODO: collection_renderer pattern is a weird inversion of control + # that is no longer needed. It should flow one way from context to web def render_one(item, conn) do hd(render_collection([item], conn)) @@ -40,7 +41,7 @@ defmodule PhilomenaWeb.MarkdownRenderer do @doc """ Renders line diffs for a list of version structs (as prepared by - `Philomena.Versions.load_post_versions/1` and `load_comment_versions/1`). + `Philomena.Versions.for_post/1` and `for_comment/1`). Each version's `:difference` field is set to the rendered safe HTML diff from the next-older revision's body to this version's body. """ @@ -72,10 +73,8 @@ defmodule PhilomenaWeb.MarkdownRenderer do defp load_images(images) do ids = Enum.map(images, fn m -> Enum.at(m, 0) end) - Image - |> where([i], i.id in ^ids) - |> preload([:sources, tags: :aliases]) - |> Repo.all() + ids + |> Images.list_images_by_ids() |> Map.new(&{&1.id, &1}) end diff --git a/lib/philomena_web/plugs/admin_counters_plug.ex b/lib/philomena_web/plugs/admin_counters_plug.ex index bf271c0ee..4538f688a 100644 --- a/lib/philomena_web/plugs/admin_counters_plug.ex +++ b/lib/philomena_web/plugs/admin_counters_plug.ex @@ -32,11 +32,11 @@ defmodule PhilomenaWeb.AdminCountersPlug do defp maybe_assign_admin_metrics(conn, _user, false), do: conn defp maybe_assign_admin_metrics(conn, user, true) do - pending_approvals = Images.count_pending_approvals(user) - duplicate_reports = DuplicateReports.count_duplicate_reports(user) - reports = Reports.count_open_reports(user) + pending_approvals = Images.count_pending_approvals(conn.assigns.actor) + duplicate_reports = DuplicateReports.count_duplicate_reports(conn.assigns.actor) + reports = Reports.count_open_reports(conn.assigns.actor) artist_links = ArtistLinks.count_artist_links(user) - dnps = DnpEntries.count_dnp_entries(user) + dnps = DnpEntries.count_dnp_entries(conn.assigns.actor) conn |> assign(:pending_approval_count, pending_approvals) diff --git a/lib/philomena_web/plugs/canary_map_plug.ex b/lib/philomena_web/plugs/canary_map_plug.ex deleted file mode 100644 index 2f07f3ae2..000000000 --- a/lib/philomena_web/plugs/canary_map_plug.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule PhilomenaWeb.CanaryMapPlug do - import Plug.Conn - - def init(opts), do: opts - - def call(conn, opts) do - phx_action = conn.private.phoenix_action - - canary_action = - case Keyword.fetch(opts, phx_action) do - {:ok, action} -> action - _ -> phx_action - end - - conn - |> assign(:canary_action, canary_action) - end -end diff --git a/lib/philomena_web/plugs/canary_plugs.ex b/lib/philomena_web/plugs/canary_plugs.ex deleted file mode 100644 index 26df5b2c5..000000000 --- a/lib/philomena_web/plugs/canary_plugs.ex +++ /dev/null @@ -1,91 +0,0 @@ -defmodule PhilomenaWeb.CanaryPlugs do - @moduledoc """ - Drop-in wrappers around `Canary.Plugs` that reject an id which cannot name a - row before Ecto tries to cast it. - - Canary loads resources with `Repo.get_by/2` on the raw path segment. When the - id field is integer-typed, a segment like `not-a-number` raises - `Ecto.Query.CastError` (a 400 under `phoenix_ecto`'s exception mappings) and - one like `99999999999999999999` raises `DBConnection.EncodeError` (a 500), - where the same route answers an unknown-but-valid id with the not-found - handler. - - Such an id can never match, so these wrappers short-circuit to - `PhilomenaWeb.NotFoundPlug` instead of querying. Controllers pick them up - through `PhilomenaWeb.controller/0`; the plug names and options are Canary's. - """ - - alias PhilomenaWeb.IntegerId - alias PhilomenaWeb.NotFoundPlug - - @doc "See `Canary.Plugs.load_resource/2`." - def load_resource(conn, opts), - do: guard_id(conn, opts, &Canary.Plugs.load_resource/2) - - @doc "See `Canary.Plugs.load_and_authorize_resource/2`." - def load_and_authorize_resource(conn, opts), - do: guard_id(conn, opts, &Canary.Plugs.load_and_authorize_resource/2) - - @doc "See `Canary.Plugs.authorize_resource/2`." - def authorize_resource(conn, opts), - do: guard_id(conn, opts, &Canary.Plugs.authorize_resource/2) - - @doc "See `Canary.Plugs.authorize_controller/2`." - defdelegate authorize_controller(conn, opts), to: Canary.Plugs - - defp guard_id(conn, opts, canary_plug) do - if unloadable_id?(conn, opts) do - NotFoundPlug.call(conn) - else - canary_plug.(conn, opts) - end - end - - defp unloadable_id?(conn, opts) do - action_valid?(conn, opts) and fetches_by_id?(conn, opts) and - integer_id_field?(opts) and not castable_id?(resource_id(conn, opts)) - end - - # Mirrors the `cond` in Canary's `do_load_resource/2`: an id is only read from - # the params when the resource is persisted or the action names one. - defp fetches_by_id?(conn, opts), - do: persisted?(opts) or action(conn) not in non_id_actions(opts) - - defp non_id_actions(opts), - do: [:index, :new, :create] ++ List.wrap(opts[:non_id_actions]) - - defp persisted?(opts), - do: !!opts[:persisted] or !!opts[:required] - - defp integer_id_field?(opts) do - field = String.to_existing_atom(opts[:id_field] || "id") - - opts[:model].__schema__(:type, field) in [:id, :integer] - rescue - ArgumentError -> false - end - - # A missing id is Canary's business, not ours - it assigns nil and lets the - # configured handler run. - defp castable_id?(nil), do: true - defp castable_id?(id), do: IntegerId.parse(id) != :error - - defp resource_id(conn, opts), - do: conn.params[opts[:id_name] || "id"] - - defp action(conn), - do: Map.get(conn.assigns, :canary_action, conn.private.phoenix_action) - - # Replicates Canary's `action_valid?/2` so `:only`/`:except` keep their meaning. - defp action_valid?(conn, opts) do - cond do - Keyword.has_key?(opts, :except) and Keyword.has_key?(opts, :only) -> false - Keyword.has_key?(opts, :except) -> not action_matches?(conn, opts[:except]) - Keyword.has_key?(opts, :only) -> action_matches?(conn, opts[:only]) - true -> true - end - end - - defp action_matches?(conn, actions) when is_list(actions), do: action(conn) in actions - defp action_matches?(conn, action), do: action(conn) == action -end diff --git a/lib/philomena_web/plugs/channel_plug.ex b/lib/philomena_web/plugs/channel_plug.ex index d736b2410..4f0ec1f73 100644 --- a/lib/philomena_web/plugs/channel_plug.ex +++ b/lib/philomena_web/plugs/channel_plug.ex @@ -1,18 +1,10 @@ defmodule PhilomenaWeb.ChannelPlug do alias Plug.Conn - alias Philomena.Channels.Channel - alias Philomena.Repo - import Ecto.Query + alias Philomena.Channels def init([]), do: [] def call(conn, _opts) do - live_channels = - Channel - |> where(is_live: true) - |> Repo.aggregate(:count, :id) - - conn - |> Conn.assign(:live_channels, live_channels) + Conn.assign(conn, :live_channels, Channels.count_live_channels()) end end diff --git a/lib/philomena_web/plugs/compromised_password_check_plug.ex b/lib/philomena_web/plugs/compromised_password_check_plug.ex deleted file mode 100644 index b83798f14..000000000 --- a/lib/philomena_web/plugs/compromised_password_check_plug.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule PhilomenaWeb.CompromisedPasswordCheckPlug do - import Phoenix.Controller - import Plug.Conn - - def init(opts), do: opts - - def call(conn, _opts) do - error_if_password_compromised(conn, conn.params) - end - - defp error_if_password_compromised(conn, %{"user" => %{"password" => password}}) do - if password_compromised?(password) do - conn - |> put_flash( - :error, - "We've detected that the password you entered has been compromised during a data breach of another website. Please choose a different password." - ) - |> redirect(external: conn.assigns.referrer) - |> halt() - else - conn - end - end - - defp error_if_password_compromised(conn, _params), - do: conn - - @doc """ - Returns whether a password appears in the Pwned Passwords database. - - The range query only sends the first five characters of the password's SHA-1 - hash. If the check is disabled or unavailable, passwords are allowed through. - """ - def password_compromised?(password) when is_binary(password) do - if pwned_passwords_enabled?() do - password_compromised_in_breach?(password) - else - false - end - end - - def password_compromised?(_password), do: false - - defp password_compromised_in_breach?(password) do - <> = - :crypto.hash(:sha, password) - |> Base.encode16() - - case PhilomenaProxy.Http.get(make_api_url(prefix)) do - {:ok, %{body: body, status: 200}} -> - Enum.any?(String.split(body, "\n"), &String.starts_with?(&1, rest <> ":")) - - _ -> - false - end - end - - defp make_api_url(prefix) do - "https://api.pwnedpasswords.com/range/#{prefix}" - end - - defp pwned_passwords_enabled? do - Application.get_env(:philomena, :pwned_passwords) != false - end -end diff --git a/lib/philomena_web/plugs/current_ban_plug.ex b/lib/philomena_web/plugs/current_ban_plug.ex deleted file mode 100644 index d112fa16d..000000000 --- a/lib/philomena_web/plugs/current_ban_plug.ex +++ /dev/null @@ -1,27 +0,0 @@ -defmodule PhilomenaWeb.CurrentBanPlug do - @moduledoc """ - This plug loads the ban for the current user. - - ## Example - - plug PhilomenaWeb.CurrentBanPlug - """ - alias Philomena.Bans - alias Plug.Conn - - @doc false - @spec init(any()) :: any() - def init(opts), do: opts - - @doc false - @spec call(Conn.t(), any()) :: Conn.t() - def call(conn, _opts) do - fingerprint = conn.assigns.fingerprint - user = conn.assigns.current_user - ip = conn.remote_ip - - ban = Bans.find(user, ip, fingerprint) - - Conn.assign(conn, :current_ban, ban) - end -end diff --git a/lib/philomena_web/plugs/current_filter_plug.ex b/lib/philomena_web/plugs/current_filter_plug.ex index b3f1c4940..524f0f150 100644 --- a/lib/philomena_web/plugs/current_filter_plug.ex +++ b/lib/philomena_web/plugs/current_filter_plug.ex @@ -1,64 +1,24 @@ defmodule PhilomenaWeb.CurrentFilterPlug do + @moduledoc """ + Resolves the actor's effective current and forced filters through the Filters + context, and assigns them to `conn`. + """ + import Plug.Conn - alias Philomena.Users - alias Philomena.{Filters, Filters.Filter} - alias Philomena.Repo + alias Philomena.Filters - # No options def init([]), do: false - # Assign current filter def call(conn, _opts) do conn = fetch_cookies(conn) - user = conn.assigns.current_user - - {filter, forced_filter} = - if user do - user = - user - |> Repo.preload([:current_filter, :forced_filter]) - |> maybe_set_default_filter() + cookie_filter_id = conn.cookies["filter_id"] - {user.current_filter, user.forced_filter} - else - filter = load_and_authorize_filter(conn.cookies, user) - - {filter || Filters.default_filter(), nil} - end + {:ok, selection} = + Filters.load_selected_filters(conn.assigns.actor, cookie_filter_id) conn - |> assign(:current_filter, filter) - |> assign(:forced_filter, forced_filter) - end - - defp maybe_set_default_filter(%{current_filter: nil} = user) do - filter = Filters.default_filter() - - {:ok, user} = Users.update_filter(user, filter) - - Map.put(user, :current_filter, filter) - end - - defp maybe_set_default_filter(user), do: user - - defp load_and_authorize_filter(%{"filter_id" => filter_id}, user) do - Filter - |> Repo.get(filter_id) - |> case do - nil -> - nil - - filter -> - if Canada.Can.can?(user, :show, filter) do - filter - else - nil - end - end - end - - defp load_and_authorize_filter(_cookies, _user) do - nil + |> assign(:current_filter, selection.current_filter) + |> assign(:forced_filter, selection.forced_filter) end end diff --git a/lib/philomena_web/plugs/filter_banned_users_plug.ex b/lib/philomena_web/plugs/filter_banned_users_plug.ex index 866bde432..a956f0845 100644 --- a/lib/philomena_web/plugs/filter_banned_users_plug.ex +++ b/lib/philomena_web/plugs/filter_banned_users_plug.ex @@ -17,22 +17,31 @@ defmodule PhilomenaWeb.FilterBannedUsersPlug do @doc false @spec call(Conn.t(), any()) :: Conn.t() def call(conn, _opts) do - redirect_url = conn.assigns.referrer - conn.assigns.current_ban - |> maybe_halt(conn, redirect_url) + |> maybe_halt(conn) |> maybe_halt_no_fingerprint() end - defp maybe_halt(nil, conn, _redirect_url), do: conn + @doc """ + Emits the ban response: flash "You are currently banned." plus an external + redirect to `conn.assigns.referrer`, then halt. - defp maybe_halt(_current_ban, conn, redirect_url) do + Shared with `PhilomenaWeb.FallbackController` so the context-driven + `{:error, :ban}` path stays byte-identical with the plug for controllers not + yet migrated off it. + """ + @spec ban_response(Conn.t()) :: Conn.t() + def ban_response(conn) do conn |> Controller.put_flash(:error, "You are currently banned.") - |> Controller.redirect(external: redirect_url) + |> Controller.redirect(external: conn.assigns.referrer) |> Conn.halt() end + defp maybe_halt(nil, conn), do: conn + + defp maybe_halt(_current_ban, conn), do: ban_response(conn) + defp maybe_halt_no_fingerprint(%{halted: true} = conn), do: conn defp maybe_halt_no_fingerprint(%{method: "GET"} = conn), do: conn diff --git a/lib/philomena_web/plugs/filter_forced_users_plug.ex b/lib/philomena_web/plugs/filter_forced_users_plug.ex deleted file mode 100644 index ff4a7fa70..000000000 --- a/lib/philomena_web/plugs/filter_forced_users_plug.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule PhilomenaWeb.FilterForcedUsersPlug do - @moduledoc """ - Halts the request pipeline if the current image belongs to the conn's - "forced filter". - """ - - import Phoenix.Controller - import Plug.Conn - alias PhilomenaQuery.Parse.String - alias PhilomenaQuery.Parse.Evaluator - alias Philomena.Images.Query - alias PhilomenaWeb.ImageView - - def init(_opts) do - [] - end - - def call(conn, _opts) do - maybe_fetch_forced(conn, conn.assigns.forced_filter) - end - - defp maybe_fetch_forced(conn, nil), do: conn - - defp maybe_fetch_forced(conn, forced) do - maybe_halt(conn, matches_filter?(conn.assigns.current_user, conn.assigns.image, forced)) - end - - defp maybe_halt(conn, false), do: conn - - defp maybe_halt(conn, true) do - conn - |> put_flash(:error, "You have been blocked from performing this action on this image.") - |> redirect(external: conn.assigns.referrer) - |> halt() - end - - defp matches_filter?(user, image, filter) do - matches_tag_filter?(image, filter.hidden_tag_ids) or - matches_complex_filter?(user, image, filter.hidden_complex_str) - end - - defp matches_tag_filter?(image, tag_ids) do - image.tags - |> MapSet.new(& &1.id) - |> MapSet.intersection(MapSet.new(tag_ids)) - |> Enum.any?() - end - - defp matches_complex_filter?(user, image, search_string) do - image - |> ImageView.image_filter_data() - |> Evaluator.hits?(compile_filter(user, search_string)) - end - - defp compile_filter(user, search_string) do - search_string - |> String.normalize() - |> Query.compile(user: user, filter: true) - |> case do - {:ok, query} -> query - _error -> %{match_all: %{}} - end - end -end diff --git a/lib/philomena_web/plugs/filter_id_plug.ex b/lib/philomena_web/plugs/filter_id_plug.ex index 11e5f2f61..d0da7bc20 100644 --- a/lib/philomena_web/plugs/filter_id_plug.ex +++ b/lib/philomena_web/plugs/filter_id_plug.ex @@ -1,21 +1,21 @@ defmodule PhilomenaWeb.FilterIdPlug do - alias Philomena.Filters.Filter - alias Philomena.Repo + @moduledoc """ + Loads and authorizes a route's `filter_id` through the Filters context and, + when visible, assigns it as the current filter. + """ + + alias Philomena.Filters # No options def init([]), do: false def call(conn, _opts) do - filter = load_filter(conn.params) - user = conn.assigns.current_user - - if not is_nil(filter) and Canada.Can.can?(user, :show, filter) do - Plug.Conn.assign(conn, :current_filter, filter) - else - conn + case load_filter(conn.assigns.actor, conn.params) do + {:ok, filter} -> Plug.Conn.assign(conn, :current_filter, filter) + {:error, _reason} -> conn end end - defp load_filter(%{"filter_id" => filter_id}), do: Repo.get(Filter, filter_id) - defp load_filter(_params), do: nil + defp load_filter(actor, %{"filter_id" => filter_id}), do: Filters.show_filter(actor, filter_id) + defp load_filter(_actor, _params), do: {:error, :not_found} end diff --git a/lib/philomena_web/plugs/filter_select_plug.ex b/lib/philomena_web/plugs/filter_select_plug.ex index 715cc8cdf..77abd1dff 100644 --- a/lib/philomena_web/plugs/filter_select_plug.ex +++ b/lib/philomena_web/plugs/filter_select_plug.ex @@ -34,12 +34,24 @@ defmodule PhilomenaWeb.FilterSelectPlug do defp maybe_assign_filters(conn, nil), do: conn defp maybe_assign_filters(conn, user) do - filters = Filters.recent_and_user_filters(user) + {:ok, filter_selection} = Filters.recent_and_user_filters(conn.assigns.actor) conn - |> Conn.assign(:user_changeset, Users.change_user(user)) - |> Conn.assign(:spoiler_changeset, Users.change_spoiler_type(user)) - |> Conn.assign(:available_filters, filters) + |> Conn.assign(:user_changeset, Users.filter_selection_changeset(user)) + |> Conn.assign(:spoiler_changeset, Users.spoiler_type_changeset(user)) + |> Conn.assign(:available_filters, filter_select_options(filter_selection)) |> Conn.assign(:spoiler_types, @spoiler_types) end + + defp filter_select_options(%{recent_filters: recent_filters, user_filters: user_filters}) do + [ + {"Your Filters", filter_options(user_filters)}, + {"Recent Filters", filter_options(recent_filters)} + ] + |> Enum.reject(fn {_label, options} -> options == [] end) + end + + defp filter_options(filters) do + Enum.map(filters, &[key: &1.name, value: &1.id]) + end end diff --git a/lib/philomena_web/plugs/forum_list_plug.ex b/lib/philomena_web/plugs/forum_list_plug.ex index b74bb762a..4b452b434 100644 --- a/lib/philomena_web/plugs/forum_list_plug.ex +++ b/lib/philomena_web/plugs/forum_list_plug.ex @@ -1,25 +1,13 @@ defmodule PhilomenaWeb.ForumListPlug do alias Plug.Conn - alias Philomena.Forums.Forum - alias Philomena.Repo - alias Canada.Can - import Ecto.Query + alias Philomena.Forums def init(opts), do: opts def call(conn, _opts) do - forums = lookup_visible_forums(conn.assigns.current_user) + forums = Forums.list_forums(conn.assigns.actor) - conn - |> Conn.assign(:forums, forums) - end - - # fixme: add caching! - defp lookup_visible_forums(user) do - Forum - |> order_by(asc: :name) - |> Repo.all() - |> Enum.filter(&Can.can?(user, :show, &1)) + Conn.assign(conn, :forums, forums) end end diff --git a/lib/philomena_web/plugs/image_filter_plug.ex b/lib/philomena_web/plugs/image_filter_plug.ex index 6f1d48496..8cc7ba80a 100644 --- a/lib/philomena_web/plugs/image_filter_plug.ex +++ b/lib/philomena_web/plugs/image_filter_plug.ex @@ -1,60 +1,19 @@ defmodule PhilomenaWeb.ImageFilterPlug do import Plug.Conn - alias PhilomenaQuery.Parse.String - alias Philomena.Images.Query + alias Philomena.Filters # No options def init([]), do: false # Assign current filter def call(conn, _opts) do - user = conn.assigns.current_user - filter = defaults(conn.assigns[:current_filter]) - forced = defaults(conn.assigns[:forced_filter]) - - tag_exclusion = %{terms: %{tag_ids: filter.hidden_tag_ids ++ forced.hidden_tag_ids}} - query_spoiler = invalid_filter_guard(user, filter.spoilered_complex_str) - - query_exclusion = %{ - bool: %{ - should: [ - invalid_filter_guard(user, filter.hidden_complex_str), - invalid_filter_guard(user, forced.hidden_complex_str) - ] - } - } - - query = %{ - bool: %{ - should: [tag_exclusion, query_exclusion] - } - } - - conn - |> assign(:compiled_complex_filter, query_exclusion) - |> assign(:compiled_complex_spoiler, query_spoiler) - |> assign(:compiled_filter, query) - end - - defp defaults(nil) do - %{ - hidden_tag_ids: [], - hidden_complex_str: nil, - spoilered_complex_str: nil - } - end - - defp defaults(filter) do - filter - end - - defp invalid_filter_guard(user, search_string) do - search_string - |> String.normalize() - |> Query.compile(user: user, filter: true) - |> case do - {:ok, query} -> query - _error -> %{match_all: %{}} - end + image_filter = + Filters.compile_image_filter( + conn.assigns.actor, + conn.assigns[:current_filter], + conn.assigns[:forced_filter] + ) + + assign(conn, :image_filter, image_filter) end end diff --git a/lib/philomena_web/plugs/limit_plug.ex b/lib/philomena_web/plugs/limit_plug.ex deleted file mode 100644 index 472d2e055..000000000 --- a/lib/philomena_web/plugs/limit_plug.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule PhilomenaWeb.LimitPlug do - @moduledoc """ - This plug automatically limits requests which are submitted faster - than should be allowed for a given client. - - ## Example - - plug PhilomenaWeb.LimitPlug, [time: 30, error: "Too fast! Slow down."] - """ - - alias Plug.Conn - alias Phoenix.Controller - alias Philomena.Users.User - - @doc false - @spec init(any()) :: any() - def init(opts), do: opts - - @doc false - @spec call(Conn.t(), any()) :: Conn.t() - def call(conn, opts) do - limit = Keyword.get(opts, :limit, 1) - time = Keyword.get(opts, :time, 5) - error = Keyword.get(opts, :error) - skip_staff = Keyword.get(opts, :skip_staff, true) - - data = [ - current_user_id(conn.assigns.current_user), - :inet_parse.ntoa(conn.remote_ip), - conn.private.phoenix_action, - conn.private.phoenix_controller - ] - - key = "rl-#{Enum.join(data, "")}" - amt = Redix.command!(:redix, ["GET", key]) || 0 - - conn = increment_after_post(conn, key, time) - - cond do - amt <= limit -> - conn - - is_staff(conn.assigns.current_user) and skip_staff -> - conn - - bypasses_rate_limits(conn.assigns.current_user) -> - conn - - conn.assigns.ajax? -> - conn - |> Controller.put_flash(:error, error) - |> Conn.send_resp(:multiple_choices, "") - |> Conn.halt() - - api?(conn) -> - conn - |> Conn.put_status(:too_many_requests) - |> Controller.text("") - |> Conn.halt() - - true -> - conn - |> Controller.put_flash(:error, error) - |> Controller.redirect(external: conn.assigns.referrer) - |> Conn.halt() - end - end - - defp is_staff(%User{role: "admin"}), do: true - defp is_staff(%User{role: "moderator"}), do: true - defp is_staff(%User{role: "assistant"}), do: true - defp is_staff(_), do: false - - defp bypasses_rate_limits(%User{bypass_rate_limits: true}), do: true - defp bypasses_rate_limits(_), do: false - - defp current_user_id(%{id: id}), do: id - defp current_user_id(_), do: nil - - defp api?(conn) do - case conn.path_info do - ["api" | _] -> true - _ -> false - end - end - - defp increment_after_post(conn, key, time) do - Conn.register_before_send(conn, fn conn -> - # Phoenix status returns 200 for form validation errors - if conn.status != 200 do - Redix.pipeline!(:redix, [ - ["INCR", key], - ["EXPIRE", key, time] - ]) - end - - conn - end) - end -end diff --git a/lib/philomena_web/plugs/load_comment_plug.ex b/lib/philomena_web/plugs/load_comment_plug.ex deleted file mode 100644 index d8d0cc44e..000000000 --- a/lib/philomena_web/plugs/load_comment_plug.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule PhilomenaWeb.LoadCommentPlug do - alias Philomena.Comments.Comment - alias Philomena.Repo - - import Plug.Conn, only: [assign: 3] - import Canada.Can, only: [can?: 3] - import Ecto.Query - - def init(opts), - do: opts - - def call(%{assigns: %{image: image}} = conn, opts) do - param = Keyword.get(opts, :param, "comment_id") - show_hidden = Keyword.get(opts, :show_hidden, false) - - Comment - |> where(image_id: ^image.id, id: ^to_string(conn.params[param])) - |> preload([:image, :deleted_by, user: [awards: :badge]]) - |> Repo.one() - |> maybe_hide_comment(conn, show_hidden) - end - - defp maybe_hide_comment(nil, conn, _show_hidden), - do: PhilomenaWeb.NotFoundPlug.call(conn) - - defp maybe_hide_comment(%{hidden_from_users: false} = comment, conn, _show_hidden), - do: assign(conn, :comment, comment) - - defp maybe_hide_comment(comment, %{assigns: %{current_user: user}} = conn, show_hidden) do - if show_hidden or can?(user, :show, comment) do - assign(conn, :comment, comment) - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end -end diff --git a/lib/philomena_web/plugs/load_poll_plug.ex b/lib/philomena_web/plugs/load_poll_plug.ex deleted file mode 100644 index 6a8bec7f7..000000000 --- a/lib/philomena_web/plugs/load_poll_plug.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule PhilomenaWeb.LoadPollPlug do - alias Philomena.Polls.Poll - alias Philomena.Repo - - import Ecto.Query - - def init(opts), do: opts - - def call(%{assigns: %{topic: topic}} = conn, _opts) do - Poll - |> where(topic_id: ^topic.id) - |> Repo.one() - |> case do - nil -> - PhilomenaWeb.NotFoundPlug.call(conn) - - poll -> - Plug.Conn.assign(conn, :poll, poll) - end - end -end diff --git a/lib/philomena_web/plugs/load_post_plug.ex b/lib/philomena_web/plugs/load_post_plug.ex deleted file mode 100644 index 6e787650e..000000000 --- a/lib/philomena_web/plugs/load_post_plug.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule PhilomenaWeb.LoadPostPlug do - alias Philomena.Posts.Post - alias Philomena.Repo - - import Plug.Conn, only: [assign: 3] - import Canada.Can, only: [can?: 3] - import Ecto.Query - - def init(opts), - do: opts - - def call(%{assigns: %{topic: topic}} = conn, opts) do - param = Keyword.get(opts, :param, "post_id") - show_hidden = Keyword.get(opts, :show_hidden, false) - - Post - |> where(topic_id: ^topic.id, id: ^to_string(conn.params[param])) - |> preload(topic: :forum, user: [awards: :badge]) - |> Repo.one() - |> maybe_hide_post(conn, show_hidden) - end - - defp maybe_hide_post(nil, conn, _show_hidden), - do: PhilomenaWeb.NotFoundPlug.call(conn) - - defp maybe_hide_post(%{hidden_from_users: false} = post, conn, _show_hidden), - do: assign(conn, :post, post) - - defp maybe_hide_post(post, %{assigns: %{current_user: user}} = conn, show_hidden) do - if show_hidden or can?(user, :show, post) do - assign(conn, :post, post) - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end -end diff --git a/lib/philomena_web/plugs/load_topic_plug.ex b/lib/philomena_web/plugs/load_topic_plug.ex deleted file mode 100644 index 8ce28f0be..000000000 --- a/lib/philomena_web/plugs/load_topic_plug.ex +++ /dev/null @@ -1,36 +0,0 @@ -defmodule PhilomenaWeb.LoadTopicPlug do - alias Philomena.Topics.Topic - alias Philomena.Repo - - import Plug.Conn, only: [assign: 3] - import Canada.Can, only: [can?: 3] - import Ecto.Query - - def init(opts), - do: opts - - def call(%{assigns: %{forum: forum}} = conn, opts) do - param = Keyword.get(opts, :param, "topic_id") - show_hidden = Keyword.get(opts, :show_hidden, false) - - Topic - |> where(forum_id: ^forum.id, slug: ^to_string(conn.params[param])) - |> preload([:user, :forum, :deleted_by, :locked_by, poll: :options]) - |> Repo.one() - |> maybe_hide_topic(conn, show_hidden) - end - - defp maybe_hide_topic(nil, conn, _show_hidden), - do: PhilomenaWeb.NotFoundPlug.call(conn) - - defp maybe_hide_topic(%{hidden_from_users: false} = topic, conn, _show_hidden), - do: assign(conn, :topic, topic) - - defp maybe_hide_topic(topic, %{assigns: %{current_user: user}} = conn, show_hidden) do - if show_hidden or can?(user, :show, topic) do - assign(conn, :topic, topic) - else - PhilomenaWeb.NotAuthorizedPlug.call(conn) - end - end -end diff --git a/lib/philomena_web/plugs/map_parameter_plug.ex b/lib/philomena_web/plugs/map_parameter_plug.ex deleted file mode 100644 index b7e0a758d..000000000 --- a/lib/philomena_web/plugs/map_parameter_plug.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule PhilomenaWeb.MapParameterPlug do - # A bunch of crappy behaviors all interacting to create a - # symphony of failure: - # - # 1.) Router helpers do not strip nil query parameters. - # iex> ~p"/galleries?#{[gallery: nil]}" - # "/galleries?gallery=" - # - # 2.) Pagination always sets the parameter in the route in order - # to preserve the query across multiple pages - # - # 3.) When received by the router, an empty param is treated as - # an empty string instead of nil - # - # 4.) Phoenix.HTML.Form.form_for/2 raises an error if you try to - # use it on a conn object which is a string instead of a map - # (or nil) - - @spec init(Keyword.t()) :: Keyword.t() - def init(opts) do - opts - end - - @spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() - def call(conn, opts) do - param = Keyword.fetch!(opts, :param) - value = conn.params[param] - - if is_map(value) do - conn - else - params = Map.delete(conn.params, param) - - Map.put(conn, :params, params) - end - end -end diff --git a/lib/philomena_web/plugs/moderation_log_plug.ex b/lib/philomena_web/plugs/moderation_log_plug.ex deleted file mode 100644 index e69da5708..000000000 --- a/lib/philomena_web/plugs/moderation_log_plug.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule PhilomenaWeb.ModerationLogPlug do - @moduledoc """ - This plug writes moderation logs. - ## Example - - plug PhilomenaWeb.ModerationLogPlug, [details: &log_details/2] - """ - - @controller_regex ~r/PhilomenaWeb\.([\w\.]+)Controller\z/ - - alias Plug.Conn - alias Phoenix.Controller - alias Philomena.ModerationLogs - - @doc false - @spec init(any()) :: any() - def init(opts), do: opts - - @type log_details :: %{subject_path: String.t(), body: String.t()} - @type details_func :: (atom(), any() -> log_details()) - @type call_opts :: [details: details_func, data: any()] - - @doc false - @spec call(Conn.t(), call_opts) :: Conn.t() - def call(conn, opts) do - details_func = Keyword.fetch!(opts, :details) - userdata = Keyword.get(opts, :data, nil) - - user = conn.assigns.current_user - action = Controller.action_name(conn) - - %{subject_path: subject_path, body: body} = details_func.(action, userdata) - - mod = Controller.controller_module(conn) - [mod_name] = Regex.run(@controller_regex, to_string(mod), capture: :all_but_first) - type = "#{mod_name}:#{action}" - - ModerationLogs.create_moderation_log(user, type, subject_path, body) - - conn - end - - @doc false - @spec moderation_log(Conn.t(), call_opts()) :: Conn.t() - def moderation_log(conn, opts), do: call(conn, opts) -end diff --git a/lib/philomena_web/plugs/notification_count_plug.ex b/lib/philomena_web/plugs/notification_count_plug.ex index 8f4f79134..a5a244883 100644 --- a/lib/philomena_web/plugs/notification_count_plug.ex +++ b/lib/philomena_web/plugs/notification_count_plug.ex @@ -22,26 +22,23 @@ defmodule PhilomenaWeb.NotificationCountPlug do @doc false @spec call(Plug.Conn.t(), any()) :: Plug.Conn.t() def call(conn, _opts) do - user = conn.assigns.current_user - conn - |> maybe_assign_notifications(user) - |> maybe_assign_conversations(user) + |> maybe_assign_notifications(conn.assigns.actor) + |> maybe_assign_conversations(conn.assigns.actor) end - defp maybe_assign_notifications(conn, nil), do: conn - - defp maybe_assign_notifications(conn, user) do - notifications = Notifications.total_unread_notification_count(user) + defp maybe_assign_notifications(conn, actor) do + notifications = Notifications.total_unread_count(actor) Conn.assign(conn, :notification_count, notifications) end - defp maybe_assign_conversations(conn, nil), do: conn - - defp maybe_assign_conversations(conn, user) do - conversations = Conversations.count_unread_conversations(user) + defp maybe_assign_conversations(conn, %{user: nil}), do: conn - Conn.assign(conn, :conversation_count, conversations) + defp maybe_assign_conversations(conn, actor) do + case Conversations.unread_conversation_count(actor) do + {:ok, conversations} -> Conn.assign(conn, :conversation_count, conversations) + {:error, _reason} -> conn + end end end diff --git a/lib/philomena_web/plugs/require_user_plug.ex b/lib/philomena_web/plugs/require_user_plug.ex deleted file mode 100644 index 292ae91de..000000000 --- a/lib/philomena_web/plugs/require_user_plug.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule PhilomenaWeb.RequireUserPlug do - import Phoenix.Controller - import Plug.Conn - - # No options - def init([]), do: false - - # Redirect if not logged in - def call(conn, _opts) do - user = conn.assigns.current_user - - if user do - conn - else - conn - |> put_flash(:error, "You must be signed in to see this page.") - |> redirect(to: "/") - |> halt() - end - end -end diff --git a/lib/philomena_web/plugs/user_attribution_plug.ex b/lib/philomena_web/plugs/user_attribution_plug.ex index 4f04936fe..13530308c 100644 --- a/lib/philomena_web/plugs/user_attribution_plug.ex +++ b/lib/philomena_web/plugs/user_attribution_plug.ex @@ -9,6 +9,7 @@ defmodule PhilomenaWeb.UserAttributionPlug do """ alias Philomena.Attribution.Actor + alias Philomena.Bans alias Plug.Conn @doc false @@ -18,42 +19,21 @@ defmodule PhilomenaWeb.UserAttributionPlug do @doc false @spec call(Plug.Conn.t(), any()) :: Plug.Conn.t() def call(conn, _opts) do - {:ok, remote_ip} = EctoNetwork.INET.cast(conn.remote_ip) - conn = Conn.fetch_cookies(conn) + {:ok, ip} = EctoNetwork.INET.cast(conn.remote_ip) + fingerprint = conn.assigns.fingerprint user = conn.assigns.current_user - fingerprint = fingerprint(conn, conn.path_info) - # Unfortunately, Elixir has no support for annotating a type of variable, so - # just make sure the shape of this keyword list satisfies the type - # `Philomena.Users.principal` - principal = [ - ip: remote_ip, - fingerprint: fingerprint, - user: user - ] + ban = Bans.find(user, ip, fingerprint) - # The typed equivalent of `principal`, built from the same values. Existing - # consumers keep using the `:attributes` keyword list unchanged; contexts - # migrated consume the `:actor` struct instead. - actor = %Actor{ip: remote_ip, fingerprint: fingerprint, user: user} + actor = %Actor{ + ip: ip, + fingerprint: fingerprint, + user: user, + ban: ban + } conn - |> Conn.assign(:attributes, principal) |> Conn.assign(:actor, actor) - end - - defp user_agent(conn) do - case Conn.get_req_header(conn, "user-agent") do - [ua] -> ua - _ -> "" - end - end - - defp fingerprint(conn, ["api" | _]) do - "a#{:erlang.crc32(user_agent(conn))}" - end - - defp fingerprint(conn, _) do - conn.cookies["_ses"] + |> Conn.assign(:current_ban, ban) end end diff --git a/lib/philomena_web/rate_limited_response.ex b/lib/philomena_web/rate_limited_response.ex new file mode 100644 index 000000000..41348889f --- /dev/null +++ b/lib/philomena_web/rate_limited_response.ex @@ -0,0 +1,27 @@ +defmodule PhilomenaWeb.RateLimitedResponse do + @moduledoc """ + Renders the response for a write refused with `{:error, :rate_limited}`. + + The flash carries the controller-specific message. AJAX requests get an + empty 300 response (ujs.ts reloads the page so the flash renders); + everything else is redirected back to the referrer. + """ + + alias Plug.Conn + alias Phoenix.Controller + + @spec call(Plug.Conn.t(), String.t()) :: Plug.Conn.t() + def call(conn, message) do + conn = Controller.put_flash(conn, :error, message) + + if conn.assigns.ajax? do + conn + |> Conn.send_resp(:multiple_choices, "") + |> Conn.halt() + else + conn + |> Controller.redirect(external: conn.assigns.referrer) + |> Conn.halt() + end + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index d986303ad..c3ed89da0 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -12,12 +12,12 @@ defmodule PhilomenaWeb.Router do plug :put_secure_browser_headers plug :fetch_fingerprint plug :fetch_current_user + plug PhilomenaWeb.EnsureUserEnabledPlug + plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.ContentSecurityPolicyPlug plug PhilomenaWeb.CurrentFilterPlug plug PhilomenaWeb.ImageFilterPlug plug PhilomenaWeb.PaginationPlug - plug PhilomenaWeb.EnsureUserEnabledPlug - plug PhilomenaWeb.CurrentBanPlug plug PhilomenaWeb.NotificationCountPlug plug PhilomenaWeb.SiteNoticePlug plug PhilomenaWeb.ForumListPlug @@ -27,8 +27,10 @@ defmodule PhilomenaWeb.Router do end pipeline :api do + plug :fetch_fingerprint plug PhilomenaWeb.ApiTokenPlug plug PhilomenaWeb.EnsureUserEnabledPlug + plug PhilomenaWeb.UserAttributionPlug plug PhilomenaWeb.CurrentFilterPlug plug PhilomenaWeb.FilterIdPlug plug PhilomenaWeb.ImageFilterPlug @@ -51,10 +53,6 @@ defmodule PhilomenaWeb.Router do plug PhilomenaWeb.TorPlug end - pipeline :ensure_not_banned do - plug PhilomenaWeb.FilterBannedUsersPlug - end - scope "/", PhilomenaWeb do pipe_through [:browser, :redirect_if_user_is_authenticated] @@ -73,25 +71,15 @@ defmodule PhilomenaWeb.Router do scope "/", PhilomenaWeb do pipe_through [ :browser, - :ensure_not_banned, :ensure_tor_authorized, :redirect_if_user_is_authenticated ] - resources "/reactivations", ReactivationController, only: [:show, :create] resources "/registrations", RegistrationController, only: [:new, :create], singleton: true - end - - scope "/", PhilomenaWeb do - pipe_through [ - :browser, - :ensure_tor_authorized, - :redirect_if_user_is_authenticated - ] - - resources "/passwords", PasswordController, only: [:new, :create, :edit, :update] resources "/confirmations", ConfirmationController, only: [:new, :create] + resources "/passwords", PasswordController, only: [:new, :create, :edit, :update] resources "/unlocks", UnlockController, only: [:new, :create, :show] + resources "/reactivations", ReactivationController, only: [:show, :create] end scope "/", PhilomenaWeb do @@ -100,7 +88,7 @@ defmodule PhilomenaWeb.Router do :ensure_tor_authorized ] - resources "/confirmations", ConfirmationController, only: [:show] + resources "/confirmations", ConfirmationController, only: [:show, :update] end scope "/", PhilomenaWeb do @@ -124,7 +112,7 @@ defmodule PhilomenaWeb.Router do end scope "/api/v1/rss", PhilomenaWeb.Api.Rss, as: :api_rss do - pipe_through [:accepts_rss, :api, :require_authenticated_user] + pipe_through [:accepts_rss, :api] resources "/watched", WatchedController, only: [:index] end @@ -300,6 +288,10 @@ defmodule PhilomenaWeb.Router do resources "/ip_history", Profile.IpHistoryController, only: [:index] resources "/fp_history", Profile.FpHistoryController, only: [:index] resources "/aliases", Profile.AliasController, only: [:index] + + resources "/tag_changes/revert", Profile.TagChange.RevertController, + only: [:create], + singleton: true end scope "/filters", Filter, as: :filter do @@ -339,10 +331,20 @@ defmodule PhilomenaWeb.Router do resources "/ip_profiles", IpProfileController, only: [:show] do resources "/source_changes", IpProfile.SourceChangeController, only: [:index] + resources "/tag_changes", IpProfile.TagChangeController, only: [:index] + + resources "/tag_changes/revert", IpProfile.TagChange.RevertController, + only: [:create], + singleton: true end resources "/fingerprint_profiles", FingerprintProfileController, only: [:show] do resources "/source_changes", FingerprintProfile.SourceChangeController, only: [:index] + resources "/tag_changes", FingerprintProfile.TagChangeController, only: [:index] + + resources "/tag_changes/revert", FingerprintProfile.TagChange.RevertController, + only: [:create], + singleton: true end resources "/moderation_logs", ModerationLogController, only: [:index] @@ -449,11 +451,6 @@ defmodule PhilomenaWeb.Router do only: [:create], singleton: true - resources "/tag_changes/full_revert", TagChange.FullRevertController, - as: :tag_change_full_revert, - only: [:create], - singleton: true - resources "/pages", PageController, only: [:index, :new, :create, :edit, :update] resources "/channels", ChannelController, only: [:new, :create, :edit, :update, :delete] resources "/rules", RuleController, only: [:new, :create, :edit, :update] @@ -482,6 +479,7 @@ defmodule PhilomenaWeb.Router do resources "/tags", Image.TagController, only: [:update], singleton: true resources "/sources", Image.SourceController, only: [:update], singleton: true resources "/source_changes", Image.SourceChangeController, only: [:index] + resources "/tag_changes", Image.TagChangeController, only: [:index] resources "/description", Image.DescriptionController, only: [:update], singleton: true resources "/navigate", Image.NavigateController, only: [:index] resources "/reports", Image.ReportController, only: [:new, :create] @@ -500,7 +498,9 @@ defmodule PhilomenaWeb.Router do resources "/themes", ThemeController, only: [:index] - resources "/tags", TagController, only: [:index, :show] + resources "/tags", TagController, only: [:index, :show] do + resources "/tag_changes", Tag.TagChangeController, only: [:index] + end resources "/tag_changes", TagChangeController, only: [:index, :delete] @@ -534,6 +534,7 @@ defmodule PhilomenaWeb.Router do resources "/reports", Profile.ReportController, only: [:new, :create] resources "/commission", Profile.CommissionController, only: [:show], singleton: true resources "/source_changes", Profile.SourceChangeController, only: [:index] + resources "/tag_changes", Profile.TagChangeController, only: [:index] end scope "/posts", Post, as: :post do diff --git a/lib/philomena_web/stats_updater.ex b/lib/philomena_web/stats_updater.ex index 7ea1dfee0..db8b5d28c 100644 --- a/lib/philomena_web/stats_updater.ex +++ b/lib/philomena_web/stats_updater.ex @@ -1,156 +1,16 @@ defmodule PhilomenaWeb.StatsUpdater do - alias Philomena.Config - alias PhilomenaQuery.Search - alias Philomena.Images.Image - alias Philomena.Comments.Comment - alias Philomena.Topics.Topic - alias Philomena.Forums.Forum - alias Philomena.Posts.Post - alias Philomena.Users.User - alias Philomena.Galleries.Gallery - alias Philomena.Galleries.Interaction - alias Philomena.Commissions.Commission - alias Philomena.Commissions.Item - alias Philomena.Reports.Report - alias Philomena.StaticPages.StaticPage - alias Philomena.Repo - import Ecto.Query + alias Philomena.SiteStatistics + alias Philomena.StaticPages def update_stats! do - {gallery_count, gallery_size, distinct_creators, images_in_galleries} = galleries() - {open_reports, report_count, response_time} = moderation() - {open_commissions, commission_items} = commissions() - {image_aggs, comment_aggs} = aggregations() - {forums, topics, posts} = forums() - {users, users_24h} = users() - - result = - Phoenix.View.render( - PhilomenaWeb.StatView, - "index.html", - image_aggs: image_aggs, - comment_aggs: comment_aggs, - forums_count: forums, - topics_count: topics, - posts_count: posts, - users_count: users, - users_24h: users_24h, - open_commissions: open_commissions, - commission_items: commission_items, - open_reports: open_reports, - report_stat_count: report_count, - response_time: response_time, - gallery_count: gallery_count, - gallery_size: gallery_size, - distinct_creators: distinct_creators, - images_in_galleries: images_in_galleries - ) + body = + SiteStatistics.calculate() + |> Map.from_struct() + |> Map.to_list() + |> then(&Phoenix.View.render(PhilomenaWeb.StatView, "index.html", &1)) |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() - now = DateTime.utc_now(:second) - - static_page = %{ - title: "Statistics", - slug: "stats", - body: result, - created_at: now, - updated_at: now - } - - Repo.insert_all(StaticPage, [static_page], - on_conflict: {:replace, [:body, :updated_at]}, - conflict_target: :slug - ) - end - - defp aggregations do - data = Config.get(:aggregation) - - { - Search.search(Image, data["images"]), - Search.search(Comment, data["comments"]) - } - end - - defp forums do - forums = - Forum - |> where(access_level: "normal") - |> Repo.aggregate(:count, :id) - - first_topic = Repo.one(first(Topic)) - last_topic = Repo.one(last(Topic)) - first_post = Repo.one(first(Post)) - last_post = Repo.one(last(Post)) - - {forums, diff(last_topic, first_topic), diff(last_post, first_post)} - end - - defp users do - total = - User - |> Repo.aggregate(:count, :id) - - last_24h = - User - |> where([u], u.created_at > ago(1, "day")) - |> Repo.aggregate(:count, :id) - - {total, last_24h} - end - - defp galleries do - gallery_count = Repo.aggregate(Gallery, :count, :id) - - gallery_size = - Repo.aggregate(Gallery, :avg, :image_count) - |> Kernel.||(Decimal.new(0)) - |> Decimal.to_float() - |> trunc() - - distinct_creators = - Gallery - |> distinct(:user_id) - |> Repo.aggregate(:count, :id) - - first_gi = Repo.one(first(Interaction)) - last_gi = Repo.one(last(Interaction)) - - {gallery_count, gallery_size, distinct_creators, diff(last_gi, first_gi)} - end - - defp commissions do - open_commissions = Repo.aggregate(where(Commission, open: true), :count, :id) - commission_items = Repo.aggregate(Item, :count, :id) - - {open_commissions, commission_items} + StaticPages.upsert_statistics_page(body) end - - defp moderation do - open_reports = Repo.aggregate(where(Report, open: true), :count, :id) - first_report = Repo.one(first(Report)) - last_report = Repo.one(last(Report)) - - closed_reports = - Report - |> where(open: false) - |> order_by(desc: :created_at) - |> limit(250) - |> Repo.all() - - response_time = - closed_reports - |> Enum.reduce(0, &(&2 + DateTime.diff(&1.updated_at, &1.created_at, :second))) - |> Kernel./(safe_length(closed_reports) * 3600) - |> trunc() - - {open_reports, diff(last_report, first_report), response_time} - end - - defp diff(nil, nil), do: 0 - defp diff(%{id: id2}, %{id: id1}), do: id2 - id1 - - defp safe_length([]), do: 1 - defp safe_length(list), do: length(list) end diff --git a/lib/philomena_web/tag_info_renderer.ex b/lib/philomena_web/tag_info_renderer.ex new file mode 100644 index 000000000..606a40dec --- /dev/null +++ b/lib/philomena_web/tag_info_renderer.ex @@ -0,0 +1,28 @@ +defmodule PhilomenaWeb.TagInfoRenderer do + @moduledoc """ + Renders the tag info shown next to image listings. + + When a search names exactly one tag, its description and DNP conditions + are rendered to HTML for the sidebar; each tag becomes a + `{tag, description, dnp_entries}` tuple, with `dnp_entries` pairing each + rendered body with its entry. Any other tag list passes through unchanged. + """ + + alias PhilomenaWeb.MarkdownRenderer + + def render_tag_info([tag], conn) do + dnp_bodies = + MarkdownRenderer.render_collection( + Enum.map(tag.dnp_entries, &%{body: &1.conditions || ""}), + conn + ) + + dnp_entries = Enum.zip(dnp_bodies, tag.dnp_entries) + + description = MarkdownRenderer.render_one(%{body: tag.description || ""}, conn) + + [{tag, description, dnp_entries}] + end + + def render_tag_info(tags, _conn), do: tags +end diff --git a/lib/philomena_web/templates/admin/artist_link/index.html.slime b/lib/philomena_web/templates/admin/artist_link/index.html.slime index 288cc31ce..541a662da 100644 --- a/lib/philomena_web/templates/admin/artist_link/index.html.slime +++ b/lib/philomena_web/templates/admin/artist_link/index.html.slime @@ -2,20 +2,25 @@ h1 Artist Links p Link creation is done via the Users menu. p Verifying a link will automatically award an artist badge if the link is public, no artist badge exists, and an "artist:" tag is specified. -= form_for :artist_link, ~p"/admin/artist_links", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/artist_links", [as: :lq, method: "get", class: "hform"], fn f -> .field - = text_input f, :lq, name: :lq, value: @conn.params["lq"], class: "input hform__text", placeholder: "Search query", autocapitalize: "none", spellcheck: "false" + = text_input f, :text, class: "input hform__text", placeholder: "Search query", autocapitalize: "none", spellcheck: "false" + = for state <- input_value(f, :states) || [] do + = hidden_input f, :states, name: "#{f.name}[states][]", value: state + = error_tag f, :states + = error_tag f, :text = submit "Search", class: "hform__button button" - route = fn p -> ~p"/admin/artist_links?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @artist_links, route: route, params: scope(@conn), conn: @conn +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @artist_links, route: route, params: [lq: @conn.params["lq"]], conn: @conn .block .block__header - = if @conn.params["all"] do - = link "Show unverified only", to: ~p"/admin/artist_links" - - else - = link "Show all", to: ~p"/admin/artist_links?#{[all: "true"]}" + span.block__header__title Display only: + => link "Pending", to: ~p"/admin/artist_links" + => link "Verified", to: ~p"/admin/artist_links?#{[lq: [states: ~w(verified)]]}" + => link "Rejected", to: ~p"/admin/artist_links?#{[lq: [states: ~w(rejected)]]}" + => link "All", to: ~p"/admin/artist_links?#{[lq: [states: Philomena.ArtistLinks.ArtistLink.states()]]}" .page__pagination = pagination diff --git a/lib/philomena_web/templates/admin/dnp_entry/index.html.slime b/lib/philomena_web/templates/admin/dnp_entry/index.html.slime index 9a7e35ea0..fd8c00b8c 100644 --- a/lib/philomena_web/templates/admin/dnp_entry/index.html.slime +++ b/lib/philomena_web/templates/admin/dnp_entry/index.html.slime @@ -1,22 +1,25 @@ h2 Do-Not-Post Requests -= form_for :dnp_entry, ~p"/admin/dnp_entries", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/dnp_entries", [as: :eq, method: "get", class: "hform"], fn f -> .field - = text_input f, :eq, name: :eq, value: @conn.params["eq"], class: "input hform__text", placeholder: "Search query", autocapitalize: "none", spellcheck: "false" + = text_input f, :text, class: "input hform__text", placeholder: "Search query", autocapitalize: "none", spellcheck: "false" + = for state <- input_value(f, :states) || [] do + = hidden_input f, :states, name: "#{f.name}[states][]", value: state = submit "Search", class: "hform__button button" - route = fn p -> ~p"/admin/dnp_entries?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @dnp_entries, route: route, params: [states: state_param(@conn.params["states"])] +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @dnp_entries, route: route, params: [eq: @conn.params["eq"]] .block .block__header .page__pagination = pagination span.block__header__title Display only: - => link "All Open", to: ~p"/admin/dnp_entries?#{[states: ~W(requested claimed rescinded acknowledged)]}" - => link "Listed", to: ~p"/admin/dnp_entries?#{[states: ~W(listed)]}" - => link "Rescinded", to: ~p"/admin/dnp_entries?#{[states: ~W(rescinded acknowledged)]}" - => link "Closed", to: ~p"/admin/dnp_entries?#{[states: ~W(closed)]}" + => link "All Entries", to: ~p"/admin/dnp_entries?#{[eq: [states: ~W(requested claimed listed rescinded acknowledged closed)]]}" + => link "All Open", to: ~p"/admin/dnp_entries?#{[eq: [states: ~W(requested claimed rescinded acknowledged)]]}" + => link "Listed", to: ~p"/admin/dnp_entries?#{[eq: [states: ~W(listed)]]}" + => link "Rescinded", to: ~p"/admin/dnp_entries?#{[eq: [states: ~W(rescinded acknowledged)]]}" + => link "Closed", to: ~p"/admin/dnp_entries?#{[eq: [states: ~W(closed)]]}" .block__content table.table diff --git a/lib/philomena_web/templates/admin/fingerprint_ban/index.html.slime b/lib/philomena_web/templates/admin/fingerprint_ban/index.html.slime index 73b770dea..6cb010821 100644 --- a/lib/philomena_web/templates/admin/fingerprint_ban/index.html.slime +++ b/lib/philomena_web/templates/admin/fingerprint_ban/index.html.slime @@ -1,11 +1,12 @@ h1 Fingerprint Bans - route = fn p -> ~p"/admin/fingerprint_bans?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @fingerprint_bans, route: route, params: page_params(@conn.params) +- pagination = if @fingerprint_bans, do: render(PhilomenaWeb.PaginationView, "_pagination.html", page: @fingerprint_bans, route: route, params: page_params(@conn.params)) -= form_for :fingerprint_ban, ~p"/admin/fingerprint_bans", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/fingerprint_bans", [method: "get", class: "hform"], fn f -> .field - = text_input f, :bq, name: :bq, value: @conn.params["bq"], class: "hform__text input", placeholder: "Search", spellcheck: "false" + = text_input f, :bq, name: :bq, class: "hform__text input", placeholder: "Search", spellcheck: "false" + = error_tag f, :bq = submit "Search", class: "button hform__button" .block @@ -28,7 +29,7 @@ h1 Fingerprint Bans th Options tbody - = for ban <- @fingerprint_bans do + = for ban <- @fingerprint_bans || [] do tr td = link ban.fingerprint, to: ~p"/fingerprint_profiles/#{ban.fingerprint}" diff --git a/lib/philomena_web/templates/admin/report/index.html.slime b/lib/philomena_web/templates/admin/report/index.html.slime index 6f6abaafd..c03b9d827 100644 --- a/lib/philomena_web/templates/admin/report/index.html.slime +++ b/lib/philomena_web/templates/admin/report/index.html.slime @@ -1,5 +1,5 @@ - route = fn p -> ~p"/admin/reports?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", route: route, page: @reports, conn: @conn, params: [rq: @conn.params["rq"] || "*"] +- pagination = if @reports, do: render(PhilomenaWeb.PaginationView, "_pagination.html", route: route, page: @reports, conn: @conn, params: [rq: @conn.params["rq"]]) h1 Reports @@ -22,7 +22,7 @@ h1 Reports span.block__header__title All Reports .page__pagination = pagination .block__content - = if Enum.any?(@reports) do + = if not is_nil(@reports) and Enum.any?(@reports) do = render PhilomenaWeb.Admin.ReportView, "_reports.html", reports: @reports, conn: @conn - else p We couldn't find any reports for you, sorry! @@ -30,9 +30,9 @@ h1 Reports .block__header.block__header--light .page__pagination = pagination -= form_for :report, ~p"/admin/reports", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/reports", [as: "rq", method: "get", class: "hform"], fn f -> .field - = text_input f, :rq, name: :rq, value: @conn.params["rq"], + = text_input f, :query, class: "input hform__text", placeholder: "Search reports", autocapitalize: "none", @@ -44,8 +44,11 @@ h1 Reports ] = submit "Search", class: "hform__button button" + .error + = error_tag f, :query + .field - label for="rq" + label for="rq[query]" ' Searchable fields: id, created_at, reason, state, open, user, user_id, admin, admin_id, ip, fingerprint, reportable_type, reportable_id, image_id br ' Report reason is used if you don't specify a field. diff --git a/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime b/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime index a6ca83b23..7bd1d30fb 100644 --- a/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime +++ b/lib/philomena_web/templates/admin/subnet_ban/_form.html.slime @@ -6,6 +6,7 @@ .field => label f, :specification, "Specification:" = text_input f, :specification, class: "input", placeholder: "Specification", required: true + = error_tag f, :specification .field => label f, :reason, "Reason (shown to the banned user, and to staff on the user's profile page):" diff --git a/lib/philomena_web/templates/admin/subnet_ban/index.html.slime b/lib/philomena_web/templates/admin/subnet_ban/index.html.slime index b7cb2007b..7292040b0 100644 --- a/lib/philomena_web/templates/admin/subnet_ban/index.html.slime +++ b/lib/philomena_web/templates/admin/subnet_ban/index.html.slime @@ -1,11 +1,13 @@ h1 Subnet Bans - route = fn p -> ~p"/admin/subnet_bans?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @subnet_bans, route: route, params: page_params(@conn.params) +- pagination = if @subnet_bans, do: render(PhilomenaWeb.PaginationView, "_pagination.html", page: @subnet_bans, route: route, params: page_params(@conn.params)) -= form_for :subnet_ban, ~p"/admin/subnet_bans", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/subnet_bans", [method: "get", class: "hform"], fn f -> .field - = text_input f, :bq, name: :bq, value: @conn.params["bq"], class: "hform__text input", placeholder: "Search", spellcheck: "false" + = text_input f, :bq, name: :bq, class: "hform__text input", placeholder: "Search", spellcheck: "false" + = error_tag f, :bq + = error_tag f, :ip = submit "Search", class: "button hform__button" .block @@ -28,7 +30,7 @@ h1 Subnet Bans th Options tbody - = for ban <- @subnet_bans do + = for ban <- @subnet_bans || [] do tr td = link ban.specification, to: ~p"/ip_profiles/#{to_string(ban.specification)}" diff --git a/lib/philomena_web/templates/admin/user/force_filter/new.html.slime b/lib/philomena_web/templates/admin/user/force_filter/new.html.slime index eda33725c..e0d85f823 100644 --- a/lib/philomena_web/templates/admin/user/force_filter/new.html.slime +++ b/lib/philomena_web/templates/admin/user/force_filter/new.html.slime @@ -5,5 +5,6 @@ h1 = form_for @changeset, ~p"/admin/users/#{@user}/force_filter", [method: "post"], fn f -> .field => text_input f, :forced_filter_id, placeholder: "Filter ID", class: "input", required: true + = error_tag f, :forced_filter_id .field = submit "Force", class: "button button--state-primary" diff --git a/lib/philomena_web/templates/admin/user/index.html.slime b/lib/philomena_web/templates/admin/user/index.html.slime index 653865196..0ca007e07 100644 --- a/lib/philomena_web/templates/admin/user/index.html.slime +++ b/lib/philomena_web/templates/admin/user/index.html.slime @@ -1,8 +1,8 @@ h1 Users -= form_for :user, ~p"/admin/users", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/users", [as: :uq, method: "get", class: "hform"], fn f -> .field - => text_input f, :uq, name: :uq, value: @conn.params["uq"], class: "hform__text input", placeholder: "Search query", spellcheck: "false" + => text_input f, :query, class: "hform__text input", placeholder: "Search query", spellcheck: "false" = submit "Search", class: "button hform__button" .field elixir: @@ -27,30 +27,26 @@ h1 Users "Ascending": :asc ] - = select f, :sf, sort_fields, class: "input", name: :sf, autocomplete: "off", selected: @conn.params["sf"] - = select f, :sd, sort_directions, class: "input input--separate-left", name: :sd, autocomplete: "off", selected: @conn.params["sd"] + = select f, :sf, sort_fields, class: "input", autocomplete: "off" + = select f, :sd, sort_directions, class: "input input--separate-left", autocomplete: "off" -=> link "Site staff", to: ~p"/admin/users?#{[uq: "NOT role:user"]}" + .error = error_tag f, :query + .error = error_tag f, :sf + .error = error_tag f, :sd + +=> link "Site staff", to: ~p"/admin/users?#{[uq: [query: "NOT role:user"]]}" ' • -=> link "2FA users", to: ~p"/admin/users?#{[uq: "otp_required_for_login:true"]}" +=> link "2FA users", to: ~p"/admin/users?#{[uq: [query: "otp_required_for_login:true"]]}" h2 Search Results -= cond do - - Enum.any?(@users) -> - - route = fn p -> ~p"/admin/users?#{p}" end - - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @users, route: route, conn: @conn, params: scope(@conn) - - = render PhilomenaWeb.Admin.UserView, "_list.html", users: @users, conn: @conn, pagination: pagination - - - assigns[:error] -> - .block.block--fixed.block--danger - ' Oops, there was an error parsing your query! Check for mistakes like mismatched parentheses. The error was: - pre = assigns[:error] - - - true -> - p - ' No users found! += if not is_nil(@users) and Enum.any?(@users) do + - route = fn p -> ~p"/admin/users?#{p}" end + - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @users, route: route, conn: @conn, params: scope(@conn) + = render PhilomenaWeb.Admin.UserView, "_list.html", users: @users, conn: @conn, pagination: pagination +- else + p + ' No users found! h3 Allowed fields table.table @@ -66,235 +62,235 @@ table.table td Literal td Matches either the name or email of the user. td - code = link "administrator", to: ~p"/admin/users?#{[uq: "administrator"]}" + code = link "administrator", to: ~p"/admin/users?#{[uq: [query: "administrator"]]}" tr td code id td Integer td Matches the user ID. td - code = link "id:123", to: ~p"/admin/users?#{[uq: "id:123"]}" + code = link "id:123", to: ~p"/admin/users?#{[uq: [query: "id:123"]]}" tr td code name td Literal td Matches the name of the user. td - code = link "name:luna", to: ~p"/admin/users?#{[uq: "name:luna"]}" + code = link "name:luna", to: ~p"/admin/users?#{[uq: [query: "name:luna"]]}" tr td code slug td Literal td Matches the slug of the user. td - code = link "slug:princess+celestia", to: ~p"/admin/users?#{[uq: "slug:princess+celestia"]}" + code = link "slug:princess+celestia", to: ~p"/admin/users?#{[uq: [query: "slug:princess+celestia"]]}" tr td code email td Literal td Matches the email of the user. td - code = link "email:admin@example.com", to: ~p"/admin/users?#{[uq: "email:admin@example.com"]}" + code = link "email:admin@example.com", to: ~p"/admin/users?#{[uq: [query: "email:admin@example.com"]]}" tr td code role td Literal td Matches the role of the user. td - code = link "role:admin", to: ~p"/admin/users?#{[uq: "role:admin"]}" + code = link "role:admin", to: ~p"/admin/users?#{[uq: [query: "role:admin"]]}" tr td code personal_title td Full Text td Matches the personal title of the user's profile. td - code = link "personal_title:pony", to: ~p"/admin/users?#{[uq: "personal_title:pony"]}" + code = link "personal_title:pony", to: ~p"/admin/users?#{[uq: [query: "personal_title:pony"]]}" tr td code description td Full Text td Matches the About Me section of the user's profile. td - code = link "description:pony", to: ~p"/admin/users?#{[uq: "description:pony"]}" + code = link "description:pony", to: ~p"/admin/users?#{[uq: [query: "description:pony"]]}" tr td code custom_avatar td Boolean td Matches whether the user has a custom avatar. td - code = link "custom_avatar:true", to: ~p"/admin/users?#{[uq: "custom_avatar:true"]}" + code = link "custom_avatar:true", to: ~p"/admin/users?#{[uq: [query: "custom_avatar:true"]]}" tr td code created_at td Date/Time td Matches the date and time the user account was created. td - code = link "created_at.lt:2 weeks ago", to: ~p"/admin/users?#{[uq: "created_at.lt:2 weeks ago"]}" + code = link "created_at.lt:2 weeks ago", to: ~p"/admin/users?#{[uq: [query: "created_at.lt:2 weeks ago"]]}" tr td code updated_at td Date/Time td Matches the date and time the user was last updated. td - code = link "updated_at.gt:1 month ago", to: ~p"/admin/users?#{[uq: "updated_at.gt:1 month ago"]}" + code = link "updated_at.gt:1 month ago", to: ~p"/admin/users?#{[uq: [query: "updated_at.gt:1 month ago"]]}" tr td code confirmed td Boolean td Matches whether the user account has been confirmed. td - code = link "confirmed:true", to: ~p"/admin/users?#{[uq: "confirmed:true"]}" + code = link "confirmed:true", to: ~p"/admin/users?#{[uq: [query: "confirmed:true"]]}" tr td code confirmed_at td Date/Time td Matches the date and time the user account was confirmed. td - code = link "confirmed_at.lt:2 weeks ago", to: ~p"/admin/users?#{[uq: "confirmed_at.lt:2 weeks ago"]}" + code = link "confirmed_at.lt:2 weeks ago", to: ~p"/admin/users?#{[uq: [query: "confirmed_at.lt:2 weeks ago"]]}" tr td code verified td Boolean td Matches whether the user has been granted verification. td - code = link "verified:true", to: ~p"/admin/users?#{[uq: "verified:true"]}" + code = link "verified:true", to: ~p"/admin/users?#{[uq: [query: "verified:true"]]}" tr td code locked td Boolean td Matches whether the user account has been locked. td - code = link "locked:true", to: ~p"/admin/users?#{[uq: "locked:true"]}" + code = link "locked:true", to: ~p"/admin/users?#{[uq: [query: "locked:true"]]}" tr td code locked_at td Date/Time td Matches the date and time the user account was locked. td - code = link "locked_at:2025", to: ~p"/admin/users?#{[uq: "locked_at:2025"]}" + code = link "locked_at:2025", to: ~p"/admin/users?#{[uq: [query: "locked_at:2025"]]}" tr td code deleted td Boolean td Matches whether the user account has been deactivated. td - code = link "deleted:true", to: ~p"/admin/users?#{[uq: "deleted:true"]}" + code = link "deleted:true", to: ~p"/admin/users?#{[uq: [query: "deleted:true"]]}" tr td code deleted_at td Date/Time td Matches the date and time the user account was deactivated. td - code = link "deleted_at.gt:1 week ago", to: ~p"/admin/users?#{[uq: "deleted_at.gt:1 week ago"]}" + code = link "deleted_at.gt:1 week ago", to: ~p"/admin/users?#{[uq: [query: "deleted_at.gt:1 week ago"]]}" tr td code deleted_by_user_id td Numeric td Matches the user ID of the admin who deleted the user account. td - code = link "deleted_by_user_id:123", to: ~p"/admin/users?#{[uq: "deleted_by_user_id:123"]}" + code = link "deleted_by_user_id:123", to: ~p"/admin/users?#{[uq: [query: "deleted_by_user_id:123"]]}" tr td code deleted_by_user td Literal td Matches the name of the admin who deleted the user account. td - code = link "deleted_by_user:moderator", to: ~p"/admin/users?#{[uq: "deleted_by_user:moderator"]}" + code = link "deleted_by_user:moderator", to: ~p"/admin/users?#{[uq: [query: "deleted_by_user:moderator"]]}" tr td code banned_until td Date/Time td Matches the date and time of any enabled bans on the user. td - code = link "banned_until.gt:0 seconds ago", to: ~p"/admin/users?#{[uq: "banned_until.gt:0 seconds ago"]}" + code = link "banned_until.gt:0 seconds ago", to: ~p"/admin/users?#{[uq: [query: "banned_until.gt:0 seconds ago"]]}" tr td code otp_required_for_login td Boolean td Matches whether the user has two-factor authentication enabled. td - code = link "otp_required_for_login:true", to: ~p"/admin/users?#{[uq: "otp_required_for_login:true"]}" + code = link "otp_required_for_login:true", to: ~p"/admin/users?#{[uq: [query: "otp_required_for_login:true"]]}" tr td code images_count td Integer td Matches the number of uploads the user has made. td - code = link "images_count.gte:200", to: ~p"/admin/users?#{[uq: "images_count.gte:200"]}" + code = link "images_count.gte:200", to: ~p"/admin/users?#{[uq: [query: "images_count.gte:200"]]}" tr td code images_favourited_count td Integer td Matches the number of images the user has favourited. td - code = link "images_favourited_count.gte:1000", to: ~p"/admin/users?#{[uq: "images_favourited_count.gte:1000"]}" + code = link "images_favourited_count.gte:1000", to: ~p"/admin/users?#{[uq: [query: "images_favourited_count.gte:1000"]]}" tr td code votes_cast_count td Integer td Matches the number of votes the user has cast. td - code = link "votes_cast_count.gte:1000", to: ~p"/admin/users?#{[uq: "votes_cast_count.gte:1000"]}" + code = link "votes_cast_count.gte:1000", to: ~p"/admin/users?#{[uq: [query: "votes_cast_count.gte:1000"]]}" tr td code metadata_updates_count td Integer td Matches the number of metadata updates the user has made. td - code = link "metadata_updates_count.gte:500", to: ~p"/admin/users?#{[uq: "metadata_updates_count.gte:500"]}" + code = link "metadata_updates_count.gte:500", to: ~p"/admin/users?#{[uq: [query: "metadata_updates_count.gte:500"]]}" tr td code comments_posted_count td Integer td Matches the number of comments the user has posted. td - code = link "comments_posted_count.gte:100", to: ~p"/admin/users?#{[uq: "comments_posted_count.gte:100"]}" + code = link "comments_posted_count.gte:100", to: ~p"/admin/users?#{[uq: [query: "comments_posted_count.gte:100"]]}" tr td code forum_posts_count td Integer td Matches the number of forum posts the user has made. td - code = link "forum_posts_count.gte:100", to: ~p"/admin/users?#{[uq: "forum_posts_count.gte:100"]}" + code = link "forum_posts_count.gte:100", to: ~p"/admin/users?#{[uq: [query: "forum_posts_count.gte:100"]]}" tr td code topics_count td Integer td Matches the number of topics the user has made. td - code = link "topics_count.gte:10", to: ~p"/admin/users?#{[uq: "topics_count.gte:10"]}" + code = link "topics_count.gte:10", to: ~p"/admin/users?#{[uq: [query: "topics_count.gte:10"]]}" tr td code current_filter_id td Numeric td Matches the ID of the filter the user is currently using. td - code = link "current_filter_id:123", to: ~p"/admin/users?#{[uq: "current_filter_id:123"]}" + code = link "current_filter_id:123", to: ~p"/admin/users?#{[uq: [query: "current_filter_id:123"]]}" tr td code forced_filter_id td Numeric td Matches the ID of the filter that was forced on the user. td - code = link "forced_filter_id:123", to: ~p"/admin/users?#{[uq: "forced_filter_id:123"]}" + code = link "forced_filter_id:123", to: ~p"/admin/users?#{[uq: [query: "forced_filter_id:123"]]}" tr td code scratchpad td Full Text td Matches the moderation scratchpad content on the user's profile. td - code = link "scratchpad:pony", to: ~p"/admin/users?#{[uq: "scratchpad:pony"]}" + code = link "scratchpad:pony", to: ~p"/admin/users?#{[uq: [query: "scratchpad:pony"]]}" tr td code last_renamed_at td Date/Time td Matches the date and time the user was last renamed. td - code = link "last_renamed_at.gt:1 month ago", to: ~p"/admin/users?#{[uq: "last_renamed_at.gt:1 month ago"]}" + code = link "last_renamed_at.gt:1 month ago", to: ~p"/admin/users?#{[uq: [query: "last_renamed_at.gt:1 month ago"]]}" tr td code names td Literal td Matches previous names the user has had. td - code = link "names:old_username", to: ~p"/admin/users?#{[uq: "names:old_username"]}" + code = link "names:old_username", to: ~p"/admin/users?#{[uq: [query: "names:old_username"]]}" diff --git a/lib/philomena_web/templates/admin/user_ban/index.html.slime b/lib/philomena_web/templates/admin/user_ban/index.html.slime index 84243bd6d..c1a27a432 100644 --- a/lib/philomena_web/templates/admin/user_ban/index.html.slime +++ b/lib/philomena_web/templates/admin/user_ban/index.html.slime @@ -1,11 +1,12 @@ h1 User Bans - route = fn p -> ~p"/admin/user_bans?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @user_bans, route: route, params: page_params(@conn.params) +- pagination = if @user_bans, do: render(PhilomenaWeb.PaginationView, "_pagination.html", page: @user_bans, route: route, params: page_params(@conn.params)) -= form_for :user_ban, ~p"/admin/user_bans", [method: "get", class: "hform"], fn f -> += form_for @changeset, ~p"/admin/user_bans", [method: "get", class: "hform"], fn f -> .field - = text_input f, :bq, name: :bq, value: @conn.params["bq"], class: "hform__text input", placeholder: "Search", spellcheck: "false" + = text_input f, :bq, name: :bq, class: "hform__text input", placeholder: "Search", spellcheck: "false" + = error_tag f, :bq = submit "Search", class: "button hform__button" .block @@ -24,7 +25,7 @@ h1 User Bans th Options tbody - = for ban <- @user_bans do + = for ban <- @user_bans || [] do tr td = link ban.user.name, to: ~p"/profiles/#{ban.user}" diff --git a/lib/philomena_web/templates/channel/index.html.slime b/lib/philomena_web/templates/channel/index.html.slime index b54c79cec..a454b17a2 100644 --- a/lib/philomena_web/templates/channel/index.html.slime +++ b/lib/philomena_web/templates/channel/index.html.slime @@ -1,11 +1,12 @@ h1 Livestreams - route = fn p -> ~p"/channels?#{p}" end -- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @channels, route: route, conn: @conn, params: [cq: @conn.params["cq"]] +- pagination = if @channels, do: render(PhilomenaWeb.PaginationView, "_pagination.html", page: @channels, route: route, conn: @conn, params: [cq: @conn.params["cq"]]) -= form_for :channels, ~p"/channels", [method: "get", class: "hform", enforce_utf8: false], fn f -> += form_for @changeset, ~p"/channels", [method: "get", class: "hform", enforce_utf8: false], fn f -> .field - = text_input f, :cq, name: :cq, value: @conn.params["cq"], class: "input hform__text", placeholder: "Search channels", autocapitalize: "none", spellcheck: "false" + = text_input f, :cq, name: :cq, class: "input hform__text", placeholder: "Search channels", autocapitalize: "none", spellcheck: "false" + = error_tag f, :cq = submit "Search", class: "hform__button button" .block @@ -23,7 +24,7 @@ h1 Livestreams .block__content .media-list - = for channel <- @channels do + = for channel <- @channels || [] do = render PhilomenaWeb.ChannelView, "_channel_box.html", channel: channel, conn: @conn, subscriptions: @subscriptions .block__header.page__header diff --git a/lib/philomena_web/templates/comment/_comment.html.slime b/lib/philomena_web/templates/comment/_comment.html.slime index e2a1206ec..cca0aa551 100644 --- a/lib/philomena_web/templates/comment/_comment.html.slime +++ b/lib/philomena_web/templates/comment/_comment.html.slime @@ -77,7 +77,7 @@ article.block.communication id="comment_#{@comment.id}" - true -> - = if can?(@conn, :show, :ip_address) do + = if can?(@conn, :show, :identity_metadata) do .communication__info.js-staff-action =<> link_to_ip(@conn, @comment.ip) .communication__info.js-staff-action diff --git a/lib/philomena_web/templates/comment/_comment_with_image.html.slime b/lib/philomena_web/templates/comment/_comment_with_image.html.slime index 3c3d9fe02..a9a3e7c0a 100644 --- a/lib/philomena_web/templates/comment/_comment_with_image.html.slime +++ b/lib/philomena_web/templates/comment/_comment_with_image.html.slime @@ -59,7 +59,7 @@ article.block.communication id="comment_#{@comment.id}" - true -> - = if can?(@conn, :show, :ip_address) do + = if can?(@conn, :show, :identity_metadata) do .communication__info.js-staff-action =<> link_to_ip(@conn, @comment.ip) .communication__info.js-staff-action diff --git a/lib/philomena_web/templates/commission/index.html.slime b/lib/philomena_web/templates/commission/index.html.slime index a6dac5e2c..9b1ab95a9 100644 --- a/lib/philomena_web/templates/commission/index.html.slime +++ b/lib/philomena_web/templates/commission/index.html.slime @@ -14,4 +14,5 @@ br .column-layout__left = render PhilomenaWeb.CommissionView, "_directory_sidebar.html", changeset: @changeset, conn: @conn .column-layout__main - = render PhilomenaWeb.CommissionView, "_directory_results.html", commissions: @commissions, conn: @conn + = if not is_nil(@commissions) do + = render PhilomenaWeb.CommissionView, "_directory_results.html", commissions: @commissions, conn: @conn diff --git a/lib/philomena_web/templates/confirmation/edit.html.slime b/lib/philomena_web/templates/confirmation/edit.html.slime new file mode 100644 index 000000000..1334d24d2 --- /dev/null +++ b/lib/philomena_web/templates/confirmation/edit.html.slime @@ -0,0 +1,5 @@ +h1 Confirm account + += form_for @conn, ~p"/confirmations/#{@token}", [method: :put], fn _f -> + .actions + = submit "Confirm my account", class: "button" diff --git a/lib/philomena_web/templates/conversation/index.html.slime b/lib/philomena_web/templates/conversation/index.html.slime index a7c33f331..50565cf59 100644 --- a/lib/philomena_web/templates/conversation/index.html.slime +++ b/lib/philomena_web/templates/conversation/index.html.slime @@ -1,44 +1,45 @@ -elixir: - route = fn p -> ~p"/conversations?#{p}" end - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @conversations, route: route, conn: @conn - h1 My Conversations -.block - .block__header.page__header - .page__pagination = pagination += if @changeset.action do + .alert.alert-danger Invalid conversation filter. += if not is_nil(@conversations) do + - route = fn p -> ~p"/conversations?#{p}" end + - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @conversations, route: route, conn: @conn + .block + .block__header.page__header + .page__pagination = pagination - .page__info - a href=~p"/conversations/new" - i.fa.fa-paper-plane> - ' Create New Conversation + .page__info + a href=~p"/conversations/new" + i.fa.fa-paper-plane> + ' Create New Conversation - .block__content - table.table.table--communication-list - thead - tr - th.table--communication-list__name Conversation - th.table--communication-list__stats With - th.table--communication-list__options Options - tbody - = for c <- @conversations do - tr class=conversation_class(@conn.assigns.current_user, c) - td.table--communication-list__name - => link c.title, to: ~p"/conversations/#{c}" + .block__content + table.table.table--communication-list + thead + tr + th.table--communication-list__name Conversation + th.table--communication-list__stats With + th.table--communication-list__options Options + tbody + = for c <- @conversations do + tr class=conversation_class(@conn.assigns.current_user, c) + td.table--communication-list__name + => link c.title, to: ~p"/conversations/#{c}" - .small-text.hide-mobile - => c.message_count - = pluralize("message", "messages", c.message_count) - ' ; started - = pretty_time(c.created_at) - ' , last message - = pretty_time(c.last_message_at) + .small-text.hide-mobile + => c.message_count + = pluralize("message", "messages", c.message_count) + ' ; started + = pretty_time(c.created_at) + ' , last message + = pretty_time(c.last_message_at) - td.table--communication-list__stats - = render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: other_party(@current_user, c)}, conn: @conn - td.table--communication-list__options - => link "Last message", to: last_message_path(c, c.message_count) - ' • - => link "Hide", to: ~p"/conversations/#{c}/hide", data: [method: "post"], data: [confirm: "Are you really, really sure?"] + td.table--communication-list__stats + = render PhilomenaWeb.UserAttributionView, "_user.html", object: %{user: other_party(@current_user, c)}, conn: @conn + td.table--communication-list__options + => link "Last message", to: last_message_path(c, c.message_count) + ' • + => link "Hide", to: ~p"/conversations/#{c}/hide", data: [method: "post"], data: [confirm: "Are you really, really sure?"] - .block__header.block__header--light.page__header - .page__pagination = pagination + .block__header.block__header--light.page__header + .page__pagination = pagination diff --git a/lib/philomena_web/templates/conversation/new.html.slime b/lib/philomena_web/templates/conversation/new.html.slime index e062cd911..d7433182e 100644 --- a/lib/philomena_web/templates/conversation/new.html.slime +++ b/lib/philomena_web/templates/conversation/new.html.slime @@ -5,7 +5,7 @@ h1 New Conversation ' » span.block__header__title New Conversation -= if not trusted?(@conn.assigns.current_user) do += if not @trusted? do .block.block--fixed.block--warning.hidden.js-hidden-warning h2 Warning! p diff --git a/lib/philomena_web/templates/conversation/show.html.slime b/lib/philomena_web/templates/conversation/show.html.slime index a9dfea21a..19fb02dba 100644 --- a/lib/philomena_web/templates/conversation/show.html.slime +++ b/lib/philomena_web/templates/conversation/show.html.slime @@ -25,13 +25,13 @@ h1 = @conversation.title = link "Mark as unread", to: ~p"/conversations/#{@conversation}/read", data: [method: "delete"] = for {message, body} <- @messages do - = render PhilomenaWeb.MessageView, "_message.html", message: message, body: body, conn: @conn + = render PhilomenaWeb.MessageView, "_message.html", conversation: @conversation, message: message, body: body, conn: @conn .block .block__header.block__header--light.page__header .page__pagination = pagination -= if not trusted?(@conn.assigns.current_user) do += if not @trusted? do .block.block--fixed.block--warning.hidden.js-hidden-warning h2 Warning! p diff --git a/lib/philomena_web/templates/dnp_entry/show.html.slime b/lib/philomena_web/templates/dnp_entry/show.html.slime index 7f9c60c38..ad8a70f97 100644 --- a/lib/philomena_web/templates/dnp_entry/show.html.slime +++ b/lib/philomena_web/templates/dnp_entry/show.html.slime @@ -6,7 +6,7 @@ h2 .block__header span.block__header__title DNP Information = if can?(@conn, :edit, @dnp_entry) do - = link "Edit listing", to: ~p"/dnp/#{@dnp_entry}/edit?#{[tag_id: @dnp_entry.tag_id]}" + = link "Edit listing", to: ~p"/dnp/#{@dnp_entry}/edit" = link "Back to DNP List", to: ~p"/dnp" @@ -50,7 +50,7 @@ h2 td = String.capitalize(@dnp_entry.aasm_state) -= if can?(@conn, :index, Philomena.DnpEntries.DnpEntry) do += if can?(@conn, :transition, @dnp_entry) do = case @dnp_entry.aasm_state do - s when s in ["requested", "claimed"] -> => link "Claim", to: ~p"/admin/dnp_entries/#{@dnp_entry}/transition?#{[state: "claimed"]}", data: [method: "post", confirm: "Are you really, really sure?"] diff --git a/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime b/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime index 9e52462c4..9dd6cc62e 100644 --- a/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime +++ b/lib/philomena_web/templates/duplicate_report/_image_cell.html.slime @@ -16,7 +16,7 @@ p = render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: @image, conn: @conn - = if can?(@conn, :edit, @report) and mergeable?(@report) do + = if can?(@conn, :accept, @report) and mergeable?(@report) do = if @source do a href=~p"/duplicate_reports/#{@report}/accept_reverse" data-method="post" button.button diff --git a/lib/philomena_web/templates/duplicate_report/_list.html.slime b/lib/philomena_web/templates/duplicate_report/_list.html.slime index 4017000ad..725c9ea15 100644 --- a/lib/philomena_web/templates/duplicate_report/_list.html.slime +++ b/lib/philomena_web/templates/duplicate_report/_list.html.slime @@ -132,11 +132,11 @@ .dr__status-options class=background_class => String.capitalize(report.state) - = if can?(@conn, :edit, report) and not is_nil(report.modifier) do + = if can?(@conn, :accept, report) and not is_nil(report.modifier) do ' by = report.modifier.name - = if can?(@conn, :edit, report) do + = if can?(@conn, :accept, report) do div = if report.state == "open" do a href=(~p"/duplicate_reports/#{report}/claim" <> "#report_options_#{report.id}") data-method="post" @@ -155,7 +155,7 @@ ' Reported => pretty_time(report.created_at) - = if can?(@conn, :edit, report) and report.user do + = if can?(@conn, :accept, report) and report.user do ' by =< link report.user.name, to: ~p"/profiles/#{report.user}" diff --git a/lib/philomena_web/templates/filter/index.html.slime b/lib/philomena_web/templates/filter/index.html.slime index f72663b46..8ee4a7be8 100644 --- a/lib/philomena_web/templates/filter/index.html.slime +++ b/lib/philomena_web/templates/filter/index.html.slime @@ -25,19 +25,38 @@ ' By default all the filters you create are private and only visible by you. You can have as many as you like and switch between them instantly with no limits. You can also create a public filter, which can be seen and used by any user on the site, allowing you to share useful filters with others. = if !@conn.params["fq"] do - h2 My Filters - = if @current_user do - p - = link("Click here to make a new filter from scratch", to: ~p"/filters/new") - = for filter <- @my_filters do - = render PhilomenaWeb.FilterView, "_filter.html", conn: @conn, filter: filter - - else - p - ' If you're logged in, you can create and maintain custom filters here. + = if @my_filters do + - route = fn p -> ~p"/filters?#{p}" end + - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @my_filters, route: route + + .block + .block__header.flex + span.block__header__title + ' My Filters + .flex__right.page__info + a href=~p"/filters/new" + i.fa.fa-plus> + span.hide-mobile + | New filter + + .block__header + .page__pagination = pagination + + .block__content + = for filter <- @my_filters do + = render PhilomenaWeb.FilterView, "_filter.html", conn: @conn, filter: filter + + .block__header.block__header--light + .page__pagination = pagination - h2 Global Filters - = for filter <- @system_filters do - = render PhilomenaWeb.FilterView, "_filter.html", conn: @conn, filter: filter + .block + .block__header + span.block__header__title + ' Global Filters + + .block__content + = for filter <- @system_filters do + = render PhilomenaWeb.FilterView, "_filter.html", conn: @conn, filter: filter = if @current_user do h2 Recent Filters @@ -45,9 +64,9 @@ ' Clicking this button will clear the recent filters list in the header dropdown. = button_to "Clear recent filter list", ~p"/filters/clear_recent", method: "delete", class: "button" - h2 Search Filters + h2 Search p - ' Some users maintain custom filters which are publicly shared; you can search these filters with the box below. + ' Some filters are publicly shared; you can search these filters with the box below. = form_for :filters, ~p"/filters", [method: "get", class: "hform", enforce_utf8: false], fn f -> .field = text_input f, :fq, name: :fq, value: @conn.params["fq"], @@ -62,22 +81,27 @@ ] = submit "Search", class: "hform__button button" - .fieldlabel + p.fieldlabel ' For more information, see the a href="/pages/search_syntax" search syntax documentation ' . Search results are sorted alphabetically. = if @conn.params["fq"] do - h2 Search Results = cond do - Enum.any?(@filters) -> - route = fn p -> ~p"/filters?#{p}" end - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @filters, route: route, params: [fq: @conn.params["fq"]], conn: @conn - = for filter <- @filters do - = render PhilomenaWeb.FilterView, "_filter.html", conn: @conn, filter: filter - .block + .block__header.page__header + span.block__header__title + ' Search Results + .page__pagination = pagination + + .block__content + = for filter <- @filters do + = render PhilomenaWeb.FilterView, "_filter.html", conn: @conn, filter: filter + .block__header.block__header--light.page__header .page__pagination = pagination .page__info diff --git a/lib/philomena_web/templates/fingerprint_profile/show.html.slime b/lib/philomena_web/templates/fingerprint_profile/show.html.slime index bde9e4339..46917439d 100644 --- a/lib/philomena_web/templates/fingerprint_profile/show.html.slime +++ b/lib/philomena_web/templates/fingerprint_profile/show.html.slime @@ -11,7 +11,7 @@ ul h2 Administration Options ul - li = link "View tag changes", to: ~p"/tag_changes?#{[tcq: "fingerprint:#{@fingerprint}", resource_type: "fingerprint", resource_id: @fingerprint]}" + li = link "View tag changes", to: ~p"/fingerprint_profiles/#{@fingerprint}/tag_changes" li = link "View source URL history", to: ~p"/fingerprint_profiles/#{@fingerprint}/source_changes" li = link "View reports this fingerprint has made", to: ~p"/admin/reports?#{[rq: "fingerprint:#{@fingerprint}"]}" li = link "View fingerprint ban history", to: ~p"/admin/fingerprint_bans?#{[fingerprint: @fingerprint]}" @@ -19,7 +19,7 @@ ul h2 Actions ul - li = link "Revert all tag changes", to: ~p"/tag_changes/full_revert?#{[fingerprint: @fingerprint]}", data: [confirm: "Are you really, really sure?", method: "create"] + li = link "Revert all tag changes", to: ~p"/fingerprint_profiles/#{@fingerprint}/tag_changes/revert", data: [confirm: "Are you really, really sure?", method: "create"] h4 Observed users table.table diff --git a/lib/philomena_web/templates/gallery/index.html.slime b/lib/philomena_web/templates/gallery/index.html.slime index 8463912ae..a2f2097ee 100644 --- a/lib/philomena_web/templates/gallery/index.html.slime +++ b/lib/philomena_web/templates/gallery/index.html.slime @@ -1,14 +1,10 @@ -elixir: - route = fn p -> ~p"/galleries?#{p}" end - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @galleries, route: route, params: [gallery: @conn.params["gallery"]] - .column-layout .column-layout__left .block .block__content h3 Search Galleries - = form_for @conn, ~p"/galleries", [as: :gallery, method: "get", class: "hform"], fn f -> + = form_for @changeset, ~p"/galleries", [as: :gallery, method: "get", class: "hform"], fn f -> .field = label f, :title, "Title" .field = text_input f, :title, class: "input hform__text", placeholder: "Gallery title (* as wildcard)" @@ -26,6 +22,10 @@ elixir: => select f, :sf, ["Creation date": "created_at", "Modification date": "updated_at", "Image count": "image_count", "Subscriber count": "subscriber_count", "Relevance": "_score"], class: "input" => select f, :sd, ["Descending": "desc", "Ascending": "asc"], class: "input" + .error = error_tag f, :include_image + .error = error_tag f, :sf + .error = error_tag f, :sd + .field = submit "Search", class: "button button--state-primary" .block @@ -35,17 +35,20 @@ elixir: .column-layout__main .block - .block__header.page__header - .page__pagination = pagination + = if not is_nil(@galleries) and Enum.any?(@galleries) do + - route = fn p -> ~p"/galleries?#{p}" end + - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @galleries, route: route, params: [gallery: @conn.params["gallery"]] + + .block__header.page__header + .page__pagination = pagination - = if Enum.any?(@galleries) do - .block__content.media-list - = for gallery <- @galleries do - = render PhilomenaWeb.GalleryView, "_gallery.html", gallery: gallery, conn: @conn + .block__content.media-list + = for gallery <- @galleries do + = render PhilomenaWeb.GalleryView, "_gallery.html", gallery: gallery, conn: @conn + + .block__header.block__header--light.page__header + .page__pagination = pagination - else .block__content .block.block--fixed.block--no-margin.block--warning ' No galleries found! - - .block__header.block__header--light.page__header - .page__pagination = pagination diff --git a/lib/philomena_web/templates/image/_description.html.slime b/lib/philomena_web/templates/image/_description.html.slime index 5f1fa052f..85ac5445c 100644 --- a/lib/philomena_web/templates/image/_description.html.slime +++ b/lib/philomena_web/templates/image/_description.html.slime @@ -3,7 +3,7 @@ span.block__header__title i.fas.fa-file-lines> ' Description - = if can?(@conn, :edit_description, @image) do + = if @changeset && can?(@conn, :edit_description, @image) do .block__header__buttons a.button.button--inline#edit-description href="#" data-click-focus="#description" data-click-hide=".image-description" data-click-show="#description-form" title="Edit description" accessKey="d" i.fas.fa-edit> diff --git a/lib/philomena_web/templates/image/_image_meta.html.slime b/lib/philomena_web/templates/image/_image_meta.html.slime index 32a2eb5ca..5715c8e60 100644 --- a/lib/philomena_web/templates/image/_image_meta.html.slime +++ b/lib/philomena_web/templates/image/_image_meta.html.slime @@ -10,30 +10,34 @@ a.js-rand href=~p"/images/random?#{scope(@conn)}" title="Random (r)" i.fa.fa-random .stretched-mobile-links - a.interaction--fave href="#" rel="nofollow" data-image-id=@image.id - span.favorites> title="Favorites" data-image-id=@image.id = @image.faves_count - span.fave-span title="Fave!" - i.fa.fa-star - a.interaction--upvote href="#" rel="nofollow" data-image-id=@image.id - = if show_vote_counts?(@conn.assigns.current_user) do - span.upvotes> title="Upvotes" data-image-id=@image.id = @image.upvotes_count - span.upvote-span title="Yay!" - i.fa.fa-arrow-up + = if @can_interact do + a.interaction--fave href="#" rel="nofollow" data-image-id=@image.id + span.favorites> title="Favorites" data-image-id=@image.id = @image.faves_count + span.fave-span title="Fave!" + i.fa.fa-star + a.interaction--upvote href="#" rel="nofollow" data-image-id=@image.id + = if show_vote_counts?(@conn.assigns.current_user) do + span.upvotes> title="Upvotes" data-image-id=@image.id = @image.upvotes_count + span.upvote-span title="Yay!" + i.fa.fa-arrow-up span.score.block__header__title data-image-id=@image.id = @image.score - a.interaction--downvote href="#" rel="nofollow" data-image-id=@image.id - span.downvote-span title="Neigh!" - i.fa.fa-arrow-down - = if show_vote_counts?(@conn.assigns.current_user) do - span.downvotes< title="Downvotes" data-image-id=@image.id = @image.downvotes_count + = if @can_interact do + a.interaction--downvote href="#" rel="nofollow" data-image-id=@image.id + span.downvote-span title="Neigh!" + i.fa.fa-arrow-down + = if show_vote_counts?(@conn.assigns.current_user) do + span.downvotes< title="Downvotes" data-image-id=@image.id = @image.downvotes_count a.interaction--comments href="#comments" title="Comments" i.fa.fa-comments span.comments_count< data-image-id=@image.id = @image.comments_count - a.interaction--hide href="#" rel="nofollow" data-image-id=@image.id - span.hide-span title="Hide" - i.fa.fa-eye-slash + = if @can_interact do + a.interaction--hide href="#" rel="nofollow" data-image-id=@image.id + span.hide-span title="Hide" + i.fa.fa-eye-slash .stretched-mobile-links - = render PhilomenaWeb.Image.SubscriptionView, "_subscription.html", watching: @watching, image: @image, conn: @conn - = render PhilomenaWeb.ImageView, "_add_to_gallery_dropdown.html", image: @image, user_galleries: @user_galleries, conn: @conn + = if @can_interact do + = render PhilomenaWeb.Image.SubscriptionView, "_subscription.html", watching: @watching, image: @image, conn: @conn + = render PhilomenaWeb.ImageView, "_add_to_gallery_dropdown.html", image: @image, user_galleries: @user_galleries, conn: @conn a href=~p"/images/#{@image}/related" title="Related Images" i.fa.fa-sitemap> span.hide-limited-desktop.hide-mobile Related diff --git a/lib/philomena_web/templates/image/_options.html.slime b/lib/philomena_web/templates/image/_options.html.slime index 36e7adcef..8b57fc8ba 100644 --- a/lib/philomena_web/templates/image/_options.html.slime +++ b/lib/philomena_web/templates/image/_options.html.slime @@ -69,7 +69,7 @@ = if display_mod_tools? do .block__tab.hidden data-tab="replace" - = form_for @changeset, ~p"/images/#{@image}/file", [method: "put", multipart: true], fn f -> + = form_for @file_changeset, ~p"/images/#{@image}/file", [method: "put", multipart: true], fn f -> #js-image-upload-previews p Upload a file from your computer .field @@ -95,13 +95,13 @@ em No mod notes present = if not @image.hidden_from_users do - = form_for @changeset, ~p"/images/#{@image}/delete", [method: "post"], fn f -> + = form_for @hide_changeset, ~p"/images/#{@image}/delete", [method: "post"], fn f -> = label f, :deletion_reason, "Deletion reason (cannot be empty)" .field.field--inline = text_input f, :deletion_reason, class: "input input--wide", placeholder: "Rule violation", required: true = submit "Delete", class: "button button--state-danger button--separate-left" - else - = form_for @changeset, ~p"/images/#{@image}/delete", [method: "put"], fn f -> + = form_for @hide_changeset, ~p"/images/#{@image}/delete", [method: "put"], fn f -> = label f, :deletion_reason, "Deletion reason (cannot be empty)" .field.field--inline = text_input f, :deletion_reason, class: "input input--wide", placeholder: "Rule violation", required: true @@ -109,18 +109,18 @@ .flex.flex--spaced-out.flex--wrap = if not @image.hidden_from_users do - = form_for @changeset, ~p"/images/#{@image}/feature", [method: "post"], fn _f -> + = form_for @feature_changeset, ~p"/images/#{@image}/feature", [method: "post"], fn _f -> .field p Marks the image as featured = submit "Feature", data: [confirm: "Are you really, really sure?"], class: "button button--state-success" - else = button_to "Restore", ~p"/images/#{@image}/delete", method: "delete", class: "button button--state-success" - = form_for @changeset, ~p"/images/#{@image}/repair", [method: "post"], fn _f -> + = form_for @repair_changeset, ~p"/images/#{@image}/repair", [method: "post"], fn _f -> .field = submit "Repair", class: "button button--state-success" - = form_for @changeset, ~p"/images/#{@image}/hash", [method: "delete"], fn _f -> + = form_for @hash_changeset, ~p"/images/#{@image}/hash", [method: "delete"], fn _f -> .field p Allows reuploading the image .flex.flex--end-bunched diff --git a/lib/philomena_web/templates/image/_source.html.slime b/lib/philomena_web/templates/image/_source.html.slime index f1dba6f0f..fb1135eee 100644 --- a/lib/philomena_web/templates/image/_source.html.slime +++ b/lib/philomena_web/templates/image/_source.html.slime @@ -1,8 +1,9 @@ +- changeset = @changeset || Ecto.Changeset.change(@image) .js-sourcesauce - has_sources = Enum.any?(@image.sources) - = form_for @changeset, ~p"/images/#{@image}/sources", [method: "put", class: "hidden", id: "source-form", data: [remote: "true"]], fn f -> - = if can?(@conn, :edit_metadata, @image) and !@conn.assigns.current_ban do - = if @changeset.action do + = form_for changeset, ~p"/images/#{@image}/sources", [method: "put", class: "hidden", id: "source-form", data: [remote: "true"]], fn f -> + = if @changeset && can?(@conn, :edit_metadata, @image) && !@conn.assigns.current_ban do + = if changeset.action do .alert.alert-danger p Oops, something went wrong! Please check the errors below. @@ -47,12 +48,13 @@ ' Sources .block__header__buttons - a.button.button--inline#edit-source data-click-focus=".js-image-source" data-click-hide="#image-source" data-click-show="#source-form" title="Edit source" accessKey="s" - i.fas.fa-edit - = if has_sources do - ' Add/Edit - - else - ' Add + = if @changeset do + a.button.button--inline#edit-source data-click-focus=".js-image-source" data-click-hide="#image-source" data-click-show="#source-form" title="Edit source" accessKey="s" + i.fas.fa-edit + = if has_sources do + ' Add/Edit + - else + ' Add = if @source_change_count > 0 do a.button.button--link.button--inline href=~p"/images/#{@image}/source_changes" title="Source history" i.fa.fa-history> diff --git a/lib/philomena_web/templates/image/_tags.html.slime b/lib/philomena_web/templates/image/_tags.html.slime index fe73ed1da..d29c72e0e 100644 --- a/lib/philomena_web/templates/image/_tags.html.slime +++ b/lib/philomena_web/templates/image/_tags.html.slime @@ -1,11 +1,12 @@ -- form_class = if @changeset.action, do: "", else: "hidden" -- tags_class = if @changeset.action, do: "hidden", else: "" +- changeset = @changeset || Ecto.Changeset.change(@image) +- form_class = if changeset.action, do: "", else: "hidden" +- tags_class = if changeset.action, do: "hidden", else: "" - tags = display_order(@image.tags) - tag_input = Enum.map_join(tags, ", ", & &1.name) .js-tagsauce#image_tags_and_source .js-imageform class=form_class - = if can?(@conn, :edit_metadata, @image) and !@conn.assigns.current_ban do + = if @changeset && can?(@conn, :edit_metadata, @image) && !@conn.assigns.current_ban do = if Enum.any?(@image.locked_tags) do .block.block--fixed.block--warning @@ -13,8 +14,8 @@ ' The following tags have been restricted on this image: code= Enum.map_join(@image.locked_tags, ", ", & &1.name) - = form_for @changeset, ~p"/images/#{@image}/tags", [id: "tags-form", method: "put", data: [remote: "true"]], fn f -> - = if @changeset.action do + = form_for changeset, ~p"/images/#{@image}/tags", [id: "tags-form", method: "put", data: [remote: "true"]], fn f -> + = if changeset.action do .alert.alert-danger p Oops, something went wrong! Please check the errors below. @@ -63,11 +64,12 @@ i.fas.fa-tag> ' Tags .block__header__buttons - a.button.button--inline.js-tag-sauce-toggle#edit-tags data-click-toggle=".tagsauce, .js-imageform" data-click-focus=".js-taginput-plain:not(.hidden), .js-taginput-input" title="Edit tags" accessKey="t" - i.fas.fa-edit> - ' Edit + = if @changeset do + a.button.button--inline.js-tag-sauce-toggle#edit-tags data-click-toggle=".tagsauce, .js-imageform" data-click-focus=".js-taginput-plain:not(.hidden), .js-taginput-input" title="Edit tags" accessKey="t" + i.fas.fa-edit> + ' Edit = if @tag_change_count > 0 do - a.button.button--link.button--inline href=~p"/tag_changes?#{[tcq: "image_id:#{@image.id}", resource_type: "image", resource_id: @image.id]}" title="Tag history" + a.button.button--link.button--inline href=~p"/images/#{@image}/tag_changes" title="Tag history" i.fa.fa-history> span.hide-mobile> History | ( diff --git a/lib/philomena_web/templates/image/_uploader.html.slime b/lib/philomena_web/templates/image/_uploader.html.slime index 504ab4373..a90d0854c 100644 --- a/lib/philomena_web/templates/image/_uploader.html.slime +++ b/lib/philomena_web/templates/image/_uploader.html.slime @@ -2,7 +2,7 @@ span.image_uploader ' by => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: @image, awards: true, conn: @conn - = if can?(@conn, :show, :ip_address) and not hide_staff_tools?(@conn) do + = if can?(@conn, :show, :identity_metadata) and not hide_staff_tools?(@conn) do => link_to_ip(@conn, @image.ip) => link_to_fingerprint(@conn, @image.fingerprint) a#edit-uploader href="#" data-click-hide=".image_uploader" data-click-show="#uploader-form" @@ -10,7 +10,7 @@ span.image_uploader a#edit-anonymous href="#" data-click-toggle=".image-anonymous" i.fas.fa-eye -= if can?(@conn, :show, :ip_address) do += if can?(@conn, :show, :identity_metadata) do = form_for @changeset, ~p"/images/#{@image}/uploader", [class: "block__content hidden", id: "uploader-form", data: [remote: "true", method: "put"]], fn f -> => label f, :username, "Uploader" => text_input f, :username, value: username(@image.user), class: "input input--short input--small" diff --git a/lib/philomena_web/templates/image/description/_form.html.slime b/lib/philomena_web/templates/image/description/_form.html.slime index e47b5d3ff..fdfbd7d04 100644 --- a/lib/philomena_web/templates/image/description/_form.html.slime +++ b/lib/philomena_web/templates/image/description/_form.html.slime @@ -1,15 +1,16 @@ -= form_for @changeset, ~p"/images/#{@image}/description", [class: "block hidden", id: "description-form", data: [remote: "true"]], fn f -> - = if @changeset.action do - .alert.alert-danger - p Oops, something went wrong! Please check the errors below. += if @changeset do + = form_for @changeset, ~p"/images/#{@image}/description", [class: "block hidden", id: "description-form", data: [remote: "true"]], fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. - = render PhilomenaWeb.MarkdownView, "_help.html", conn: @conn - = render PhilomenaWeb.MarkdownView, "_toolbar.html", conn: @conn + = render PhilomenaWeb.MarkdownView, "_help.html", conn: @conn + = render PhilomenaWeb.MarkdownView, "_toolbar.html", conn: @conn - .field - = textarea f, :description, id: "description", class: "input input--wide js-toolbar-input", placeholder: "Describe this image in plain words - this should generally be info about the image that doesn't belong in the tags or source." + .field + = textarea f, :description, id: "description", class: "input input--wide js-toolbar-input", placeholder: "Describe this image in plain words - this should generally be info about the image that doesn't belong in the tags or source." - = submit "Save changes", class: "button", autocomplete: "off" + = submit "Save changes", class: "button", autocomplete: "off" - button.button.button--separate-left type="reset" data-click-hide="#description-form" data-click-show=".image-description" - ' Cancel + button.button.button--separate-left type="reset" data-click-hide="#description-form" data-click-show=".image-description" + ' Cancel diff --git a/lib/philomena_web/templates/image/show.html.slime b/lib/philomena_web/templates/image/show.html.slime index 47522873e..dbaeccff2 100644 --- a/lib/philomena_web/templates/image/show.html.slime +++ b/lib/philomena_web/templates/image/show.html.slime @@ -1,5 +1,5 @@ = render PhilomenaWeb.ImageView, "_image_approval_banner.html", image: @image, conn: @conn -= render PhilomenaWeb.ImageView, "_image_meta.html", image: @image, watching: @watching, user_galleries: @user_galleries, changeset: @image_changeset, conn: @conn += render PhilomenaWeb.ImageView, "_image_meta.html", image: @image, watching: @watching, user_galleries: @user_galleries, changeset: @uploader_changeset, can_interact: @can_interact, conn: @conn = render PhilomenaWeb.ImageView, "_image_page.html", image: @image, conn: @conn .layout--narrow @@ -7,19 +7,19 @@ = render PhilomenaWeb.AdvertView, "_box.html", advert: @conn.assigns.advert, conn: @conn .image-description - = render PhilomenaWeb.ImageView, "_description.html", image: @image, body: @description, conn: @conn - = render PhilomenaWeb.Image.DescriptionView, "_form.html", image: @image, changeset: @image_changeset, conn: @conn + = render PhilomenaWeb.ImageView, "_description.html", image: @image, body: @description, changeset: @description_changeset, conn: @conn + = render PhilomenaWeb.Image.DescriptionView, "_form.html", image: @image, changeset: @description_changeset, conn: @conn - = render PhilomenaWeb.ImageView, "_tags.html", image: @image, tag_change_count: @tag_change_count, tag_change_tag_count: @tag_change_tag_count, changeset: @image_changeset, conn: @conn - = render PhilomenaWeb.ImageView, "_source.html", image: @image, source_change_count: @source_change_count, changeset: @image_changeset, conn: @conn - = render PhilomenaWeb.ImageView, "_options.html", image: @image, changeset: @image_changeset, conn: @conn + = render PhilomenaWeb.ImageView, "_tags.html", image: @image, tag_change_count: @tag_change_count, tag_change_tag_count: @tag_change_tag_count, changeset: @tag_changeset, conn: @conn + = render PhilomenaWeb.ImageView, "_source.html", image: @image, source_change_count: @source_change_count, changeset: @source_changeset, conn: @conn + = render PhilomenaWeb.ImageView, "_options.html", image: @image, file_changeset: @file_changeset, hide_changeset: @hide_changeset, feature_changeset: @feature_changeset, repair_changeset: @repair_changeset, hash_changeset: @hash_changeset, conn: @conn h4 Comments = cond do - @conn.assigns.current_ban -> = render PhilomenaWeb.BanView, "_ban_reason.html", conn: @conn - - @image.commenting_allowed -> + - @image.commenting_allowed && @comment_changeset -> = render PhilomenaWeb.Image.CommentView, "_form.html", image: @image, changeset: @comment_changeset, remote: true, conn: @conn - true -> diff --git a/lib/philomena_web/templates/ip_profile/show.html.slime b/lib/philomena_web/templates/ip_profile/show.html.slime index 4782cbbf1..d11eeac5a 100644 --- a/lib/philomena_web/templates/ip_profile/show.html.slime +++ b/lib/philomena_web/templates/ip_profile/show.html.slime @@ -12,10 +12,10 @@ ul h2 Administration Options ul li - => link "View tag changes", to: ~p"/tag_changes?#{[tcq: "ip:#{to_string(@ip)}", resource_type: "ip", resource_id: to_string(@ip)]}" + => link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" = if ipv6?(@ip) do ' … - = link "(/64)", to: ~p"/tag_changes?#{[tcq: "ip:#{to_string(to_ipv6_mask(@ip))}", resource_type: "ip", resource_id: to_string(to_ipv6_mask(@ip))]}" + = link "(/64)", to: ~p"/ip_profiles/#{to_string(to_ipv6_mask(@ip))}/tag_changes" li => link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" = if ipv6?(@ip) do @@ -28,7 +28,7 @@ ul h2 Actions ul - li = link "Revert all tag changes", to: ~p"/tag_changes/full_revert?#{[ip: to_string(@ip)]}", data: [confirm: "Are you really, really sure?", method: "create"] + li = link "Revert all tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes/revert", data: [confirm: "Are you really, really sure?", method: "create"] h4 Observed users table.table diff --git a/lib/philomena_web/templates/message/_message.html.slime b/lib/philomena_web/templates/message/_message.html.slime index b01032f3b..5b1ac7947 100644 --- a/lib/philomena_web/templates/message/_message.html.slime +++ b/lib/philomena_web/templates/message/_message.html.slime @@ -9,7 +9,7 @@ article.block.communication p ul.horizontal-list li - = link(to: ~p"/conversations/#{@message.conversation_id}/messages/#{@message}/approve", data: [confirm: "Are you sure?"], method: "post", class: "button") do + = link(to: ~p"/conversations/#{@conversation}/messages/#{@message}/approve", data: [confirm: "Are you sure?"], method: "post", class: "button") do i.fas.fa-check> ' Approve diff --git a/lib/philomena_web/templates/post/_post.html.slime b/lib/philomena_web/templates/post/_post.html.slime index d7f7b24d1..5879fb20e 100644 --- a/lib/philomena_web/templates/post/_post.html.slime +++ b/lib/philomena_web/templates/post/_post.html.slime @@ -75,7 +75,7 @@ article.block.communication id="post_#{@post.id}" - true -> - = if can?(@conn, :show, :ip_address) do + = if can?(@conn, :show, :identity_metadata) do .communication__info =<> link_to_ip(@conn, @post.ip) .communication__info diff --git a/lib/philomena_web/templates/post/index.html.slime b/lib/philomena_web/templates/post/index.html.slime index 08be83277..728216016 100644 --- a/lib/philomena_web/templates/post/index.html.slime +++ b/lib/philomena_web/templates/post/index.html.slime @@ -27,7 +27,7 @@ h2 Search Results - route = fn p -> ~p"/posts?#{p}" end - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @posts, route: route, params: [pq: @conn.params["pq"]], conn: @conn - = for {body, post} <- @posts, post.topic.hidden_from_users == false and can_view_communication?(@conn, post) do + = for {body, post} <- @posts do div h3 =<> link post.topic.forum.name, to: ~p"/forums/#{post.topic.forum}" diff --git a/lib/philomena_web/templates/profile/_admin_block.html.slime b/lib/philomena_web/templates/profile/_admin_block.html.slime index a800f31c0..c359c2a1b 100644 --- a/lib/philomena_web/templates/profile/_admin_block.html.slime +++ b/lib/philomena_web/templates/profile/_admin_block.html.slime @@ -20,8 +20,8 @@ ' from => link_to_ip(@conn, @last_ip.ip) - = if @last_fp do - => link_to_fingerprint(@conn, @last_fp.fingerprint) + = if @last_fingerprint do + => link_to_fingerprint(@conn, @last_fingerprint.fingerprint) - else em ' (never) @@ -175,6 +175,6 @@ a.label.label--primary.label--block href="#" data-click-toggle=".js-admin__optio = if @user.role == "user" and can?(@conn, :revert, Philomena.TagChanges.TagChange) do li - = link to: ~p"/tag_changes/full_revert?#{[user_id: @user.id]}", data: [confirm: "Are you really, really sure?", method: "create"] do + = link to: ~p"/profiles/#{@user}/tag_changes/revert", data: [confirm: "Are you really, really sure?", method: "create"] do i.fa.fa-fw.fa-tag span.admin__button Revert All Tag Changes diff --git a/lib/philomena_web/templates/profile/fp_history/index.html.slime b/lib/philomena_web/templates/profile/fp_history/index.html.slime index 182c30c58..f8255b00e 100644 --- a/lib/philomena_web/templates/profile/fp_history/index.html.slime +++ b/lib/philomena_web/templates/profile/fp_history/index.html.slime @@ -1,14 +1,19 @@ h2 - ' FP History for + ' Fingerprint History for = @user.name +- route = fn p -> ~p"/profiles/#{@user}/fp_history?#{p}" end +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @user_fingerprints, route: route, conn: @conn + +.page__pagination = pagination + ul - = for ufp <- @user_fps do + = for user_fingerprint <- @user_fingerprints do li - = link_to_fingerprint @conn, ufp.fingerprint + = link_to_fingerprint @conn, user_fingerprint.fingerprint ul - = for u <- @other_users[ufp.fingerprint] do + = for u <- Map.get(@other_users, user_fingerprint.fingerprint, []) do li => link u.user.name, to: ~p"/profiles/#{u.user}" | ( @@ -16,3 +21,5 @@ ul ' uses, last used = pretty_time(u.updated_at) ' ) + +.page__pagination = pagination diff --git a/lib/philomena_web/templates/profile/ip_history/index.html.slime b/lib/philomena_web/templates/profile/ip_history/index.html.slime index d5ffc0ffa..2e7e86f7a 100644 --- a/lib/philomena_web/templates/profile/ip_history/index.html.slime +++ b/lib/philomena_web/templates/profile/ip_history/index.html.slime @@ -2,13 +2,18 @@ h2 ' IP History for = @user.name +- route = fn p -> ~p"/profiles/#{@user}/ip_history?#{p}" end +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @user_ips, route: route, conn: @conn + +.page__pagination = pagination + ul = for uip <- @user_ips do li = link_to_ip @conn, uip.ip ul - = for u <- @other_users[uip.ip] do + = for u <- Map.get(@other_users, uip.ip, []) do li => link u.user.name, to: ~p"/profiles/#{u.user}" | ( @@ -16,3 +21,5 @@ ul ' uses, last used = pretty_time(u.updated_at) ' ) + +.page__pagination = pagination diff --git a/lib/philomena_web/templates/profile/show.html.slime b/lib/philomena_web/templates/profile/show.html.slime index e4dc7d407..ef4851949 100644 --- a/lib/philomena_web/templates/profile/show.html.slime +++ b/lib/philomena_web/templates/profile/show.html.slime @@ -37,10 +37,7 @@ ul.profile-top__options__column li = link("Favorites", to: ~p"/search?#{[q: "faved_by_id:#{@user.id}"]}") - = if @conn.assigns.current_user && @conn.assigns.current_user.role in ~W(moderator admin) do - li = link("Tag changes", to: ~p"/tag_changes?#{[tcq: "true_user_id:#{@user.id}", resource_type: "user", resource_id: @user.name]}") - - else - li = link("Tag changes", to: ~p"/tag_changes?#{[tcq: "user_id:#{@user.id}", resource_type: "user", resource_id: @user.name]}") + li = link("Tag changes", to: ~p"/profiles/#{@user}/tag_changes") li = link("Source changes", to: ~p"/profiles/#{@user}/source_changes") = if can_index_user?(@conn) do @@ -139,7 +136,7 @@ = render PhilomenaWeb.ProfileView, "_about_me.html", user: @user, about_me: @about_me, conn: @conn - = if can_read_mod_notes?(@conn) and not hide_staff_tools?(@conn) do + = if can_read_mod_notes?(@conn, @user) and not hide_staff_tools?(@conn) do .block .block__header.flex.flex--no-wrap a.flex__grow href=~p"/admin/mod_notes?#{[user_id: @user.id]}" Mod Notes @@ -161,7 +158,7 @@ a.block__header--single-item href=~p"/profiles/#{@user}/scratchpad/edit" Moderation Scratchpad .block__content.profile-about = @scratchpad - = if can_see_user_name_changes?(@conn) and not hide_staff_tools?(@conn) and Enum.any?(@name_changes) do + = if can_see_user_name_changes?(@conn, @user) and not hide_staff_tools?(@conn) and Enum.any?(@name_changes) do .block .block__header span.block__header__title Name History diff --git a/lib/philomena_web/templates/registration/edit.html.slime b/lib/philomena_web/templates/registration/edit.html.slime index f730fb885..9828f2368 100644 --- a/lib/philomena_web/templates/registration/edit.html.slime +++ b/lib/philomena_web/templates/registration/edit.html.slime @@ -39,7 +39,7 @@ h3 Change email p Oops, something went wrong! Please check the errors below. .field - = email_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ~S/[^\s]+@[^\s]+\.[^\s]+/ + = email_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ~S/[^@,;\s]+@[^@,;\s]+\.[^@,;\s]+/ = error_tag f, :email .field diff --git a/lib/philomena_web/templates/registration/new.html.slime b/lib/philomena_web/templates/registration/new.html.slime index 8f70bebe6..36d8ab3b0 100644 --- a/lib/philomena_web/templates/registration/new.html.slime +++ b/lib/philomena_web/templates/registration/new.html.slime @@ -16,7 +16,7 @@ h1 Register ' You'll use your email address to log in, and we'll use this to get in ' touch if we need to. Don't worry, we won't share this or spam you. .field - = email_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ~S/[^\s]+@[^\s]+\.[^\s]+/ + = email_input f, :email, class: "input", placeholder: "Email", required: true, pattern: ~S/[^@,;\s]+@[^@,;\s]+\.[^@,;\s]+/ = error_tag f, :email .fieldlabel diff --git a/lib/philomena_web/templates/report/new.html.slime b/lib/philomena_web/templates/report/new.html.slime index bfdaed510..e401e9443 100644 --- a/lib/philomena_web/templates/report/new.html.slime +++ b/lib/philomena_web/templates/report/new.html.slime @@ -43,7 +43,7 @@ p = form_for @changeset, @action, fn f -> .field - = select f, :rule_id, report_categories(), class: "input", prompt: [key: "Select a rule", disabled: true, selected: true], required: true + = select f, :rule_id, report_categories(@rules), class: "input", prompt: [key: "Select a rule", disabled: true, selected: true], required: true .block = render PhilomenaWeb.MarkdownView, "_input.html", conn: @conn, f: f, placeholder: "Provide anything else we should know here.", name: :reason, required: false diff --git a/lib/philomena_web/templates/session/new.html.slime b/lib/philomena_web/templates/session/new.html.slime index 963b410d0..f6fda352f 100644 --- a/lib/philomena_web/templates/session/new.html.slime +++ b/lib/philomena_web/templates/session/new.html.slime @@ -10,7 +10,7 @@ h1 Sign in p = link "Forgot your password?", to: ~p"/passwords/new" .field - = email_input f, :email, class: "input", required: true, placeholder: "Email", autofocus: true, pattern: ~S/[^\s]+@[^\s]+\.[^\s]+/ + = email_input f, :email, class: "input", required: true, placeholder: "Email", autofocus: true, pattern: ~S/[^@,;\s]+@[^@,;\s]+\.[^@,;\s]+/ = error_tag f, :email .field diff --git a/lib/philomena_web/templates/source_change/index.html.slime b/lib/philomena_web/templates/source_change/index.html.slime index 2e2b0fc97..e8e6e1c8d 100644 --- a/lib/philomena_web/templates/source_change/index.html.slime +++ b/lib/philomena_web/templates/source_change/index.html.slime @@ -34,7 +34,7 @@ td class=user_column_class(source_change) => render PhilomenaWeb.UserAttributionView, "_anon_user.html", object: source_change, conn: @conn - = if can?(@conn, :show, :ip_address) do + = if can?(@conn, :show, :identity_metadata) do => link_to_ip @conn, source_change.ip => link_to_fingerprint @conn,source_change.fingerprint diff --git a/lib/philomena_web/templates/staff/index.html.slime b/lib/philomena_web/templates/staff/index.html.slime index 1add36daa..dfbf60545 100644 --- a/lib/philomena_web/templates/staff/index.html.slime +++ b/lib/philomena_web/templates/staff/index.html.slime @@ -19,14 +19,15 @@ h1 Staff p Keep in mind that all staff are unpaid volunteers who donate their time and effort into making sure this site remains organized and operational. Please do not harass them, and try to keep your PMs constructive. We will happily answer your questions, however receiving plenty of PMs for no reason gets tiring and impacts our ability to tend to more important matters, so please make sure you actually have a need to contact a staff member before doing so. .staff-block - = for {header, users} <- @categories do - - header = to_string(header) + = for category <- [:administrators, :developers, :public_relations, :moderators, :assistants, :others] do + - users = @categories[category] + - header = category_title(category) = if Enum.any?(users) do - div class="block block--fixed staff-block__category #{category_class(header)}" = header + div class="block block--fixed staff-block__category #{category_class(category)}" = header p.staff-block__description i.fa.fa-fw.fa-info-circle> - = category_description(header) + = category_description(category) .staff-block__grid = for user <- users do diff --git a/lib/philomena_web/templates/tag/_tag_info_row.html.slime b/lib/philomena_web/templates/tag/_tag_info_row.html.slime index 4288c65fe..f4a1f37df 100644 --- a/lib/philomena_web/templates/tag/_tag_info_row.html.slime +++ b/lib/philomena_web/templates/tag/_tag_info_row.html.slime @@ -7,7 +7,7 @@ .flex__grow => render PhilomenaWeb.TagView, "_tag.html", tag: @tag, conn: @conn - = link "Tag changes", to: ~p"/tag_changes?#{[tcq: "tag_id:#{@tag.id}", resource_type: "tag", resource_id: @tag.name]}", class: "detail-link" + = link "Tag changes", to: ~p"/tags/#{@tag}/tag_changes", class: "detail-link" = if manages_tags?(@conn) do = link "Edit details", to: ~p"/tags/#{@tag}/edit", class: "detail-link" = link "Usage", to: ~p"/tags/#{@tag}/details", class: "detail-link" diff --git a/lib/philomena_web/templates/tag_change/index.html.slime b/lib/philomena_web/templates/tag_change/index.html.slime index 948e2075c..e4babd13f 100644 --- a/lib/philomena_web/templates/tag_change/index.html.slime +++ b/lib/philomena_web/templates/tag_change/index.html.slime @@ -1,14 +1,9 @@ -- route = fn p -> ~p"/tag_changes?#{p}" end +- route = @pagination_route - pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @tag_changes, route: route, conn: @conn, params: scope(@conn) -= if @resource_type != nil and @resource_id != nil do - h1 - ' Showing tag changes for - = link_to_resource(@resource_type, @resource_id) -- else - h1 Tag Changes +h1 = @title -= form_for :tag_changes, ~p"/tag_changes", [method: "get", class: "hform hform__text", enforce_utf8: false], fn f -> += form_for :tag_changes, @path, [method: "get", class: "hform hform__text", enforce_utf8: false], fn f -> .field = text_input f, :tcq, name: :tcq, value: @conn.params["tcq"], class: "input hform__text", @@ -96,9 +91,9 @@ = for tag <- removed_tags do div class=non_retained_class(non_retained, tag) = render PhilomenaWeb.TagView, "_tag.html", tag: tag, conn: @conn - = if reverts_tag_changes?(@conn) or can?(@conn, :show, :ip_address) do + = if reverts_tag_changes?(@conn) or can?(@conn, :show, :identity_metadata) do .tag__change-tools - = if can?(@conn, :show, :ip_address) do + = if can?(@conn, :show, :identity_metadata) do => link_to_ip @conn, tag_change.ip => link_to_fingerprint @conn, tag_change.fingerprint diff --git a/lib/philomena_web/templates/topic/show.html.slime b/lib/philomena_web/templates/topic/show.html.slime index 613db6d7e..b0fa9f12a 100644 --- a/lib/philomena_web/templates/topic/show.html.slime +++ b/lib/philomena_web/templates/topic/show.html.slime @@ -59,7 +59,7 @@ h1 = @topic.title / The actual posts .posts-area .post-list - = for {post, body} <- @posts, can_view_communication?(@conn, post) do + = for {post, body} <- @posts do = render PhilomenaWeb.PostView, "_post.html", conn: @conn, post: post, body: body = if @conn.assigns.advert do @@ -147,7 +147,7 @@ h1 = @topic.title = form_for :topic, ~p"/forums/#{@forum}/topics/#{@topic}/move", [method: :post, class: "hform"], fn f -> .field - = select f, :target_forum_id, Enum.map(@conn.assigns.forums, &{&1.name, &1.id}), class: "input hform__text" + = select f, :target_forum, Enum.map(@conn.assigns.forums, &{&1.name, &1.short_name}), class: "input hform__text" = submit class: "hform__button button" do i.fas.fa-truck> ' Move diff --git a/lib/philomena_web/user_auth.ex b/lib/philomena_web/user_auth.ex index e814cba06..32a224114 100644 --- a/lib/philomena_web/user_auth.ex +++ b/lib/philomena_web/user_auth.ex @@ -3,8 +3,8 @@ defmodule PhilomenaWeb.UserAuth do import Phoenix.Controller alias Philomena.Users - alias PhilomenaWeb.UserIpUpdater - alias PhilomenaWeb.UserFingerprintUpdater + alias Philomena.UserFingerprints + alias Philomena.UserIps use PhilomenaWeb, :verified_routes @@ -12,6 +12,7 @@ defmodule PhilomenaWeb.UserAuth do # If you want bump or reduce this value, also change # the token expiry itself in UserToken. @max_age 60 * 60 * 24 * 365 + @session_reissue_age_in_days 7 @remember_me_cookie "user_remember_me" @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] @totp_auth_cookie "user_totp_auth" @@ -38,11 +39,14 @@ defmodule PhilomenaWeb.UserAuth do |> put_session(:user_token, token) |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") |> maybe_write_remember_me_cookie(token, params) + |> maybe_preserve_return_to(user, user_return_to) |> redirect(to: user_return_to || signed_in_path(conn)) end defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do - put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + conn + |> put_session(:user_remember_me, true) + |> put_resp_cookie(@remember_me_cookie, token, @remember_me_options) end defp maybe_write_remember_me_cookie(conn, _token, _params) do @@ -59,11 +63,14 @@ defmodule PhilomenaWeb.UserAuth do conn |> put_session(:totp_token, token) |> maybe_write_totp_auth_cookie(token, params) + |> delete_session(:user_return_to) |> redirect(to: user_return_to || signed_in_path(conn)) end defp maybe_write_totp_auth_cookie(conn, token, %{"remember_me" => "true"}) do - put_resp_cookie(conn, @totp_auth_cookie, token, @totp_auth_options) + conn + |> put_session(:totp_remember_me, true) + |> put_resp_cookie(@totp_auth_cookie, token, @totp_auth_options) end defp maybe_write_totp_auth_cookie(conn, _token, _params) do @@ -86,6 +93,8 @@ defmodule PhilomenaWeb.UserAuth do # end # defp renew_session(conn) do + delete_csrf_token() + conn |> configure_session(renew: true) |> clear_session() @@ -109,20 +118,30 @@ defmodule PhilomenaWeb.UserAuth do conn |> renew_session() - |> delete_resp_cookie(@remember_me_cookie) - |> delete_resp_cookie(@totp_auth_cookie) + |> delete_resp_cookie(@remember_me_cookie, @remember_me_options) + |> delete_resp_cookie(@totp_auth_cookie, @totp_auth_options) |> redirect(to: "/") end @doc """ Authenticates the user by looking into the session and remember me token. + + Will reissue the session token if it is older than the configured age. """ def fetch_current_user(conn, _opts) do {user_token, conn} = ensure_user_token(conn) {totp_token, conn} = ensure_totp_token(conn) - user = user_token && Users.get_user_by_session_token(user_token) + {user, conn} = + case user_token && Users.get_user_by_session_token_with_timestamp(user_token) do + {user, token_inserted_at} -> + {user, maybe_reissue_user_session_token(conn, user, token_inserted_at)} + + _ -> + {nil, conn} + end + totp = totp_token && Users.user_totp_token_valid?(user, totp_token) cond do @@ -148,7 +167,10 @@ defmodule PhilomenaWeb.UserAuth do conn = fetch_cookies(conn, signed: [@remember_me_cookie]) if user_token = conn.cookies[@remember_me_cookie] do - {user_token, put_session(conn, :user_token, user_token)} + {user_token, + conn + |> put_session(:user_token, user_token) + |> put_session(:user_remember_me, true)} else {nil, conn} end @@ -169,6 +191,38 @@ defmodule PhilomenaWeb.UserAuth do end end + # Reissue the session token if it is older than the configured reissue age. + defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do + if DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day) >= + @session_reissue_age_in_days do + token = Users.generate_user_session_token(user) + + conn + |> put_session(:user_token, token) + |> maybe_refresh_remember_me_cookie(token) + else + conn + end + end + + defp maybe_refresh_remember_me_cookie(conn, token) do + if get_session(conn, :user_remember_me) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + else + conn + end + end + + # An OTP-enabled user still needs the return path during the second-factor + # step. It is cleared when that step completes in `totp_auth_user/3`. + defp maybe_preserve_return_to(conn, %{otp_required_for_login: true}, return_to) + when is_binary(return_to) do + put_session(conn, :user_return_to, return_to) + end + + defp maybe_preserve_return_to(conn, _user, _return_to), + do: delete_session(conn, :user_return_to) + @doc """ Used for routes that require the user to not be authenticated. """ @@ -201,8 +255,8 @@ defmodule PhilomenaWeb.UserAuth do end end - defp maybe_store_return_to(%{method: "GET", request_path: request_path} = conn) do - put_session(conn, :user_return_to, request_path) + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) end defp maybe_store_return_to(conn), do: conn @@ -215,7 +269,7 @@ defmodule PhilomenaWeb.UserAuth do def update_usages(conn, user) do now = DateTime.utc_now(:second) - UserIpUpdater.cast(user.id, conn.remote_ip, now) - UserFingerprintUpdater.cast(user.id, conn.assigns.fingerprint, now) + UserIps.record_usage(user, conn.remote_ip, now) + UserFingerprints.record_usage(user, conn.assigns.fingerprint, now) end end diff --git a/lib/philomena_web/user_fingerprint_updater.ex b/lib/philomena_web/user_fingerprint_updater.ex deleted file mode 100644 index 41863dcf1..000000000 --- a/lib/philomena_web/user_fingerprint_updater.ex +++ /dev/null @@ -1,68 +0,0 @@ -defmodule PhilomenaWeb.UserFingerprintUpdater do - alias Philomena.UserFingerprints.UserFingerprint - alias Philomena.Repo - import Ecto.Query - - alias PhilomenaWeb.Fingerprint - - def child_spec([]) do - %{ - id: PhilomenaWeb.UserFingerprintUpdater, - start: {PhilomenaWeb.UserFingerprintUpdater, :start_link, [[]]} - } - end - - def start_link([]) do - {:ok, spawn_link(&init/0)} - end - - def cast(user_id, fingerprint, updated_at) do - if Fingerprint.valid_format?(fingerprint) do - pid = Process.whereis(:fingerprint_updater) - if pid, do: send(pid, {user_id, fingerprint, updated_at}) - end - end - - defp init do - Process.register(self(), :fingerprint_updater) - run() - end - - defp run do - user_fps = Enum.map(receive_all(), &into_insert_all/1) - - update_query = - update(UserFingerprint, inc: [uses: 1], set: [updated_at: fragment("EXCLUDED.updated_at")]) - - Repo.insert_all(UserFingerprint, user_fps, - on_conflict: update_query, - conflict_target: [:user_id, :fingerprint] - ) - - :timer.sleep(:timer.seconds(60)) - - run() - end - - defp receive_all(user_fps \\ %{}) do - receive do - {user_id, fingerprint, updated_at} -> - user_fps - |> Map.put({user_id, fingerprint}, updated_at) - |> receive_all() - after - 0 -> - user_fps - end - end - - defp into_insert_all({{user_id, fingerprint}, updated_at}) do - %{ - user_id: user_id, - fingerprint: fingerprint, - uses: 1, - created_at: updated_at, - updated_at: updated_at - } - end -end diff --git a/lib/philomena_web/user_ip_updater.ex b/lib/philomena_web/user_ip_updater.ex deleted file mode 100644 index 393a6ebcc..000000000 --- a/lib/philomena_web/user_ip_updater.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule PhilomenaWeb.UserIpUpdater do - alias Philomena.UserIps.UserIp - alias Philomena.Repo - import Ecto.Query - - def child_spec([]) do - %{ - id: PhilomenaWeb.UserIpUpdater, - start: {PhilomenaWeb.UserIpUpdater, :start_link, [[]]} - } - end - - def start_link([]) do - {:ok, spawn_link(&init/0)} - end - - def cast(user_id, ip_address, updated_at) do - pid = Process.whereis(:ip_updater) - if pid, do: send(pid, {user_id, ip_address, updated_at}) - end - - defp init do - Process.register(self(), :ip_updater) - run() - end - - defp run do - user_ips = Enum.map(receive_all(), &into_insert_all/1) - - update_query = - update(UserIp, inc: [uses: 1], set: [updated_at: fragment("EXCLUDED.updated_at")]) - - Repo.insert_all(UserIp, user_ips, on_conflict: update_query, conflict_target: [:user_id, :ip]) - - :timer.sleep(:timer.seconds(60)) - - run() - end - - defp receive_all(user_ips \\ %{}) do - receive do - {user_id, ip_address, updated_at} -> - user_ips - |> Map.put({user_id, ip_address}, updated_at) - |> receive_all() - after - 0 -> - user_ips - end - end - - defp into_insert_all({{user_id, ip_address}, updated_at}) do - %{ - user_id: user_id, - ip: cast_ip(ip_address), - uses: 1, - created_at: updated_at, - updated_at: updated_at - } - end - - # There exists no EctoNetwork.INET.cast!/1 - defp cast_ip(ip), do: elem(EctoNetwork.INET.cast(ip), 1) -end diff --git a/lib/philomena_web/user_loader.ex b/lib/philomena_web/user_loader.ex deleted file mode 100644 index 4126b14fd..000000000 --- a/lib/philomena_web/user_loader.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule PhilomenaWeb.UserLoader do - alias PhilomenaQuery.Search - alias Philomena.Users.User - - @sortable_fields ~W( - name - confirmed_at - updated_at - deleted_at - images_count - image_faves_count - comments_count - image_votes_count - metadata_updates_count - posts_count - topics_count - _score - ) - - def query(conn, body, options \\ []) do - pagination = Keyword.get(options, :pagination, conn.assigns.pagination) - sort = Keyword.get(options, :sort) || parse_sort(conn.params) - - Search.search_definition( - User, - %{ - query: body, - sort: sort - }, - pagination - ) - end - - defp parse_sort(params), - do: parse_sf(params, parse_sd(params)) - - defp parse_sd(%{"sd" => sd}) when sd in ~W(asc desc), do: sd - defp parse_sd(_params), do: "desc" - - defp parse_sf(%{"sf" => sf}, sd) when sf in @sortable_fields, - do: [%{sf => sd}, %{"id" => sd}] - - defp parse_sf(_params, sd), - do: [%{"id" => sd}] -end diff --git a/lib/philomena_web/views/admin/artist_link_view.ex b/lib/philomena_web/views/admin/artist_link_view.ex index 4a604dfab..fcd957352 100644 --- a/lib/philomena_web/views/admin/artist_link_view.ex +++ b/lib/philomena_web/views/admin/artist_link_view.ex @@ -21,20 +21,6 @@ defmodule PhilomenaWeb.Admin.ArtistLinkView do |> String.capitalize() end - def scope(conn) do - [] - |> scope(conn, "lq", :lq) - |> scope(conn, "all", :all) - end - - defp scope(list, conn, key, key_atom) do - case conn.params[key] do - nil -> list - "" -> list - val -> [{key_atom, val} | list] - end - end - def contacted?(%{aasm_state: state}), do: state == "contacted" def verified?(%{aasm_state: state}), do: state == "verified" def link_verified?(%{aasm_state: state}), do: state == "link_verified" diff --git a/lib/philomena_web/views/admin/ban_view.ex b/lib/philomena_web/views/admin/ban_view.ex index b26ea8079..5da168725 100644 --- a/lib/philomena_web/views/admin/ban_view.ex +++ b/lib/philomena_web/views/admin/ban_view.ex @@ -15,10 +15,8 @@ defmodule PhilomenaWeb.Admin.BanView do end def page_params(params) do - case params["bq"] do - nil -> [] - "" -> [] - q -> [bq: q] - end + params + |> Map.take(["bq", "fingerprint", "ip", "user_id"]) + |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) end end diff --git a/lib/philomena_web/views/api/json/comment_view.ex b/lib/philomena_web/views/api/json/comment_view.ex index 5d80b42b7..8474e4671 100644 --- a/lib/philomena_web/views/api/json/comment_view.ex +++ b/lib/philomena_web/views/api/json/comment_view.ex @@ -1,5 +1,6 @@ defmodule PhilomenaWeb.Api.Json.CommentView do use PhilomenaWeb, :view + alias Philomena.Attribution.AnonymousName alias PhilomenaWeb.UserAttributionView def render("index.json", %{comments: comments, total: total} = assigns) do @@ -36,7 +37,7 @@ defmodule PhilomenaWeb.Api.Json.CommentView do id: comment.id, image_id: comment.image_id, user_id: if(not comment.anonymous, do: comment.user_id), - author: UserAttributionView.name(comment), + author: AnonymousName.name(comment), avatar: UserAttributionView.avatar_url(comment), body: nil, created_at: comment.created_at, @@ -51,7 +52,7 @@ defmodule PhilomenaWeb.Api.Json.CommentView do id: comment.id, image_id: comment.image_id, user_id: if(not comment.anonymous, do: comment.user_id), - author: UserAttributionView.name(comment), + author: AnonymousName.name(comment), avatar: UserAttributionView.avatar_url(comment), body: comment.body, created_at: comment.created_at, diff --git a/lib/philomena_web/views/api/json/forum/topic/post_view.ex b/lib/philomena_web/views/api/json/forum/topic/post_view.ex index 4d6ecdcb0..d992d6b01 100644 --- a/lib/philomena_web/views/api/json/forum/topic/post_view.ex +++ b/lib/philomena_web/views/api/json/forum/topic/post_view.ex @@ -1,5 +1,6 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostView do use PhilomenaWeb, :view + alias Philomena.Attribution.AnonymousName alias PhilomenaWeb.UserAttributionView def render("index.json", %{posts: posts, total: total} = assigns) do @@ -38,7 +39,7 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostView do %{ id: post.id, user_id: if(not post.anonymous, do: post.user_id), - author: UserAttributionView.name(post), + author: AnonymousName.name(post), avatar: UserAttributionView.avatar_url(post), body: nil, created_at: post.created_at, @@ -52,7 +53,7 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostView do %{ id: post.id, user_id: if(not post.anonymous, do: post.user_id), - author: UserAttributionView.name(post), + author: AnonymousName.name(post), avatar: UserAttributionView.avatar_url(post), body: post.body, created_at: post.created_at, diff --git a/lib/philomena_web/views/api/json/forum/topic_view.ex b/lib/philomena_web/views/api/json/forum/topic_view.ex index 564eb5f27..de287ab1d 100644 --- a/lib/philomena_web/views/api/json/forum/topic_view.ex +++ b/lib/philomena_web/views/api/json/forum/topic_view.ex @@ -1,6 +1,6 @@ defmodule PhilomenaWeb.Api.Json.Forum.TopicView do use PhilomenaWeb, :view - alias PhilomenaWeb.UserAttributionView + alias Philomena.Attribution.AnonymousName def render("index.json", %{topics: topics, total: total} = assigns) do %{ @@ -39,7 +39,7 @@ defmodule PhilomenaWeb.Api.Json.Forum.TopicView do user_id: if(not topic.anonymous, do: topic.user_id), author: if(topic.anonymous or is_nil(topic.user), - do: UserAttributionView.anonymous_name(topic), + do: AnonymousName.generate(topic), else: topic.user.name ) } diff --git a/lib/philomena_web/views/api/json/image_view.ex b/lib/philomena_web/views/api/json/image_view.ex index 4646af232..f981a9a03 100644 --- a/lib/philomena_web/views/api/json/image_view.ex +++ b/lib/philomena_web/views/api/json/image_view.ex @@ -1,5 +1,6 @@ defmodule PhilomenaWeb.Api.Json.ImageView do use PhilomenaWeb, :view + alias Philomena.Images alias PhilomenaWeb.ImageView def render("index.json", %{images: images, interactions: interactions, total: total} = assigns) do @@ -47,7 +48,7 @@ defmodule PhilomenaWeb.Api.Json.ImageView do def render("image.json", %{conn: conn, image: %{hidden_from_users: false} = image}) do result = render_one(image, PhilomenaWeb.Api.Json.ImageView, "image.json", %{image: image}) - Map.put(result, :spoilered, ImageView.filter_or_spoiler_hits?(conn, image)) + Map.put(result, :spoilered, Images.filter_or_spoiler_hits?(image, conn.assigns.image_filter)) end def render("image.json", %{image: %{hidden_from_users: false} = image}) do diff --git a/lib/philomena_web/views/app_view.ex b/lib/philomena_web/views/app_view.ex index 61aa5ef86..5d121dc36 100644 --- a/lib/philomena_web/views/app_view.ex +++ b/lib/philomena_web/views/app_view.ex @@ -160,28 +160,6 @@ defmodule PhilomenaWeb.AppView do def communication_body_class(%{destroyed_content: true}), do: "communication--destroyed" def communication_body_class(_communication), do: nil - def can_view_communication?(conn, communication) do - cond do - can?(conn, :hide, communication) and not hide_staff_tools?(conn) -> - true - - communication.destroyed_content -> - false - - not communication.approved -> - %{user_id: user_id, ip: %{address: ip}} = communication - - case conn do - %{assigns: %{current_user: %{id: ^user_id}}} -> true - %{remote_ip: ^ip} -> true - _ -> false - end - - true -> - true - end - end - def hide_staff_tools?(conn), do: conn.cookies["hide_staff_tools"] == "true" diff --git a/lib/philomena_web/views/conversation_view.ex b/lib/philomena_web/views/conversation_view.ex index bba3ff8e7..ba4472149 100644 --- a/lib/philomena_web/views/conversation_view.ex +++ b/lib/philomena_web/views/conversation_view.ex @@ -1,12 +1,6 @@ defmodule PhilomenaWeb.ConversationView do use PhilomenaWeb, :view - alias Philomena.Schema.Approval - - def trusted?(user) do - Approval.trusted?(user) - end - def other_party(%{id: user_id}, %{to_id: user_id} = conversation), do: conversation.from diff --git a/lib/philomena_web/views/duplicate_report_view.ex b/lib/philomena_web/views/duplicate_report_view.ex index 200e430ed..14bd6cf66 100644 --- a/lib/philomena_web/views/duplicate_report_view.ex +++ b/lib/philomena_web/views/duplicate_report_view.ex @@ -1,18 +1,13 @@ defmodule PhilomenaWeb.DuplicateReportView do use PhilomenaWeb, :view + alias Philomena.DuplicateReports.Comparison alias PhilomenaWeb.ImageView - @formats_order ~W(video/webm image/svg+xml image/png image/gif image/jpeg other) - def comparison_url(conn, image), do: ImageView.thumb_url(image, can?(conn, :show, image), :full) - def largest_dimensions(images) do - images - |> Enum.map(&{&1.image_width, &1.image_height}) - |> Enum.max_by(fn {w, h} -> w * h end) - end + defdelegate largest_dimensions(images), to: Comparison def background_class(%{state: "rejected"}), do: "background-danger" def background_class(%{state: "accepted"}), do: "background-success" @@ -26,127 +21,28 @@ defmodule PhilomenaWeb.DuplicateReportView do "(#{source_type}, #{target_type})" end - def forward_merge?(%{image_id: image_id, duplicate_of_image_id: duplicate_of_image_id}), - do: duplicate_of_image_id > image_id - - def higher_res?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: - duplicate_of_image.image_width > image.image_width or - duplicate_of_image.image_height > image.image_height - - def same_res?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: - duplicate_of_image.image_width == image.image_width and - duplicate_of_image.image_height == image.image_height - - def same_format?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: duplicate_of_image.image_mime_type == image.image_mime_type - - def better_format?(%{image: image, duplicate_of_image: duplicate_of_image}) do - source_index = - Enum.find_index(@formats_order, &(image.image_mime_type == &1)) || - length(@formats_order) - 1 - - target_index = - Enum.find_index(@formats_order, &(duplicate_of_image.image_mime_type == &1)) || - length(@formats_order) - 1 - - target_index < source_index - end - - def same_aspect_ratio?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: abs(duplicate_of_image.image_aspect_ratio - image.image_aspect_ratio) <= 0.009 - - def neither_have_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: Enum.empty?(duplicate_of_image.sources) and Enum.empty?(image.sources) - - def same_source?(%{image: image, duplicate_of_image: duplicate_of_image}) do - MapSet.equal?(MapSet.new(image.sources), MapSet.new(duplicate_of_image.sources)) - end - - def similar_source?(%{image: image, duplicate_of_image: duplicate_of_image}) do - MapSet.equal?( - MapSet.new(image.sources, &URI.parse(&1.source).host), - MapSet.new(duplicate_of_image.sources, &URI.parse(&1.source).host) - ) - end - - def source_on_target?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: Enum.any?(duplicate_of_image.sources) and Enum.empty?(image.sources) - - def source_on_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: Enum.empty?(duplicate_of_image.sources) && Enum.any?(image.sources) - - def same_artist_tags?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: MapSet.equal?(artist_tags(image), artist_tags(duplicate_of_image)) - - def more_artist_tags_on_target?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: proper_subset?(artist_tags(image), artist_tags(duplicate_of_image)) - - def more_artist_tags_on_source?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: proper_subset?(artist_tags(duplicate_of_image), artist_tags(image)) - - def same_rating_tags?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: MapSet.equal?(rating_tags(image), rating_tags(duplicate_of_image)) - - def target_is_edit?(%{duplicate_of_image: duplicate_of_image}), - do: edit?(duplicate_of_image) - - def source_is_edit?(%{image: image}), - do: edit?(image) - - def both_are_edits?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: edit?(image) and edit?(duplicate_of_image) - - def target_is_alternate_version?(%{duplicate_of_image: duplicate_of_image}), - do: alternate_version?(duplicate_of_image) - - def source_is_alternate_version?(%{image: image}), - do: alternate_version?(image) - - def both_are_alternate_versions?(%{image: image, duplicate_of_image: duplicate_of_image}), - do: alternate_version?(image) and alternate_version?(duplicate_of_image) - - def mergeable?(%{image: image, duplicate_of_image: duplicate_of_image} = report) do - same_rating_tags?(report) and not image.hidden_from_users and - not duplicate_of_image.hidden_from_users and image.approved and - duplicate_of_image.approved - end - - def source_approved?(%{image: image}) do - image.approved - end - - def target_approved?(%{duplicate_of_image: image}) do - image.approved - end - - defp artist_tags(%{tags: tags}) do - tags - |> Enum.filter(&(&1.namespace == "artist")) - |> Enum.map(& &1.name) - |> MapSet.new() - end - - defp rating_tags(%{tags: tags}) do - tags - |> Enum.filter(&(&1.category == "rating")) - |> Enum.map(& &1.name) - |> MapSet.new() - end - - defp edit?(%{tags: tags}) do - tags - |> Enum.filter(&(&1.name == "edit")) - |> Enum.any?() - end - - defp alternate_version?(%{tags: tags}) do - tags - |> Enum.filter(&(&1.name == "alternate version")) - |> Enum.any?() - end - - defp proper_subset?(set1, set2), - do: MapSet.subset?(set1, set2) and not MapSet.equal?(set1, set2) + defdelegate forward_merge?(report), to: Comparison + defdelegate higher_res?(report), to: Comparison + defdelegate same_res?(report), to: Comparison + defdelegate same_format?(report), to: Comparison + defdelegate better_format?(report), to: Comparison + defdelegate same_aspect_ratio?(report), to: Comparison + defdelegate neither_have_source?(report), to: Comparison + defdelegate same_source?(report), to: Comparison + defdelegate similar_source?(report), to: Comparison + defdelegate source_on_target?(report), to: Comparison + defdelegate source_on_source?(report), to: Comparison + defdelegate same_artist_tags?(report), to: Comparison + defdelegate more_artist_tags_on_target?(report), to: Comparison + defdelegate more_artist_tags_on_source?(report), to: Comparison + defdelegate same_rating_tags?(report), to: Comparison + defdelegate target_is_edit?(report), to: Comparison + defdelegate source_is_edit?(report), to: Comparison + defdelegate both_are_edits?(report), to: Comparison + defdelegate target_is_alternate_version?(report), to: Comparison + defdelegate source_is_alternate_version?(report), to: Comparison + defdelegate both_are_alternate_versions?(report), to: Comparison + defdelegate mergeable?(report), to: Comparison + defdelegate source_approved?(report), to: Comparison + defdelegate target_approved?(report), to: Comparison end diff --git a/lib/philomena_web/views/image_view.ex b/lib/philomena_web/views/image_view.ex index 3a852023b..a39a4a0f6 100644 --- a/lib/philomena_web/views/image_view.ex +++ b/lib/philomena_web/views/image_view.ex @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.ImageView do use PhilomenaWeb, :view alias Philomena.Tags.Tag + alias Philomena.Images alias Philomena.Images.Thumbnailer def show_vote_counts?(%{settings: %{hide_vote_counts: true}}), do: false @@ -266,52 +267,8 @@ defmodule PhilomenaWeb.ImageView do defp thumb_format(_, :rendered, _download), do: "png" defp thumb_format(format, _name, _download), do: format - def image_filter_data(image) do - %{ - id: image.id, - tags: image.tags |> Enum.flat_map(&([&1] ++ &1.aliases)) |> Enum.map_join(", ", & &1.name), - tag_count: length(image.tags), - score: image.score, - faves: image.faves_count, - upvotes: image.upvotes_count, - downvotes: image.downvotes_count, - comment_count: image.comments_count, - created_at: image.created_at, - first_seen_at: image.first_seen_at, - source_url: image.source_url, - width: image.image_width, - height: image.image_height, - aspect_ratio: image.image_aspect_ratio, - sha512_hash: image.image_sha512_hash, - orig_sha512_hash: image.image_orig_sha512_hash, - description: image.description - } - end - def filter_or_spoiler_hits?(conn, image) do - tag_filter_or_spoiler_hits?(conn, image) or complex_filter_or_spoiler_hits?(conn, image) - end - - defp tag_filter_or_spoiler_hits?(conn, image) do - filter = conn.assigns.current_filter - filtered_tag_ids = MapSet.new(filter.spoilered_tag_ids ++ filter.hidden_tag_ids) - image_tag_ids = MapSet.new(image.tags, & &1.id) - - MapSet.size(MapSet.intersection(filtered_tag_ids, image_tag_ids)) > 0 - end - - defp complex_filter_or_spoiler_hits?(conn, image) do - doc = image_filter_data(image) - complex_filter = conn.assigns.compiled_complex_filter - complex_spoiler = conn.assigns.compiled_complex_spoiler - - query = %{ - bool: %{ - should: [complex_filter, complex_spoiler] - } - } - - PhilomenaQuery.Parse.Evaluator.hits?(doc, query) + Images.filter_or_spoiler_hits?(image, conn.assigns.image_filter) end def image_source_icon(nil), do: "fa fa-link" diff --git a/lib/philomena_web/views/post_view.ex b/lib/philomena_web/views/post_view.ex index f038eb4dd..d2882364d 100644 --- a/lib/philomena_web/views/post_view.ex +++ b/lib/philomena_web/views/post_view.ex @@ -1,5 +1,6 @@ defmodule PhilomenaWeb.PostView do alias Philomena.Attribution + alias Philomena.Attribution.AnonymousName use PhilomenaWeb, :view @@ -9,7 +10,7 @@ defmodule PhilomenaWeb.PostView do defp author_name(object) do if Attribution.anonymous?(object) || !object.user do - PhilomenaWeb.UserAttributionView.anonymous_name(object) + AnonymousName.generate(object) else object.user.name end diff --git a/lib/philomena_web/views/profile_view.ex b/lib/philomena_web/views/profile_view.ex index acd6fe2b7..4f334f6ac 100644 --- a/lib/philomena_web/views/profile_view.ex +++ b/lib/philomena_web/views/profile_view.ex @@ -71,11 +71,13 @@ defmodule PhilomenaWeb.ProfileView do def can_index_user?(conn), do: can?(conn, :index, Philomena.Users.User) - def can_read_mod_notes?(conn), - do: can?(conn, :index, Philomena.ModNotes.ModNote) + def can_read_mod_notes?(conn, user), + do: can?(conn, :show_details, user) and can?(conn, :index, Philomena.ModNotes.ModNote) - def can_see_user_name_changes?(conn), - do: can?(conn, :index, Philomena.UserNameChanges.UserNameChange) + def can_see_user_name_changes?(conn, user), + do: + can?(conn, :show_details, user) and + can?(conn, :index, Philomena.UserNameChanges.UserNameChange) def can_reveal_anon?(conn), do: can?(conn, :reveal_anon, nil) diff --git a/lib/philomena_web/views/report_view.ex b/lib/philomena_web/views/report_view.ex index 06a75036f..408157be6 100644 --- a/lib/philomena_web/views/report_view.ex +++ b/lib/philomena_web/views/report_view.ex @@ -9,14 +9,10 @@ defmodule PhilomenaWeb.ReportView do alias Philomena.Posts.Post alias Philomena.Users.User alias Philomena.Reports.Report - alias Philomena.Rules - import Ecto.Changeset - def report_categories do - Rules.list_reportable_rules() - |> Enum.map(&{"#{&1.name}: #{&1.short_description}", &1.id}) - end + def report_categories(rules), + do: Enum.map(rules, &{"#{&1.name}: #{&1.short_description}", &1.id}) def image?(changeset), do: not is_nil(get_field(changeset, :image_id)) def conversation?(changeset), do: not is_nil(get_field(changeset, :conversation_id)) diff --git a/lib/philomena_web/views/staff_view.ex b/lib/philomena_web/views/staff_view.ex index edceaa69c..296b8d884 100644 --- a/lib/philomena_web/views/staff_view.ex +++ b/lib/philomena_web/views/staff_view.ex @@ -3,36 +3,43 @@ defmodule PhilomenaWeb.StaffView do @desc_regex ~r/^([^\n]+)/ - def category_description("Administrators"), + def category_title(:administrators), do: "Administrators" + def category_title(:developers), do: "Technical Team" + def category_title(:public_relations), do: "Public Relations" + def category_title(:moderators), do: "Moderators" + def category_title(:assistants), do: "Assistants" + def category_title(:others), do: "Others" + + def category_description(:administrators), do: "High-level staff of the site, typically handling larger-scope tasks, such as technical operation of the site or writing rules and policies." - def category_description("Technical Team"), + def category_description(:developers), do: "Developers and system administrators of the site, people who make sure the site keeps running." - def category_description("Public Relations"), + def category_description(:public_relations), do: "People handling public announcements, events and such." - def category_description("Moderators"), + def category_description(:moderators), do: "The main moderation force of the site, handling a wide range of tasks from maintaining tags to making sure the rules are followed." - def category_description("Assistants"), + def category_description(:assistants), do: "Volunteers who help us run the site by taking simpler tasks off the hands of administrators and moderators." - def category_description("Others"), + def category_description(:others), do: "People associated with the site in some other way, sometimes (but not necessarily) having staff-like permissions." def category_description(_), do: "This category has no description provided." - def category_class("Administrators"), do: "block--danger" - def category_class("Technical Team"), do: "block--warning" - def category_class("Public Relations"), do: "block--warning" - def category_class("Moderators"), do: "block--success" - def category_class("Assistants"), do: "block--assistant" + def category_class(:administrators), do: "block--danger" + def category_class(:developers), do: "block--warning" + def category_class(:public_relations), do: "block--warning" + def category_class(:moderators), do: "block--success" + def category_class(:assistants), do: "block--assistant" def category_class(_), do: "" def staff_description(%{description: desc}) when desc not in [nil, ""] do diff --git a/lib/philomena_web/views/tag_change_view.ex b/lib/philomena_web/views/tag_change_view.ex index d780dbde1..172c95afb 100644 --- a/lib/philomena_web/views/tag_change_view.ex +++ b/lib/philomena_web/views/tag_change_view.ex @@ -1,5 +1,4 @@ defmodule PhilomenaWeb.TagChangeView do - alias Philomena.Slug alias Philomena.Tags.Tag use PhilomenaWeb, :view @@ -9,8 +8,6 @@ defmodule PhilomenaWeb.TagChangeView do |> scope(conn, "tcq", :tcq) |> scope(conn, "sf", :sf) |> scope(conn, "sd", :sd) - |> scope(conn, "resource_type", :resource_type) - |> scope(conn, "resource_id", :resource_id) end defp scope(list, conn, key, key_atom) do @@ -37,8 +34,8 @@ defmodule PhilomenaWeb.TagChangeView do def reverts_tag_changes?(conn), do: can?(conn, :revert, Philomena.TagChanges.TagChange) - def non_retained_tags(%{image: image, tags: tags}) do - tags + def non_retained_tags(%{image: image, tag_change_tags: tag_change_tags}) do + tag_change_tags |> Enum.filter(fn tct -> tct.added != Enum.any?(image.tags, &(&1.id == tct.tag.id)) end) @@ -57,18 +54,11 @@ defmodule PhilomenaWeb.TagChangeView do end def split_tags(tag_change) do - {added_tags, removed_tags} = Enum.split_with(tag_change.tags, & &1.added) + {added_tags, removed_tags} = Enum.split_with(tag_change.tag_change_tags, & &1.added) { added_tags |> Enum.map(& &1.tag) |> Tag.display_order(), removed_tags |> Enum.map(& &1.tag) |> Tag.display_order() } end - - def link_to_resource("image", id), do: link("image ##{id}", to: ~p"/images/#{id}") - def link_to_resource("ip", ip), do: link(ip, to: ~p"/ip_profiles/#{ip}") - def link_to_resource("fingerprint", fp), do: link(fp, to: ~p"/fingerprint_profiles/#{fp}") - def link_to_resource("user", name), do: link(name, to: ~p"/profiles/#{Slug.slug(name)}") - def link_to_resource("tag", name), do: link("tag '#{name}'", to: ~p"/tags/#{Slug.slug(name)}") - def link_to_resource(_, _), do: "" end diff --git a/lib/philomena_web/views/tag_view.ex b/lib/philomena_web/views/tag_view.ex index ef860d711..ff2f1a919 100644 --- a/lib/philomena_web/views/tag_view.ex +++ b/lib/philomena_web/views/tag_view.ex @@ -1,13 +1,9 @@ defmodule PhilomenaWeb.TagView do use PhilomenaWeb, :view - # this is bad practice, don't copy this. - alias Philomena.Config - alias PhilomenaQuery.Search + alias Philomena.Tags alias Philomena.Tags.Tag - alias Philomena.Repo alias PhilomenaWeb.ImageScope - import Ecto.Query def scope(conn), do: ImageScope.scope(conn) @@ -32,20 +28,8 @@ defmodule PhilomenaWeb.TagView do end def quick_tags(conn) do - case Application.get_env(:philomena, :quick_tags) do - nil -> - quick_tags = - Config.get(:quick_tag_table) - |> lookup_quick_tags() - |> render_quick_tags(conn) - - Application.put_env(:philomena, :quick_tags, quick_tags) - - quick_tags - - quick_tags -> - quick_tags - end + Tags.quick_tag_table() + |> render_quick_tags(conn) end def tab_class(0), do: "selected" @@ -85,27 +69,9 @@ defmodule PhilomenaWeb.TagView do defp title([]), do: nil defp title(descriptions), do: Enum.join(descriptions, "\n") - defp lookup_quick_tags(%{"tabs" => tabs, "tab_modes" => tab_modes} = data) do - tags = - tabs - |> Enum.flat_map(&names_in_tab(tab_modes[&1], data[&1])) - |> tags_indexed_by_name() - - shipping = - tabs - |> Enum.filter(&(tab_modes[&1] == "shipping")) - |> Map.new(fn tab -> - sd = data[tab] - - {tab, implied_by_multitag(sd["implying"], sd["not_implying"])} - end) - - {tags, shipping, data} - end - # This is a rendered template, so raw/1 has no effect on safety # sobelow_skip ["XSS.Raw"] - defp render_quick_tags({tags, shipping, data}, conn) do + defp render_quick_tags(%{tags: tags, shipping: shipping, data: data}, conn) do render(PhilomenaWeb.TagView, "_quick_tag_table.html", tags: tags, shipping: shipping, @@ -116,48 +82,6 @@ defmodule PhilomenaWeb.TagView do |> Phoenix.HTML.raw() end - defp names_in_tab("default", data) do - Map.values(data) - |> List.flatten() - end - - defp names_in_tab("season", data) do - Enum.map(data, fn [_number, name] -> name end) - end - - defp names_in_tab("shorthand", data) do - data - |> Enum.map(fn [_title, tags] -> tags end) - |> Enum.flat_map(&Enum.map(&1, fn [_shorthand, tag] -> tag end)) - end - - defp names_in_tab(_mode, _data), do: [] - - defp tags_indexed_by_name(names) do - Tag - |> where([t], t.name in ^names) - |> preload(:implied_tags) - |> Repo.all() - |> Map.new(&{&1.name, &1}) - end - - defp implied_by_multitag(tag_names, ignore_tag_names) do - Tag - |> Search.search_definition( - %{ - query: %{ - bool: %{ - must: Enum.map(tag_names, &%{term: %{implied_tags: &1}}), - must_not: Enum.map(ignore_tag_names, &%{term: %{implied_tags: &1}}) - } - }, - sort: %{images: :desc} - }, - %{page_size: 40} - ) - |> Search.search_records(preload(Tag, :implied_tags)) - end - defp manages_links?(conn), do: can?(conn, :index, Philomena.ArtistLinks.ArtistLink) diff --git a/lib/philomena_web/views/user_attribution_view.ex b/lib/philomena_web/views/user_attribution_view.ex index 3a377be22..1b45628d8 100644 --- a/lib/philomena_web/views/user_attribution_view.ex +++ b/lib/philomena_web/views/user_attribution_view.ex @@ -1,23 +1,14 @@ defmodule PhilomenaWeb.UserAttributionView do use PhilomenaWeb, :view - alias Philomena.Attribution + alias Philomena.Attribution.AnonymousName alias PhilomenaWeb.AvatarGeneratorView - def anonymous?(object) do - # This function may accept objects that don't have `Attribution` implemented. - not is_nil(Attribution.impl_for(object)) and Attribution.anonymous?(object) - end + defdelegate anonymous?(object), to: AnonymousName - def anonymous_user?(object), do: is_nil(object.user) or anonymous?(object) + defdelegate anonymous_user?(object), to: AnonymousName - def name(object) do - if anonymous_user?(object) do - anonymous_name(object) - else - object.user.name - end - end + defdelegate name(object), to: AnonymousName def avatar_url(object) do if anonymous_user?(object) do @@ -27,24 +18,8 @@ defmodule PhilomenaWeb.UserAttributionView do end end - def anonymous_name(object, reveal_anon? \\ false) do - salt = anonymous_name_salt() - id = Attribution.object_identifier(object) - user_id = Attribution.best_user_identifier(object) - - {:ok, <>} = :pbkdf2.pbkdf2(:sha256, id <> user_id, salt, 100, 2) - - hash = - key - |> Integer.to_string(16) - |> String.pad_leading(4, "0") - - if not is_nil(object.user) and reveal_anon? do - "#{object.user.name} (##{hash}, hidden)" - else - "Background Pony ##{hash}" - end - end + def anonymous_name(object, reveal_anon? \\ false), + do: AnonymousName.generate(object, reveal_anon?) def user_avatar(object, opts \\ []) do class = Keyword.get(opts, :class) || "avatar--100px" @@ -139,9 +114,4 @@ defmodule PhilomenaWeb.UserAttributionView do defp avatar_url_root do Application.get_env(:philomena, :avatar_url_root) end - - defp anonymous_name_salt do - Application.get_env(:philomena, :anonymous_name_salt) - |> to_string() - end end diff --git a/mix.exs b/mix.exs index 5b5378287..7dce56efc 100644 --- a/mix.exs +++ b/mix.exs @@ -68,7 +68,7 @@ defmodule Philomena.MixProject do {:redix, "~> 1.5"}, {:remote_ip, "~> 1.2"}, {:briefly, "~> 0.5"}, - {:req, "~> 0.7.4"}, + {:req, "~> 0.7"}, {:exq, "~> 0.21"}, {:ex_aws, "~> 2.6"}, {:ex_aws_s3, "~> 2.5"}, @@ -88,6 +88,7 @@ defmodule Philomena.MixProject do {:credo_envvar, "~> 0.1", only: [:dev, :test], runtime: false}, {:credo_naming, "~> 2.1", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.38", only: [:dev], runtime: false}, + {:patch, "~> 0.16", only: [:dev, :test]}, # Security checks {:sobelow, "~> 0.14", only: [:dev, :test], runtime: true}, @@ -97,7 +98,7 @@ defmodule Philomena.MixProject do {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, # Authorization - {:canary, "~> 1.2"} + {:canada, "~> 2.0"} ] end diff --git a/mix.lock b/mix.lock index 966f78afd..ce97a4f39 100644 --- a/mix.lock +++ b/mix.lock @@ -4,7 +4,6 @@ "briefly": {:hex, :briefly, "0.5.1", "ee10d48da7f79ed2aebdc3e536d5f9a0c3e36ff76c0ad0d4254653a152b13a8a", [:mix], [], "hexpm", "bd684aa92ad8b7b4e0d92c31200993c4bc1469fc68cd6d5f15144041bd15cb57"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "canada": {:hex, :canada, "2.0.0", "ce5e058f576a0625959fc5427fcde15311fb28a5ebc13775eafd13468ad16553", [:mix], [], "hexpm", "49a648c48d8b0864380f38f02a7f316bd30fd45602205c48197432b5225d8596"}, - "canary": {:hex, :canary, "1.2.0", "6decbf704685ef655ff5d27d3556aaccad6f4f9823289162d514d5b82459be28", [:mix], [{:canada, "~> 2.0.0", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "919f68b2a09488063475f04ee82bb26c83f1abb7201de7dacbda36c866d86745"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, @@ -44,6 +43,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "patch": {:hex, :patch, "0.16.0", "dfa2e1c381959d821ab1ddf4acd55f42d1c798003f0fef270154c0c5bd139b08", [:mix], [], "hexpm", "50e06ef77a9b4987edfc717a9bf047429f3d69af397a42d9ff311f27ccb23408"}, "pbkdf2": {:git, "https://github.com/basho/erlang-pbkdf2.git", "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca", [ref: "7e9bd5fcd3cc3062159e4c9214bb628aa6feb5ca"]}, "phoenix": {:hex, :phoenix, "1.8.13", "e33192826d9bed4022bdb3f5a7b36c04362049d9390fd1b581d5ad6261779268", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ad14e24d10e5a52d5f80429053bbe3a5d124311a2868fceb0a01a2e859c44539"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, diff --git a/priv/repo/migrations/20260716190444_normalize_versions.exs b/priv/repo/migrations/20260716190444_normalize_versions.exs index eeecd4a0e..9a87bd262 100644 --- a/priv/repo/migrations/20260716190444_normalize_versions.exs +++ b/priv/repo/migrations/20260716190444_normalize_versions.exs @@ -3,7 +3,8 @@ defmodule Philomena.Repo.Migrations.NormalizeVersions do # Deliberately retain the old paper_trail versions table (renamed), in order # to retain the ability to rollback easily and verify the conversion. - # TODO: drop versions_legacy in a later cleanup migration + # Keep versions_legacy while Release.backfill_versions/0 supports deployed + # installations. Drop it only in a dedicated compatibility cleanup. def up do rename table(:versions), to: table(:versions_legacy) diff --git a/priv/repo/migrations/20260806180557_unique_commission_per_user.exs b/priv/repo/migrations/20260806180557_unique_commission_per_user.exs new file mode 100644 index 000000000..c4c1962ec --- /dev/null +++ b/priv/repo/migrations/20260806180557_unique_commission_per_user.exs @@ -0,0 +1,8 @@ +defmodule Philomena.Repo.Migrations.UniqueCommissionPerUser do + use Ecto.Migration + + def change do + drop(index(:commissions, [:user_id], name: :index_commissions_on_user_id)) + create(unique_index(:commissions, [:user_id], name: :index_commissions_on_user_id)) + end +end diff --git a/priv/repo/migrations/20260810212302_cascade_image_intensities_on_image_delete.exs b/priv/repo/migrations/20260810212302_cascade_image_intensities_on_image_delete.exs new file mode 100644 index 000000000..94fbcbf50 --- /dev/null +++ b/priv/repo/migrations/20260810212302_cascade_image_intensities_on_image_delete.exs @@ -0,0 +1,23 @@ +defmodule Philomena.Repo.Migrations.CascadeImageIntensitiesOnImageDelete do + use Ecto.Migration + + def change do + execute( + """ + ALTER TABLE image_intensities + DROP CONSTRAINT fk_rails_b861f027a7, + ADD CONSTRAINT fk_rails_b861f027a7 + FOREIGN KEY (image_id) + REFERENCES images(id) + ON DELETE CASCADE + """, + """ + ALTER TABLE image_intensities + DROP CONSTRAINT fk_rails_b861f027a7, + ADD CONSTRAINT fk_rails_b861f027a7 + FOREIGN KEY (image_id) + REFERENCES images(id) + """ + ) + end +end diff --git a/priv/repo/migrations/20260831235832_add_missing_indices.exs b/priv/repo/migrations/20260831235832_add_missing_indices.exs new file mode 100644 index 000000000..e46d8e20e --- /dev/null +++ b/priv/repo/migrations/20260831235832_add_missing_indices.exs @@ -0,0 +1,15 @@ +defmodule Philomena.Repo.Migrations.AddMissingIndices do + use Ecto.Migration + + def change do + create index(:user_fingerprints, [:user_id, desc: :updated_at, desc: :id]) + create index(:user_ips, [:user_id, desc: :updated_at, desc: :id]) + create index(:topics, [:forum_id, desc: :last_replied_to_at, desc: :id]) + create index(:topics, [:forum_id, desc: :sticky, desc: :last_replied_to_at, desc: :id]) + create index(:source_changes, [:fingerprint]) + + drop index(:user_ips, [:user_id, desc: :updated_at], + name: :index_user_ips_on_user_id_and_updated_at + ) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 43e08359e..7d3bd6b9c 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -52,12 +52,15 @@ for filter_def <- resources["system_filters"] do hidden_tag_list = Enum.join(filter_def["hidden"], ",") %Filter{system: true} - |> Filter.changeset(%{ - name: filter_def["name"], - description: filter_def["description"], - spoilered_tag_list: spoilered_tag_list, - hidden_tag_list: hidden_tag_list - }) + |> Filter.changeset( + nil, + %{ + name: filter_def["name"], + description: filter_def["description"], + spoilered_tag_list: spoilered_tag_list, + hidden_tag_list: hidden_tag_list + } + ) |> Repo.insert(on_conflict: :nothing) |> case do {:ok, filter} -> @@ -85,7 +88,12 @@ user_def = %{ "role" => "admin" } -{:ok, user} = Users.register_user(user_def) +initial_actor = %Philomena.Attribution.Actor{ + ip: {127, 0, 0, 1}, + fingerprint: "d123456789abcde" +} + +{:ok, user} = Users.create_registration(initial_actor, user_def) user |> Repo.preload([:roles]) diff --git a/priv/repo/seeds_development.exs b/priv/repo/seeds_development.exs index 1492e341d..1a0887623 100644 --- a/priv/repo/seeds_development.exs +++ b/priv/repo/seeds_development.exs @@ -12,10 +12,11 @@ alias Philomena.{Repo, Forums.Forum, Users, Users.User} alias Philomena.Comments +alias Philomena.Comments.Comment alias Philomena.Images alias Philomena.Topics alias Philomena.Posts -alias Philomena.Tags +alias Philomena.RateLimiter {:ok, ip} = EctoNetwork.INET.cast({203, 0, 113, 0}) {:ok, _} = Application.ensure_all_started(:plug) @@ -25,9 +26,15 @@ resources = |> File.read!() |> JSON.decode!() -IO.puts "---- Generating users" +initial_actor = %Philomena.Attribution.Actor{ + ip: {127, 0, 0, 1}, + fingerprint: "d123456789abcde" +} + +IO.puts("---- Generating users") + for user_def <- resources["users"] do - {:ok, user} = Users.register_user(user_def) + {:ok, user} = Users.create_registration(initial_actor, user_def) user |> Repo.preload([:roles]) @@ -37,81 +44,93 @@ for user_def <- resources["users"] do end pleb = Repo.get_by!(User, name: "Pleb") -request_attributes = [ +admin = Repo.get_by!(User, name: "Administrator") + +pleb_actor = %Philomena.Attribution.Actor{ + user: pleb, + ip: ip, fingerprint: "c1836832948", + ban: nil +} + +admin_actor = %Philomena.Attribution.Actor{ + user: admin, ip: ip, - user_id: pleb.id, - user: pleb -] + fingerprint: "c1836832948", + ban: nil +} + +IO.puts("---- Generating images") -IO.puts "---- Generating images" for image_def <- resources["remote_images"] do file = Briefly.create!(extname: ".png") now = DateTime.utc_now() |> DateTime.to_unix(:microsecond) - IO.puts "Fetching #{image_def["url"]} ..." - {:ok, %{body: body}} = PhilomenaProxy.Http.get(image_def["url"]) + IO.puts("Fetching #{image_def["url"]} ...") + {:ok, %{body: body, status: 200}} = PhilomenaProxy.Http.get(image_def["url"]) File.write!(file, body) - upload = %Plug.Upload{ + upload = %PhilomenaMedia.Upload{ path: file, - content_type: "application/octet-stream", filename: "fixtures-#{now}" } - IO.puts "Inserting ..." + IO.puts("Inserting ...") Images.create_image( - request_attributes, - Map.merge(image_def, %{"image" => upload}) + pleb_actor, + image_def, + upload ) |> case do {:ok, %{image: image}} -> - Images.approve_image(image) - Images.reindex_image(image) - Tags.reindex_tags(image.added_tags) + Images.create_image_approve(admin_actor, image.id) - IO.puts "Created image ##{image.id}" + IO.puts("Created image ##{image.id}") {:error, :image, changeset, _so_far} -> - IO.inspect changeset.errors + IO.inspect(changeset.errors) end + + RateLimiter.reset_limits_globally!() end -IO.puts "---- Generating comments for image #1" +IO.puts("---- Generating comments for image #1") + for comment_body <- resources["comments"] do - image = Images.get_image!(1) + image_id = 1 Comments.create_comment( - image, - request_attributes, + pleb_actor, + image_id, %{"body" => comment_body} ) |> case do - {:ok, %{comment: comment}} -> - Comments.approve_comment(comment, pleb) - Comments.reindex_comment(comment) - Images.reindex_image(image) + {:ok, %Comment{} = comment} -> + Comments.create_comment_approve(admin_actor, image_id, comment.id) {:error, :comment, changeset, _so_far} -> - IO.inspect changeset.errors + IO.inspect(changeset.errors) end + + RateLimiter.reset_limits_globally!() end -IO.puts "---- Generating forum posts" +IO.puts("---- Generating forum posts") + for %{"forum" => forum_name, "topics" => topics} <- resources["forum_posts"] do forum = Repo.get_by!(Forum, short_name: forum_name) for %{"title" => topic_name, "posts" => [first_post | posts]} <- topics do Topics.create_topic( - forum, - request_attributes, + pleb_actor, + forum.short_name, %{ "title" => topic_name, "posts" => %{ "0" => %{ - "body" => first_post, + "body" => first_post } } } @@ -120,24 +139,33 @@ for %{"forum" => forum_name, "topics" => topics} <- resources["forum_posts"] do {:ok, %{topic: topic}} -> for post <- posts do Posts.create_post( - topic, - request_attributes, + pleb_actor, + forum.short_name, + topic.slug, %{"body" => post} ) |> case do - {:ok, %{post: post}} -> - Posts.approve_post(post, pleb) - Posts.reindex_post(post) - - {:error, :post, changeset, _so_far} -> - IO.inspect changeset.errors + {:ok, post} -> + Posts.create_post_approve( + admin_actor, + forum.short_name, + topic.slug, + post.id + ) + + {:error, forum, topic} -> + IO.inspect({forum.short_name, topic.slug}) end + + RateLimiter.reset_limits_globally!() end {:error, :topic, changeset, _so_far} -> - IO.inspect changeset.errors + IO.inspect(changeset.errors) end + + RateLimiter.reset_limits_globally!() end end -IO.puts "---- Done." +IO.puts("---- Done.") diff --git a/priv/repo/seeds_development.json b/priv/repo/seeds_development.json index 4788b0f5d..9133bd082 100644 --- a/priv/repo/seeds_development.json +++ b/priv/repo/seeds_development.json @@ -22,35 +22,43 @@ "remote_images": [ { "url": "https://derpicdn.net/img/2015/9/26/988000/thumb.gif", - "sources": ["https://derpibooru.org/988000"], + "sources": { "0": { "source": "https://derpibooru.org/988000" } }, "description": "Fairly large GIF (~23MB), use to test WebM stuff.", "tag_input": "alicorn, angry, animated, art, artist:assasinmonkey, artist:equum_amici, badass, barrier, crying, dark, epic, female, fight, force field, glare, glow, good vs evil, lord tirek, low angle, magic, mare, messy mane, metal as fuck, perspective, plot, pony, raised hoof, safe, size difference, spread wings, stomping, twilight's kingdom, twilight sparkle, twilight sparkle (alicorn), twilight vs tirek, underhoof" }, { "url": "https://derpicdn.net/img/2012/1/2/25/large.png", - "sources": ["https://derpibooru.org/25"], + "sources": { "0": { "source": "https://derpibooru.org/25" } }, "tag_input": "artist:moe, canterlot, castle, cliff, cloud, detailed background, fog, forest, grass, mountain, mountain range, nature, no pony, outdoors, path, river, safe, scenery, scenery porn, signature, source needed, sunset, technical advanced, town, tree, useless source url, water, waterfall, widescreen, wood" }, { "url": "https://derpicdn.net/img/2018/6/28/1767886/full.webm", - "sources": ["http://hydrusbeta.deviantart.com/art/Gleaming-in-the-Sun-Our-Colors-Shine-in-Every-Hue-611497309"], + "sources": { + "0": { + "source": "http://hydrusbeta.deviantart.com/art/Gleaming-in-the-Sun-Our-Colors-Shine-in-Every-Hue-611497309" + } + }, "tag_input": "3d, animated, architecture, artist:hydrusbeta, castle, cloud, crystal empire, crystal palace, flag, flag waving, no pony, no sound, safe, scenery, webm" }, { "url": "https://derpicdn.net/img/view/2015/2/19/832750.jpg", - "sources": [ - "http://sovietrussianbrony.tumblr.com/post/111504505079/this-image-actually-took-me-ages-to-edit-the" - ], + "sources": { + "0": { + "source": "http://sovietrussianbrony.tumblr.com/post/111504505079/this-image-actually-took-me-ages-to-edit-the" + } + }, "tag_input": "artist:rhads, artist:the sexy assistant, canterlot, cloud, cloudsdale, cloudy, edit, lens flare, no pony, ponyville, rainbow, river, safe, scenery, sweet apple acres" }, { "url": "https://derpicdn.net/img/view/2016/3/17/1110529.jpg", - "sources": ["https://www.deviantart.com/devinian/art/Commission-Crystals-of-thy-heart-511134926"], + "sources": { + "0": { "source": "https://www.deviantart.com/devinian/art/Commission-Crystals-of-thy-heart-511134926" } + }, "tag_input": "artist:devinian, aurora crystialis, bridge, cloud, crepuscular rays, crystal empire, crystal palace, edit, flower, forest, grass, log, mountain, no pony, river, road, safe, scenery, scenery porn, source needed, stars, sunset, swing, tree, wallpaper" }, { "url": "https://derpicdn.net/img/view/2019/6/16/2067468.svg", - "sources": ["https://derpibooru.org/2067468"], + "sources": { "0": { "source": "https://derpibooru.org/2067468" } }, "tag_input": "artist:cheezedoodle96, babs seed, bloom and gloom, cutie mark, cutie mark only, no pony, safe, scissors, simple background, svg, .svg available, transparent background, vector" } ], diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index cac6603fa..8dd8befad 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict zz99oBd8amrHGQvE3YMjAT6I0qnHMNmvLb5mJBfFO1JqTafWgFbLRcafUqyghEC +\restrict O6OYrjIYDwvGhgBIGh2SR1j95V8aVnMGs9WcuKijctXbT5ypaF3nbIkiah3iFpk -- Dumped from database version 18.4 -- Dumped by pg_dump version 18.4 @@ -3625,7 +3625,7 @@ CREATE INDEX index_commissions_on_sheet_image_id ON public.commissions USING btr -- Name: index_commissions_on_user_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX index_commissions_on_user_id ON public.commissions USING btree (user_id); +CREATE UNIQUE INDEX index_commissions_on_user_id ON public.commissions USING btree (user_id); -- @@ -4447,13 +4447,6 @@ CREATE UNIQUE INDEX index_user_ips_on_ip_and_user_id ON public.user_ips USING bt CREATE INDEX index_user_ips_on_updated_at ON public.user_ips USING btree (updated_at); --- --- Name: index_user_ips_on_user_id_and_updated_at; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_user_ips_on_user_id_and_updated_at ON public.user_ips USING btree (user_id, updated_at DESC); - - -- -- Name: index_user_name_changes_on_user_id; Type: INDEX; Schema: public; Owner: - -- @@ -4720,6 +4713,13 @@ CREATE UNIQUE INDEX rules_name_index ON public.rules USING btree (name); CREATE UNIQUE INDEX rules_position_index ON public.rules USING btree ("position"); +-- +-- Name: source_changes_fingerprint_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX source_changes_fingerprint_index ON public.source_changes USING btree (fingerprint); + + -- -- Name: tag_change_tags_tag_change_id_tag_id_index; Type: INDEX; Schema: public; Owner: - -- @@ -4762,6 +4762,34 @@ CREATE INDEX tag_changes_ip_inet_ops_index ON public.tag_changes USING gist (ip CREATE INDEX tag_changes_user_id_index ON public.tag_changes USING btree (user_id); +-- +-- Name: topics_forum_id_last_replied_to_at_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX topics_forum_id_last_replied_to_at_id_index ON public.topics USING btree (forum_id, last_replied_to_at DESC, id DESC); + + +-- +-- Name: topics_forum_id_sticky_last_replied_to_at_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX topics_forum_id_sticky_last_replied_to_at_id_index ON public.topics USING btree (forum_id, sticky DESC, last_replied_to_at DESC, id DESC); + + +-- +-- Name: user_fingerprints_user_id_updated_at_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_fingerprints_user_id_updated_at_id_index ON public.user_fingerprints USING btree (user_id, updated_at DESC, id DESC); + + +-- +-- Name: user_ips_user_id_updated_at_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_ips_user_id_updated_at_id_index ON public.user_ips USING btree (user_id, updated_at DESC, id DESC); + + -- -- Name: user_tokens_context_token_index; Type: INDEX; Schema: public; Owner: - -- @@ -5413,7 +5441,7 @@ ALTER TABLE ONLY public.tags_implied_tags -- ALTER TABLE ONLY public.image_intensities - ADD CONSTRAINT fk_rails_b861f027a7 FOREIGN KEY (image_id) REFERENCES public.images(id); + ADD CONSTRAINT fk_rails_b861f027a7 FOREIGN KEY (image_id) REFERENCES public.images(id) ON DELETE CASCADE; -- @@ -5924,7 +5952,7 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- -\unrestrict zz99oBd8amrHGQvE3YMjAT6I0qnHMNmvLb5mJBfFO1JqTafWgFbLRcafUqyghEC +\unrestrict O6OYrjIYDwvGhgBIGh2SR1j95V8aVnMGs9WcuKijctXbT5ypaF3nbIkiah3iFpk INSERT INTO public."schema_migrations" (version) VALUES (20200503002523); INSERT INTO public."schema_migrations" (version) VALUES (20200607000511); @@ -5967,3 +5995,6 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260719123608); INSERT INTO public."schema_migrations" (version) VALUES (20260719123609); INSERT INTO public."schema_migrations" (version) VALUES (20260719123610); INSERT INTO public."schema_migrations" (version) VALUES (20260719123611); +INSERT INTO public."schema_migrations" (version) VALUES (20260806180557); +INSERT INTO public."schema_migrations" (version) VALUES (20260810212302); +INSERT INTO public."schema_migrations" (version) VALUES (20260831235832); diff --git a/scripts/philomena.sh b/scripts/philomena.sh index 40cf0b6e7..ea2ad2a8f 100755 --- a/scripts/philomena.sh +++ b/scripts/philomena.sh @@ -7,6 +7,32 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# All `docker compose` commands go to the host daemon through the mounted +# socket, so bind mount sources in docker-compose.yml must resolve to host +# paths. When this script runs inside the app container, HOST_WORKSPACE must +# therefore point at the repo's path on the host; shells that enter the +# container without the devcontainer environment (e.g. `docker exec`) lack it, +# so derive it from this container's own mounts rather than trusting the +# environment. Without it, `${HOST_WORKSPACE:-.}` in docker-compose.yml would +# resolve to a path that does not exist on the host, and the daemon would +# recreate `opensearch`/`web` with broken auto-created mount sources. +if [[ -f /.dockerenv ]]; then + if [[ -z "${HOST_WORKSPACE:-}" ]]; then + HOST_WORKSPACE=$( + docker inspect "$(hostname)" \ + --format '{{range .Mounts}}{{if eq .Destination "/srv/philomena"}}{{.Source}}{{end}}{{end}}' + ) || die "Running inside a container, but 'docker inspect' failed - cannot derive HOST_WORKSPACE" + + if [[ -z "$HOST_WORKSPACE" ]]; then + die "Running inside a container without a /srv/philomena bind mount - cannot derive HOST_WORKSPACE" + fi + + export HOST_WORKSPACE + fi + + export DEVCONTAINER=1 +fi + # Devcontainer runs in the `app` service. We must make sure this service stays # intact during development, so all docker compose operations that might recreate # or remove the containers/volumes should exclude it and its volumes. @@ -38,7 +64,19 @@ function up { if [[ "${DEVCONTAINER:-0}" == "1" ]]; then step docker compose build "${services[@]}" step docker compose up --wait "${services[@]}" - step run-development + + # A container created without DEVCONTAINER=1 in its environment (e.g. by + # a host-side `docker compose up`) already runs the dev server as PID 1 + # (see docker/app/run); starting a second one would fail to bind its + # ports. The stack is fully served by PID 1 in that case, so just skip. + if [[ -f /.dockerenv ]] && [[ "$(tr '\0' ' ' < /proc/1/cmdline)" == *run-development* ]]; then + warn "PID 1 of this container is already running the dev server, skipping run-development." + warn "For the intended devcontainer flow (server logs in this terminal), rebuild the" + warn "container so it gets the devcontainer environment: VS Code -> 'Dev Containers:" + warn "Rebuild Container'." + else + step run-development + fi else step docker compose up --build --no-log-prefix fi diff --git a/test/CONVENTIONS.md b/test/CONVENTIONS.md index 1c0af2fb3..563a9bae4 100644 --- a/test/CONVENTIONS.md +++ b/test/CONVENTIONS.md @@ -59,8 +59,8 @@ One test per auth level that can reach the action: - Attribution-taking contexts (topics, posts, comments, reports) use `Philomena.AttributionFixtures.attribution/1`; pass a user or `nil` for anonymous. Attrs for these fixtures are string-keyed, controller-style. -- Context functions that enqueue Exq jobs are safe: test config consumes no - queues, so nothing reaches OpenSearch. +- Context functions that enqueue Exq jobs are safe: test config uses Exq's + in-memory fake queue, so jobs neither reach Valkey nor OpenSearch. ## Singleton toggle controllers (phase 3) @@ -156,11 +156,12 @@ See `PhilomenaQuery.SearchHelpers` (`test/support/search_helpers.ex`) and names. No golden HTML files. - JSON bodies: assert the full decoded structure; pull unordered association lists out and compare them sorted, asserting the rest with `Map.delete/2`. -- Give every by-id endpoint a non-integer-id test. An id that no row could - have is treated as a missing resource: JSON endpoints answer 404, HTML - endpoints redirect to `/` with the not-found flash. Note that on - `load_and_authorize_resource` routes an unknown _but valid_ id instead takes - the unauthorized path, so the two cases carry different flashes. +- Give every by-id endpoint a non-integer-id test. An id that no `integer` + column could hold is treated as a missing resource: JSON endpoints answer + 404, HTML endpoints redirect to `/` with the not-found flash. Note that a + well-formed _but unknown_ id is authorized as a `nil` load instead: an actor + whose grant does not cover `nil` takes the unauthorized path, so the two + cases can carry different flashes. ## Route coverage checklist diff --git a/test/KNOWN-ODDITIES.md b/test/KNOWN-ODDITIES.md new file mode 100644 index 000000000..c917ac1cd --- /dev/null +++ b/test/KNOWN-ODDITIES.md @@ -0,0 +1,20 @@ +# Known oddities + +This register tracks characterized behavior that is surprising enough to need +an explicit decision. The nearby `# NOTE:` comments in controller and context +tests remain the executable, fine-grained record; this file lists the issues +that affect context-boundary design or can produce a server error. + +| Area | Characterized behavior | Planned resolution | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Nested/member loading | Several contexts still authorize a `nil` load, so an absent well-formed locator can be unauthorized for one role and not-found for another. | Migrate each loader to `Philomena.Loader`; wave 0 fixes the shared primitive and exemplars, while waves 2–4 migrate the remaining contexts. | +| Forum/topic subscriptions | Raw subscription helpers accept loaded structs and users, and some public-resource paths permit anonymous actors farther into the operation than expected. | Keep raw macro-generated functions internal and wrap every controller path in an actor-scoped context operation during waves 3–4. | +| Nested resources | Some post, poll, award, commission-item, and gallery-image paths load a child globally instead of proving membership in every route parent. | Replace them with parent-constrained query loaders in waves 3–4. | +| Missing parameter maps | A few controller actions have only a destructuring clause; requests without the expected top-level key raise `Phoenix.ActionClauseError`. | Add explicit validation/fallback clauses in the owning context wave. | +| Commission items | An invalid item locator reaches a bang loader on an edit path. | Replace it with a parent-scoped safe query in wave 2. | +| Donation schema | Every donation field is optional, so an empty submission creates a row. | Decide the intended minimum audit data before tightening validation; the current behavior remains covered by donation tests. | +| Asynchronous destructive work | Some delete/wipe controller paths report success after enqueueing a worker and do not observe the eventual job result. | Define job acceptance versus completed-deletion semantics in the owning context wave. | + +When an oddity is resolved, remove its row only after updating the corresponding +`# NOTE:` assertion to the intended contract (or deleting the note when the new +behavior is no longer surprising). diff --git a/test/philomena/activities_test.exs b/test/philomena/activities_test.exs new file mode 100644 index 000000000..f54c0782c --- /dev/null +++ b/test/philomena/activities_test.exs @@ -0,0 +1,277 @@ +defmodule Philomena.ActivitiesTest do + @moduledoc """ + Context-level tests for `Philomena.Activities.load_front_page/4`, which + assembles the homepage strips for a viewer. + + The recent, top-scoring, comment, and watched strips run against the real + OpenSearch indexes; the featured image, streams, and topics load from + Postgres. + """ + + use Philomena.DataCase, async: false + + @moduletag :search + + import Philomena.AttributionFixtures + import Philomena.ChannelsFixtures + import Philomena.CommentsFixtures + import Philomena.ForumsFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.Activities + alias Philomena.Activities.FrontPage + alias Philomena.Channels.Channel + alias Philomena.Comments.Comment + alias Philomena.Filters.Filter + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Search.Scope + alias Philomena.Tags + alias Philomena.Topics + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + setup do + Search.clear_index!(Image) + Search.clear_index!(Comment) + :ok + end + + # The compiled filter body the web layer produces for a viewer with no active + # filter: an empty tag_ids exclusion plus a pair of match_none clauses, so it + # excludes nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp scope do + Scope.new(default_filter(), %{page_number: 1, page_size: 25}) + end + + # The %Filter{} the web layer passes as the comment-strip filter; only its + # hidden_tag_ids are read. + defp filter, do: %Filter{hidden_tag_ids: []} + + defp hours_ago(hours) do + DateTime.utc_now() + |> DateTime.add(-hours * 3600, :second) + |> DateTime.truncate(:second) + end + + # A live channel needs a fetch time to appear at all; the front page filters + # on last_fetched_at. create_channel casts only the type and short name, so + # the fetch time and nsfw flag are set directly on the row. + defp live_channel(fields) do + channel_fixture(%{}) + |> Ecto.Changeset.change(Enum.into(fields, %{last_fetched_at: hours_ago(1)})) + |> Repo.update!() + end + + describe "show_activity/3 for an anonymous scope" do + test "empty search and database sections stay empty" do + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + assert front.images.entries == [] + assert front.top_scoring.entries == [] + assert front.comments.entries == [] + assert front.watched == nil + assert front.featured_image == nil + assert Enum.count(front.streams) == 0 + assert Enum.count(front.topics) == 0 + assert front.interactions == [] + end + + test "returns a FrontPage struct with every key populated" do + image = image_fixture(created_at: hours_ago(1)) + comment = comment_fixture(image, confirmed_user_fixture()) + topic = topic_fixture(forum_fixture()) + + SearchHelpers.reindex_all!(Image) + SearchHelpers.reindex_all!(Comment) + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + assert %FrontPage{} = front + + assert %Scrivener.Page{} = front.images + assert image.id in Enum.map(front.images.entries, & &1.id) + + assert %Scrivener.Page{} = front.top_scoring + + assert %Scrivener.Page{} = front.comments + assert comment.id in Enum.map(front.comments.entries, & &1.id) + + # Anonymous viewers have no watched strip. + assert front.watched == nil + + # No feature and no channels on a fresh site. + assert front.featured_image == nil + assert Enum.count(front.streams) == 0 + + assert Enum.any?(front.topics, &(&1.id == topic.id)) + assert is_list(front.interactions) + end + + test "the recent listing preloads each image's tags" do + image = image_fixture(created_at: hours_ago(1)) + SearchHelpers.reindex_all!(Image) + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + entry = Enum.find(front.images.entries, &(&1.id == image.id)) + assert Ecto.assoc_loaded?(entry.tags) + end + + test "the featured image is set when an image feature exists" do + image = image_fixture(created_at: hours_ago(1)) + {:ok, _feature} = Images.create_image_feature(actor(moderator_user_fixture()), image.id) + + SearchHelpers.reindex_all!(Image) + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + assert front.featured_image.id == image.id + end + end + + describe "show_activity/3 for a signed-in scope" do + test "the watched strip is a page rather than nil" do + user = confirmed_user_fixture() + + assert {:ok, front} = + Activities.show_activity(actor(user), scope(), filter(), false) + + assert %Scrivener.Page{} = front.watched + assert is_list(front.watched.entries) + end + + test "the actor's watched tags are authoritative over the search scope" do + user = confirmed_user_fixture() + tag = tag_fixture() + image = image_fixture(tags: tag.name) + {:ok, watching_user} = Tags.create_tag_watch(actor(user), tag.slug) + SearchHelpers.reindex_all!(Image) + + assert {:ok, front} = + Activities.show_activity(actor(watching_user), scope(), filter(), false) + + assert image.id in Enum.map(front.watched.entries, & &1.id) + end + + test "image strips carry the actor's interactions" do + user = confirmed_user_fixture() + image = image_fixture(created_at: hours_ago(1)) + {:ok, _image} = Images.create_image_fave(actor(user), image.id) + SearchHelpers.reindex_all!(Image) + + assert {:ok, front} = + Activities.show_activity(actor(user), scope(), filter(), false) + + assert Enum.any?(front.interactions, fn interaction -> + interaction.image_id == image.id and interaction.interaction_type == "faved" + end) + end + + test "a personal hide controls the featured image unless hidden results are requested" do + user = confirmed_user_fixture() + image = image_fixture(created_at: hours_ago(1)) + {:ok, _feature} = Images.create_image_feature(actor(moderator_user_fixture()), image.id) + {:ok, _image} = Images.create_image_user_hide(actor(user), image.id) + + assert {:ok, hidden_front} = + Activities.show_activity(actor(user), scope(), filter(), false) + + assert hidden_front.featured_image == nil + + include_hidden_scope = %{scope() | hidden: true} + + assert {:ok, visible_front} = + Activities.show_activity(actor(user), include_hidden_scope, filter(), false) + + assert visible_front.featured_image.id == image.id + end + end + + describe "show_activity/3 topic visibility" do + test "hidden topics are never shown in front-page topics, but staff topics can be shown to staff" do + moderator = moderator_user_fixture() + forum = forum_fixture() + hidden = topic_fixture(forum) + + {:ok, {_forum, hidden}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, hidden.slug, %{ + "deletion_reason" => "Rule #0" + }) + + staff_topic = topic_fixture(forum_fixture(%{access_level: "staff"})) + + assert {:ok, public_front} = + Activities.show_activity(actor(), scope(), filter(), false) + + public_ids = Enum.map(public_front.topics, & &1.id) + refute hidden.id in public_ids + refute staff_topic.id in public_ids + + assert {:ok, moderator_front} = + Activities.show_activity(actor(moderator), scope(), filter(), false) + + moderator_ids = Enum.map(moderator_front.topics, & &1.id) + refute hidden.id in moderator_ids + assert staff_topic.id in moderator_ids + end + end + + describe "show_activity/3 stream strip" do + test "a channel with a fetch time appears in the streams" do + channel = live_channel(%{}) + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + assert Enum.any?(front.streams, &(&1.id == channel.id)) + end + + test "a channel without a fetch time never appears" do + channel = channel_fixture(%{}) + assert channel.last_fetched_at == nil + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + refute Enum.any?(front.streams, &(&1.id == channel.id)) + end + + test "an nsfw channel is hidden when nsfw channels are off" do + channel = live_channel(%{nsfw: true}) + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + refute Enum.any?(front.streams, &(&1.id == channel.id)) + end + + test "an nsfw channel appears when nsfw channels are on" do + channel = live_channel(%{nsfw: true}) + + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), true) + + assert Enum.any?(front.streams, &(&1.id == channel.id)) + end + + test "a safe channel appears regardless of the nsfw switch" do + channel = live_channel(%{nsfw: false}) + + assert %Channel{} = channel + assert {:ok, front} = Activities.show_activity(actor(), scope(), filter(), false) + + assert Enum.any?(front.streams, &(&1.id == channel.id)) + end + end +end diff --git a/test/philomena/adverts_test.exs b/test/philomena/adverts_test.exs new file mode 100644 index 000000000..302fa0a03 --- /dev/null +++ b/test/philomena/adverts_test.exs @@ -0,0 +1,423 @@ +defmodule Philomena.AdvertsTest do + @moduledoc """ + Context-level tests for the actor-first advert loaders and writers on + `Philomena.Adverts`. + + Advert administration is admin/`Advert`-role-map-moderator only; these pin the + module-level `:index` gate (a plain moderator is rejected before any advert + loads), uniform not-found results for absent IDs, the byte-exact moderation + logs each write emits, and that the image-upload pipeline runs on the real + fixture uploads. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.AdvertsFixtures + import Philomena.UsersFixtures + + alias Philomena.Adverts + alias Philomena.Adverts.Advert + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + + @pagination %{page_number: 1, page_size: 25} + @ban %{reason: "Rule #0", valid_until: ~U[3000-01-01 00:00:00Z]} + + defp moderation_logs, do: Repo.all(ModerationLog) + + defp no_moderation_logs! do + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + # Advert params in the shape the admin form posts, with a real 700x85 upload. + defp advert_params(attrs \\ %{}) do + Enum.into(attrs, %{ + "title" => "Created Advert #{System.unique_integer([:positive])}", + "link" => "https://example.com/created-#{System.unique_integer([:positive])}", + "start_date" => "now", + "finish_date" => "1 year from now", + "restrictions" => "none" + }) + end + + describe "list_adverts/2" do + test "an admin and an Advert-role moderator may list, others may not" do + _advert = advert_fixture() + + assert {:ok, %Scrivener.Page{}} = + Adverts.list_adverts(actor(admin_user_fixture()), @pagination) + + assert {:ok, %Scrivener.Page{}} = + Adverts.list_adverts(actor(role_moderator_fixture("Advert")), @pagination) + + assert Adverts.list_adverts(actor(moderator_user_fixture()), @pagination) == + {:error, :unauthorized} + + assert Adverts.list_adverts(actor(confirmed_user_fixture()), @pagination) == + {:error, :unauthorized} + + assert Adverts.list_adverts(actor(), @pagination) == {:error, :unauthorized} + end + + test "the listing is ordered by finish date descending" do + now = DateTime.utc_now(:second) + sooner = advert_fixture(%{finish_date: DateTime.add(now, 1, :hour)}) + later = advert_fixture(%{finish_date: DateTime.add(now, 500, :day)}) + + assert {:ok, page} = Adverts.list_adverts(actor(admin_user_fixture()), @pagination) + + ids = Enum.map(page.entries, & &1.id) + assert Enum.find_index(ids, &(&1 == later.id)) < Enum.find_index(ids, &(&1 == sooner.id)) + end + end + + describe "new_advert/1" do + test "an admin and an Advert-role moderator get a changeset, others do not" do + assert {:ok, %Ecto.Changeset{data: %Advert{}}} = + Adverts.new_advert(actor(admin_user_fixture())) + + assert {:ok, %Ecto.Changeset{data: %Advert{}}} = + Adverts.new_advert(actor(role_moderator_fixture("Advert"))) + + assert Adverts.new_advert(actor(moderator_user_fixture())) == {:error, :unauthorized} + assert Adverts.new_advert(actor()) == {:error, :unauthorized} + end + end + + describe "create_advert/3" do + test "an admin creates an advert through the upload pipeline and writes a byte-exact log" do + admin = admin_user_fixture() + + assert {:ok, %Advert{} = advert} = + Adverts.create_advert( + actor(admin), + advert_params(%{"title" => "Advert To Create"}), + media_png_upload() + ) + + assert advert.title == "Advert To Create" + assert Repo.get_by(Advert, title: "Advert To Create") + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Advert:create" + assert log.body == "Created advert #{advert.id}" + assert log.subject_path == "/admin/adverts" + end + + test "an Advert-role moderator creates an advert" do + assert {:ok, %Advert{}} = + Adverts.create_advert( + actor(role_moderator_fixture("Advert")), + advert_params(), + media_png_upload() + ) + end + + test "a plain moderator is unauthorized and writes no log" do + assert Adverts.create_advert( + actor(moderator_user_fixture()), + advert_params(%{"title" => "nope"}), + media_png_upload() + ) == + {:error, :unauthorized} + + refute Repo.get_by(Advert, title: "nope") + no_moderation_logs!() + end + + test "a blank title is a changeset error and writes no log" do + assert {:error, %Ecto.Changeset{} = changeset} = + Adverts.create_advert( + actor(admin_user_fixture()), + advert_params(%{"title" => ""}), + media_png_upload() + ) + + refute changeset.valid? + no_moderation_logs!() + end + end + + describe "edit_advert/2" do + test "an admin loads the advert and a changeset" do + advert = advert_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Adverts.edit_advert(actor(admin_user_fixture()), "#{advert.id}") + + assert loaded.id == advert.id + end + + test "a plain moderator is rejected by the module gate" do + advert = advert_fixture() + + assert Adverts.edit_advert(actor(moderator_user_fixture()), "#{advert.id}") == + {:error, :unauthorized} + end + + test "an Advert-role moderator is authorized for the edit action" do + advert = advert_fixture() + + assert {:ok, {%Advert{id: id}, %Ecto.Changeset{}}} = + Adverts.edit_advert( + actor(role_moderator_fixture("Advert")), + advert.id + ) + + assert id == advert.id + end + + test "a non-castable id is not-found" do + assert Adverts.edit_advert(actor(admin_user_fixture()), "abc") == + {:error, :not_found} + end + + test "an unknown id is not-found for every actor" do + assert Adverts.edit_advert(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Adverts.edit_advert(actor(role_moderator_fixture("Advert")), "2147483647") == + {:error, :not_found} + end + end + + describe "update_advert/3" do + test "an admin updates an advert and writes a byte-exact log" do + admin = admin_user_fixture() + advert = advert_fixture(%{title: "Before Advert"}) + + assert {:ok, updated} = + Adverts.update_advert(actor(admin), "#{advert.id}", %{"title" => "After Advert"}) + + assert updated.title == "After Advert" + assert Repo.get(Advert, advert.id).title == "After Advert" + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Advert:update" + assert log.body == "Updated advert #{advert.id}" + assert log.subject_path == "/admin/adverts" + end + + test "a plain moderator is rejected by the module gate and writes no log" do + advert = advert_fixture(%{title: "Unchanged Advert"}) + + assert Adverts.update_advert(actor(moderator_user_fixture()), "#{advert.id}", %{ + "title" => "changed" + }) == {:error, :unauthorized} + + assert Repo.get(Advert, advert.id).title == "Unchanged Advert" + no_moderation_logs!() + end + + test "an Advert-role moderator is authorized for the update action" do + advert = advert_fixture() + + assert {:ok, %Advert{title: "Role Updated Advert"}} = + Adverts.update_advert(actor(role_moderator_fixture("Advert")), advert.id, %{ + "title" => "Role Updated Advert" + }) + end + + test "an unknown id is not-found for every actor" do + assert Adverts.update_advert(actor(admin_user_fixture()), "2147483647", %{"title" => "x"}) == + {:error, :not_found} + + assert Adverts.update_advert(actor(role_moderator_fixture("Advert")), "2147483647", %{ + "title" => "x" + }) == {:error, :not_found} + end + + test "a blank title is a changeset error and writes no log" do + advert = advert_fixture(%{title: "Keep Advert"}) + + assert {:error, %Ecto.Changeset{} = changeset} = + Adverts.update_advert(actor(admin_user_fixture()), "#{advert.id}", %{"title" => ""}) + + refute changeset.valid? + assert Repo.get(Advert, advert.id).title == "Keep Advert" + no_moderation_logs!() + end + end + + describe "update_advert_image/3" do + test "an admin updates the image through the upload pipeline and writes a byte-exact log" do + admin = admin_user_fixture() + advert = advert_fixture() + + assert {:ok, %Advert{}} = + Adverts.update_advert_image(actor(admin), "#{advert.id}", media_png_upload()) + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Advert.Image:update" + assert log.body == "Updated image for advert #{advert.id}" + assert log.subject_path == "/admin/adverts" + end + + test "an undersized image is a changeset error and writes no log" do + advert = advert_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Adverts.update_advert_image( + actor(admin_user_fixture()), + "#{advert.id}", + media_undersized_png_upload() + ) + + refute changeset.valid? + no_moderation_logs!() + end + + test "a plain moderator is rejected by the module gate" do + advert = advert_fixture() + + assert Adverts.update_advert_image( + actor(moderator_user_fixture()), + "#{advert.id}", + media_png_upload() + ) == {:error, :unauthorized} + + no_moderation_logs!() + end + + test "an Advert-role moderator is authorized for the image-update action" do + advert = advert_fixture() + + assert {:ok, %Advert{}} = + Adverts.update_advert_image( + actor(role_moderator_fixture("Advert")), + advert.id, + media_png_upload() + ) + end + + test "an unknown id is not-found for every actor" do + assert Adverts.update_advert_image( + actor(admin_user_fixture()), + "2147483647", + media_png_upload() + ) == {:error, :not_found} + + assert Adverts.update_advert_image( + actor(role_moderator_fixture("Advert")), + "2147483647", + media_png_upload() + ) == {:error, :not_found} + end + end + + describe "delete_advert/2" do + test "an admin deletes an advert and writes a byte-exact log" do + admin = admin_user_fixture() + advert = advert_fixture() + + assert {:ok, deleted} = Adverts.delete_advert(actor(admin), "#{advert.id}") + assert deleted.id == advert.id + assert Repo.get(Advert, advert.id) == nil + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Advert:delete" + assert log.body == "Deleted advert #{advert.id}" + assert log.subject_path == "/admin/adverts" + end + + test "a plain moderator is rejected by the module gate and writes no log" do + advert = advert_fixture() + + assert Adverts.delete_advert(actor(moderator_user_fixture()), "#{advert.id}") == + {:error, :unauthorized} + + assert Repo.get(Advert, advert.id).id == advert.id + no_moderation_logs!() + end + + test "an Advert-role moderator is authorized for the delete action" do + advert = advert_fixture() + + assert {:ok, %Advert{id: id}} = + Adverts.delete_advert(actor(role_moderator_fixture("Advert")), advert.id) + + assert id == advert.id + end + + test "a non-castable id is not-found" do + assert Adverts.delete_advert(actor(admin_user_fixture()), "abc") == {:error, :not_found} + end + + test "an unknown id is not-found for every actor" do + assert Adverts.delete_advert(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Adverts.delete_advert(actor(role_moderator_fixture("Advert")), "2147483647") == + {:error, :not_found} + end + end + + describe "public recording" do + test "record_click/1 accepts only a currently live advert" do + now = DateTime.utc_now(:second) + live = advert_fixture() + disabled = advert_fixture(%{live: false}) + expired = advert_fixture(%{finish_date: DateTime.add(now, -1, :second)}) + future = advert_fixture(%{start_date: DateTime.add(now, 1, :hour)}) + + assert {:ok, %Advert{id: id}} = Adverts.record_click(live.id) + assert id == live.id + assert Adverts.record_click(disabled.id) == {:error, :not_found} + assert Adverts.record_click(expired.id) == {:error, :not_found} + assert Adverts.record_click(future.id) == {:error, :not_found} + assert Adverts.record_click("not-an-id") == {:error, :not_found} + assert Adverts.record_click(2_000_000_000) == {:error, :not_found} + end + + test "flushes counters for adverts that are not live" do + live = + advert_fixture() |> Ecto.Changeset.change(impressions: 10, clicks: 4) |> Repo.update!() + + disabled = + advert_fixture(%{live: false}) + |> Ecto.Changeset.change(impressions: 20, clicks: 8) + |> Repo.update!() + + absent_id = 2_000_000_000 + + assert :ok = + Adverts.record_counters(%{ + impressions: %{live.id => 3, disabled.id => 5, absent_id => 7}, + clicks: %{live.id => 2, disabled.id => 4, absent_id => 6} + }) + + assert %{impressions: 13, clicks: 6} = Repo.get!(Advert, live.id) + assert %{impressions: 25, clicks: 12} = Repo.get!(Advert, disabled.id) + refute Repo.get(Advert, absent_id) + end + end + + describe "global write prerequisite" do + test "forms and mutations reject banned and unattributed administrators" do + admin = admin_user_fixture() + advert = advert_fixture(%{title: "Unchanged by policy"}) + + operations = [ + fn actor -> Adverts.new_advert(actor) end, + fn actor -> Adverts.create_advert(actor, %{}, nil) end, + fn actor -> Adverts.edit_advert(actor, advert.id) end, + fn actor -> Adverts.update_advert(actor, advert.id, %{"title" => "Changed"}) end, + fn actor -> Adverts.update_advert_image(actor, advert.id, nil) end, + fn actor -> Adverts.delete_advert(actor, advert.id) end + ] + + for operation <- operations do + assert operation.(actor(admin, ban: @ban)) == {:error, :ban} + assert operation.(actor(admin, fingerprint: nil)) == {:error, :unauthorized} + end + + assert Repo.get!(Advert, advert.id).title == "Unchanged by policy" + no_moderation_logs!() + end + end +end diff --git a/test/philomena/artist_links_test.exs b/test/philomena/artist_links_test.exs new file mode 100644 index 000000000..e5897d203 --- /dev/null +++ b/test/philomena/artist_links_test.exs @@ -0,0 +1,668 @@ +defmodule Philomena.ArtistLinksTest do + @moduledoc """ + Context-level tests for the actor-first artist-link loaders and writers on + `Philomena.ArtistLinks`. + + These pin the two-layer authorization the link routes preserve: the raw + `ArtistLink` action (`:show`/`:edit`/`:update`) on the loaded link, followed + by the mapped `:create_links`/`:edit_links` action on the profile `User`. The + owner/unrelated/staff matrix is exercised against each layer, including the + asymmetry where a profile owner may create and view their own links but not + edit them. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures + import Philomena.ArtistLinksFixtures + import Philomena.BadgesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + alias Philomena.ArtistLinks + alias Philomena.ArtistLinks.ArtistLink + alias Philomena.Badges.Award + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + + defp moderation_logs, do: Repo.all(ModerationLog) + + defp no_moderation_logs! do + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + # A truthy ban value in the shape production passes; only its presence matters + # to the write-access and not-banned checks the loaders run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + defp artist_tag_fixture do + tag_fixture(name: "artist:test-link-artist-#{System.unique_integer([:positive])}") + end + + # A link owner with an all-unreserved slug, so a moderation-log subject_path + # is identical to "/profiles//artist_links/" with no percent-encoding. + defp link_owner_fixture do + confirmed_user_fixture(%{name: "linkowner#{System.unique_integer([:positive])}"}) + end + + # Link params in the shape the artist-link form posts. + defp link_params(attrs \\ %{}) do + Enum.into(attrs, %{ + "tag_name" => artist_tag_fixture().name, + "uri" => "https://example.com/gallery-#{System.unique_integer([:positive])}" + }) + end + + describe "list_artist_links/2" do + test "the profile owner lists their own links" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, {loaded_user, links}} = ArtistLinks.list_artist_links(actor(user), user.slug) + assert loaded_user.id == user.id + assert Enum.map(links, & &1.id) == [link.id] + end + + test "a moderator lists another user's links" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, {_user, links}} = + ArtistLinks.list_artist_links(actor(moderator_user_fixture()), user.slug) + + assert Enum.map(links, & &1.id) == [link.id] + end + + test "an unrelated user may not list another user's links" do + user = confirmed_user_fixture() + + assert ArtistLinks.list_artist_links(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "an anonymous viewer may not list links" do + assert ArtistLinks.list_artist_links(actor(), confirmed_user_fixture().slug) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization for every actor" do + assert ArtistLinks.list_artist_links(actor(confirmed_user_fixture()), "no-such-user") == + {:error, :not_found} + + assert ArtistLinks.list_artist_links(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "new_artist_link/2" do + test "the profile owner gets a changeset" do + user = confirmed_user_fixture() + + assert {:ok, {loaded_user, %Ecto.Changeset{data: %ArtistLink{}}}} = + ArtistLinks.new_artist_link(actor(user), user.slug) + + assert loaded_user.id == user.id + end + + test "a banned actor is rejected before any authorization" do + user = confirmed_user_fixture() + + assert ArtistLinks.new_artist_link(actor(user, ban: @ban), user.slug) == + {:error, :ban} + end + + test "an actor without a fingerprint is rejected before authorization" do + user = confirmed_user_fixture() + + assert ArtistLinks.new_artist_link( + actor(user, fingerprint: nil), + user.slug + ) == {:error, :unauthorized} + end + + test "an unrelated user may not open another user's new form" do + user = confirmed_user_fixture() + + assert ArtistLinks.new_artist_link(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + end + + describe "create_artist_link/3" do + test "the profile owner inserts an unverified link" do + user = confirmed_user_fixture() + + assert {:ok, {loaded_user, %ArtistLink{} = link}} = + ArtistLinks.create_artist_link(actor(user), user.slug, link_params()) + + assert loaded_user.id == user.id + assert Repo.get(ArtistLink, link.id).user_id == user.id + assert link.aasm_state == "unverified" + end + + test "an aliased tag resolves to its canonical tag" do + user = confirmed_user_fixture() + canonical = artist_tag_fixture() + + alias_tag = + artist_tag_fixture() + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + assert {:ok, {_user, link}} = + ArtistLinks.create_artist_link( + actor(user), + user.slug, + Map.merge(link_params(), %{"tag_name" => alias_tag.name}) + ) + + assert link.tag_id == canonical.id + end + + test "an actor with no fingerprint is unauthorized" do + user = confirmed_user_fixture() + + assert ArtistLinks.create_artist_link( + actor(user, fingerprint: nil), + user.slug, + link_params() + ) == {:error, :unauthorized} + end + + test "an unrelated user may not create a link on another profile" do + user = confirmed_user_fixture() + + assert ArtistLinks.create_artist_link( + actor(confirmed_user_fixture()), + user.slug, + link_params() + ) == {:error, :unauthorized} + end + end + + describe "show_artist_link/3" do + test "the profile owner views their own link" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, {loaded_user, loaded_link}} = + ArtistLinks.show_artist_link(actor(user), user.slug, "#{link.id}") + + assert loaded_user.id == user.id + assert loaded_link.id == link.id + end + + test "a moderator views another user's link" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, {_user, loaded_link}} = + ArtistLinks.show_artist_link( + actor(moderator_user_fixture()), + user.slug, + "#{link.id}" + ) + + assert loaded_link.id == link.id + end + + test "a non-castable id is not-found" do + user = confirmed_user_fixture() + + assert ArtistLinks.show_artist_link(actor(user), user.slug, "abc") == + {:error, :not_found} + end + + test "a link cannot be shown through another profile slug" do + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + link = artist_link_fixture(owner, artist_tag_fixture()) + + assert ArtistLinks.show_artist_link( + actor(moderator_user_fixture()), + other.slug, + link.id + ) == {:error, :not_found} + end + end + + describe "edit_artist_link/3" do + test "a moderator loads the edit form" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, {loaded_link, %Ecto.Changeset{}}} = + ArtistLinks.edit_artist_link( + actor(moderator_user_fixture()), + user.slug, + "#{link.id}" + ) + + assert loaded_link.id == link.id + end + + test "the profile owner may not edit their own link" do + # Owners get :create_links and :show on their own links, but neither :edit + # on the link nor :edit_links on the profile, so editing is refused. + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert ArtistLinks.edit_artist_link(actor(user), user.slug, "#{link.id}") == + {:error, :unauthorized} + end + + test "a non-castable id is not-found" do + user = confirmed_user_fixture() + + assert ArtistLinks.edit_artist_link( + actor(moderator_user_fixture()), + user.slug, + "abc" + ) == + {:error, :not_found} + end + + test "a link cannot be edited through another profile slug" do + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + link = artist_link_fixture(owner, artist_tag_fixture()) + + assert ArtistLinks.edit_artist_link( + actor(moderator_user_fixture()), + other.slug, + link.id + ) == {:error, :not_found} + end + end + + describe "update_artist_link/4" do + test "a moderator updates a link" do + user = confirmed_user_fixture() + tag = artist_tag_fixture() + link = artist_link_fixture(user, tag) + + assert {:ok, {loaded_user, updated}} = + ArtistLinks.update_artist_link( + actor(moderator_user_fixture()), + user.slug, + "#{link.id}", + %{"tag_name" => tag.name, "uri" => "https://example.com/updated-gallery"} + ) + + assert loaded_user.id == user.id + assert updated.uri == "https://example.com/updated-gallery" + assert Repo.get(ArtistLink, link.id).uri == "https://example.com/updated-gallery" + end + + test "an aliased tag resolves to its canonical tag" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + canonical = artist_tag_fixture() + + alias_tag = + artist_tag_fixture() + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + assert {:ok, {_user, updated}} = + ArtistLinks.update_artist_link( + actor(moderator_user_fixture()), + user.slug, + "#{link.id}", + %{"tag_name" => alias_tag.name, "uri" => link.uri} + ) + + assert updated.tag_id == canonical.id + end + + test "the profile owner may not update their own link" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert ArtistLinks.update_artist_link( + actor(user), + user.slug, + "#{link.id}", + %{"uri" => "https://example.com/updated-gallery"} + ) == {:error, :unauthorized} + end + + # A missing "tag_name" resolves to no tag, same as a name that matches + # nothing, and the edit clears the link's tag. + test "an update without a tag name clears the tag" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, {_loaded_user, updated}} = + ArtistLinks.update_artist_link( + actor(moderator_user_fixture()), + user.slug, + "#{link.id}", + %{"uri" => "https://example.com/updated-gallery"} + ) + + assert updated.tag_id == nil + end + + test "a mismatched profile slug does not update the link" do + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + link = artist_link_fixture(owner, artist_tag_fixture()) + + assert ArtistLinks.update_artist_link( + actor(moderator_user_fixture()), + other.slug, + link.id, + %{"uri" => "https://example.com/not-applied"} + ) == {:error, :not_found} + + assert Repo.get!(ArtistLink, link.id).uri == link.uri + end + end + + describe "list_admin_artist_links/3 authorization" do + @pagination %{page_number: 1, page_size: 25} + + test "a moderator and an admin may list, an anonymous viewer and a regular user may not" do + assert {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} = + ArtistLinks.list_admin_artist_links( + actor(moderator_user_fixture()), + %{}, + @pagination + ) + + assert {:ok, %Scrivener.Page{}, %Ecto.Changeset{}} = + ArtistLinks.list_admin_artist_links(actor(admin_user_fixture()), %{}, @pagination) + + assert ArtistLinks.list_admin_artist_links(actor(), %{}, @pagination) == + {:error, :unauthorized} + + assert ArtistLinks.list_admin_artist_links( + actor(confirmed_user_fixture()), + %{}, + @pagination + ) == + {:error, :unauthorized} + end + end + + describe "list_admin_artist_links/3 listing modes" do + @pagination %{page_number: 1, page_size: 25} + + test "the default listing shows only links awaiting moderation" do + moderator = moderator_user_fixture() + user = confirmed_user_fixture() + pending = artist_link_fixture(user, artist_tag_fixture()) + verified = verified_artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, page, _changeset} = + ArtistLinks.list_admin_artist_links(actor(moderator), %{}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + assert pending.id in ids + refute verified.id in ids + end + + test "an explicit state list includes every link regardless of state" do + moderator = moderator_user_fixture() + user = confirmed_user_fixture() + pending = artist_link_fixture(user, artist_tag_fixture()) + verified = verified_artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, page, _changeset} = + ArtistLinks.list_admin_artist_links( + actor(moderator), + %{"states" => ArtistLink.states()}, + @pagination + ) + + ids = Enum.map(page.entries, & &1.id) + assert pending.id in ids + assert verified.id in ids + end + + test "an empty state list does not filter links" do + moderator = moderator_user_fixture() + user = confirmed_user_fixture() + pending = artist_link_fixture(user, artist_tag_fixture()) + verified = verified_artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, page, _changeset} = + ArtistLinks.list_admin_artist_links( + actor(moderator), + %{"states" => []}, + @pagination + ) + + ids = Enum.map(page.entries, & &1.id) + assert pending.id in ids + assert verified.id in ids + end + + test "the text filter matches the link uri" do + moderator = moderator_user_fixture() + user = confirmed_user_fixture() + + wanted = + artist_link_fixture(user, artist_tag_fixture(), %{ + "uri" => "https://match.example.com/needle" + }) + + _other = + artist_link_fixture(user, artist_tag_fixture(), %{ + "uri" => "https://other.example.com/haystack" + }) + + assert {:ok, page, _changeset} = + ArtistLinks.list_admin_artist_links( + actor(moderator), + %{"text" => "needle"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [wanted.id] + end + + test "the text filter matches the profile user name" do + moderator = moderator_user_fixture() + + wanted_user = + confirmed_user_fixture(%{name: "lqmatchowner#{System.unique_integer([:positive])}"}) + + other_user = confirmed_user_fixture() + wanted = artist_link_fixture(wanted_user, artist_tag_fixture()) + _other = artist_link_fixture(other_user, artist_tag_fixture()) + + assert {:ok, page, _changeset} = + ArtistLinks.list_admin_artist_links( + actor(moderator), + %{"text" => wanted_user.name}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [wanted.id] + end + end + + describe "create_artist_link_verification/2" do + test "a moderator verifies a link, awards the Artist badge, and writes a byte-exact log" do + moderator = moderator_user_fixture() + user = link_owner_fixture() + badge = badge_fixture(%{title: "Artist"}) + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, verified} = + ArtistLinks.create_artist_link_verification(actor(moderator), "#{link.id}") + + assert verified.aasm_state == "verified" + assert Repo.get(ArtistLink, link.id).aasm_state == "verified" + + # The verification awards the owner the badge titled "Artist". + assert Repo.get_by(Award, badge_id: badge.id, user_id: user.id) + + assert [log] = moderation_logs() + assert log.user_id == moderator.id + assert log.type == "Admin.ArtistLink.Verification:create" + assert log.body == "Verified artist link #{link.uri} created by #{user.name}" + assert log.subject_path == "/profiles/#{user.slug}/artist_links/#{link.id}" + end + + test "an admin verifies a link" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, verified} = + ArtistLinks.create_artist_link_verification( + actor(admin_user_fixture()), + "#{link.id}" + ) + + assert verified.aasm_state == "verified" + end + + test "a regular user is unauthorized and writes no log" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert ArtistLinks.create_artist_link_verification( + actor(confirmed_user_fixture()), + "#{link.id}" + ) == + {:error, :unauthorized} + + assert Repo.get(ArtistLink, link.id).aasm_state == "unverified" + no_moderation_logs!() + end + + test "a non-castable id is not-found" do + assert ArtistLinks.create_artist_link_verification(actor(moderator_user_fixture()), "abc") == + {:error, :not_found} + end + + test "an unknown integer id is not-found for every actor" do + assert ArtistLinks.create_artist_link_verification( + actor(moderator_user_fixture()), + "2147483647" + ) == + {:error, :not_found} + + assert ArtistLinks.create_artist_link_verification( + actor(admin_user_fixture()), + "2147483647" + ) == + {:error, :not_found} + end + end + + describe "create_artist_link_contact/2" do + test "a moderator marks a link as contacted and writes a byte-exact log" do + moderator = moderator_user_fixture() + user = link_owner_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, contacted} = + ArtistLinks.create_artist_link_contact(actor(moderator), "#{link.id}") + + assert contacted.aasm_state == "contacted" + assert Repo.get(ArtistLink, link.id).contacted_by_user_id == moderator.id + + assert [log] = moderation_logs() + assert log.user_id == moderator.id + assert log.type == "Admin.ArtistLink.Contact:create" + assert log.body == "Contacted artist #{user.name} at #{link.uri}" + assert log.subject_path == "/profiles/#{user.slug}/artist_links/#{link.id}" + end + + test "a regular user is unauthorized and writes no log" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert ArtistLinks.create_artist_link_contact(actor(confirmed_user_fixture()), "#{link.id}") == + {:error, :unauthorized} + + assert Repo.get(ArtistLink, link.id).aasm_state == "unverified" + no_moderation_logs!() + end + + test "a non-castable id is not-found" do + assert ArtistLinks.create_artist_link_contact(actor(moderator_user_fixture()), "abc") == + {:error, :not_found} + end + + test "an unknown integer id is not-found for every actor" do + assert ArtistLinks.create_artist_link_contact(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + + assert ArtistLinks.create_artist_link_contact(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "create_artist_link_reject/2" do + test "a moderator rejects a link and writes a byte-exact log" do + moderator = moderator_user_fixture() + user = link_owner_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert {:ok, rejected} = + ArtistLinks.create_artist_link_reject(actor(moderator), "#{link.id}") + + assert rejected.aasm_state == "rejected" + assert Repo.get(ArtistLink, link.id).aasm_state == "rejected" + + assert [log] = moderation_logs() + assert log.user_id == moderator.id + assert log.type == "Admin.ArtistLink.Reject:create" + assert log.body == "Rejected artist link #{link.uri} created by #{user.name}" + assert log.subject_path == "/profiles/#{user.slug}/artist_links/#{link.id}" + end + + test "a regular user is unauthorized and writes no log" do + user = confirmed_user_fixture() + link = artist_link_fixture(user, artist_tag_fixture()) + + assert ArtistLinks.create_artist_link_reject(actor(confirmed_user_fixture()), "#{link.id}") == + {:error, :unauthorized} + + assert Repo.get(ArtistLink, link.id).aasm_state == "unverified" + no_moderation_logs!() + end + + test "a non-castable id is not-found" do + assert ArtistLinks.create_artist_link_reject(actor(moderator_user_fixture()), "abc") == + {:error, :not_found} + end + + test "an unknown integer id is not-found for every actor" do + assert ArtistLinks.create_artist_link_reject(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + + assert ArtistLinks.create_artist_link_reject(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "transition write prerequisite" do + test "verification, rejection, and contact reject banned and unattributed moderators" do + moderator = moderator_user_fixture() + link = artist_link_fixture(confirmed_user_fixture(), artist_tag_fixture()) + + for operation <- [ + &ArtistLinks.create_artist_link_verification(&1, link.id), + &ArtistLinks.create_artist_link_reject(&1, link.id), + &ArtistLinks.create_artist_link_contact(&1, link.id) + ] do + assert operation.(actor(moderator, ban: @ban)) == {:error, :ban} + assert operation.(actor(moderator, fingerprint: nil)) == {:error, :unauthorized} + end + + assert Repo.get!(ArtistLink, link.id).aasm_state == "unverified" + no_moderation_logs!() + end + end +end diff --git a/test/philomena/attribution/anonymous_name_test.exs b/test/philomena/attribution/anonymous_name_test.exs new file mode 100644 index 000000000..6abbd944b --- /dev/null +++ b/test/philomena/attribution/anonymous_name_test.exs @@ -0,0 +1,39 @@ +defmodule Philomena.Attribution.AnonymousNameTest do + use Philomena.DataCase, async: true + + alias Philomena.Attribution.AnonymousName + alias Philomena.Posts.Post + + import Philomena.UsersFixtures + + test "generates a stable parent-scoped anonymous pseudonym" do + post = %Post{ + topic_id: 12, + fingerprint: "stable-fingerprint", + anonymous: true, + user: nil + } + + assert name = AnonymousName.generate(post) + assert name == AnonymousName.generate(post) + assert name =~ ~r/^Background Pony #[0-9A-F]{4}$/ + + refute name == AnonymousName.generate(%{post | topic_id: 13}) + end + + test "reveals an anonymously posting user's name only when requested" do + user = confirmed_user_fixture() + post = %Post{topic_id: 12, user_id: user.id, user: user, anonymous: true} + + assert AnonymousName.name(post) =~ ~r/^Background Pony #[0-9A-F]{4}$/ + assert AnonymousName.generate(post, true) =~ "#{user.name} (#" + assert AnonymousName.generate(post, true) =~ ", hidden)" + end + + test "returns the account name for a non-anonymous attribution" do + user = confirmed_user_fixture() + post = %Post{topic_id: 12, user_id: user.id, user: user, anonymous: false} + + assert AnonymousName.name(post) == user.name + end +end diff --git a/test/philomena/authorization_test.exs b/test/philomena/authorization_test.exs index 03ae8ddc6..89427b111 100644 --- a/test/philomena/authorization_test.exs +++ b/test/philomena/authorization_test.exs @@ -91,4 +91,62 @@ defmodule Philomena.AuthorizationTest do assert Authorization.authorize(nil, :show, image) == :ok end end + + describe "authorize/3 with an Attribution.Actor" do + # The struct's user alone decides permissions; the IP and fingerprint + # attribute the action but grant nothing. + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + + test "resolves to the wrapped moderator", %{moderator: moderator} do + assert Authorization.authorize(actor(moderator), :edit, %Tag{}) == :ok + end + + test "resolves to the wrapped regular user", %{user: user} do + assert Authorization.authorize(actor(user), :edit, %Tag{}) == {:error, :unauthorized} + end + + test "an actor with no user is an anonymous visitor" do + assert Authorization.authorize(actor(), :edit, %Tag{}) == {:error, :unauthorized} + end + end + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to the verifiers. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + describe "verify_write_access/1" do + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + + test "passes an anonymous actor with a fingerprint and no ban" do + assert Authorization.verify_write_access(actor()) == :ok + end + + test "passes a signed-in actor with a fingerprint and no ban", %{user: user} do + assert Authorization.verify_write_access(actor(user)) == :ok + end + + test "rejects an actor carrying an active ban" do + assert Authorization.verify_write_access(actor(nil, ban: @ban)) == {:error, :ban} + end + + test "the ban wins over a missing fingerprint" do + actor = actor(nil, ban: @ban, fingerprint: nil) + assert Authorization.verify_write_access(actor) == {:error, :ban} + end + + test "rejects an unbanned actor with no fingerprint" do + assert Authorization.verify_write_access(actor(nil, fingerprint: nil)) == + {:error, :unauthorized} + end + + test "the fingerprint rule ignores sign-in state", %{user: user} do + assert Authorization.verify_write_access(actor(user, fingerprint: nil)) == + {:error, :unauthorized} + end + end end diff --git a/test/philomena/autocomplete_test.exs b/test/philomena/autocomplete_test.exs new file mode 100644 index 000000000..d788bd114 --- /dev/null +++ b/test/philomena/autocomplete_test.exs @@ -0,0 +1,38 @@ +defmodule Philomena.AutocompleteTest do + use Philomena.DataCase, async: true + + import Philomena.AutocompleteFixtures + + alias Philomena.Autocomplete + alias Philomena.Autocomplete.Autocomplete, as: Artifact + alias Philomena.Repo + + describe "show_compiled_autocomplete/0" do + test "returns not-found before an artifact has been generated" do + assert Autocomplete.show_compiled_autocomplete() == {:error, :not_found} + end + + test "returns stored bytes unchanged because the artifact is opaque" do + artifact = autocomplete_fixture(<<255, 0, 17>>) + + assert {:ok, loaded} = Autocomplete.show_compiled_autocomplete() + assert loaded.created_at == artifact.created_at + assert loaded.content == <<255, 0, 17>> + end + end + + describe "generate_autocomplete!/0" do + test "atomically replaces every stale artifact with one generated row" do + autocomplete_fixture(<<1>>) + autocomplete_fixture(<<2>>) + + generated = Autocomplete.generate_autocomplete!() + + assert %Artifact{} = generated + assert is_binary(generated.content) + assert Repo.aggregate(Artifact, :count) == 1 + assert {:ok, loaded} = Autocomplete.show_compiled_autocomplete() + assert loaded.content == generated.content + end + end +end diff --git a/test/philomena/background_jobs_test.exs b/test/philomena/background_jobs_test.exs new file mode 100644 index 000000000..46a7b99f7 --- /dev/null +++ b/test/philomena/background_jobs_test.exs @@ -0,0 +1,302 @@ +defmodule Philomena.BackgroundJobsTest do + use Philomena.DataCase, async: false + use Patch + + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + alias Philomena.Comments + alias Philomena.Comments.Comment + alias Philomena.Filters + alias Philomena.Filters.Filter + alias Philomena.Galleries + alias Philomena.Galleries.Gallery + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Thumbnailer + alias Philomena.Multi + alias Philomena.Posts + alias Philomena.Posts.Post + alias Philomena.Reports + alias Philomena.Reports.Report + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChange + alias Philomena.Tags + alias Philomena.Tags.Tag + alias Philomena.Topics.Topic + alias Philomena.Users + alias Philomena.Users.User + + defp assert_enqueued(queue, worker, arguments, count \\ 1) do + call = {:enqueue, [Exq, queue, worker, arguments]} + assert Enum.count(history(Exq), &(&1 == call)) == count + end + + describe "search indexing jobs" do + test "comments enqueue the selected id column and values" do + comment = %Comment{id: 11} + image = %Image{id: 12} + spy(Exq) + + assert Comments.reindex_comment(comment) == comment + assert Comments.reindex_comments_on_image(image) == image + assert Comments.reindex_comments_on_images([12, 13]) == [12, 13] + + assert_enqueued("indexing", Philomena.IndexWorker, ["Comments", "id", [11]]) + assert_enqueued("indexing", Philomena.IndexWorker, ["Comments", "image_id", [12]]) + assert_enqueued("indexing", Philomena.IndexWorker, ["Comments", "image_id", [12, 13]]) + end + + test "filters enqueue their id" do + filter = %Filter{id: 21} + spy(Exq) + + assert Filters.reindex_filter(filter) == filter + + assert_enqueued("indexing", Philomena.IndexWorker, ["Filters", "id", [21]]) + end + + test "galleries enqueue one or many ids and skip an empty batch" do + gallery = %Gallery{id: 31} + spy(Exq) + + assert Galleries.reindex_gallery(gallery) == gallery + assert Galleries.reindex_galleries([31, 32]) == [31, 32] + assert Galleries.reindex_galleries([]) == [] + + assert_enqueued("indexing", Philomena.IndexWorker, ["Galleries", "id", [31]]) + assert_enqueued("indexing", Philomena.IndexWorker, ["Galleries", "id", [31, 32]]) + end + + test "images enqueue one or many ids" do + image = %Image{id: 41} + spy(Exq) + + assert Images.reindex_image(image) == image + assert Images.reindex_images([41, 42]) == [41, 42] + + assert_enqueued("indexing", Philomena.IndexWorker, ["Images", "id", [41]]) + assert_enqueued("indexing", Philomena.IndexWorker, ["Images", "id", [41, 42]]) + end + + test "posts enqueue a post id or a topic id" do + post = %Post{id: 51} + topic = %Topic{id: 52} + spy(Exq) + + assert Posts.reindex_post(post) == post + assert Posts.reindex_posts_in_topic(topic) == :ok + + assert_enqueued("indexing", Philomena.IndexWorker, ["Posts", "id", [51]]) + assert_enqueued("indexing", Philomena.IndexWorker, ["Posts", "topic_id", [52]]) + end + + test "persisted tag changes enqueue their generated id after commit" do + user = confirmed_user_fixture() + tags = "safe, background base one, background base two" + image = image_fixture(tags: tags) + reset_tag_change_limits(attribution(user)) + spy(Exq) + + assert {:ok, _image} = + Images.update_image_tags(actor(user), image.id, %{ + "old_tag_input" => tags, + "tag_input" => "#{tags}, background added tag" + }) + + tag_change = Repo.get_by!(TagChange, image_id: image.id) + + assert_enqueued("indexing", Philomena.IndexWorker, [ + "TagChanges", + "id", + [tag_change.id] + ]) + end + + test "tags enqueue a batch of ids" do + tag = %Tag{id: 71} + other_tag = %Tag{id: 72} + spy(Exq) + + assert Tags.reindex_tags([tag, other_tag]) == [tag, other_tag] + + assert_enqueued("indexing", Philomena.IndexWorker, ["Tags", "id", [71, 72]]) + end + + test "users enqueue their id" do + user = %User{id: 81} + spy(Exq) + + assert Users.reindex_user(user) == user + + assert_enqueued("indexing", Philomena.IndexWorker, ["Users", "id", [81]]) + end + end + + describe "image processing jobs" do + test "repairs use the image queue for still images and the video queue for WebM" do + moderator = moderator_user_fixture() + image = image_fixture() + video = image_fixture(image_mime_type: "video/webm", image_format: "webm") + image_id = image.id + video_id = video.id + spy(Exq) + + assert {:ok, repaired_image} = Images.create_image_repair(actor(moderator), image.id) + assert {:ok, repaired_video} = Images.create_image_repair(actor(moderator), video.id) + assert repaired_image.id == image.id + assert repaired_video.id == video.id + + assert_enqueued("images", Philomena.ThumbnailWorker, [image_id]) + assert_enqueued("videos", Philomena.ThumbnailWorker, [video_id]) + end + + test "purges every visible and hidden thumbnail path" do + moderator = moderator_user_fixture() + hidden_key = "hidden-key" + image = image_fixture(hidden_image_key: hidden_key) + + expected_files = + Thumbnailer.thumbnail_urls(image, hidden_key) ++ Thumbnailer.thumbnail_urls(image, nil) + + spy(Exq) + + assert {:ok, _image} = Images.create_image_repair(actor(moderator), image.id) + + assert_enqueued("indexing", Philomena.ImagePurgeWorker, [expected_files]) + end + end + + describe "tag maintenance jobs" do + test "delete, alias, unalias, and image reindex actions enqueue exact ids" do + admin = admin_user_fixture() + target = tag_fixture(name: "background target") + delete_tag = tag_fixture(name: "background delete") + alias_tag = tag_fixture(name: "background alias") + target_id = target.id + delete_tag_id = delete_tag.id + alias_tag_id = alias_tag.id + spy(Exq) + + assert {:ok, %Tag{id: ^delete_tag_id}} = Tags.delete_tag(actor(admin), delete_tag.slug) + + assert {:ok, aliased} = + Tags.update_tag_alias(actor(admin), alias_tag.slug, %{"target_tag" => target.name}) + + finalized_alias = Repo.get!(Tag, alias_tag_id) + Repo.update!(Ecto.Changeset.change(finalized_alias, aliased_tag_id: target_id)) + assert {:ok, %Tag{id: ^alias_tag_id}} = Tags.delete_tag_alias(actor(admin), aliased.slug) + + assert {:ok, %Tag{id: ^target_id}} = + Tags.create_tag_reindex(actor(admin), target.slug) + + assert_enqueued("indexing", Philomena.TagDeleteWorker, [delete_tag_id]) + assert_enqueued("indexing", Philomena.TagAliasWorker, [alias_tag_id, target_id]) + assert_enqueued("indexing", Philomena.TagReindexWorker, [target_id], 2) + assert_enqueued("indexing", Philomena.IndexWorker, ["Tags", "id", [target_id]]) + end + end + + describe "user maintenance jobs" do + test "vote, downvote, PII, rename, and erasure workflows enqueue exact arguments" do + admin = admin_user_fixture() + target = user_fixture(name: "background target") + target_id = target.id + spy(Exq) + + assert {:ok, %User{id: ^target_id}} = Users.delete_user_downvotes(actor(admin), target.slug) + assert {:ok, %User{id: ^target_id}} = Users.delete_user_votes(actor(admin), target.slug) + assert {:ok, %User{id: ^target_id}} = Users.create_user_wipe(actor(admin), target.slug) + + assert_enqueued("indexing", Philomena.UserUnvoteWorker, [target_id, false]) + assert_enqueued("indexing", Philomena.UserUnvoteWorker, [target_id, true]) + assert_enqueued("indexing", Philomena.UserWipeWorker, [target_id]) + + old_name = admin.name + assert {:ok, renamed_admin} = Users.update_name(actor(admin), %{"name" => "renamed admin"}) + new_name = renamed_admin.name + admin_id = renamed_admin.id + + assert_enqueued("indexing", Philomena.UserRenameWorker, [old_name, new_name]) + + assert {:ok, erased} = Users.create_user_erase(actor(renamed_admin), target.slug) + erased_id = erased.id + + assert_enqueued("indexing", Philomena.UserEraseWorker, [target_id, admin_id]) + assert_enqueued("indexing", Philomena.IndexWorker, ["Users", "id", [erased_id]]) + end + end + + describe "tag-change revert jobs" do + test "full reverts carry each target and the moderator attribution" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + moderator_actor = actor(moderator) + target_id = target.id + + attributes = %{ + ip: to_string(moderator_actor.ip), + fingerprint: moderator_actor.fingerprint, + user_id: moderator.id, + batch_size: 100 + } + + spy(Exq) + + assert {:ok, %User{id: ^target_id}} = + TagChanges.create_user_tag_change_revert(moderator_actor, target.slug) + + assert {:ok, "203.0.113.9"} = + TagChanges.create_ip_tag_change_revert(moderator_actor, "203.0.113.9") + + assert {:ok, "c1774"} = + TagChanges.create_fingerprint_tag_change_revert(moderator_actor, "c1774") + + assert_enqueued("indexing", Philomena.TagChangeRevertWorker, [ + %{user_id: target_id, attributes: attributes} + ]) + + assert_enqueued("indexing", Philomena.TagChangeRevertWorker, [ + %{ip: "203.0.113.9", attributes: attributes} + ]) + + assert_enqueued("indexing", Philomena.TagChangeRevertWorker, [ + %{fingerprint: "c1774", attributes: attributes} + ]) + end + end + + describe "report jobs" do + test "closing a report enqueues its id after commit" do + image = image_fixture() + reporter = confirmed_user_fixture() + moderator = moderator_user_fixture() + report = Philomena.ReportsFixtures.report_fixture(reporter, %{}, image_id: image.id) + report_id = report.id + spy(Exq) + + assert {:ok, %Report{id: ^report_id}} = + Reports.create_report_close(actor(moderator), report_id) + + assert_enqueued("indexing", Philomena.IndexWorker, ["Reports", "id", [report_id]]) + end + + test "bulk report closure enqueues the returned id list" do + image = image_fixture() + reporter = confirmed_user_fixture() + moderator = moderator_user_fixture() + report = Philomena.ReportsFixtures.report_fixture(reporter, %{}, image_id: image.id) + spy(Exq) + + assert {:ok, %{reports: {1, [report_id]}}} = + Multi.new() + |> Reports.put_close_reports(:reports, moderator, image_id: image.id) + |> Multi.transact() + + assert report_id == report.id + assert_enqueued("indexing", Philomena.IndexWorker, ["Reports", "id", [report_id]]) + end + end +end diff --git a/test/philomena/badges_test.exs b/test/philomena/badges_test.exs new file mode 100644 index 000000000..145fd492b --- /dev/null +++ b/test/philomena/badges_test.exs @@ -0,0 +1,613 @@ +defmodule Philomena.BadgesTest do + @moduledoc """ + Context-level tests for the actor-first badge-award loaders and writers on + `Philomena.Badges`. + + Awarding is admin/moderator-only (the `:create` permission on `Award`); these + pin that matrix, the not-found shapes for unknown slugs and award ids, and the + byte-exact moderation logs each write emits (type, body, and subject path), + including that failure and authorization paths write none. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.BadgesFixtures + import Philomena.UsersFixtures + + alias Philomena.Badges + alias Philomena.Badges.Award + alias Philomena.Badges.Badge + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + + @pagination %{page_number: 1, page_size: 25} + @ban %{reason: "Rule #0", valid_until: ~U[3000-01-01 00:00:00Z]} + + # A profile user with an all-unreserved slug, so the moderation-log + # subject_path is byte-identical to "/profiles/" with no percent-encoding. + defp awardee_fixture do + confirmed_user_fixture(%{name: "awardee#{System.unique_integer([:positive])}"}) + end + + defp moderation_logs, do: Repo.all(ModerationLog) + + defp no_moderation_logs! do + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + describe "new_award/2" do + test "returns enabled badges ordered by title and excludes disabled ones" do + beta = badge_fixture(%{title: "Beta Badge"}) + alpha = badge_fixture(%{title: "Alpha Badge"}) + _disabled = badge_fixture(%{title: "Gamma Badge", disable_award: true}) + user = awardee_fixture() + + assert {:ok, {_user, _changeset, badges}} = + Badges.new_award(actor(admin_user_fixture()), user.slug) + + titles = Enum.map(badges, & &1.title) + + # Ordered ascending by title, and the disabled badge is absent. + assert Enum.find_index(titles, &(&1 == alpha.title)) < + Enum.find_index(titles, &(&1 == beta.title)) + + refute "Gamma Badge" in titles + end + + test "an admin gets the profile user and a changeset" do + user = awardee_fixture() + + assert {:ok, {loaded_user, %Ecto.Changeset{data: %Award{}}, badges}} = + Badges.new_award(actor(admin_user_fixture()), user.slug) + + assert loaded_user.id == user.id + assert is_list(badges) + end + + test "a regular user may not award badges" do + user = awardee_fixture() + + assert Badges.new_award(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "a permitted actor naming an unknown slug is not-found" do + assert Badges.new_award(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "create_award/3" do + test "an admin awards a badge, inserting the award and a byte-exact create log" do + admin = admin_user_fixture() + user = awardee_fixture() + badge = badge_fixture(%{title: "Test Award Badge"}) + + assert {:ok, {loaded_user, %Award{} = award}} = + Badges.create_award(actor(admin), user.slug, %{"badge_id" => badge.id}) + + assert loaded_user.id == user.id + assert Repo.get(Award, award.id).user_id == user.id + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Profile.Award:create" + assert log.body == "Awarded badge 'Test Award Badge' to #{user.name}" + assert log.subject_path == "/profiles/#{user.slug}" + end + + test "a rejected award writes no log and returns the user with the changeset" do + user = awardee_fixture() + + # No badge_id, so the award changeset is invalid. + assert {:error, {loaded_user, %Ecto.Changeset{} = changeset, badges}} = + Badges.create_award(actor(admin_user_fixture()), user.slug, %{}) + + assert loaded_user.id == user.id + refute changeset.valid? + assert is_list(badges) + assert Repo.aggregate(Award, :count) == 0 + no_moderation_logs!() + end + + test "a regular user is unauthorized and writes no log" do + user = awardee_fixture() + badge = badge_fixture() + + assert Badges.create_award(actor(confirmed_user_fixture()), user.slug, %{ + "badge_id" => badge.id + }) == + {:error, :unauthorized} + + assert Repo.aggregate(Award, :count) == 0 + no_moderation_logs!() + end + + test "duplicate grants are intentionally retained as separate awards" do + moderator = moderator_user_fixture() + user = awardee_fixture() + badge = badge_fixture() + + assert {:ok, {_user, first}} = + Badges.create_award(actor(moderator), user.slug, %{"badge_id" => badge.id}) + + assert {:ok, {_user, second}} = + Badges.create_award(actor(moderator), user.slug, %{"badge_id" => badge.id}) + + refute first.id == second.id + assert Repo.aggregate(Award, :count) == 2 + end + end + + describe "edit_award/3" do + test "an admin loads the award and a changeset" do + admin = admin_user_fixture() + user = awardee_fixture() + award = badge_award_fixture(admin, user) + + assert {:ok, {loaded_user, loaded_award, %Ecto.Changeset{}, badges}} = + Badges.edit_award(actor(admin), user.slug, "#{award.id}") + + assert loaded_user.id == user.id + assert loaded_award.id == award.id + assert is_list(badges) + end + + test "a non-castable award id is not-found" do + user = awardee_fixture() + + assert Badges.edit_award(actor(admin_user_fixture()), user.slug, "abc") == + {:error, :not_found} + end + + test "an unknown award id is not-found" do + user = awardee_fixture() + + assert Badges.edit_award(actor(admin_user_fixture()), user.slug, "2147483647") == + {:error, :not_found} + end + + test "an award cannot be loaded through another profile slug" do + admin = admin_user_fixture() + owner = awardee_fixture() + other = awardee_fixture() + award = badge_award_fixture(admin, owner) + + assert Badges.edit_award(actor(admin), other.slug, award.id) == + {:error, :not_found} + end + end + + describe "update_award/4" do + test "an admin updates an award and writes a byte-exact update log" do + admin = admin_user_fixture() + user = awardee_fixture() + badge = badge_fixture(%{title: "Test Award Badge"}) + award = badge_award_fixture(admin, user, badge) + + assert {:ok, {loaded_user, updated}} = + Badges.update_award(actor(admin), user.slug, "#{award.id}", %{ + "label" => "Best" + }) + + assert loaded_user.id == user.id + assert Repo.get(Award, updated.id).label == "Best" + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Profile.Award:update" + assert log.body == "Updated award of badge 'Test Award Badge' on #{user.name}" + assert log.subject_path == "/profiles/#{user.slug}" + end + + test "a mismatched profile slug does not update the award" do + moderator = moderator_user_fixture() + owner = awardee_fixture() + other = awardee_fixture() + award = badge_award_fixture(moderator, owner, nil, %{label: "Before"}) + + assert Badges.update_award(actor(moderator), other.slug, award.id, %{ + "label" => "After" + }) == {:error, :not_found} + + assert Repo.get(Award, award.id).label == "Before" + no_moderation_logs!() + end + end + + describe "delete_award/3" do + test "an admin revokes an award, deleting it and writing a byte-exact delete log" do + admin = admin_user_fixture() + user = awardee_fixture() + badge = badge_fixture(%{title: "Test Award Badge"}) + award = badge_award_fixture(admin, user, badge) + + assert {:ok, {loaded_user, revoked}} = + Badges.delete_award(actor(admin), user.slug, "#{award.id}") + + assert loaded_user.id == user.id + assert revoked.id == award.id + assert Repo.get(Award, award.id) == nil + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Profile.Award:delete" + assert log.body == "Removed badge 'Test Award Badge' from #{user.name}" + assert log.subject_path == "/profiles/#{user.slug}" + end + + test "a regular user is unauthorized and writes no log" do + admin = admin_user_fixture() + user = awardee_fixture() + award = badge_award_fixture(admin, user) + + assert Badges.delete_award(actor(confirmed_user_fixture()), user.slug, "#{award.id}") == + {:error, :unauthorized} + + assert Repo.get(Award, award.id).id == award.id + no_moderation_logs!() + end + + test "a mismatched profile slug does not revoke the award" do + moderator = moderator_user_fixture() + owner = awardee_fixture() + other = awardee_fixture() + award = badge_award_fixture(moderator, owner) + + assert Badges.delete_award(actor(moderator), other.slug, award.id) == + {:error, :not_found} + + assert Repo.get(Award, award.id) + no_moderation_logs!() + end + end + + describe "global write prerequisite" do + test "badge and award form loaders reject banned and unattributed staff" do + admin = admin_user_fixture() + user = awardee_fixture() + badge = badge_fixture() + award = badge_award_fixture(admin, user, badge) + + for operation <- [ + fn actor -> Badges.new_badge(actor) end, + fn actor -> Badges.edit_badge(actor, badge.id) end, + fn actor -> Badges.new_award(actor, user.slug) end, + fn actor -> Badges.edit_award(actor, user.slug, award.id) end + ] do + assert operation.(actor(admin, ban: @ban)) == {:error, :ban} + assert operation.(actor(admin, fingerprint: nil)) == {:error, :unauthorized} + end + end + + test "badge and award mutations reject banned and unattributed staff" do + admin = admin_user_fixture() + user = awardee_fixture() + badge = badge_fixture(%{title: "Unchanged by policy"}) + award = badge_award_fixture(admin, user, badge, %{label: "Unchanged"}) + + operations = [ + fn actor -> Badges.create_badge(actor, %{}, nil) end, + fn actor -> Badges.update_badge(actor, badge.id, %{"title" => "Changed"}) end, + fn actor -> Badges.update_badge_image(actor, badge.id, nil) end, + fn actor -> Badges.create_award(actor, user.slug, %{"badge_id" => badge.id}) end, + fn actor -> + Badges.update_award(actor, user.slug, award.id, %{"label" => "Changed"}) + end, + fn actor -> Badges.delete_award(actor, user.slug, award.id) end + ] + + for operation <- operations do + assert operation.(actor(admin, ban: @ban)) == {:error, :ban} + assert operation.(actor(admin, fingerprint: nil)) == {:error, :unauthorized} + end + + assert Repo.get(Badge, badge.id).title == "Unchanged by policy" + assert Repo.get(Award, award.id).label == "Unchanged" + no_moderation_logs!() + end + end + + describe "list_badges/2" do + test "an admin and a Badge-role moderator may list, others may not" do + _badge = badge_fixture() + + assert {:ok, %Scrivener.Page{}} = + Badges.list_badges(actor(admin_user_fixture()), @pagination) + + assert {:ok, %Scrivener.Page{}} = + Badges.list_badges(actor(role_moderator_fixture("Badge")), @pagination) + + assert Badges.list_badges(actor(moderator_user_fixture()), @pagination) == + {:error, :unauthorized} + + assert Badges.list_badges(actor(confirmed_user_fixture()), @pagination) == + {:error, :unauthorized} + + assert Badges.list_badges(actor(), @pagination) == {:error, :unauthorized} + end + + test "the listing is ordered by title" do + beta = badge_fixture(%{title: "Zeta Listing Badge"}) + alpha = badge_fixture(%{title: "Alpha Listing Badge"}) + + assert {:ok, page} = Badges.list_badges(actor(admin_user_fixture()), @pagination) + + titles = Enum.map(page.entries, & &1.title) + + assert Enum.find_index(titles, &(&1 == alpha.title)) < + Enum.find_index(titles, &(&1 == beta.title)) + end + end + + describe "new_badge/1" do + test "an admin and a Badge-role moderator get a changeset, others do not" do + assert {:ok, %Ecto.Changeset{data: %Badge{}}} = + Badges.new_badge(actor(admin_user_fixture())) + + assert {:ok, %Ecto.Changeset{data: %Badge{}}} = + Badges.new_badge(actor(role_moderator_fixture("Badge"))) + + assert Badges.new_badge(actor(moderator_user_fixture())) == {:error, :unauthorized} + assert Badges.new_badge(actor()) == {:error, :unauthorized} + end + end + + describe "create_badge/3" do + test "an admin creates a badge through the upload pipeline and writes a byte-exact log" do + admin = admin_user_fixture() + + assert {:ok, %Badge{} = badge} = + Badges.create_badge( + actor(admin), + %{ + "title" => "Created Badge" + }, + media_svg_upload() + ) + + assert badge.title == "Created Badge" + assert Repo.get_by(Badge, title: "Created Badge") + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Badge:create" + assert log.body == "Created badge 'Created Badge'" + assert log.subject_path == "/admin/badges" + end + + test "a Badge-role moderator creates a badge" do + assert {:ok, %Badge{}} = + Badges.create_badge( + actor(role_moderator_fixture("Badge")), + %{ + "title" => "Mod Created Badge" + }, + media_svg_upload() + ) + end + + test "a plain moderator is unauthorized and writes no log" do + assert Badges.create_badge( + actor(moderator_user_fixture()), + %{"title" => "nope"}, + media_svg_upload() + ) == {:error, :unauthorized} + + refute Repo.get_by(Badge, title: "nope") + no_moderation_logs!() + end + + test "a missing image is a changeset error and writes no log" do + assert {:error, %Ecto.Changeset{} = changeset} = + Badges.create_badge( + actor(admin_user_fixture()), + %{"title" => "No Image Badge"}, + nil + ) + + refute changeset.valid? + refute Repo.get_by(Badge, title: "No Image Badge") + no_moderation_logs!() + end + end + + describe "edit_badge/2" do + test "an admin loads the badge and a changeset" do + badge = badge_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Badges.edit_badge(actor(admin_user_fixture()), "#{badge.id}") + + assert loaded.id == badge.id + end + + test "a plain moderator is unauthorized" do + badge = badge_fixture() + + assert Badges.edit_badge(actor(moderator_user_fixture()), "#{badge.id}") == + {:error, :unauthorized} + end + + test "a Badge-role moderator is authorized for the edit action" do + badge = badge_fixture() + + assert {:ok, {%Badge{id: id}, %Ecto.Changeset{}}} = + Badges.edit_badge(actor(role_moderator_fixture("Badge")), badge.id) + + assert id == badge.id + end + + test "a non-castable id is not-found" do + assert Badges.edit_badge(actor(admin_user_fixture()), "abc") == + {:error, :not_found} + end + + test "an unknown id is not-found for a Badge-role moderator and an admin alike" do + assert Badges.edit_badge(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Badges.edit_badge(actor(role_moderator_fixture("Badge")), "2147483647") == + {:error, :not_found} + end + end + + describe "update_badge/3" do + test "an admin updates a badge and writes a byte-exact log" do + admin = admin_user_fixture() + badge = badge_fixture(%{title: "Before Title"}) + + assert {:ok, updated} = + Badges.update_badge(actor(admin), "#{badge.id}", %{"title" => "After Title"}) + + assert updated.title == "After Title" + assert Repo.get(Badge, badge.id).title == "After Title" + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Badge:update" + assert log.body == "Updated badge 'After Title'" + assert log.subject_path == "/admin/badges" + end + + test "a plain moderator is unauthorized and writes no log" do + badge = badge_fixture(%{title: "Unchanged"}) + + assert Badges.update_badge(actor(moderator_user_fixture()), "#{badge.id}", %{ + "title" => "changed" + }) == + {:error, :unauthorized} + + assert Repo.get(Badge, badge.id).title == "Unchanged" + no_moderation_logs!() + end + + test "a Badge-role moderator is authorized for the update action" do + badge = badge_fixture() + + assert {:ok, %Badge{title: "Role Updated"}} = + Badges.update_badge(actor(role_moderator_fixture("Badge")), badge.id, %{ + "title" => "Role Updated" + }) + end + + test "an unknown id is not-found" do + assert Badges.update_badge(actor(admin_user_fixture()), "2147483647", %{"title" => "x"}) == + {:error, :not_found} + end + + test "a blank title is a changeset error and writes no log" do + badge = badge_fixture(%{title: "Keep This"}) + + assert {:error, %Ecto.Changeset{} = changeset} = + Badges.update_badge(actor(admin_user_fixture()), "#{badge.id}", %{"title" => ""}) + + refute changeset.valid? + assert Repo.get(Badge, badge.id).title == "Keep This" + no_moderation_logs!() + end + end + + describe "update_badge_image/3" do + test "an admin updates the image through the upload pipeline and writes a byte-exact log" do + admin = admin_user_fixture() + badge = badge_fixture(%{title: "Image Badge"}) + + assert {:ok, %Badge{}} = + Badges.update_badge_image(actor(admin), "#{badge.id}", media_svg_upload()) + + assert [log] = moderation_logs() + assert log.user_id == admin.id + assert log.type == "Admin.Badge.Image:update" + assert log.body == "Updated image of badge 'Image Badge'" + assert log.subject_path == "/admin/badges" + end + + test "a plain moderator is unauthorized and writes no log" do + badge = badge_fixture() + + assert Badges.update_badge_image( + actor(moderator_user_fixture()), + "#{badge.id}", + media_svg_upload() + ) == {:error, :unauthorized} + + no_moderation_logs!() + end + + test "a missing image is a changeset error and preserves the old image" do + badge = badge_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Badges.update_badge_image(actor(admin_user_fixture()), badge.id, nil) + + refute changeset.valid? + assert Repo.get!(Badge, badge.id).image == "test.svg" + no_moderation_logs!() + end + + test "a Badge-role moderator is authorized for the image-update action" do + badge = badge_fixture() + + assert {:ok, %Badge{}} = + Badges.update_badge_image( + actor(role_moderator_fixture("Badge")), + badge.id, + media_svg_upload() + ) + end + + test "an unknown id is not-found" do + assert Badges.update_badge_image( + actor(admin_user_fixture()), + "2147483647", + media_svg_upload() + ) == {:error, :not_found} + end + end + + describe "list_badge_users/3" do + test "an admin loads the badge with the users who hold it" do + admin = admin_user_fixture() + badge = badge_fixture() + user = awardee_fixture() + _award = badge_award_fixture(admin, user, badge) + + assert {:ok, {loaded, users}} = + Badges.list_badge_users(actor(admin), "#{badge.id}", @pagination) + + assert loaded.id == badge.id + assert %Scrivener.Page{} = users + assert user.id in Enum.map(users.entries, & &1.id) + end + + test "a plain moderator is unauthorized" do + badge = badge_fixture() + + assert Badges.list_badge_users(actor(moderator_user_fixture()), "#{badge.id}", @pagination) == + {:error, :unauthorized} + end + + test "a Badge-role moderator is authorized for the show-users action" do + badge = badge_fixture() + + assert {:ok, {%Badge{}, %Scrivener.Page{}}} = + Badges.list_badge_users( + actor(role_moderator_fixture("Badge")), + badge.id, + @pagination + ) + end + + test "a non-castable id is not-found" do + assert Badges.list_badge_users(actor(admin_user_fixture()), "abc", @pagination) == + {:error, :not_found} + end + + test "an unknown id is not-found" do + assert Badges.list_badge_users(actor(admin_user_fixture()), "2147483647", @pagination) == + {:error, :not_found} + end + end +end diff --git a/test/philomena/bans_test.exs b/test/philomena/bans_test.exs new file mode 100644 index 000000000..bf77c4478 --- /dev/null +++ b/test/philomena/bans_test.exs @@ -0,0 +1,1067 @@ +defmodule Philomena.BansTest do + @moduledoc """ + Context-level tests for `Philomena.Bans`. + + Two groups. The profile-page lookups (`subnet_bans_for_ip/1`, + `fingerprint_bans_for/1`) pin subnet-containment, exact fingerprint match, + newest-first ordering, and the empty result for an uncovered value. + + The admin ban management functions (index/new/create/edit/update/delete for + user, subnet, and fingerprint bans) pin the per-role authorization matrix, the + non-castable/unknown-id split, the admin-only restriction on deletes, the + invalid-ip shapes on the subnet index and new form, and the byte-exact + moderation-log type/subject_path/body written on each successful write. + + The actor here is a `Philomena.Attribution.Actor`, matching what the + controller hands in as `conn.assigns.actor`. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.BansFixtures + import Philomena.UserIpsFixtures, only: [inet: 1, user_ip_fixture: 2] + import Philomena.UsersFixtures + + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Bans + + @pagination %{page_number: 1, page_size: 25} + + defp only_moderation_log!, do: Repo.one!(ModerationLog) + + defp moderation_log_count, do: Repo.aggregate(ModerationLog, :count) + + describe "subnet_bans_for_ip/1" do + test "returns a subnet ban whose specification contains the address" do + ban = subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + + assert ban.id in Enum.map(Bans.subnet_bans_for_ip(inet("203.0.113.50")), & &1.id) + end + + test "excludes a subnet ban that does not contain the address" do + subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + + assert Bans.subnet_bans_for_ip(inet("198.51.100.1")) == [] + end + + test "orders matching bans newest first" do + older = subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + newer = subnet_ban_fixture(%{"specification" => "203.0.113.0/25"}) + + set_created_at(Bans.Subnet, older.id, ~U[2020-01-01 00:00:00Z]) + set_created_at(Bans.Subnet, newer.id, ~U[2024-01-01 00:00:00Z]) + + ids = Enum.map(Bans.subnet_bans_for_ip(inet("203.0.113.50")), & &1.id) + assert Enum.find_index(ids, &(&1 == newer.id)) < Enum.find_index(ids, &(&1 == older.id)) + end + end + + describe "fingerprint_bans_for/1" do + test "returns a fingerprint ban matching the fingerprint" do + ban = fingerprint_ban_fixture(%{"fingerprint" => "c0ffee1234"}) + + assert ban.id in Enum.map(Bans.fingerprint_bans_for("c0ffee1234"), & &1.id) + end + + test "excludes a ban for a different fingerprint" do + fingerprint_ban_fixture(%{"fingerprint" => "c0ffee1234"}) + + assert Bans.fingerprint_bans_for("deadbeef") == [] + end + + test "orders matching bans newest first" do + older = fingerprint_ban_fixture(%{"fingerprint" => "abc123"}) + newer = fingerprint_ban_fixture(%{"fingerprint" => "abc123"}) + + set_created_at(Bans.Fingerprint, older.id, ~U[2020-01-01 00:00:00Z]) + set_created_at(Bans.Fingerprint, newer.id, ~U[2024-01-01 00:00:00Z]) + + ids = Enum.map(Bans.fingerprint_bans_for("abc123"), & &1.id) + assert ids == [newer.id, older.id] + end + end + + describe "list_user_bans/3" do + test "a moderator gets the paginated user bans" do + moderator = moderator_user_fixture() + ban = user_ban_fixture() + + assert {:ok, page, _changeset} = Bans.list_user_bans(actor(moderator), %{}, @pagination) + assert %Scrivener.Page{} = page + assert ban.id in Enum.map(page.entries, & &1.id) + end + + test "an admin gets the paginated user bans" do + admin = admin_user_fixture() + ban = user_ban_fixture() + + assert {:ok, page, _changeset} = Bans.list_user_bans(actor(admin), %{}, @pagination) + assert ban.id in Enum.map(page.entries, & &1.id) + end + + test "a regular user is not authorized" do + assert Bans.list_user_bans(actor(confirmed_user_fixture()), %{}, @pagination) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Bans.list_user_bans(actor(), %{}, @pagination) == {:error, :unauthorized} + end + + test "the bq branch matches a ban by its generated ban id" do + moderator = moderator_user_fixture() + ban = user_ban_fixture() + _other = user_ban_fixture() + + assert {:ok, page, _changeset} = + Bans.list_user_bans( + actor(moderator), + %{"bq" => ban.generated_ban_id}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [ban.id] + end + + test "the bq branch matches a ban by the banned user's name" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture(%{name: "bantargetname"}) + ban = user_ban_fixture(target) + + assert {:ok, page, _changeset} = + Bans.list_user_bans(actor(moderator), %{"bq" => "bantargetname"}, @pagination) + + assert ban.id in Enum.map(page.entries, & &1.id) + end + + test "the user_id branch filters to that user's bans" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + ban = user_ban_fixture(target) + _other = user_ban_fixture() + + assert {:ok, page, _changeset} = + Bans.list_user_bans(actor(moderator), %{"user_id" => "#{target.id}"}, @pagination) + + assert Enum.map(page.entries, & &1.id) == [ban.id] + end + + test "a non-integer user_id filter returns a changeset error" do + moderator = moderator_user_fixture() + + assert {:error, changeset} = + Bans.list_user_bans(actor(moderator), %{"user_id" => "abc"}, @pagination) + + assert {"is invalid", _opts} = changeset.errors[:user_id] + end + end + + describe "new_user_ban/2" do + test "a moderator gets the target and a changeset for a known user id" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Bans.new_user_ban(actor(moderator), "#{target.id}") + + assert loaded.id == target.id + end + + test "an unknown but castable user id is not found" do + moderator = moderator_user_fixture() + + assert Bans.new_user_ban(actor(moderator), "2147483647") == {:error, :not_found} + end + + test "a non-castable user id is not found" do + moderator = moderator_user_fixture() + + assert Bans.new_user_ban(actor(moderator), "abc") == {:error, :not_found} + end + + test "a nil user id is not found" do + moderator = moderator_user_fixture() + + assert Bans.new_user_ban(actor(moderator), nil) == {:error, :not_found} + end + + test "a regular user is not authorized" do + target = confirmed_user_fixture() + + assert Bans.new_user_ban(actor(confirmed_user_fixture()), "#{target.id}") == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + target = confirmed_user_fixture() + assert Bans.new_user_ban(actor(), "#{target.id}") == {:error, :unauthorized} + end + end + + describe "create_user_ban/2" do + test "a moderator creates a ban and a moderation log is written" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + + assert {:ok, ban} = + Bans.create_user_ban(actor(moderator), target.id, valid_user_ban_attrs()) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Admin.UserBan:create" + assert log.subject_path == "/admin/user_bans" + assert log.body == "Created a user ban #{ban.generated_ban_id}" + end + + test "an admin creates a ban" do + admin = admin_user_fixture() + target = confirmed_user_fixture() + + assert {:ok, _ban} = Bans.create_user_ban(actor(admin), target.id, valid_user_ban_attrs()) + end + + test "creation automatically bans the target's latest IPv6 /64" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + user_ip_fixture(target, "2001:db8:1:2:3:4:5:6") + + assert {:ok, _ban} = + Bans.create_user_ban(actor(moderator), target.id, valid_user_ban_attrs()) + + assert %Bans.Subnet{ + specification: %Postgrex.INET{ + address: {0x2001, 0xDB8, 1, 2, 0, 0, 0, 0}, + netmask: 64 + } + } = Repo.one!(Bans.Subnet) + end + + test "creation automatically bans the target's latest IPv4 address unchanged" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + user_ip_fixture(target, "203.0.113.51") + + assert {:ok, _ban} = + Bans.create_user_ban(actor(moderator), target.id, valid_user_ban_attrs()) + + assert %Bans.Subnet{ + specification: %Postgrex.INET{address: {203, 0, 113, 51}, netmask: 32} + } = Repo.one!(Bans.Subnet) + end + + test "invalid attributes return a changeset and write no log" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + + assert {:error, %Ecto.Changeset{}} = + Bans.create_user_ban(actor(moderator), target.id, %{ + valid_user_ban_attrs() + | "reason" => "" + }) + + assert moderation_log_count() == 0 + end + + test "a regular user is not authorized and creates nothing" do + target = confirmed_user_fixture() + + assert Bans.create_user_ban( + actor(confirmed_user_fixture()), + target.id, + valid_user_ban_attrs() + ) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "an anonymous visitor is not authorized" do + target = confirmed_user_fixture() + + assert Bans.create_user_ban(actor(), target.id, valid_user_ban_attrs()) == + {:error, :unauthorized} + end + end + + describe "edit_user_ban/2" do + test "a moderator loads the ban with the banned user preloaded" do + moderator = moderator_user_fixture() + ban = user_ban_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Bans.edit_user_ban(actor(moderator), ban.id) + + assert loaded.id == ban.id + refute match?(%Ecto.Association.NotLoaded{}, loaded.user) + end + + test "an admin loads the ban" do + admin = admin_user_fixture() + ban = user_ban_fixture() + + assert {:ok, {loaded, _}} = Bans.edit_user_ban(actor(admin), ban.id) + assert loaded.id == ban.id + end + + test "an unknown id is not found for a moderator" do + moderator = moderator_user_fixture() + assert Bans.edit_user_ban(actor(moderator), 2_147_483_647) == {:error, :not_found} + end + + test "an unknown id is not found for an admin" do + admin = admin_user_fixture() + assert Bans.edit_user_ban(actor(admin), 2_147_483_647) == {:error, :not_found} + end + + test "a non-castable id is not found for a moderator" do + moderator = moderator_user_fixture() + assert Bans.edit_user_ban(actor(moderator), "abc") == {:error, :not_found} + end + + test "a non-castable id is not found for an admin" do + admin = admin_user_fixture() + assert Bans.edit_user_ban(actor(admin), "abc") == {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = user_ban_fixture() + + assert Bans.edit_user_ban(actor(confirmed_user_fixture()), ban.id) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + ban = user_ban_fixture() + assert Bans.edit_user_ban(actor(), ban.id) == {:error, :unauthorized} + end + end + + describe "update_user_ban/3" do + test "a moderator updates the ban and a moderation log is written" do + moderator = moderator_user_fixture() + ban = user_ban_fixture() + + assert {:ok, updated} = + Bans.update_user_ban(actor(moderator), ban.id, %{"reason" => "Changed"}) + + assert updated.reason == "Changed" + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Admin.UserBan:update" + assert log.subject_path == "/admin/user_bans" + assert log.body == "Updated a user ban #{ban.generated_ban_id}" + end + + test "an admin updates the ban" do + admin = admin_user_fixture() + ban = user_ban_fixture() + + assert {:ok, _} = Bans.update_user_ban(actor(admin), ban.id, %{"reason" => "Changed"}) + end + + test "invalid attributes return a changeset and write no log" do + moderator = moderator_user_fixture() + ban = user_ban_fixture() + + assert {:error, %Ecto.Changeset{}} = + Bans.update_user_ban(actor(moderator), ban.id, %{"reason" => ""}) + + assert moderation_log_count() == 0 + end + + test "an unknown id is not found" do + moderator = moderator_user_fixture() + + assert Bans.update_user_ban(actor(moderator), 2_147_483_647, %{"reason" => "x"}) == + {:error, :not_found} + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Bans.update_user_ban(actor(moderator), "abc", %{"reason" => "x"}) == + {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = user_ban_fixture() + + assert Bans.update_user_ban(actor(confirmed_user_fixture()), ban.id, %{"reason" => "x"}) == + {:error, :unauthorized} + end + end + + describe "delete_user_ban/2" do + test "an admin deletes the ban and a moderation log is written" do + admin = admin_user_fixture() + ban = user_ban_fixture() + + assert {:ok, deleted} = Bans.delete_user_ban(actor(admin), ban.id) + assert deleted.id == ban.id + refute Repo.get(Bans.User, ban.id) + + log = only_moderation_log!() + assert log.user_id == admin.id + assert log.type == "Admin.UserBan:delete" + assert log.subject_path == "/admin/user_bans" + assert log.body == "Deleted a user ban #{ban.generated_ban_id}" + end + + test "a moderator with a real ban id is not authorized to delete" do + # Deleting is admin-only; a moderator passes the module-level authorize and + # loads the ban, then fails the admin-only delete check. + moderator = moderator_user_fixture() + ban = user_ban_fixture() + + assert Bans.delete_user_ban(actor(moderator), ban.id) == {:error, :unauthorized} + assert Repo.get(Bans.User, ban.id) + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown id is not found, not unauthorized" do + # The load runs before the admin-only delete check, so a missing ban is + # not_found even for a moderator who could never delete it. + moderator = moderator_user_fixture() + assert Bans.delete_user_ban(actor(moderator), 2_147_483_647) == {:error, :not_found} + end + + test "an admin with an unknown id is not found" do + admin = admin_user_fixture() + assert Bans.delete_user_ban(actor(admin), 2_147_483_647) == {:error, :not_found} + end + + test "a non-castable id is not found for a moderator" do + moderator = moderator_user_fixture() + assert Bans.delete_user_ban(actor(moderator), "abc") == {:error, :not_found} + end + + test "a non-castable id is not found for an admin" do + admin = admin_user_fixture() + assert Bans.delete_user_ban(actor(admin), "abc") == {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = user_ban_fixture() + + assert Bans.delete_user_ban(actor(confirmed_user_fixture()), ban.id) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + ban = user_ban_fixture() + assert Bans.delete_user_ban(actor(), ban.id) == {:error, :unauthorized} + end + end + + describe "list_subnet_bans/3" do + test "a moderator gets the paginated subnet bans" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture() + + assert {:ok, page, _changeset} = Bans.list_subnet_bans(actor(moderator), %{}, @pagination) + assert ban.id in Enum.map(page.entries, & &1.id) + end + + test "the bq branch matches a ban by its generated ban id" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture() + _other = subnet_ban_fixture() + + assert {:ok, page, _changeset} = + Bans.list_subnet_bans( + actor(moderator), + %{"bq" => ban.generated_ban_id}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [ban.id] + end + + test "the ip branch matches a subnet ban containing the address" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + + assert {:ok, page, _changeset} = + Bans.list_subnet_bans(actor(moderator), %{"ip" => "203.0.113.50"}, @pagination) + + assert ban.id in Enum.map(page.entries, & &1.id) + end + + test "an invalid ip in the ip branch returns a changeset error" do + moderator = moderator_user_fixture() + + assert {:error, changeset} = + Bans.list_subnet_bans(actor(moderator), %{"ip" => "not-an-ip"}, @pagination) + + assert {"is invalid", _opts} = changeset.errors[:ip] + end + + test "a regular user is not authorized" do + assert Bans.list_subnet_bans(actor(confirmed_user_fixture()), %{}, @pagination) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Bans.list_subnet_bans(actor(), %{}, @pagination) == {:error, :unauthorized} + end + end + + describe "new_subnet_ban/2" do + test "a moderator gets a blank changeset for a nil specification" do + moderator = moderator_user_fixture() + + assert {:ok, %Ecto.Changeset{} = changeset} = Bans.new_subnet_ban(actor(moderator), nil) + assert Ecto.Changeset.get_field(changeset, :specification) == nil + end + + test "a moderator gets a changeset prefilled with a valid specification" do + moderator = moderator_user_fixture() + + assert {:ok, %Ecto.Changeset{} = changeset} = + Bans.new_subnet_ban(actor(moderator), "203.0.113.0/24") + + refute is_nil(Ecto.Changeset.get_field(changeset, :specification)) + end + + test "an invalid specification is returned in the changeset" do + moderator = moderator_user_fixture() + + assert {:ok, changeset} = Bans.new_subnet_ban(actor(moderator), "not-an-ip") + assert {"is invalid", _opts} = changeset.errors[:specification] + end + + test "a regular user is not authorized even with an invalid specification" do + # Authorization runs ahead of validation, so an unprivileged actor gets + # the unauthorized error rather than a changeset. + assert Bans.new_subnet_ban(actor(confirmed_user_fixture()), "not-an-ip") == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Bans.new_subnet_ban(actor(), "203.0.113.0/24") == {:error, :unauthorized} + end + end + + describe "create_subnet_ban/2" do + test "a moderator creates a ban and a moderation log is written" do + moderator = moderator_user_fixture() + + assert {:ok, ban} = Bans.create_subnet_ban(actor(moderator), valid_subnet_ban_attrs()) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Admin.SubnetBan:create" + assert log.subject_path == "/admin/subnet_bans" + assert log.body == "Created a subnet ban #{ban.generated_ban_id}" + end + + test "invalid attributes return a changeset and write no log" do + moderator = moderator_user_fixture() + + assert {:error, %Ecto.Changeset{}} = + Bans.create_subnet_ban(actor(moderator), %{ + valid_subnet_ban_attrs() + | "reason" => "" + }) + + assert moderation_log_count() == 0 + end + + test "a regular user is not authorized and creates nothing" do + assert Bans.create_subnet_ban(actor(confirmed_user_fixture()), valid_subnet_ban_attrs()) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "an anonymous visitor is not authorized" do + assert Bans.create_subnet_ban(actor(), valid_subnet_ban_attrs()) == {:error, :unauthorized} + end + end + + describe "edit_subnet_ban/2" do + test "a moderator loads the ban" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Bans.edit_subnet_ban(actor(moderator), ban.id) + + assert loaded.id == ban.id + end + + test "an unknown id is not found for a moderator" do + moderator = moderator_user_fixture() + + assert Bans.edit_subnet_ban(actor(moderator), 2_147_483_647) == + {:error, :not_found} + end + + test "an unknown id is not found for an admin" do + admin = admin_user_fixture() + assert Bans.edit_subnet_ban(actor(admin), 2_147_483_647) == {:error, :not_found} + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + assert Bans.edit_subnet_ban(actor(moderator), "abc") == {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = subnet_ban_fixture() + + assert Bans.edit_subnet_ban(actor(confirmed_user_fixture()), ban.id) == + {:error, :unauthorized} + end + end + + describe "update_subnet_ban/3" do + test "a moderator updates the ban and a moderation log is written" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture() + + assert {:ok, updated} = + Bans.update_subnet_ban(actor(moderator), ban.id, %{"reason" => "Changed"}) + + assert updated.reason == "Changed" + + log = only_moderation_log!() + assert log.type == "Admin.SubnetBan:update" + assert log.subject_path == "/admin/subnet_bans" + assert log.body == "Updated a subnet ban #{ban.generated_ban_id}" + end + + test "invalid attributes return a changeset and write no log" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture() + + assert {:error, %Ecto.Changeset{}} = + Bans.update_subnet_ban(actor(moderator), ban.id, %{"reason" => ""}) + + assert moderation_log_count() == 0 + end + + test "an unknown id is not found" do + moderator = moderator_user_fixture() + + assert Bans.update_subnet_ban(actor(moderator), 2_147_483_647, %{"reason" => "x"}) == + {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = subnet_ban_fixture() + + assert Bans.update_subnet_ban(actor(confirmed_user_fixture()), ban.id, %{"reason" => "x"}) == + {:error, :unauthorized} + end + end + + describe "delete_subnet_ban/2" do + test "an admin deletes the ban and a moderation log is written" do + admin = admin_user_fixture() + ban = subnet_ban_fixture() + + assert {:ok, deleted} = Bans.delete_subnet_ban(actor(admin), ban.id) + assert deleted.id == ban.id + refute Repo.get(Bans.Subnet, ban.id) + + log = only_moderation_log!() + assert log.type == "Admin.SubnetBan:delete" + assert log.subject_path == "/admin/subnet_bans" + assert log.body == "Deleted a subnet ban #{ban.generated_ban_id}" + end + + test "a moderator with a real ban id is not authorized to delete" do + moderator = moderator_user_fixture() + ban = subnet_ban_fixture() + + assert Bans.delete_subnet_ban(actor(moderator), ban.id) == {:error, :unauthorized} + assert Repo.get(Bans.Subnet, ban.id) + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown id is not found" do + moderator = moderator_user_fixture() + assert Bans.delete_subnet_ban(actor(moderator), 2_147_483_647) == {:error, :not_found} + end + + test "a non-castable id is not found" do + admin = admin_user_fixture() + assert Bans.delete_subnet_ban(actor(admin), "abc") == {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = subnet_ban_fixture() + + assert Bans.delete_subnet_ban(actor(confirmed_user_fixture()), ban.id) == + {:error, :unauthorized} + end + end + + describe "list_fingerprint_bans/3" do + test "a moderator gets the paginated fingerprint bans" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture() + + assert {:ok, page, _changeset} = + Bans.list_fingerprint_bans(actor(moderator), %{}, @pagination) + + assert ban.id in Enum.map(page.entries, & &1.id) + end + + test "the bq branch matches a ban by its generated ban id" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture() + _other = fingerprint_ban_fixture() + + assert {:ok, page, _changeset} = + Bans.list_fingerprint_bans( + actor(moderator), + %{"bq" => ban.generated_ban_id}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [ban.id] + end + + test "the fingerprint branch filters to an exact fingerprint" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture(%{"fingerprint" => "c0ffee1234"}) + _other = fingerprint_ban_fixture(%{"fingerprint" => "deadbeef"}) + + assert {:ok, page, _changeset} = + Bans.list_fingerprint_bans( + actor(moderator), + %{"fingerprint" => "c0ffee1234"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [ban.id] + end + + test "a regular user is not authorized" do + assert Bans.list_fingerprint_bans(actor(confirmed_user_fixture()), %{}, @pagination) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Bans.list_fingerprint_bans(actor(), %{}, @pagination) == {:error, :unauthorized} + end + end + + describe "new_fingerprint_ban/2" do + test "a moderator gets a changeset prefilled with the fingerprint" do + moderator = moderator_user_fixture() + + assert {:ok, %Ecto.Changeset{} = changeset} = + Bans.new_fingerprint_ban(actor(moderator), "c0ffee1234") + + assert Ecto.Changeset.get_field(changeset, :fingerprint) == "c0ffee1234" + end + + test "a regular user is not authorized" do + assert Bans.new_fingerprint_ban(actor(confirmed_user_fixture()), "c0ffee1234") == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Bans.new_fingerprint_ban(actor(), "c0ffee1234") == {:error, :unauthorized} + end + end + + describe "create_fingerprint_ban/2" do + test "a moderator creates a ban and a moderation log is written" do + moderator = moderator_user_fixture() + + assert {:ok, ban} = + Bans.create_fingerprint_ban(actor(moderator), valid_fingerprint_ban_attrs()) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Admin.FingerprintBan:create" + assert log.subject_path == "/admin/fingerprint_bans" + assert log.body == "Created a fingerprint ban #{ban.generated_ban_id}" + end + + test "invalid attributes return a changeset and write no log" do + moderator = moderator_user_fixture() + + assert {:error, %Ecto.Changeset{}} = + Bans.create_fingerprint_ban(actor(moderator), %{ + valid_fingerprint_ban_attrs() + | "reason" => "" + }) + + assert moderation_log_count() == 0 + end + + test "a regular user is not authorized" do + assert Bans.create_fingerprint_ban( + actor(confirmed_user_fixture()), + valid_fingerprint_ban_attrs() + ) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Bans.create_fingerprint_ban(actor(), valid_fingerprint_ban_attrs()) == + {:error, :unauthorized} + end + end + + describe "edit_fingerprint_ban/2" do + test "a moderator loads the ban" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Bans.edit_fingerprint_ban(actor(moderator), ban.id) + + assert loaded.id == ban.id + end + + test "an unknown id is not found for a moderator" do + moderator = moderator_user_fixture() + + assert Bans.edit_fingerprint_ban(actor(moderator), 2_147_483_647) == + {:error, :not_found} + end + + test "an unknown id is not found for an admin" do + admin = admin_user_fixture() + + assert Bans.edit_fingerprint_ban(actor(admin), 2_147_483_647) == + {:error, :not_found} + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + assert Bans.edit_fingerprint_ban(actor(moderator), "abc") == {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = fingerprint_ban_fixture() + + assert Bans.edit_fingerprint_ban(actor(confirmed_user_fixture()), ban.id) == + {:error, :unauthorized} + end + end + + describe "update_fingerprint_ban/3" do + test "a moderator updates the ban and a moderation log is written" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture() + + assert {:ok, updated} = + Bans.update_fingerprint_ban(actor(moderator), ban.id, %{"reason" => "Changed"}) + + assert updated.reason == "Changed" + + log = only_moderation_log!() + assert log.type == "Admin.FingerprintBan:update" + assert log.subject_path == "/admin/fingerprint_bans" + assert log.body == "Updated a fingerprint ban #{ban.generated_ban_id}" + end + + test "invalid attributes return a changeset and write no log" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture() + + assert {:error, %Ecto.Changeset{}} = + Bans.update_fingerprint_ban(actor(moderator), ban.id, %{"reason" => ""}) + + assert moderation_log_count() == 0 + end + + test "an unknown id is not found" do + moderator = moderator_user_fixture() + + assert Bans.update_fingerprint_ban(actor(moderator), 2_147_483_647, %{"reason" => "x"}) == + {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = fingerprint_ban_fixture() + + assert Bans.update_fingerprint_ban(actor(confirmed_user_fixture()), ban.id, %{ + "reason" => "x" + }) == + {:error, :unauthorized} + end + end + + describe "delete_fingerprint_ban/2" do + test "an admin deletes the ban and a moderation log is written" do + admin = admin_user_fixture() + ban = fingerprint_ban_fixture() + + assert {:ok, deleted} = Bans.delete_fingerprint_ban(actor(admin), ban.id) + assert deleted.id == ban.id + refute Repo.get(Bans.Fingerprint, ban.id) + + log = only_moderation_log!() + assert log.type == "Admin.FingerprintBan:delete" + assert log.subject_path == "/admin/fingerprint_bans" + assert log.body == "Deleted a fingerprint ban #{ban.generated_ban_id}" + end + + test "a moderator with a real ban id is not authorized to delete" do + moderator = moderator_user_fixture() + ban = fingerprint_ban_fixture() + + assert Bans.delete_fingerprint_ban(actor(moderator), ban.id) == {:error, :unauthorized} + assert Repo.get(Bans.Fingerprint, ban.id) + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown id is not found" do + moderator = moderator_user_fixture() + assert Bans.delete_fingerprint_ban(actor(moderator), 2_147_483_647) == {:error, :not_found} + end + + test "a non-castable id is not found" do + admin = admin_user_fixture() + assert Bans.delete_fingerprint_ban(actor(admin), "abc") == {:error, :not_found} + end + + test "a regular user is not authorized" do + ban = fingerprint_ban_fixture() + + assert Bans.delete_fingerprint_ban(actor(confirmed_user_fixture()), ban.id) == + {:error, :unauthorized} + end + end + + describe "shared ban-management contracts" do + test "a ban takes precedence over authorization for every ban creation flow" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + banned_actor = actor(moderator, ban: %{active: true}) + + assert Bans.create_user_ban(banned_actor, target.id, valid_user_ban_attrs()) == + {:error, :ban} + + assert Bans.create_subnet_ban(banned_actor, valid_subnet_ban_attrs()) == + {:error, :ban} + + assert Bans.create_fingerprint_ban(banned_actor, valid_fingerprint_ban_attrs()) == + {:error, :ban} + + assert moderation_log_count() == 0 + end + + test "a missing fingerprint rejects every ban creation flow" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + unattributed_actor = actor(moderator, fingerprint: nil) + + assert Bans.create_user_ban(unattributed_actor, target.id, valid_user_ban_attrs()) == + {:error, :unauthorized} + + assert Bans.create_subnet_ban(unattributed_actor, valid_subnet_ban_attrs()) == + {:error, :unauthorized} + + assert Bans.create_fingerprint_ban(unattributed_actor, valid_fingerprint_ban_attrs()) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "malformed member IDs are not found before authorization for all ban kinds" do + user_actor = actor(confirmed_user_fixture()) + + assert Bans.edit_user_ban(user_actor, "bad-id") == {:error, :not_found} + assert Bans.update_user_ban(user_actor, "bad-id", %{}) == {:error, :not_found} + assert Bans.delete_user_ban(user_actor, "bad-id") == {:error, :not_found} + + assert Bans.edit_subnet_ban(user_actor, "bad-id") == {:error, :not_found} + assert Bans.update_subnet_ban(user_actor, "bad-id", %{}) == {:error, :not_found} + assert Bans.delete_subnet_ban(user_actor, "bad-id") == {:error, :not_found} + + assert Bans.edit_fingerprint_ban(user_actor, "bad-id") == + {:error, :not_found} + + assert Bans.update_fingerprint_ban(user_actor, "bad-id", %{}) == + {:error, :not_found} + + assert Bans.delete_fingerprint_ban(user_actor, "bad-id") == {:error, :not_found} + end + end + + describe "find/3" do + test "an anonymous request prefers a subnet ban over a fingerprint ban" do + fingerprint = "d015c342859dde3" + _fingerprint_ban = fingerprint_ban_fixture(%{"fingerprint" => fingerprint}) + _subnet_ban = subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + + assert %{type: "Subnet"} = Bans.find(nil, inet("203.0.113.50"), fingerprint) + end + + test "a signed-in request ignores matching subnet and fingerprint bans" do + user = confirmed_user_fixture() + fingerprint = "d015c342859dde3" + _fingerprint_ban = fingerprint_ban_fixture(%{"fingerprint" => fingerprint}) + _subnet_ban = subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + + assert Bans.find(user, inet("203.0.113.50"), fingerprint) == nil + end + + test "a signed-in request returns its user ban" do + user = confirmed_user_fixture() + _user_ban = user_ban_fixture(user) + + assert %{type: "User"} = Bans.find(user, inet("203.0.113.50"), "d015c342859dde3") + end + + test "the newest matching ban wins within one ban kind" do + fingerprint = "d015c342859dde3" + older = fingerprint_ban_fixture(%{"fingerprint" => fingerprint, "reason" => "older"}) + newer = fingerprint_ban_fixture(%{"fingerprint" => fingerprint, "reason" => "newer"}) + + set_created_at(Bans.Fingerprint, older.id, ~U[2020-01-01 00:00:00Z]) + set_created_at(Bans.Fingerprint, newer.id, ~U[2024-01-01 00:00:00Z]) + + assert %{generated_ban_id: generated_ban_id} = Bans.find(nil, nil, fingerprint) + assert generated_ban_id == newer.generated_ban_id + end + + test "an identity with no attributes has no ban" do + assert Bans.find(nil, nil, nil) == nil + end + end + + # Controller-shaped attrs (string keys) a user ban insert requires: a target, + # a reason, and a valid_until (a RelativeDate a plain DateTime casts fine). + defp valid_user_ban_attrs do + %{ + "reason" => "Test ban reason", + "valid_until" => DateTime.add(DateTime.utc_now(:second), 365, :day) + } + end + + defp valid_subnet_ban_attrs do + %{ + "specification" => "203.0.113.0/24", + "reason" => "Test subnet reason", + "valid_until" => DateTime.add(DateTime.utc_now(:second), 365, :day) + } + end + + defp valid_fingerprint_ban_attrs do + %{ + "fingerprint" => "d015c342859dde3", + "reason" => "Test fingerprint reason", + "valid_until" => DateTime.add(DateTime.utc_now(:second), 365, :day) + } + end + + # Stamps created_at directly so the newest-first ordering can be observed + # without relying on insertion timing. + defp set_created_at(schema, id, created_at) do + schema + |> where(id: ^id) + |> Repo.update_all(set: [created_at: created_at]) + end +end diff --git a/test/philomena/channels_test.exs b/test/philomena/channels_test.exs new file mode 100644 index 000000000..c7dd67d47 --- /dev/null +++ b/test/philomena/channels_test.exs @@ -0,0 +1,537 @@ +defmodule Philomena.ChannelsTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.Channels` functions: + the livestreams index, the visit/notification/subscription actions, and the + staff-only CRUD form loaders and writes. + + These pin the fetched-only index scoping with its NSFW filter and `cq` search, + the per-role authorization matrices (the `:show` actions any visitor may + reach, the `:new`/`:create`/`:edit`/`:update`/`:delete` actions restricted to + moderators and up), uniform not-found results for malformed and absent IDs, + and the preserved oddity that an update ignores the fetcher-managed fields. + + Every controller-facing function takes a `%Philomena.Attribution.Actor{}`, + matching what the controller hands in as `conn.assigns.actor`; its `:user` is + `nil` for an anonymous visitor. + """ + + use Philomena.DataCase, async: true + + alias Philomena.Channels + alias Philomena.Channels.Channel + alias Philomena.Repo + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.ChannelsFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + @pagination %{page_number: 1, page_size: 25} + + describe "count_live_channels/0" do + test "counts only live channels" do + listed_channel_fixture(%{}, %{is_live: true}) + listed_channel_fixture(%{}, %{is_live: true}) + listed_channel_fixture(%{}, %{is_live: false}) + + assert Channels.count_live_channels() == 2 + end + end + + describe "list_channels/4" do + test "lists only channels the fetcher has stamped" do + fetched = listed_channel_fixture() + unfetched = channel_fixture() + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + + assert fetched.id in ids + refute unfetched.id in ids + end + + test "excludes NSFW channels when NSFW is not shown" do + sfw = listed_channel_fixture(%{}, %{nsfw: false}) + nsfw = listed_channel_fixture(%{}, %{nsfw: true}) + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), false, %{}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + + assert sfw.id in ids + refute nsfw.id in ids + end + + test "includes NSFW channels when NSFW is shown" do + nsfw = listed_channel_fixture(%{}, %{nsfw: true}) + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{}, @pagination) + + assert nsfw.id in Enum.map(page.entries, & &1.id) + end + + test "orders live channels ahead of offline ones" do + offline = listed_channel_fixture(%{}, %{is_live: false, title: "zzz offline"}) + live = listed_channel_fixture(%{}, %{is_live: true, title: "aaa live"}) + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + + assert Enum.find_index(ids, &(&1 == live.id)) < + Enum.find_index(ids, &(&1 == offline.id)) + end + + test "a cq matches the channel title by prefix" do + match = listed_channel_fixture(%{}, %{title: "Pony Stream"}) + other = listed_channel_fixture(%{}, %{title: "Cat Stream"}) + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{"cq" => "Pony"}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + + assert match.id in ids + refute other.id in ids + end + + test "a cq matches the channel short name by prefix" do + match = listed_channel_fixture(%{"short_name" => "searchablechan"}) + other = listed_channel_fixture() + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{"cq" => "searchable"}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + + assert match.id in ids + refute other.id in ids + end + + test "a cq matches the associated artist tag name by substring" do + tag = tag_fixture(%{name: "artist:cqsearchtarget"}) + match = listed_channel_fixture(%{"artist_tag" => tag.name}) + other = listed_channel_fixture() + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{"cq" => "cqsearchtarget"}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + + assert match.id in ids + refute other.id in ids + end + + test "the associated artist tag is preloaded" do + tag = tag_fixture(%{name: "artist:preloadtag"}) + listed_channel_fixture(%{"artist_tag" => tag.name}) + + {:ok, page, _subscriptions, _changeset} = + Channels.list_channels(actor(), true, %{}, @pagination) + + [channel | _] = page.entries + + assert Ecto.assoc_loaded?(channel.associated_artist_tag) + end + + test "returns subscription state only for the actor's user" do + channel = listed_channel_fixture() + user = confirmed_user_fixture() + other_user = confirmed_user_fixture() + {:ok, _subscription} = Channels.create_subscription(channel, user) + + {:ok, _page, subscriptions, _changeset} = + Channels.list_channels(actor(user), true, %{}, @pagination) + + assert subscriptions == %{channel.id => true} + + {:ok, _page, subscriptions, _changeset} = + Channels.list_channels(actor(other_user), true, %{}, @pagination) + + assert subscriptions == %{} + end + end + + describe "show_channel/2" do + test "an anonymous visitor visits a channel" do + channel = channel_fixture() + + assert {:ok, loaded} = Channels.show_channel(actor(), to_string(channel.id)) + assert loaded.id == channel.id + end + + test "a signed-in user visits a channel" do + channel = channel_fixture() + + assert {:ok, loaded} = + Channels.show_channel(actor(confirmed_user_fixture()), to_string(channel.id)) + + assert loaded.id == channel.id + end + + test "an unknown well-formed id is not found for an anonymous visitor" do + assert Channels.show_channel(actor(), "2147483647") == {:error, :not_found} + end + + test "an unknown well-formed id is not found for a regular user" do + assert Channels.show_channel(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + assert Channels.show_channel(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-integer id is not found" do + assert Channels.show_channel(actor(), "not-an-integer") == {:error, :not_found} + end + + test "offline and NSFW channels remain directly visitable" do + channel = listed_channel_fixture(%{}, %{is_live: false, nsfw: true}) + + assert {:ok, loaded} = Channels.show_channel(actor(), to_string(channel.id)) + assert loaded.id == channel.id + end + end + + describe "create_channel_read/2" do + test "an anonymous actor is unauthorized" do + channel = channel_fixture() + + assert Channels.create_channel_read(actor(), to_string(channel.id)) == + {:error, :unauthorized} + end + + test "a signed-in user clears the notification and gets the channel back" do + channel = channel_fixture() + + assert {:ok, loaded} = + Channels.create_channel_read( + actor(confirmed_user_fixture()), + to_string(channel.id) + ) + + assert loaded.id == channel.id + end + + test "an unknown well-formed id is not found, with no authorization involved" do + assert Channels.create_channel_read(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-integer id is not found" do + assert Channels.create_channel_read(actor(confirmed_user_fixture()), "not-an-integer") == + {:error, :not_found} + end + end + + describe "new_channel/1" do + test "a regular user is unauthorized" do + assert Channels.new_channel(actor(confirmed_user_fixture())) == {:error, :unauthorized} + end + + test "a moderator gets a blank changeset" do + assert {:ok, %Ecto.Changeset{data: %Channel{id: nil}}} = + Channels.new_channel(actor(moderator_user_fixture())) + end + + test "an admin gets a blank changeset" do + assert {:ok, %Ecto.Changeset{}} = Channels.new_channel(actor(admin_user_fixture())) + end + end + + describe "create_channel/2" do + test "a regular user is unauthorized" do + assert Channels.create_channel(actor(confirmed_user_fixture()), %{ + "type" => "PicartoChannel", + "short_name" => unique_channel_short_name() + }) == {:error, :unauthorized} + end + + test "a moderator creates a channel" do + assert {:ok, %Channel{} = channel} = + Channels.create_channel(actor(moderator_user_fixture()), %{ + "type" => "PicartoChannel", + "short_name" => unique_channel_short_name() + }) + + assert channel.type == "PicartoChannel" + end + + test "an invalid type is a rejected changeset" do + assert {:error, %Ecto.Changeset{} = changeset} = + Channels.create_channel(actor(moderator_user_fixture()), %{ + "type" => "NotARealChannel", + "short_name" => unique_channel_short_name() + }) + + refute changeset.valid? + end + + test "the artist tag is resolved from the attributes" do + tag = tag_fixture(%{name: "artist:createwithtag"}) + + assert {:ok, %Channel{} = channel} = + Channels.create_channel(actor(moderator_user_fixture()), %{ + "type" => "PicartoChannel", + "short_name" => unique_channel_short_name(), + "artist_tag" => tag.name + }) + + assert channel.associated_artist_tag_id == tag.id + end + + test "an aliased artist tag resolves to its canonical tag" do + canonical = tag_fixture(%{name: "artist:channelcanonical"}) + + alias_tag = + tag_fixture(%{name: "artist:channelalias"}) + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + assert {:ok, %Channel{} = channel} = + Channels.create_channel(actor(moderator_user_fixture()), %{ + "type" => "PicartoChannel", + "short_name" => unique_channel_short_name(), + "artist_tag" => alias_tag.name + }) + + assert channel.associated_artist_tag_id == canonical.id + end + end + + describe "edit_channel/2" do + test "a moderator loads a channel paired with an edit changeset" do + channel = channel_fixture() + + assert {:ok, {%Channel{} = loaded, %Ecto.Changeset{} = changeset}} = + Channels.edit_channel( + actor(moderator_user_fixture()), + to_string(channel.id) + ) + + assert loaded.id == channel.id + assert changeset.data.id == channel.id + end + + test "a regular user is unauthorized" do + channel = channel_fixture() + + assert Channels.edit_channel( + actor(confirmed_user_fixture()), + to_string(channel.id) + ) == + {:error, :unauthorized} + end + + test "a non-integer id is not found" do + assert Channels.edit_channel(actor(moderator_user_fixture()), "not-an-integer") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for every actor" do + assert Channels.edit_channel(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Channels.edit_channel(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "update_channel/3" do + test "a moderator renames a channel" do + channel = channel_fixture() + new_name = unique_channel_short_name() + + assert {:ok, %Channel{} = updated} = + Channels.update_channel(actor(moderator_user_fixture()), to_string(channel.id), %{ + "short_name" => new_name + }) + + assert updated.short_name == new_name + assert Repo.reload!(channel).short_name == new_name + end + + test "an update silently ignores fetcher-managed fields" do + # Channel.changeset/2 casts only :type and :short_name, so a crafted update + # carrying a fetcher-managed field like :title succeeds but leaves it + # unchanged. + channel = listed_channel_fixture(%{}, %{title: "Original Title"}) + + assert {:ok, %Channel{} = updated} = + Channels.update_channel(actor(moderator_user_fixture()), to_string(channel.id), %{ + "title" => "Crafted Title" + }) + + assert updated.title == "Original Title" + assert Repo.reload!(channel).title == "Original Title" + end + + test "an invalid type is a rejected changeset" do + channel = channel_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Channels.update_channel(actor(moderator_user_fixture()), to_string(channel.id), %{ + "type" => "NotARealChannel" + }) + + refute changeset.valid? + end + + test "a regular user is unauthorized and leaves the row unchanged" do + channel = channel_fixture() + + assert Channels.update_channel(actor(confirmed_user_fixture()), to_string(channel.id), %{ + "short_name" => "hijacked" + }) == {:error, :unauthorized} + + assert Repo.reload!(channel).short_name == channel.short_name + end + + test "a non-integer id is not found" do + assert Channels.update_channel(actor(moderator_user_fixture()), "not-an-integer", %{ + "short_name" => "x" + }) == {:error, :not_found} + end + + test "an unknown well-formed id is not found for every actor" do + assert Channels.update_channel(actor(moderator_user_fixture()), "2147483647", %{ + "short_name" => "x" + }) == {:error, :not_found} + + assert Channels.update_channel(actor(admin_user_fixture()), "2147483647", %{ + "short_name" => "x" + }) == + {:error, :not_found} + end + end + + describe "delete_channel/2" do + test "a moderator deletes a channel" do + channel = channel_fixture() + + assert {:ok, %Channel{}} = + Channels.delete_channel(actor(moderator_user_fixture()), to_string(channel.id)) + + assert Repo.reload(channel) == nil + end + + test "a regular user is unauthorized and leaves the row" do + channel = channel_fixture() + + assert Channels.delete_channel(actor(confirmed_user_fixture()), to_string(channel.id)) == + {:error, :unauthorized} + + refute Repo.reload(channel) == nil + end + + test "a non-integer id is not found" do + assert Channels.delete_channel(actor(moderator_user_fixture()), "not-an-integer") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for every actor" do + assert Channels.delete_channel(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Channels.delete_channel(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "create_channel_subscription/2 and delete_channel_subscription/2" do + test "an anonymous actor cannot mutate subscription state" do + channel = channel_fixture() + + assert Channels.create_channel_subscription(actor(), to_string(channel.id)) == + {:error, :unauthorized} + + assert Channels.delete_channel_subscription(actor(), to_string(channel.id)) == + {:error, :unauthorized} + end + + test "a user subscribes to and then unsubscribes from a channel" do + user = confirmed_user_fixture() + channel = channel_fixture() + + assert {:ok, subscribed} = + Channels.create_channel_subscription(actor(user), to_string(channel.id)) + + assert subscribed.id == channel.id + assert Channels.subscribed?(channel, user) + + assert {:ok, unsubscribed} = + Channels.delete_channel_subscription(actor(user), to_string(channel.id)) + + assert unsubscribed.id == channel.id + refute Channels.subscribed?(channel, user) + end + + test "subscribing is idempotent" do + user = confirmed_user_fixture() + channel = channel_fixture() + + assert {:ok, _} = Channels.create_channel_subscription(actor(user), to_string(channel.id)) + assert {:ok, _} = Channels.create_channel_subscription(actor(user), to_string(channel.id)) + assert Channels.subscribed?(channel, user) + end + + test "unsubscribing when not subscribed is an idempotent success" do + user = confirmed_user_fixture() + channel = channel_fixture() + + assert {:ok, loaded} = + Channels.delete_channel_subscription(actor(user), to_string(channel.id)) + + assert loaded.id == channel.id + end + + test "an unknown well-formed id is not found for every actor" do + assert Channels.create_channel_subscription(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Channels.create_channel_subscription(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-integer id is not found on create_channel_subscription" do + assert Channels.create_channel_subscription( + actor(confirmed_user_fixture()), + "not-an-integer" + ) == + {:error, :not_found} + end + + test "an unknown well-formed id is not found on delete_channel_subscription" do + assert Channels.delete_channel_subscription(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Channels.delete_channel_subscription(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "subscription management is exempt from the global write prerequisite" do + user = confirmed_user_fixture() + channel = channel_fixture() + + assert {:ok, _} = + Channels.create_channel_subscription(actor(user, ban: %{}), to_string(channel.id)) + + assert Channels.subscribed?(channel, user) + + assert {:ok, _} = + Channels.delete_channel_subscription( + actor(user, fingerprint: nil), + to_string(channel.id) + ) + + refute Channels.subscribed?(channel, user) + end + end +end diff --git a/test/philomena/comments_test.exs b/test/philomena/comments_test.exs new file mode 100644 index 000000000..0504a2340 --- /dev/null +++ b/test/philomena/comments_test.exs @@ -0,0 +1,1725 @@ +defmodule Philomena.CommentsTest do + @moduledoc """ + Context-level tests for `Philomena.Comments`. + + `search_comments/4` runs a comment search against OpenSearch and applies the + viewer's visibility rules. `approve_comment/3` is the actor-first moderation + wrapper: it pins the authorization matrix, the two global error shapes routed + through the id guard, the approval effects (report closure, author comment + count, reindex), and the moderation log entry - type, body, and subject path + asserted exactly. + """ + + use Philomena.DataCase, async: false + + @moduletag :search + + import Ecto.Query + import Philomena.AttributionFixtures + import Philomena.CommentsFixtures + import Philomena.FiltersFixtures + import Philomena.ImagesFixtures + import Philomena.ReportsFixtures + import Philomena.RulesFixtures + import Philomena.UsersFixtures + + alias Philomena.Comments + alias Philomena.Filters.Filter + alias Philomena.Repo + alias Philomena.Comments.{Comment, CommentHistory, CommentVersion} + alias Philomena.Images.Image + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Reports.Report + alias Philomena.Users.User + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to the write-access and + # not-banned checks the actor-first writes run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + setup do + Search.clear_index!(Comment) + :ok + end + + @pagination %{page_number: 1, page_size: 25} + @empty_filter %Filter{hidden_tag_ids: []} + + describe "query_comments/4" do + test "returns an error for an uncompilable query string" do + assert {:error, msg} = + Comments.query_comments( + actor(), + @empty_filter, + "created_at.gte:not-a-date", + @pagination + ) + + assert is_binary(msg) + end + + test "finds an indexed comment with preloads for an anonymous viewer" do + user = user_fixture() + image = image_fixture() + comment = comment_fixture(image, user, %{"body" => "Test grapefruit comment"}) + SearchHelpers.reindex_all!(Comment) + + assert {:ok, results} = + Comments.query_comments(actor(), @empty_filter, "grapefruit", @pagination) + + assert [entry] = results.entries + assert entry.id == comment.id + + # Display preloads are loaded on the returned records. + assert %Philomena.Users.User{} = entry.user + assert is_list(entry.image.tags) + assert is_list(entry.image.sources) + end + + test "excludes a hidden comment from an anonymous viewer" do + image = image_fixture() + comment = comment_fixture(image, nil, %{"body" => "Test grapefruit comment"}) + + comment + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + SearchHelpers.reindex_all!(Comment) + + assert {:ok, results} = + Comments.query_comments(actor(), @empty_filter, "grapefruit", @pagination) + + assert results.entries == [] + end + + test "includes a hidden comment for a moderator" do + moderator = moderator_user_fixture() + image = image_fixture() + comment = comment_fixture(image, nil, %{"body" => "Test grapefruit comment"}) + + comment + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + SearchHelpers.reindex_all!(Comment) + + assert {:ok, results} = + Comments.query_comments( + actor(moderator), + @empty_filter, + "grapefruit", + @pagination + ) + + assert [entry] = results.entries + assert entry.id == comment.id + end + + test "uses abilities independently for hidden-comment visibility and sensitive fields" do + image = image_fixture() + comment = comment_fixture(image, nil, %{"body" => "Test grapefruit comment"}) + + comment + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + assistant = assistant_user_fixture() + comment_assistant = %{assistant | role_map: %{"Comment" => %{"moderator" => []}}} + SearchHelpers.reindex_all!(Comment) + + assert {:ok, plain_results} = + Comments.query_comments( + actor(assistant), + @empty_filter, + "grapefruit", + @pagination + ) + + assert plain_results.entries == [] + + assert {:ok, scoped_results} = + Comments.query_comments( + actor(comment_assistant), + @empty_filter, + "grapefruit", + @pagination + ) + + assert Enum.map(scoped_results.entries, & &1.id) == [comment.id] + + ip_query = "ip:#{comment.ip}" + + assert {:ok, assistant_ip_results} = + Comments.query_comments( + actor(comment_assistant), + @empty_filter, + ip_query, + @pagination + ) + + assert assistant_ip_results.entries == [] + + assert {:ok, moderator_ip_results} = + Comments.query_comments( + actor(moderator_user_fixture()), + @empty_filter, + ip_query, + @pagination + ) + + assert Enum.map(moderator_ip_results.entries, & &1.id) == [comment.id] + end + + test "excludes a comment on an image carrying a hidden tag" do + image = image_fixture(tags: "grimdark") + tag = Enum.find(image.tags, &(&1.name == "grimdark")) + _comment = comment_fixture(image, nil, %{"body" => "Test grapefruit comment"}) + SearchHelpers.reindex_all!(Comment) + + hidden_filter = %Filter{hidden_tag_ids: [tag.id]} + + assert {:ok, results} = + Comments.query_comments(actor(), hidden_filter, "grapefruit", @pagination) + + assert results.entries == [] + + # The same comment is visible when the tag is not hidden. + assert {:ok, results} = + Comments.query_comments(actor(), @empty_filter, "grapefruit", @pagination) + + assert [_entry] = results.entries + end + end + + describe "show_comment/2 visibility matrix" do + test "normalizes malformed, missing, hidden-parent, and hidden-comment rows" do + user = confirmed_user_fixture() + moderator = moderator_user_fixture() + assistant = assistant_user_fixture() + comment_assistant = %{assistant | role_map: %{"Comment" => %{"moderator" => []}}} + + visible_image = image_fixture() + visible_comment = comment_fixture(visible_image) + + hidden_comment = + visible_image + |> comment_fixture() + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + hidden_image = image_fixture(hidden_from_users: true) + parent_hidden_comment = comment_fixture(hidden_image, moderator) + + for viewer <- [actor(), actor(user), actor(moderator), actor(comment_assistant)] do + assert Comments.show_comment(viewer, "not-an-id") == {:error, :not_found} + assert Comments.show_comment(viewer, "2147483647") == {:error, :not_found} + end + + for viewer <- [actor(), actor(user)] do + assert Comments.show_comment(viewer, hidden_comment.id) == {:error, :unauthorized} + + assert Comments.show_comment(viewer, parent_hidden_comment.id) == + {:error, :unauthorized} + end + + assert {:ok, %{id: id}} = Comments.show_comment(actor(moderator), hidden_comment.id) + assert id == hidden_comment.id + + assert {:ok, %{id: id}} = Comments.show_comment(actor(moderator), parent_hidden_comment.id) + assert id == parent_hidden_comment.id + + assert {:ok, %{id: id}} = + Comments.show_comment(actor(comment_assistant), hidden_comment.id) + + assert id == hidden_comment.id + + assert Comments.show_comment(actor(comment_assistant), parent_hidden_comment.id) == + {:error, :unauthorized} + + assert {:ok, %{id: id}} = Comments.show_comment(actor(), visible_comment.id) + assert id == visible_comment.id + end + end + + # A comment authored by a fresh (untrusted) user containing an external link + # is not auto-approved on creation (see Philomena.Schema.Approval); returns the + # comment together with its author so the comments_count bump can be checked. + defp unapproved_comment(image) do + approval_rule!() + author = confirmed_user_fixture() + + comment = + comment_fixture(image, author, %{"body" => "check this out https://spam.example/"}) + + refute comment.approved + {comment, author} + end + + defp approval_rule! do + rule_fixture() + |> Ecto.Changeset.change(name: "Approval") + |> Repo.update!() + end + + defp no_moderation_logs! do + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + defp force_filter_for_image(user, image) do + filter = system_filter_fixture(hidden_complex_str: "id:#{image.id}") + + user + |> Ecto.Changeset.change(forced_filter_id: filter.id) + |> Repo.update!() + end + + describe "create_comment_approve/3" do + setup do + %{image: image_fixture()} + end + + test "denies an anonymous actor", %{image: image} do + {comment, _author} = unapproved_comment(image) + + assert Comments.create_comment_approve(actor(), "#{image.id}", "#{comment.id}") == + {:error, :unauthorized} + + refute Repo.reload!(comment).approved + no_moderation_logs!() + end + + test "denies a regular user", %{image: image} do + {comment, _author} = unapproved_comment(image) + + assert Comments.create_comment_approve( + actor(confirmed_user_fixture()), + "#{image.id}", + "#{comment.id}" + ) == + {:error, :unauthorized} + + refute Repo.reload!(comment).approved + no_moderation_logs!() + end + + test "a moderator approves the comment, which is returned approved", %{image: image} do + {comment, _author} = unapproved_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, %Comment{} = approved} = + Comments.create_comment_approve(actor(moderator), "#{image.id}", "#{comment.id}") + + assert approved.id == comment.id + assert approved.approved + + assert Repo.reload!(comment).approved + end + + test "the moderation log names the image and comment exactly", %{image: image} do + {comment, _author} = unapproved_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, _} = + Comments.create_comment_approve(actor(moderator), "#{image.id}", "#{comment.id}") + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Image.Comment.Approve:create" + assert log.body == "Approved comment on image #{image.id}" + assert log.subject_path == "/images/#{image.id}#comment_#{comment.id}" + end + + test "approving increments the author's comments_count by one", %{image: image} do + {comment, author} = unapproved_comment(image) + before = Repo.get!(User, author.id).comments_count + + assert {:ok, _} = + Comments.create_comment_approve( + actor(moderator_user_fixture()), + "#{image.id}", + "#{comment.id}" + ) + + assert Repo.get!(User, author.id).comments_count == before + 1 + end + + test "approving closes the comment's open reports", %{image: image} do + {comment, _author} = unapproved_comment(image) + report = report_fixture(confirmed_user_fixture(), comment_id: comment.id) + + assert report.open + assert report.state == "open" + + assert {:ok, _} = + Comments.create_comment_approve( + actor(moderator_user_fixture()), + "#{image.id}", + "#{comment.id}" + ) + + closed = Repo.get!(Report, report.id) + refute closed.open + assert closed.state == "closed" + end + + # Repeated approval fails and does not increment the author's count a second time. + test "approving an already-approved comment fails", %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "A perfectly ordinary comment"}) + assert comment.approved + + before = Repo.get!(User, author.id).comments_count + + assert {:error, _changeset} = + Comments.create_comment_approve( + actor(moderator_user_fixture()), + "#{image.id}", + "#{comment.id}" + ) + + assert Repo.get!(User, author.id).comments_count == before + refute Repo.exists?(ModerationLog) + end + + test "a well-formed id naming no row is not found", %{image: image} do + assert Comments.create_comment_approve( + actor(moderator_user_fixture()), + image.id, + "999999999" + ) == {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found", %{image: image} do + assert Comments.create_comment_approve(actor(moderator_user_fixture()), image.id, "abc") == + {:error, :not_found} + + no_moderation_logs!() + end + end + + # A visible comment authored by a fresh user, ready to be destroyed. + defp visible_comment(image) do + comment_fixture(image, confirmed_user_fixture(), %{"body" => "Rule-breaking comment"}) + end + + # An already-hidden comment, set up so no moderation log exists + # before the destroy under test runs. + defp already_hidden_comment(image) do + {:ok, hidden} = + Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + visible_comment(image).id, + %{"deletion_reason" => "Spam"} + ) + + Repo.delete_all(ModerationLog) + + hidden + end + + describe "create_comment_delete/3" do + setup do + %{image: image_fixture()} + end + + test "denies an anonymous actor, leaving the body intact", %{image: image} do + comment = visible_comment(image) + + assert Comments.create_comment_delete(actor(), image.id, comment.id) == + {:error, :unauthorized} + + reloaded = Repo.reload!(comment) + assert reloaded.body == "Rule-breaking comment" + refute reloaded.destroyed_content + no_moderation_logs!() + end + + test "denies a regular user, leaving the body intact", %{image: image} do + comment = visible_comment(image) + + assert Comments.create_comment_delete(actor(confirmed_user_fixture()), image.id, comment.id) == + {:error, :unauthorized} + + reloaded = Repo.reload!(comment) + assert reloaded.body == "Rule-breaking comment" + refute reloaded.destroyed_content + no_moderation_logs!() + end + + test "a moderator destroys the comment, emptying its body", %{image: image} do + comment = already_hidden_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, %Comment{} = destroyed} = + Comments.create_comment_delete(actor(moderator), image.id, comment.id) + + assert destroyed.id == comment.id + + # The destroy engine blanks the body and marks the content destroyed. It + # requires but does not touch the comment's hidden/deletion_reason fields. + reloaded = Repo.reload!(comment) + assert reloaded.body == "" + assert reloaded.destroyed_content + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + end + + test "destroys an already-hidden comment, keeping its hidden flag and reason", + %{image: image} do + comment = already_hidden_comment(image) + + no_moderation_logs!() + + assert {:ok, %Comment{}} = + Comments.create_comment_delete( + actor(moderator_user_fixture()), + image.id, + comment.id + ) + + reloaded = Repo.reload!(comment) + assert reloaded.body == "" + assert reloaded.destroyed_content + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + + assert {:error, %Ecto.Changeset{} = changeset} = + Comments.create_comment_delete( + actor(moderator_user_fixture()), + image.id, + comment.id + ) + + reloaded = Repo.reload!(comment) + assert %{destroyed_content: ["has already been destroyed"]} = errors_on(changeset) + assert reloaded.body == "" + assert reloaded.destroyed_content + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + end + + test "fails to destroy a visible comment", %{image: image} do + comment = visible_comment(image) + + assert {:error, %Ecto.Changeset{} = changeset} = + Comments.create_comment_delete(actor(admin_user_fixture()), image.id, comment.id) + + no_moderation_logs!() + + reloaded = Repo.reload!(comment) + + assert %{destroyed_content: ["cannot be set while comment is visible"]} = + errors_on(changeset) + + refute reloaded.body == "" + refute reloaded.destroyed_content + refute reloaded.hidden_from_users + refute reloaded.deletion_reason == "Spam" + end + + test "the moderation log names the image and comment exactly", %{image: image} do + comment = already_hidden_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, _} = Comments.create_comment_delete(actor(moderator), image.id, comment.id) + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Image.Comment.Delete:create" + assert log.body == "Destroyed comment on image #{image.id}" + assert log.subject_path == "/images/#{image.id}#comment_#{comment.id}" + end + + test "destroying decrements the author's comments_count by one", %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "Rule-breaking comment"}) + before = Repo.get!(User, author.id).comments_count + + assert {:ok, _} = + Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + comment.id, + %{ + "deletion_reason" => "spam" + } + ) + + assert {:ok, _} = + Comments.create_comment_delete( + actor(moderator_user_fixture()), + image.id, + comment.id + ) + + assert Repo.get!(User, author.id).comments_count == before - 1 + end + + test "destroying a withheld comment does not decrement the author's comments_count", + %{image: image} do + {comment, author} = unapproved_comment(image) + before = Repo.get!(User, author.id).comments_count + + refute comment.approved + + assert {:ok, _} = + Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + comment.id, + %{"deletion_reason" => "Spam"} + ) + + assert {:ok, _} = + Comments.create_comment_delete( + actor(moderator_user_fixture()), + image.id, + comment.id + ) + + assert Repo.get!(User, author.id).comments_count == before + end + + test "a well-formed id naming no row is not found", %{image: image} do + assert Comments.create_comment_delete( + actor(moderator_user_fixture()), + image.id, + "999999999" + ) == {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found", %{image: image} do + assert Comments.create_comment_delete(actor(moderator_user_fixture()), image.id, "abc") == + {:error, :not_found} + + no_moderation_logs!() + end + end + + describe "create_comment_hide/4" do + setup do + %{image: image_fixture()} + end + + test "denies an anonymous actor, leaving the comment visible", %{image: image} do + comment = visible_comment(image) + + assert Comments.create_comment_hide(actor(), image.id, comment.id, %{ + "deletion_reason" => "Spam" + }) == + {:error, :unauthorized} + + refute Repo.reload!(comment).hidden_from_users + no_moderation_logs!() + end + + test "denies a regular user, leaving the comment visible", %{image: image} do + comment = visible_comment(image) + + assert Comments.create_comment_hide( + actor(confirmed_user_fixture()), + image.id, + comment.id, + %{"deletion_reason" => "Spam"} + ) == + {:error, :unauthorized} + + reloaded = Repo.reload!(comment) + refute reloaded.hidden_from_users + assert reloaded.deletion_reason == "" + no_moderation_logs!() + end + + test "a moderator hides the comment with the given reason", %{image: image} do + comment = visible_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, %Comment{} = hidden} = + Comments.create_comment_hide(actor(moderator), image.id, comment.id, %{ + "deletion_reason" => "Spam" + }) + + assert hidden.id == comment.id + assert hidden.hidden_from_users + assert hidden.deletion_reason == "Spam" + + reloaded = Repo.reload!(comment) + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + end + + test "the moderation log names the image, comment, and reason exactly", %{image: image} do + comment = visible_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, _} = + Comments.create_comment_hide(actor(moderator), image.id, comment.id, %{ + "deletion_reason" => "Spam" + }) + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Image.Comment.Hide:create" + assert log.body == "Deleted comment on image #{image.id} (Spam)" + assert log.subject_path == "/images/#{image.id}#comment_#{comment.id}" + end + + test "a blank deletion reason is a rejected changeset carrying the loaded comment", + %{image: image} do + comment = visible_comment(image) + + assert {:error, %Ecto.Changeset{data: %Comment{} = returned}} = + Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + comment.id, + %{ + "deletion_reason" => "" + } + ) + + assert returned.id == comment.id + refute Repo.reload!(comment).hidden_from_users + no_moderation_logs!() + end + + test "a well-formed id naming no row is not found", %{image: image} do + assert Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + "999999999", + %{"deletion_reason" => "Spam"} + ) == {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found", %{image: image} do + assert Comments.create_comment_hide(actor(moderator_user_fixture()), image.id, "abc", %{ + "deletion_reason" => "Spam" + }) == + {:error, :not_found} + + no_moderation_logs!() + end + end + + describe "delete_comment_hide/3" do + setup do + %{image: image_fixture()} + end + + test "denies an anonymous actor, leaving the comment hidden", %{image: image} do + comment = already_hidden_comment(image) + + assert Comments.delete_comment_hide(actor(), image.id, comment.id) == + {:error, :unauthorized} + + assert Repo.reload!(comment).hidden_from_users + no_moderation_logs!() + end + + test "denies a regular user, leaving the comment hidden", %{image: image} do + comment = already_hidden_comment(image) + + assert Comments.delete_comment_hide(actor(confirmed_user_fixture()), image.id, comment.id) == + {:error, :unauthorized} + + reloaded = Repo.reload!(comment) + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + no_moderation_logs!() + end + + test "a moderator restores the comment, clearing its hidden flag and reason", + %{image: image} do + comment = already_hidden_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, %Comment{} = restored} = + Comments.delete_comment_hide(actor(moderator), image.id, comment.id) + + assert restored.id == comment.id + refute restored.hidden_from_users + assert restored.deletion_reason == "" + + reloaded = Repo.reload!(comment) + refute reloaded.hidden_from_users + assert reloaded.deletion_reason == "" + end + + test "the moderation log names the image and comment exactly", %{image: image} do + comment = already_hidden_comment(image) + moderator = moderator_user_fixture() + + assert {:ok, _} = Comments.delete_comment_hide(actor(moderator), image.id, comment.id) + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Image.Comment.Hide:delete" + assert log.body == "Restored comment on image #{image.id}" + assert log.subject_path == "/images/#{image.id}#comment_#{comment.id}" + end + + # The restore is an unconditional column write, so restoring a comment that + # is not hidden succeeds and still writes a log. + test "restoring a non-hidden comment succeeds and logs", %{image: image} do + comment = visible_comment(image) + refute comment.hidden_from_users + + assert {:ok, %Comment{} = restored} = + Comments.delete_comment_hide(actor(moderator_user_fixture()), image.id, comment.id) + + refute restored.hidden_from_users + + log = Repo.one!(ModerationLog) + assert log.type == "Image.Comment.Hide:delete" + assert log.body == "Restored comment on image #{image.id}" + end + + test "a well-formed id naming no row is not found", %{image: image} do + assert Comments.delete_comment_hide(actor(moderator_user_fixture()), image.id, "999999999") == + {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found", %{image: image} do + assert Comments.delete_comment_hide(actor(moderator_user_fixture()), image.id, "abc") == + {:error, :not_found} + + no_moderation_logs!() + end + end + + describe "list_comment_history/3" do + # A public read routed by image id and comment id. It writes no moderation + # log and runs no ban check; it authorizes :show on the image and, for a + # hidden comment, :show on the comment. + + setup do + %{image: image_fixture()} + end + + test "an anonymous actor reads the history of a visible comment", %{image: image} do + comment = comment_fixture(image, confirmed_user_fixture(), %{"body" => "A visible comment"}) + + assert {:ok, %CommentHistory{} = history} = + Comments.list_comment_history(actor(), "#{image.id}", "#{comment.id}") + + assert history.image.id == image.id + assert history.comment.id == comment.id + + # The comment comes back with the associations the history page renders. + assert %Philomena.Images.Image{} = history.comment.image + assert %User{} = history.comment.user + + # A never-edited comment has recorded no versions. + assert history.versions == [] + end + + test "an unknown image id is not found" do + assert Comments.list_comment_history(actor(), "999999999", "1") == {:error, :not_found} + end + + test "an unknown comment id on a real image is not found", %{image: image} do + assert Comments.list_comment_history(actor(), "#{image.id}", "999999999") == + {:error, :not_found} + end + + test "an anonymous actor cannot read the history of a hidden comment", %{image: image} do + comment = already_hidden_comment(image) + + assert Comments.list_comment_history(actor(), "#{image.id}", "#{comment.id}") == + {:error, :unauthorized} + end + + test "a regular user cannot read the history of a hidden comment", %{image: image} do + comment = already_hidden_comment(image) + + assert Comments.list_comment_history( + actor(confirmed_user_fixture()), + "#{image.id}", + "#{comment.id}" + ) == + {:error, :unauthorized} + end + + test "a moderator reads the history of a hidden comment", %{image: image} do + comment = already_hidden_comment(image) + + assert {:ok, %CommentHistory{} = history} = + Comments.list_comment_history( + actor(moderator_user_fixture()), + "#{image.id}", + "#{comment.id}" + ) + + assert history.comment.id == comment.id + assert history.comment.hidden_from_users + assert is_list(history.versions) + end + + test "an edited comment reports the recorded version, its author, and the pre-edit body", + %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "Original comment body"}) + + {:ok, _} = + Comments.update_comment(actor(author), image.id, comment.id, %{ + "body" => "Original comment body plus an edit", + "edit_reason" => "typo fix" + }) + + assert {:ok, %CommentHistory{versions: [%CommentVersion{} = version]}} = + Comments.list_comment_history(actor(), "#{image.id}", "#{comment.id}") + + # previous_body records the body as it stood before the edit, so the + # single version carries the original text and names its editor. + assert version.previous_body == "Original comment body" + assert version.user.id == author.id + end + + test "the history is capped at the most recent 25 versions", %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "edit 0"}) + + # Each update records one version, so 26 edits record 26 versions; the + # query limits the result to 25. + Enum.reduce(1..26, comment, fn n, current -> + {:ok, {_image, updated}} = + Comments.update_comment(actor(author), image.id, current.id, %{"body" => "edit #{n}"}) + + updated + end) + + assert {:ok, %CommentHistory{versions: versions}} = + Comments.list_comment_history(actor(), "#{image.id}", "#{comment.id}") + + assert length(versions) == 25 + end + end + + describe "load_image/3" do + # Loads and per-action authorizes the image a comment listing or write hangs + # off. It accepts an Actor and runs no ban check. + + test ":index on a visible image succeeds for an anonymous actor" do + image = image_fixture() + + assert {:ok, %Image{} = loaded} = + Comments.load_image(actor(), "#{image.id}", :index) + + assert loaded.id == image.id + + # The listing preloads are loaded on the returned image. + assert is_list(loaded.tags) + assert is_list(loaded.sources) + end + + test ":show on a visible image succeeds for an anonymous actor" do + image = image_fixture() + + assert {:ok, %Image{} = loaded} = + Comments.load_image(actor(), "#{image.id}", :show) + + assert loaded.id == image.id + end + + test ":show on a hidden image is unauthorized for a regular user" do + image = image_fixture(%{hidden_from_users: true}) + + assert Comments.load_image( + actor(confirmed_user_fixture()), + "#{image.id}", + :show + ) == + {:error, :unauthorized} + end + + test ":create/:edit/:update require create_comment, rejecting a commenting-disabled image" do + image = image_fixture(%{commenting_allowed: false}) + user = confirmed_user_fixture() + + assert Comments.load_image(actor(user), "#{image.id}", :create_comment) == + {:error, :unauthorized} + end + + test ":create_comment on a comment-enabled image succeeds for a regular user" do + image = image_fixture() + + assert {:ok, %Image{} = loaded} = + Comments.load_image( + actor(confirmed_user_fixture()), + "#{image.id}", + :create_comment + ) + + assert loaded.id == image.id + end + + test "a well-formed id naming no row is not found" do + assert Comments.load_image(actor(confirmed_user_fixture()), "999999999", :index) == + {:error, :not_found} + end + + test "an id that cannot name a row is not found" do + assert Comments.load_image(actor(confirmed_user_fixture()), "abc", :index) == + {:error, :not_found} + end + end + + describe "create_comment/3" do + # A write taking an actor. It verifies write access first (ban -> :ban, + # missing fingerprint -> :unauthorized), then authorizes the image. + + setup do + %{image: image_fixture()} + end + + test "a banned actor is rejected", %{image: image} do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Comments.create_comment(actor, image.id, %{"body" => "Hi"}) == {:error, :ban} + end + + test "a forced-filter match is rejected before inserting", %{image: image} do + user = force_filter_for_image(confirmed_user_fixture(), image) + + assert Comments.create_comment(actor(user), image.id, %{"body" => "Blocked"}) == + {:error, :forced_filter} + + assert Repo.aggregate(Comment, :count) == 0 + end + + test "an actor with no fingerprint is unauthorized, signed in or not", %{image: image} do + signed_in = actor(confirmed_user_fixture(), fingerprint: nil) + anonymous = actor(nil, fingerprint: nil) + + assert Comments.create_comment(signed_in, image.id, %{"body" => "Hi"}) == + {:error, :unauthorized} + + assert Comments.create_comment(anonymous, image.id, %{"body" => "Hi"}) == + {:error, :unauthorized} + end + + test "a valid anonymous fingerprinted actor creates a comment with no author", + %{image: image} do + assert {:ok, %Comment{} = comment} = + Comments.create_comment(actor(nil), image.id, %{"body" => "An anonymous comment"}) + + assert comment.user_id == nil + assert comment.body == "An anonymous comment" + no_moderation_logs!() + end + + test "a signed-in actor creates a comment attributed to the user", %{image: image} do + user = confirmed_user_fixture() + + assert {:ok, %Comment{} = comment} = + Comments.create_comment(actor(user), image.id, %{"body" => "A logged-in comment"}) + + assert comment.user_id == user.id + assert comment.body == "A logged-in comment" + no_moderation_logs!() + end + + test "an empty body is a creation failure, inserting nothing", %{image: image} do + assert {:error, {_image, _changeset}} = + Comments.create_comment(actor(confirmed_user_fixture()), image.id, %{"body" => ""}) + + # The insert and its image comment-count bump are rolled back together. + assert Repo.get!(Image, image.id).comments_count == 0 + end + + test "an approved comment increments the author's comments_count by one", %{image: image} do + author = confirmed_user_fixture() + before = Repo.get!(User, author.id).comments_count + + assert {:ok, %Comment{} = comment} = + Comments.create_comment(actor(author), image.id, %{ + "body" => "A trustworthy comment" + }) + + assert comment.approved + assert Repo.get!(User, author.id).comments_count == before + 1 + end + + test "a withheld comment does not increment the author's comments_count and is reported", + %{image: image} do + {comment, author} = unapproved_comment(image) + before = Repo.get!(User, author.id).comments_count + + refute comment.approved + assert Repo.get!(User, author.id).comments_count == before + assert Repo.aggregate(from(r in Report, where: r.comment_id == ^comment.id), :count) == 1 + end + + test "an over-limit actor is rate limited and no comment is created", %{image: image} do + # The :comment_create counter is primed past the limit, so the rate check + # (after write-access, before the insert) refuses the write. + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :comment_create) + + assert Comments.create_comment(actor, image.id, %{"body" => "Hi"}) == + {:error, :rate_limited} + + # The comment and its image comment-count bump never happened. + assert Repo.get!(Image, image.id).comments_count == 0 + end + + test "a successful create records the counter", %{image: image} do + actor = actor(confirmed_user_fixture()) + track_rate_limit(actor, :comment_create) + + assert {:ok, %Comment{}} = Comments.create_comment(actor, image.id, %{"body" => "Hi"}) + assert rate_limit_count(actor, :comment_create) == "1" + end + + test "an invalid comment does not consume the rate limit", %{image: image} do + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :comment_create) + + assert {:error, {%Image{}, %Ecto.Changeset{}}} = + Comments.create_comment(actor, image.id, %{"body" => ""}) + + assert rate_limit_count(actor, :comment_create) == "2" + end + end + + describe "show_comment/3" do + setup do + %{image: image_fixture()} + end + + test "returns a visible comment with display preloads", %{image: image} do + comment = comment_fixture(image, confirmed_user_fixture(), %{"body" => "A visible comment"}) + + assert {:ok, {_image, %Comment{} = loaded}} = + Comments.show_comment(actor(), image.id, "#{comment.id}") + + assert loaded.id == comment.id + assert %User{} = loaded.user + end + + test "rejects a hidden comment for an anonymous actor", %{image: image} do + comment = already_hidden_comment(image) + + assert Comments.show_comment(actor(), image.id, "#{comment.id}") == + {:error, :unauthorized} + end + + test "an unknown comment id is not found", %{image: image} do + assert Comments.show_comment(actor(), image.id, "999999999") == + {:error, :not_found} + end + end + + describe "edit_comment/3" do + # Backs the edit write, so it runs the global write prerequisite before + # loading and authorizing the comment for :edit. + + setup do + %{image: image_fixture()} + end + + test "a banned actor is rejected", %{image: image} do + comment = comment_fixture(image, confirmed_user_fixture()) + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Comments.edit_comment(actor, image.id, "#{comment.id}") == {:error, :ban} + end + + test "an actor without a fingerprint is rejected before loading", %{image: image} do + assert Comments.edit_comment(actor(nil, fingerprint: nil), image.id, "1") == + {:error, :unauthorized} + end + + test "the author loads the form", %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "My comment"}) + + assert {:ok, %Ecto.Changeset{} = changeset} = + Comments.edit_comment(actor(author), image.id, "#{comment.id}") + + assert changeset.data.id == comment.id + + # The changeset is over the loaded comment, driving the edit form. + assert %Comment{} = changeset.data + assert changeset.data.id == comment.id + end + + test "the edit form rejects an image matching the author's forced filter", %{image: image} do + user = confirmed_user_fixture() + comment = comment_fixture(image, user) + user = force_filter_for_image(user, image) + + assert Comments.edit_comment(actor(user), image.id, comment.id) == + {:error, :forced_filter} + end + + test "another regular user cannot load the form", %{image: image} do + comment = comment_fixture(image, confirmed_user_fixture()) + + assert Comments.edit_comment( + actor(confirmed_user_fixture()), + image.id, + "#{comment.id}" + ) == + {:error, :unauthorized} + end + + test "a moderator loads the form", %{image: image} do + comment = comment_fixture(image, confirmed_user_fixture()) + + assert {:ok, %Ecto.Changeset{} = changeset} = + Comments.edit_comment( + actor(moderator_user_fixture()), + image.id, + "#{comment.id}" + ) + + assert changeset.data.id == comment.id + end + + test "an unknown comment id is not found", %{image: image} do + assert Comments.edit_comment( + actor(confirmed_user_fixture()), + image.id, + "999999999" + ) == + {:error, :not_found} + end + end + + describe "update_comment/4" do + # A write, so it runs the write-access check first (ban -> :ban), then the + # same load-and-authorize chain as the edit form, then the edit engine which + # records a version. + + setup do + %{image: image_fixture()} + end + + test "a banned actor is rejected", %{image: image} do + comment = comment_fixture(image, confirmed_user_fixture()) + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Comments.update_comment(actor, image, "#{comment.id}", %{"body" => "Edited"}) == + {:error, :ban} + end + + test "the author edits the body and the comment is marked edited", %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "Original comment body"}) + + assert {:ok, {_image, %Comment{} = updated}} = + Comments.update_comment(actor(author), image.id, "#{comment.id}", %{ + "body" => "Original comment body plus an edit", + "edit_reason" => "typo" + }) + + assert updated.body == "Original comment body plus an edit" + assert updated.edited_at != nil + + reloaded = Repo.reload!(comment) + assert reloaded.body == "Original comment body plus an edit" + assert reloaded.edited_at != nil + no_moderation_logs!() + end + + test "editing an approved comment into a withheld one decrements its count once and reports it", + %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "An ordinary comment"}) + before = Repo.get!(User, author.id).comments_count + approval_rule!() + + assert {:ok, {_image, %Comment{approved: false}}} = + Comments.update_comment(actor(author), image.id, comment.id, %{ + "body" => "Now containing https://spam.example/" + }) + + assert Repo.get!(User, author.id).comments_count == before - 1 + assert Repo.aggregate(from(r in Report, where: r.comment_id == ^comment.id), :count) == 1 + + assert {:ok, {_image, %Comment{approved: false}}} = + Comments.update_comment(actor(author), image.id, comment.id, %{ + "body" => "Still containing https://spam.example/" + }) + + assert Repo.get!(User, author.id).comments_count == before - 1 + assert Repo.aggregate(from(r in Report, where: r.comment_id == ^comment.id), :count) == 1 + + assert {:ok, %Comment{approved: true}} = + Comments.create_comment_approve( + actor(moderator_user_fixture()), + image.id, + comment.id + ) + + assert Repo.get!(User, author.id).comments_count == before + end + + test "a forced-filter match prevents an update", %{image: image} do + user = confirmed_user_fixture() + comment = comment_fixture(image, user, %{"body" => "Original"}) + user = force_filter_for_image(user, image) + + assert Comments.update_comment(actor(user), image.id, comment.id, %{"body" => "Changed"}) == + {:error, :forced_filter} + + assert Repo.reload!(comment).body == "Original" + end + + test "another regular user cannot edit, leaving the body unchanged", %{image: image} do + comment = + comment_fixture(image, confirmed_user_fixture(), %{"body" => "Original comment body"}) + + assert Comments.update_comment( + actor(confirmed_user_fixture()), + image.id, + "#{comment.id}", + %{"body" => "Hijacked"} + ) == + {:error, :unauthorized} + + assert Repo.reload!(comment).body == "Original comment body" + end + + test "a blank body is a rejected changeset carrying the loaded comment", %{image: image} do + author = confirmed_user_fixture() + comment = comment_fixture(image, author, %{"body" => "Original comment body"}) + + assert {:error, %Ecto.Changeset{} = changeset} = + Comments.update_comment(actor(author), image.id, "#{comment.id}", %{"body" => ""}) + + assert changeset.data.id == comment.id + assert Repo.reload!(comment).body == "Original comment body" + end + + test "an unknown comment id is not found", %{image: image} do + assert Comments.update_comment( + actor(confirmed_user_fixture()), + image, + "999999999", + %{"body" => "Edited"} + ) == + {:error, :not_found} + end + end + + describe "load_report_target/3" do + test "loads a visible comment through its image parent" do + image = image_fixture() + comment = comment_fixture(image) + + assert {:ok, loaded} = + Comments.load_report_target(actor(), image.id, comment.id) + + assert loaded.id == comment.id + assert loaded.image_id == image.id + end + + test "normalizes malformed, missing, and mismatched IDs" do + first_image = image_fixture() + second_image = image_fixture() + comment = comment_fixture(first_image) + + assert Comments.load_report_target(actor(), first_image.id, "bad") == + {:error, :not_found} + + assert Comments.load_report_target(actor(), first_image.id, "2147483647") == + {:error, :not_found} + + assert Comments.load_report_target(actor(), second_image.id, comment.id) == + {:error, :not_found} + end + + test "rejects a hidden comment for a regular user" do + image = image_fixture() + comment = comment_fixture(image) + + hidden = + comment + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + assert Comments.load_report_target(actor(confirmed_user_fixture()), image.id, hidden.id) == + {:error, :unauthorized} + end + end + + describe "parent-scoped request services" do + test "reject every comment operation when the route image does not own the comment" do + moderator = moderator_user_fixture() + moderator_actor = actor(moderator) + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image, confirmed_user_fixture(), %{"body" => "Unchanged"}) + + assert Comments.show_comment(moderator_actor, other_image, comment.id) == + {:error, :not_found} + + assert Comments.edit_comment(moderator_actor, other_image, comment.id) == + {:error, :not_found} + + assert Comments.update_comment( + moderator_actor, + other_image, + comment.id, + %{"body" => "Changed"} + ) == {:error, :not_found} + + assert Comments.list_comment_history(moderator_actor, other_image.id, comment.id) == + {:error, :not_found} + + assert Comments.load_report_target(moderator_actor, other_image.id, comment.id) == + {:error, :not_found} + + assert Comments.create_comment_hide( + moderator_actor, + other_image.id, + comment.id, + %{"deletion_reason" => "Spam"} + ) == {:error, :not_found} + + assert Comments.delete_comment_hide(moderator_actor, other_image.id, comment.id) == + {:error, :not_found} + + assert Comments.create_comment_delete(moderator_actor, other_image.id, comment.id) == + {:error, :not_found} + + assert Comments.create_comment_approve(moderator_actor, other_image.id, comment.id) == + {:error, :not_found} + + unchanged = Repo.reload!(comment) + assert unchanged.body == "Unchanged" + refute unchanged.hidden_from_users + refute unchanged.destroyed_content + assert Repo.aggregate(ModerationLog, :count) == 0 + end + end + + describe "sequential comment erasure" do + test "hides then destroys content, closes reports, and updates counters" do + moderator = moderator_user_fixture() + author = confirmed_user_fixture() + image = image_fixture() + comment = comment_fixture(image, author, %{"body" => "Personal content"}) + report = report_fixture(confirmed_user_fixture(), comment_id: comment.id) + moderator_actor = actor(moderator) + + author + |> Ecto.Changeset.change(comments_count: 1) + |> Repo.update!() + + assert Repo.reload!(image).comments_count == 1 + + assert {:ok, _hidden} = + Comments.create_comment_hide( + moderator_actor, + image.id, + comment.id, + %{"deletion_reason" => "Site abuse"} + ) + + assert {:ok, erased} = Comments.create_comment_delete(moderator_actor, image.id, comment.id) + assert erased.hidden_from_users + assert erased.destroyed_content + assert erased.body == "" + assert erased.deletion_reason == "Site abuse" + assert Repo.reload!(image).comments_count == 0 + assert Repo.reload!(author).comments_count == 0 + + closed_report = Repo.reload!(report) + refute closed_report.open + assert closed_report.state == "closed" + assert Repo.aggregate(ModerationLog, :count) == 2 + end + end + + # Pulls the must_not exclusion list out of an unexecuted comment search + # definition. + defp must_not(definition), do: definition.body.query.bool.must_not + + describe "comment_search_definition/4" do + test "an anonymous viewer excludes deleted, non-approved, and hidden-tag comments" do + filter = %Filter{hidden_tag_ids: [7, 8]} + definition = Comments.comment_search_definition(actor(), filter, %{match_all: %{}}) + + assert definition.module == Comment + assert definition.body.query.bool.must == %{match_all: %{}} + assert definition.body.sort == %{created_at: :desc} + + filters = must_not(definition) + assert %{term: %{approved: false}} in filters + assert %{term: %{"image.approved" => false}} in filters + assert %{term: %{hidden_from_users: true}} in filters + assert %{term: %{"image.hidden_from_users" => true}} in filters + assert %{term: %{destroyed_content: true}} in filters + assert %{terms: %{"image.tag_ids" => [7, 8]}} in filters + end + + test "a signed-in viewer scopes the approved exception to their own id" do + user = confirmed_user_fixture() + + filters = + must_not( + Comments.comment_search_definition(actor(user), @empty_filter, %{match_all: %{}}) + ) + + # Comment and image approval are independent. The comment exclusion keeps + # the viewer's own pending comment; an unapproved image stays excluded. + assert %{ + bool: %{ + must: [%{term: %{approved: false}}], + must_not: [%{term: %{user_id: user.id}}] + } + } in filters + + refute %{term: %{approved: false}} in filters + assert %{term: %{"image.approved" => false}} in filters + + # The deleted and hidden-tag excludes still apply. + assert %{term: %{hidden_from_users: true}} in filters + end + + test "an authorized moderator drops deleted and non-approved excludes by default" do + moderator = moderator_user_fixture() + filter = %Filter{hidden_tag_ids: [7]} + + filters = + must_not(Comments.comment_search_definition(actor(moderator), filter, %{match_all: %{}})) + + assert filters == [%{terms: %{"image.tag_ids" => [7]}}] + end + + test "show_hidden false keeps public excludes for an authorized moderator" do + moderator = moderator_user_fixture() + + filters = + must_not( + Comments.comment_search_definition( + actor(moderator), + @empty_filter, + %{match_all: %{}}, + show_hidden: false + ) + ) + + assert %{ + bool: %{ + must: [%{term: %{approved: false}}], + must_not: [%{term: %{user_id: moderator.id}}] + } + } in filters + + assert %{term: %{"image.approved" => false}} in filters + assert %{term: %{hidden_from_users: true}} in filters + end + + test "passes pagination through to the search window" do + definition = + Comments.comment_search_definition( + actor(), + @empty_filter, + %{match_all: %{}}, + pagination: %{page_number: 3, page_size: 10} + ) + + assert definition.page_number == 3 + assert definition.page_size == 10 + assert definition.body.from == 20 + assert definition.body.size == 10 + end + end + + # Creates an approved comment on `image`, its created_at fixed to a distinct + # second so listing order is deterministic under the second-precision column. + defp comment_at(image, offset) do + comment_fixture(image, confirmed_user_fixture(), %{"body" => "Comment #{offset}"}) + |> Ecto.Changeset.change(created_at: DateTime.add(~U[2024-01-01 00:00:00Z], offset, :second)) + |> Repo.update!() + end + + defp pending_comment(image, user \\ nil) do + comment_fixture(image, user || confirmed_user_fixture()) + |> Ecto.Changeset.change(approved: false) + |> Repo.update!() + end + + defp destroyed_comment(image) do + comment_fixture(image, confirmed_user_fixture()) + |> Ecto.Changeset.change(destroyed_content: true) + |> Repo.update!() + end + + defp oldest_first_user do + user = confirmed_user_fixture() + + settings = + user.settings + |> Ecto.Changeset.change(comments_newest_first: false) + |> Repo.update!() + + %{user | settings: settings} + end + + describe "list_image_comments/3" do + setup do + %{image: image_fixture()} + end + + test "returns a Scrivener page ordered newest first by default", %{image: image} do + c1 = comment_at(image, 1) + c2 = comment_at(image, 2) + c3 = comment_at(image, 3) + + page = Comments.list_image_comments(actor(nil), image, page: 1, page_size: 25) + + assert %Scrivener.Page{} = page + assert Enum.map(page.entries, & &1.id) == [c3.id, c2.id, c1.id] + end + + test "orders oldest first for a user who reads oldest first", %{image: image} do + c1 = comment_at(image, 1) + c2 = comment_at(image, 2) + c3 = comment_at(image, 3) + + page = + Comments.list_image_comments(actor(oldest_first_user()), image, + page: 1, + page_size: 25 + ) + + assert Enum.map(page.entries, & &1.id) == [c1.id, c2.id, c3.id] + end + + test "an anonymous viewer sees only approved, non-destroyed comments", %{image: image} do + approved = comment_fixture(image, confirmed_user_fixture()) + _pending = pending_comment(image) + _destroyed = destroyed_comment(image) + + page = Comments.list_image_comments(actor(nil), image, page: 1, page_size: 25) + + assert Enum.map(page.entries, & &1.id) == [approved.id] + end + + test "a signed-in user additionally sees their own non-approved comments", + %{image: image} do + author = confirmed_user_fixture() + approved = comment_fixture(image, confirmed_user_fixture()) + own_pending = pending_comment(image, author) + others_pending = pending_comment(image) + + page = Comments.list_image_comments(actor(author), image, page: 1, page_size: 25) + ids = Enum.map(page.entries, & &1.id) + + assert approved.id in ids + assert own_pending.id in ids + refute others_pending.id in ids + end + + test "authorized moderators see destroyed and non-approved comments", %{image: image} do + approved = comment_fixture(image, confirmed_user_fixture()) + pending = pending_comment(image) + destroyed = destroyed_comment(image) + + page = + Comments.list_image_comments(actor(moderator_user_fixture()), image, + page: 1, + page_size: 25 + ) + + ids = Enum.map(page.entries, & &1.id) + + assert approved.id in ids + assert pending.id in ids + assert destroyed.id in ids + end + end + + describe "list_comment_page/4" do + setup do + image = image_fixture() + + %{ + image: image, + c1: comment_at(image, 1), + c2: comment_at(image, 2), + c3: comment_at(image, 3) + } + end + + test "returns the page a comment falls on for a newest-first reader", + %{image: image, c1: c1, c2: c2, c3: c3} do + # Newest first, page size 2: c3 and c2 share page 1, c1 falls to page 2. + assert {:ok, {_image, 1}} = + Comments.list_comment_page(actor(), image.id, c3.id, page_size: 2) + + assert {:ok, {_image, 1}} = + Comments.list_comment_page(actor(), image.id, c2.id, page_size: 2) + + assert {:ok, {_image, 2}} = + Comments.list_comment_page(actor(), image.id, c1.id, page_size: 2) + end + + test "honors an oldest-first reader's direction", %{image: image, c1: c1, c2: c2, c3: c3} do + user = oldest_first_user() + + # Oldest first, page size 2: c1 and c2 share page 1, c3 falls to page 2. + assert {:ok, {_image, 1}} = + Comments.list_comment_page(actor(user), image.id, c1.id, page_size: 2) + + assert {:ok, {_image, 1}} = + Comments.list_comment_page(actor(user), image.id, c2.id, page_size: 2) + + assert {:ok, {_image, 2}} = + Comments.list_comment_page(actor(user), image.id, c3.id, page_size: 2) + end + + test "returns not-found when the comment does not belong to the image", %{image: image} do + foreign = comment_fixture(image_fixture(), confirmed_user_fixture()) + + assert Comments.list_comment_page(actor(), image, foreign.id, page_size: 2) == + {:error, :not_found} + end + end + + describe "last_comment_page/3" do + test "returns the last page of a populated listing" do + image = image_fixture() + for offset <- 1..3, do: comment_at(image, offset) + + assert Comments.last_comment_page(actor(), image, page_size: 2) == 2 + end + + test "returns page 1 for an empty listing" do + assert Comments.last_comment_page(actor(), image_fixture(), page_size: 2) == 1 + end + + test "counts only the comments visible to the viewer" do + image = image_fixture() + comment_fixture(image, confirmed_user_fixture()) + pending_comment(image) + + assert Comments.last_comment_page(actor(), image, page_size: 1) == 1 + + assert Comments.last_comment_page(actor(moderator_user_fixture()), image, page_size: 1) == + 2 + end + end +end diff --git a/test/philomena/commissions_test.exs b/test/philomena/commissions_test.exs index 3e9bedbfa..9e2c620d5 100644 --- a/test/philomena/commissions_test.exs +++ b/test/philomena/commissions_test.exs @@ -1,28 +1,288 @@ defmodule Philomena.CommissionsTest do + @moduledoc """ + Context-level tests for the actor-first commission and commission-item loaders + and writers on `Philomena.Commissions`. + + These pin typed page/form/directory results, stable profile and nested-item + lookup errors, the staff bypass on commission management (and its absence on + item management), verified-link and write-access gates, and persistence + invariants. + """ + use Philomena.DataCase, async: true + import Philomena.AttributionFixtures + import Philomena.ArtistLinksFixtures + import Philomena.CommissionsFixtures + import Philomena.TagsFixtures + import Philomena.UserIpsFixtures + import Philomena.UsersFixtures + import Philomena.ReportsFixtures + alias Philomena.Commissions alias Philomena.Commissions.Commission - alias Philomena.Reports - alias Philomena.Reports.Report + alias Philomena.Commissions.Directory + alias Philomena.Commissions.Item alias Philomena.Repo + alias Philomena.Reports.Report - import Philomena.CommissionsFixtures - import Philomena.ReportsFixtures - import Philomena.UsersFixtures + # A truthy ban value in the shape production passes; only its presence matters + # to the write-access and not-banned checks the loaders run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + defp artist_tag_fixture do + tag_fixture(name: "artist:test-commission-artist-#{System.unique_integer([:positive])}") + end + + # A confirmed user holding a verified artist link, which the commission gates + # require the profile to have. + defp verified_user_with_link do + user = confirmed_user_fixture() + verified_artist_link_fixture(user, artist_tag_fixture()) + user + end + + defp commission_params(attrs \\ %{}) do + Enum.into(attrs, %{ + "information" => "Test commission information", + "contact" => "Test contact info", + "will_create" => "Test subjects", + "open" => true + }) + end + + defp item_params(attrs \\ %{}) do + Enum.into(attrs, %{ + "item_type" => "Sketch", + "description" => "Test item description", + "base_price" => 20 + }) + end + + describe "show_commission/2" do + test "returns an ordered commission page" do + user = confirmed_user_fixture() + commission = commission_fixture(user) + expensive = commission_item_fixture(commission, base_price: 30) + cheap = commission_item_fixture(commission, base_price: 10) + + assert {:ok, %Commission{} = loaded_commission} = + Commissions.show_commission(actor(), user.slug) + + assert loaded_commission.user.id == user.id + assert loaded_commission.id == commission.id + assert Enum.map(loaded_commission.items, & &1.id) == [cheap.id, expensive.id] + end + + test "missing and deactivated profiles are not found for every viewer" do + deactivated = confirmed_user_fixture() + commission_fixture(deactivated) + + deactivated + |> Ecto.Changeset.change(deleted_at: DateTime.utc_now(:second)) + |> Repo.update!() + + for viewer <- [actor(), actor(admin_user_fixture())] do + assert Commissions.show_commission(viewer, "no-such-user") == + {:error, :not_found} + + assert Commissions.show_commission(viewer, deactivated.slug) == + {:error, :not_found} + end + end + + test "a user without a commission is not-found" do + assert Commissions.show_commission(actor(), confirmed_user_fixture().slug) == + {:error, :not_found} + end + end + + describe "new_commission/2" do + test "the owner with a verified link and no commission gets a form" do + user = verified_user_with_link() + + assert {:ok, %Ecto.Changeset{data: commission}} = + Commissions.new_commission(actor(user), user.slug) + + assert commission.user.id == user.id + end + + test "a moderator may open the new form for another user (staff bypass)" do + user = verified_user_with_link() + + assert {:ok, %Ecto.Changeset{data: commission}} = + Commissions.new_commission(actor(moderator_user_fixture()), user.slug) + + assert commission.user.id == user.id + end + + test "a banned actor is rejected before any gating" do + user = verified_user_with_link() + + assert Commissions.new_commission(actor(user, ban: @ban), user.slug) == + {:error, :ban} + end + + test "an actor without a fingerprint is rejected before gating" do + user = verified_user_with_link() + + assert Commissions.new_commission( + actor(user, fingerprint: nil), + user.slug + ) == {:error, :unauthorized} + end + + test "an owner whose profile already has a commission is unauthorized" do + user = verified_user_with_link() + commission_fixture(user) + + assert Commissions.new_commission(actor(user), user.slug) == + {:error, :unauthorized} + end + + test "an unrelated user may not open another owner's new form" do + user = verified_user_with_link() + + assert Commissions.new_commission(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "an owner without a verified link gets the no-verified-links shape" do + user = confirmed_user_fixture() + + assert Commissions.new_commission(actor(user), user.slug) == + {:error, :no_verified_links} + end + end + + describe "create_commission/3" do + test "the owner creates a commission" do + user = verified_user_with_link() + + assert {:ok, %Commission{} = commission} = + Commissions.create_commission(actor(user), user.slug, commission_params()) + + assert commission.user.id == user.id + assert Repo.get(Commission, commission.id).user_id == user.id + assert commission.items == [] + end + + test "the database enforces one commission per profile" do + user = confirmed_user_fixture() + new_commission = fn -> Ecto.build_assoc(user, :commission) end + + assert {:ok, %Commission{}} = + new_commission.() |> Commission.changeset(commission_params()) |> Repo.insert() + + assert {:error, changeset} = + new_commission.() |> Commission.changeset(commission_params()) |> Repo.insert() + + assert {"has already been taken", _} = changeset.errors[:user_id] + end + + test "an actor with no fingerprint is unauthorized" do + user = verified_user_with_link() + + assert Commissions.create_commission( + actor(user, fingerprint: nil), + user.slug, + commission_params() + ) == {:error, :unauthorized} + end + + test "validation errors retain the scoped commission" do + user = verified_user_with_link() + + assert {:error, %Ecto.Changeset{data: commission} = changeset} = + Commissions.create_commission(actor(user), user.slug, %{}) + + assert commission.user.id == user.id + refute changeset.valid? + end + end + + describe "edit_commission/2" do + test "the owner loads their commission and a changeset" do + user = verified_user_with_link() + commission = commission_fixture(user) + + assert {:ok, %Ecto.Changeset{data: loaded_commission}} = + Commissions.edit_commission(actor(user), user.slug) + + assert loaded_commission.user.id == user.id + assert loaded_commission.id == commission.id + end + + test "a profile without a commission is not-found" do + user = verified_user_with_link() + + assert Commissions.edit_commission(actor(user), user.slug) == {:error, :not_found} + end + + test "an unrelated user may not edit another owner's commission" do + user = verified_user_with_link() + commission_fixture(user) + + assert Commissions.edit_commission(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "an actor without a fingerprint is rejected before loading" do + user = verified_user_with_link() + commission_fixture(user) + + assert Commissions.edit_commission( + actor(user, fingerprint: nil), + user.slug + ) == {:error, :unauthorized} + end + end + + describe "update_commission/3" do + test "the owner updates their commission" do + user = verified_user_with_link() + commission = commission_fixture(user) + + assert {:ok, %Commission{} = updated} = + Commissions.update_commission( + actor(user), + user.slug, + commission_params(%{"information" => "Updated information"}) + ) + + assert updated.id == commission.id + assert Repo.get(Commission, commission.id).information == "Updated information" + end + end describe "delete_commission/2" do + test "the owner deletes their commission" do + user = verified_user_with_link() + commission = commission_fixture(user) + + assert {:ok, %Commission{}} = Commissions.delete_commission(actor(user), user.slug) + assert Repo.get(Commission, commission.id) == nil + end + end + + describe "delete_commission/2 report cleanup" do test "closes the commission's open reports and nulls the target FK while keeping the row" do - commission = commission_fixture(confirmed_user_fixture()) + owner = verified_user_with_link() + commission = commission_fixture(owner) report = report_fixture(commission_id: commission.id) admin = admin_user_fixture() assert report.open assert report.commission_id == commission.id - assert {:ok, _commission} = Commissions.delete_commission(commission, admin) + assert {:ok, _commission} = Commissions.delete_commission(actor(admin), owner.slug) - closed = Reports.get_report!(report.id) + closed = Repo.get!(Report, report.id) refute closed.open assert closed.state == "closed" assert closed.admin_id == admin.id @@ -33,4 +293,214 @@ defmodule Philomena.CommissionsTest do refute Repo.get(Commission, commission.id) end end + + describe "new_item/2" do + test "the owner loads the item form" do + user = verified_user_with_link() + commission = commission_fixture(user) + + assert {:ok, %Ecto.Changeset{data: item}} = Commissions.new_item(actor(user), user.slug) + + assert item.commission.user.id == user.id + assert item.commission.id == commission.id + end + + test "items can be edited by moderators and admins" do + user = verified_user_with_link() + commission_fixture(user) + + for staff <- [moderator_user_fixture(), admin_user_fixture()] do + assert {:ok, _changeset} = Commissions.new_item(actor(staff), user.slug) + end + end + + test "an actor without a fingerprint is rejected before loading" do + user = verified_user_with_link() + commission_fixture(user) + + assert Commissions.new_item(actor(user, fingerprint: nil), user.slug) == + {:error, :unauthorized} + end + end + + describe "create_item/3" do + test "the owner adds an item" do + user = verified_user_with_link() + commission = commission_fixture(user) + + assert {:ok, item} = + Commissions.create_item(actor(user), user.slug, item_params()) + + assert item.commission.user.id == user.id + + assert Repo.aggregate(from(i in Item, where: i.commission_id == ^commission.id), :count) == + 1 + end + + test "validation errors retain the parent association" do + user = verified_user_with_link() + commission = commission_fixture(user) + + assert {:error, %Ecto.Changeset{data: item} = changeset} = + Commissions.create_item(actor(user), user.slug, %{}) + + assert item.commission.user.id == user.id + assert item.commission.id == commission.id + refute changeset.valid? + end + end + + describe "edit_item/3" do + test "the owner loads an item for editing" do + user = verified_user_with_link() + commission = commission_fixture(user) + item = commission_item_fixture(commission) + + assert {:ok, %Ecto.Changeset{data: item}} = + Commissions.edit_item(actor(user), user.slug, "#{item.id}") + + assert item.commission.user.id == user.id + assert item.id == item.id + end + + test "malformed, absent, and wrong-commission item IDs are not found" do + user = verified_user_with_link() + commission_fixture(user) + other_user = verified_user_with_link() + other_item = other_user |> commission_fixture() |> commission_item_fixture() + + for id <- ["bad", "2147483647", "#{other_item.id}"] do + assert Commissions.edit_item(actor(user), user.slug, id) == + {:error, :not_found} + end + end + + test "an actor without a fingerprint is rejected before the item lookup" do + user = verified_user_with_link() + commission_fixture(user) + + assert Commissions.edit_item( + actor(user, fingerprint: nil), + user.slug, + "2147483647" + ) == {:error, :unauthorized} + end + end + + describe "delete_item/3" do + test "the owner deletes an item" do + user = verified_user_with_link() + commission = commission_fixture(user) + item = commission_item_fixture(commission) + + assert {:ok, deleted_item} = Commissions.delete_item(actor(user), user.slug, "#{item.id}") + assert deleted_item.commission.user.id == user.id + assert Repo.get(Item, item.id) == nil + end + + test "malformed, absent, and wrong-commission IDs are not found" do + user = verified_user_with_link() + commission_fixture(user) + other_user = verified_user_with_link() + other_item = other_user |> commission_fixture() |> commission_item_fixture() + + for id <- ["bad", "2147483647", "#{other_item.id}"] do + assert Commissions.delete_item(actor(user), user.slug, id) == {:error, :not_found} + end + + assert Repo.get(Item, other_item.id) + end + end + + describe "update_item/4" do + test "the owner updates an item" do + user = verified_user_with_link() + commission = commission_fixture(user) + item = commission_item_fixture(commission) + + assert {:ok, item} = + Commissions.update_item(actor(user), user.slug, "#{item.id}", %{ + "description" => "Updated description" + }) + + assert item.commission.user.id == user.id + assert Repo.get!(Item, item.id).description == "Updated description" + end + + test "malformed, absent, and wrong-commission IDs are not found" do + user = verified_user_with_link() + commission_fixture(user) + other_item = verified_user_with_link() |> commission_fixture() |> commission_item_fixture() + + for id <- ["bad", "2147483647", "#{other_item.id}"] do + assert Commissions.update_item(actor(user), user.slug, id, item_params()) == + {:error, :not_found} + end + + assert Repo.get(Item, other_item.id) + end + end + + describe "list_commissions/3" do + @pagination [page: 1, page_size: 25] + + # A commission the directory query surfaces: open, with an item, whose owner + # has recent IP activity (the query excludes artists idle over two weeks). + defp directory_commission do + user = verified_user_with_link() + commission = commission_fixture(user) + commission_item_fixture(commission) + user_ip_fixture(user) + {user, commission} + end + + test "an empty search returns a page and a fresh search changeset" do + {_user, commission} = directory_commission() + + assert {:ok, %Directory{} = directory} = + Commissions.list_commissions(actor(), %{}, @pagination) + + assert commission.id in Enum.map(directory.commissions.entries, & &1.id) + assert %Ecto.Changeset{} = directory.changeset + assert directory.current_user == nil + end + + test "an invalid search returns a blank page and the invalid changeset" do + directory_commission() + + assert {:ok, %Directory{} = directory} = + Commissions.list_commissions( + actor(), + %{"price_min" => "not-a-number"}, + @pagination + ) + + assert directory.commissions == nil + refute directory.changeset.valid? + end + + test "returns the signed-in viewer with their commission preloaded" do + viewer = confirmed_user_fixture() + commission_fixture(viewer) + + assert {:ok, %Directory{current_user: current_user}} = + Commissions.list_commissions(actor(viewer), %{}, @pagination) + + refute match?(%Ecto.Association.NotLoaded{}, current_user.commission) + assert current_user.commission.user_id == viewer.id + end + + test "excludes commissions belonging to deactivated profiles" do + {user, commission} = directory_commission() + + user + |> Ecto.Changeset.change(deleted_at: DateTime.utc_now(:second)) + |> Repo.update!() + + assert {:ok, %Directory{} = directory} = + Commissions.list_commissions(actor(), %{}, @pagination) + + refute commission.id in Enum.map(directory.commissions.entries, & &1.id) + end + end end diff --git a/test/philomena/context_boundary_check_test.exs b/test/philomena/context_boundary_check_test.exs new file mode 100644 index 000000000..fa6b11e95 --- /dev/null +++ b/test/philomena/context_boundary_check_test.exs @@ -0,0 +1,94 @@ +defmodule Philomena.ContextBoundaryCheckTest do + use ExUnit.Case, async: true + + alias Philomena.ContextBoundaryCheck + + test "all application sources respect context boundaries" do + violations = ContextBoundaryCheck.violations(File.cwd!()) + + assert violations == [], format_violations(violations) + end + + test "reports direct Canada calls and undocumented context functions" do + with_fixture(fn root -> + write_fixture(root, "lib/philomena/new_context.ex", """ + defmodule Philomena.NewContext do + def visible?(actor, image), do: Canada.Can.can?(actor, :show, image) + end + """) + + assert [canada, docs] = ContextBoundaryCheck.violations(root) + assert canada.message == "contexts must call Philomena.Authorization.authorize/3" + assert docs.message == "public context function visible?/2 has no @doc" + end) + end + + test "reports controller Repo calls and context bang loaders" do + with_fixture(fn root -> + write_fixture(root, "lib/philomena_web/controllers/image_controller.ex", """ + defmodule PhilomenaWeb.ImageController do + alias Philomena.Images + alias Philomena.Repo, as: Database + + def show(id) do + Database.get(Philomena.Images.Image, id) + Images.load_image_for_reindex!(id) + end + end + """) + + assert [repo, bang_loader] = ContextBoundaryCheck.violations(root) + assert repo.message == "controllers must not call Repo directly" + assert bang_loader.message == "controllers must not call bang loaders" + end) + end + + test "accepts documented context functions and actor-scoped controller calls" do + with_fixture(fn root -> + write_fixture(root, "lib/philomena/images.ex", """ + defmodule Philomena.Images do + @doc "Loads an image." + def load_image(actor, id), do: {actor, id} + end + """) + + write_fixture(root, "lib/philomena_web/controllers/image_controller.ex", """ + defmodule PhilomenaWeb.ImageController do + alias Philomena.Images + + def show(actor, id), do: Images.load_image(actor, id) + end + """) + + assert ContextBoundaryCheck.violations(root) == [] + end) + end + + defp with_fixture(callback) do + root = Path.join(System.tmp_dir!(), "context-boundary-#{System.unique_integer([:positive])}") + File.mkdir_p!(root) + + try do + callback.(root) + after + File.rm_rf!(root) + end + end + + defp write_fixture(root, path, contents) do + path = Path.join(root, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + end + + defp format_violations([]), do: "Context boundary checks passed" + + defp format_violations(violations) do + details = + Enum.map_join(violations, "\n", fn violation -> + "#{violation.file}:#{violation.line}: #{violation.message}" + end) + + "Context boundary violations:\n#{details}" + end +end diff --git a/test/philomena/conversations_test.exs b/test/philomena/conversations_test.exs new file mode 100644 index 000000000..6e6581b12 --- /dev/null +++ b/test/philomena/conversations_test.exs @@ -0,0 +1,609 @@ +defmodule Philomena.ConversationsTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.Conversations` + functions. + + These pin typed index/form/message results, load-before-authorize missing + behavior, participant/staff abilities, form/write prerequisite parity, + normalized parameters, idempotent personal state, parent-scoped approval, + and transactional approval logging. + """ + + use Philomena.DataCase, async: true + + import Philomena.ConversationsFixtures + import Philomena.RulesFixtures + import Philomena.AttributionFixtures + import Philomena.UsersFixtures + + alias Philomena.Conversations + alias Philomena.Conversations.Conversation + alias Philomena.Conversations.ConversationIndex + alias Philomena.Conversations.ConversationPage + alias Philomena.Conversations.Message + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + alias Philomena.Reports.Report + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to the write-access and + # not-banned checks the write paths run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + @pagination %{page_number: 1, page_size: 25} + + # A message body whose markdown image embed causes an untrusted sender's + # message to be withheld from approval. Posting it files a system report + # against the "Approval" rule, which must exist. + @spam_body "look ![here](http://spam.example/x.png)" + + defp only_moderation_log!, do: Repo.one!(ModerationLog) + + # Builds a conversation with a single unapproved reply, returning the reply. + defp unapproved_message(from, to) do + _rule = rule_fixture(name: "Approval") + conversation = conversation_fixture(from, to) + message = message_fixture(conversation, from, %{"body" => @spam_body}) + refute Repo.reload!(message).approved + {conversation, message} + end + + describe "list_conversations/3" do + test "lists the user's sent and received conversations but not unrelated ones" do + user = confirmed_user_fixture() + received = conversation_fixture(confirmed_user_fixture(), user) + sent = conversation_fixture(user, confirmed_user_fixture()) + unrelated = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert {:ok, %ConversationIndex{} = index} = + Conversations.list_conversations(actor(user), %{}, @pagination) + + ids = Enum.map(index.conversations.entries, & &1.id) + assert received.id in ids + assert sent.id in ids + refute unrelated.id in ids + end + + test "does not list conversations the user has hidden" do + user = confirmed_user_fixture() + hidden = conversation_fixture(confirmed_user_fixture(), user) + {:ok, _} = Conversations.update_conversation_hide(actor(user), hidden.slug) + + assert {:ok, %ConversationIndex{} = index} = + Conversations.list_conversations(actor(user), %{}, @pagination) + + refute hidden.id in Enum.map(index.conversations.entries, & &1.id) + end + + test "a with filter restricts the list to the named partner" do + user = confirmed_user_fixture() + partner = confirmed_user_fixture() + with_partner = conversation_fixture(partner, user) + other = conversation_fixture(confirmed_user_fixture(), user) + + assert {:ok, %ConversationIndex{} = index} = + Conversations.list_conversations( + actor(user), + %{"with" => "#{partner.id}"}, + @pagination + ) + + ids = Enum.map(index.conversations.entries, & &1.id) + assert with_partner.id in ids + refute other.id in ids + end + + test "malformed and out-of-range filters return a blank page and invalid changeset" do + user = confirmed_user_fixture() + + for filter <- ["not-a-number", "99999999999999999999"] do + assert {:ok, %ConversationIndex{} = index} = + Conversations.list_conversations( + actor(user), + %{"with" => filter}, + @pagination + ) + + assert index.conversations == nil + refute index.changeset.valid? + end + end + + test "anonymous actors are unauthorized" do + assert Conversations.list_conversations(actor(), %{}, @pagination) == + {:error, :unauthorized} + end + end + + describe "unread_conversation_count/1" do + test "counts unread visible conversations and excludes hidden ones" do + user = confirmed_user_fixture() + unread = conversation_fixture(confirmed_user_fixture(), user) + hidden = conversation_fixture(confirmed_user_fixture(), user) + {:ok, _} = Conversations.update_conversation_hide(actor(user), hidden.slug) + + assert {:ok, 1} = Conversations.unread_conversation_count(actor(user)) + assert unread.id != hidden.id + end + + test "anonymous actors are unauthorized" do + assert Conversations.unread_conversation_count(actor()) == {:error, :unauthorized} + end + end + + describe "show_conversation/3" do + test "the recipient loads the page, its messages, and marks their side read" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + refute conversation.to_read + + assert {:ok, %ConversationPage{} = page} = + Conversations.show_conversation( + actor(recipient), + conversation.slug, + @pagination + ) + + assert page.conversation.id == conversation.id + assert %Ecto.Changeset{data: %Message{}} = page.changeset + assert Enum.any?(page.messages.entries, &(&1.body == "Test message body")) + + # The recipient's side of the conversation is marked read as a side effect. + assert Repo.reload!(conversation).to_read + end + + test "a non-participant moderator loads the page" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert {:ok, %ConversationPage{}} = + Conversations.show_conversation( + actor(moderator_user_fixture()), + conversation.slug, + @pagination + ) + end + + test "a non-participant regular user is unauthorized" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert Conversations.show_conversation( + actor(confirmed_user_fixture()), + conversation.slug, + @pagination + ) == {:error, :unauthorized} + end + + test "an unknown slug is not found for users, moderators, and admins" do + for viewer <- [ + confirmed_user_fixture(), + moderator_user_fixture(), + admin_user_fixture() + ] do + assert Conversations.show_conversation( + actor(viewer), + "no-such-slug", + @pagination + ) == {:error, :not_found} + end + end + end + + describe "new_conversation/2" do + test "a signed-in actor gets a changeset prefilled with the recipient" do + recipient = confirmed_user_fixture() + + assert {:ok, %Ecto.Changeset{} = changeset} = + Conversations.new_conversation( + actor(confirmed_user_fixture()), + %{"recipient" => recipient.name} + ) + + assert fetch_change!(changeset, :recipient) == recipient.name + end + + test "a banned actor is rejected even while carrying a fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Conversations.new_conversation(actor, %{"recipient" => "anyone"}) == {:error, :ban} + end + + test "an actor without a fingerprint may not reach the form" do + assert Conversations.new_conversation(actor(nil, fingerprint: nil), %{ + "recipient" => "anyone" + }) == + {:error, :unauthorized} + end + end + + describe "trusted_sender?/1" do + test "uses conversation approval eligibility for anonymous, new, and verified actors" do + refute Conversations.trusted_sender?(actor()) + refute Conversations.trusted_sender?(actor(confirmed_user_fixture())) + assert Conversations.trusted_sender?(actor(verified_user_fixture())) + assert Conversations.trusted_sender?(actor(moderator_user_fixture())) + end + end + + describe "create_conversation/2" do + test "a signed-in actor creates a conversation and its first message" do + user = confirmed_user_fixture() + recipient = confirmed_user_fixture() + + params = %{ + "recipient" => recipient.name, + "title" => "Hello there", + "messages" => %{"0" => %{"body" => "A fine day to you"}} + } + + assert {:ok, %Conversation{} = conversation} = + Conversations.create_conversation(actor(user), params) + + assert conversation.from_id == user.id + assert conversation.to_id == recipient.id + assert conversation.title == "Hello there" + end + + test "an unknown recipient is a rejected changeset" do + params = %{ + "recipient" => "nobody by this name", + "title" => "Hello there", + "messages" => %{"0" => %{"body" => "A fine day to you"}} + } + + assert {:error, %Ecto.Changeset{} = changeset} = + Conversations.create_conversation(actor(confirmed_user_fixture()), params) + + refute changeset.valid? + end + + test "a deactivated recipient is a rejected changeset" do + sender = confirmed_user_fixture() + recipient = deactivated_user_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Conversations.create_conversation(actor(sender), %{ + "recipient" => recipient.name, + "title" => "Hello", + "messages" => %{"0" => %{"body" => "Hello"}} + }) + + refute changeset.valid? + assert {"can't be blank", _} = changeset.errors[:to] + end + + test "non-map params raise" do + assert_raise Ecto.CastError, + fn -> + Conversations.create_conversation(actor(confirmed_user_fixture()), "invalid") + end + end + + test "a banned actor is rejected" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Conversations.create_conversation(actor, %{"recipient" => "anyone"}) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Conversations.create_conversation(actor, %{"recipient" => "anyone"}) == + {:error, :unauthorized} + end + + test "the ban wins over a missing fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban, fingerprint: nil) + + assert Conversations.create_conversation(actor, %{"recipient" => "anyone"}) == + {:error, :ban} + end + + test "an over-limit actor is rate limited and no conversation is created" do + # The :conversation_create counter is primed past the limit, so the + # rate check (after write-access, before the insert) refuses the write. + user = confirmed_user_fixture() + recipient = confirmed_user_fixture() + actor = actor(user) + exceed_rate_limit(actor, :conversation_create) + + params = %{ + "recipient" => recipient.name, + "title" => "Hello there", + "messages" => %{"0" => %{"body" => "A fine day to you"}} + } + + assert Conversations.create_conversation(actor, params) == {:error, :rate_limited} + assert Repo.aggregate(Conversation, :count) == 0 + end + + test "a successful create records the counter" do + user = confirmed_user_fixture() + recipient = confirmed_user_fixture() + actor = actor(user) + track_rate_limit(actor, :conversation_create) + + params = %{ + "recipient" => recipient.name, + "title" => "Hello there", + "messages" => %{"0" => %{"body" => "A fine day to you"}} + } + + assert {:ok, %Conversation{}} = Conversations.create_conversation(actor, params) + assert rate_limit_count(actor, :conversation_create) == "1" + end + + test "invalid recipient input does not consume the rate limit" do + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :conversation_create) + + assert {:error, %Ecto.Changeset{}} = + Conversations.create_conversation(actor, %{"recipient" => "nobody by this name"}) + end + end + + describe "update_conversation_read/2 and update_conversation_read/3" do + test "the recipient marks their conversation read then unread" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_read(actor(recipient), conversation.slug) + + assert Repo.reload!(conversation).to_read + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_read(actor(recipient), conversation.slug, false) + + refute Repo.reload!(conversation).to_read + end + + test "a non-participant moderator succeeds without changing either read flag" do + # The moderator is authorized for :show but is not a participant, so the + # read flag is set for neither side. + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_read( + actor(moderator_user_fixture()), + conversation.slug + ) + + reloaded = Repo.reload!(conversation) + refute reloaded.to_read + assert reloaded.from_read + end + + test "a non-participant regular user is unauthorized" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert Conversations.update_conversation_read( + actor(confirmed_user_fixture()), + conversation.slug + ) == + {:error, :unauthorized} + end + + test "repeated updates are idempotent" do + recipient = confirmed_user_fixture() + conversation = conversation_fixture(confirmed_user_fixture(), recipient) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_read(actor(recipient), conversation.slug) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_read(actor(recipient), conversation.slug) + + assert Repo.reload!(conversation).to_read + end + + test "an unknown slug is always not found" do + for viewer <- [confirmed_user_fixture(), admin_user_fixture()] do + assert Conversations.update_conversation_read(actor(viewer), "no-such-slug") == + {:error, :not_found} + end + end + end + + describe "update_conversation_hide/2 and update_conversation_hide/3" do + test "the recipient hides then restores their conversation" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_hide(actor(recipient), conversation.slug) + + assert Repo.reload!(conversation).to_hidden + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_hide(actor(recipient), conversation.slug, false) + + refute Repo.reload!(conversation).to_hidden + end + + test "a non-participant regular user is unauthorized" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert Conversations.update_conversation_hide( + actor(confirmed_user_fixture()), + conversation.slug + ) == + {:error, :unauthorized} + end + + test "repeated updates are idempotent" do + recipient = confirmed_user_fixture() + conversation = conversation_fixture(confirmed_user_fixture(), recipient) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_hide(actor(recipient), conversation.slug) + + assert {:ok, %Conversation{}} = + Conversations.update_conversation_hide(actor(recipient), conversation.slug) + + assert Repo.reload!(conversation).to_hidden + end + + test "an unknown slug is always not found" do + for viewer <- [confirmed_user_fixture(), admin_user_fixture()] do + assert Conversations.update_conversation_hide(actor(viewer), "no-such-slug") == + {:error, :not_found} + end + end + end + + describe "create_message/3" do + test "a participant posts a reply and both sides are marked unread" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + # The recipient reads the conversation, clearing their unread flag. + {:ok, _} = Conversations.update_conversation_read(actor(recipient), conversation.slug) + + assert {:ok, %Message{} = message} = + Conversations.create_message(actor(recipient), conversation.slug, %{ + "body" => "a reply from the recipient" + }) + + assert message.body == "a reply from the recipient" + assert message.conversation.message_count == 2 + + # Posting a message marks both sides unread again. + reloaded = Repo.reload!(conversation) + refute reloaded.to_read + refute reloaded.from_read + end + + test "a blank body returns the actual message changeset and conversation" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + + assert {:error, %Ecto.Changeset{} = changeset} = + Conversations.create_message(actor(recipient), conversation.slug, %{"body" => ""}) + + assert changeset.data.conversation_id == conversation.id + refute changeset.valid? + end + + test "a non-participant regular user is unauthorized" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert Conversations.create_message(actor(confirmed_user_fixture()), conversation.slug, %{ + "body" => "intruding" + }) == {:error, :unauthorized} + end + + test "a banned participant is rejected before any loading" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + actor = actor(recipient, ban: @ban) + + assert Conversations.create_message(actor, conversation.slug, %{"body" => "hi"}) == + {:error, :ban} + end + + test "a participant with no fingerprint is unauthorized" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + conversation = conversation_fixture(sender, recipient) + actor = actor(recipient, fingerprint: nil) + + assert Conversations.create_message(actor, conversation.slug, %{"body" => "hi"}) == + {:error, :unauthorized} + end + + test "an unknown slug is always not found" do + for viewer <- [confirmed_user_fixture(), admin_user_fixture()] do + assert Conversations.create_message(actor(viewer), "no-such-slug", %{ + "body" => "hi" + }) == {:error, :not_found} + end + end + end + + describe "create_message_approve/3" do + test "a missing route conversation is not found before the message lookup" do + assert Conversations.create_message_approve( + actor(moderator_user_fixture()), + "missing-conversation", + "1" + ) == {:error, :not_found} + end + + test "a moderator approves a withheld message and a moderation log is written" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + {conversation, message} = unapproved_message(sender, recipient) + moderator = moderator_user_fixture() + report = Repo.get_by!(Report, conversation_id: conversation.id) + assert report.open + + assert {:ok, %Message{} = approved} = + Conversations.create_message_approve( + actor(moderator), + conversation.slug, + "#{message.id}" + ) + + assert approved.id == message.id + assert Repo.reload!(message).approved + refute Repo.reload!(report).open + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Conversation.Message.Approve:create" + assert log.subject_path == "/" + assert log.body == "Approved private message in conversation ##{conversation.id}" + end + + test "an admin approves a withheld message" do + sender = confirmed_user_fixture() + recipient = confirmed_user_fixture() + {conversation, message} = unapproved_message(sender, recipient) + + assert {:ok, %Message{}} = + Conversations.create_message_approve( + actor(admin_user_fixture()), + conversation.slug, + "#{message.id}" + ) + + assert Repo.reload!(message).approved + end + + test "a non-integer message id is not-found" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert Conversations.create_message_approve( + actor(moderator_user_fixture()), + conversation.slug, + "not-a-number" + ) == + {:error, :not_found} + end + + test "missing and wrong-conversation message IDs are always not found" do + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + other = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + other_message = message_fixture(other, other.from) + + for staff <- [moderator_user_fixture(), admin_user_fixture()], + id <- ["999999999", "#{other_message.id}"] do + assert Conversations.create_message_approve(actor(staff), conversation.slug, id) == + {:error, :not_found} + end + end + end +end diff --git a/test/philomena/dnp_entries_test.exs b/test/philomena/dnp_entries_test.exs new file mode 100644 index 000000000..a190be49d --- /dev/null +++ b/test/philomena/dnp_entries_test.exs @@ -0,0 +1,705 @@ +defmodule Philomena.DnpEntriesTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.DnpEntries` functions. + + These pin the typed listing, page, and form contracts; load-before-authorize + behavior; action-specific permissions; safe selectable-tag handling; global + write prerequisites; and transactional staff transitions. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures + import Philomena.ArtistLinksFixtures + import Philomena.DnpEntriesFixtures + import Philomena.ModNotesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + alias Philomena.DnpEntries + alias Philomena.DnpEntries.{DnpEntry, DnpEntryForm, DnpEntryPage, DnpListing} + alias Philomena.ModerationLogs.ModerationLog + + # A truthy ban value in the shape production passes; only its presence matters + # to the write-access and not-banned checks the write paths run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + @pagination [page: 1, page_size: 25] + + defp artist_tag do + tag_fixture(name: "artist:dnp-test-#{System.unique_integer([:positive])}") + end + + # A confirmed user holding a verified artist link, so the user has a linked + # tag to file a DNP request against. Returns {user, tag}. + defp linked_user do + user = confirmed_user_fixture() + tag = artist_tag() + verified_artist_link_fixture(user, tag) + {user, tag} + end + + defp dnp_entry_attrs(tag, attrs \\ %{}) do + Enum.into(attrs, %{ + "tag_id" => to_string(tag.id), + "dnp_type" => "No Edits", + "reason" => "Test DNP reason" + }) + end + + describe "list_dnp_entries/3" do + test "the mine listing returns the user's own entries with the status column" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert %DnpListing{status_column: true, dnp_entries: page} = + DnpEntries.list_dnp_entries(actor(user), %{"mine" => "1"}, @pagination) + + assert entry.id in Enum.map(page.entries, & &1.id) + end + + test "the default listing returns only listed entries, without the status column" do + {user, tag} = linked_user() + listed = dnp_entry_fixture(user, tag, %{state: "listed"}) + + {other, other_tag} = linked_user() + requested = dnp_entry_fixture(other, other_tag) + + assert %DnpListing{status_column: false, dnp_entries: page} = + DnpEntries.list_dnp_entries(actor(), %{}, @pagination) + + ids = Enum.map(page.entries, & &1.id) + assert listed.id in ids + refute requested.id in ids + end + + test "the viewer's linked tags travel along for the sidebar" do + {user, tag} = linked_user() + + assert %DnpListing{linked_tags: tags} = + DnpEntries.list_dnp_entries(actor(user), %{}, @pagination) + + assert tag.id in Enum.map(tags, & &1.id) + end + + test "an anonymous viewer has no linked tags" do + assert %DnpListing{linked_tags: []} = DnpEntries.list_dnp_entries(actor(), %{}, @pagination) + end + end + + describe "show_dnp_entry/3" do + test "loads a listed entry for an anonymous viewer, with the tag preloaded" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag, %{state: "listed"}) + + assert {:ok, %DnpEntryPage{dnp_entry: loaded}} = + DnpEntries.show_dnp_entry(actor(), to_string(entry.id), & &1) + + assert loaded.id == entry.id + refute match?(%Ecto.Association.NotLoaded{}, loaded.tag) + end + + test "the requesting user may load their own not-yet-listed entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, %DnpEntryPage{dnp_entry: loaded}} = + DnpEntries.show_dnp_entry(actor(user), to_string(entry.id), & &1) + + assert loaded.id == entry.id + end + + test "an unrelated user may not load a still-requested entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.show_dnp_entry( + actor(confirmed_user_fixture()), + to_string(entry.id), + & &1 + ) == + {:error, :unauthorized} + end + + test "an unknown well-formed id is not-found for every signed-in role" do + assert DnpEntries.show_dnp_entry( + actor(confirmed_user_fixture()), + "2147483647", + & &1 + ) == + {:error, :not_found} + + assert DnpEntries.show_dnp_entry( + actor(moderator_user_fixture()), + "2147483647", + & &1 + ) == + {:error, :not_found} + + assert DnpEntries.show_dnp_entry( + actor(admin_user_fixture()), + "2147483647", + & &1 + ) == + {:error, :not_found} + end + + test "an unknown well-formed id is not-found for an anonymous viewer" do + assert DnpEntries.show_dnp_entry(actor(), "2147483647", & &1) == + {:error, :not_found} + end + + test "a non-integer id is not-found" do + assert DnpEntries.show_dnp_entry(actor(), "not-a-number", & &1) == + {:error, :not_found} + end + end + + describe "DNP page moderation notes" do + test "a moderator gets the rendered mod notes for the entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + note = + mod_note_fixture_for(moderator_user_fixture(), %{"dnp_entry_id" => entry.id}) + + # The renderer zips each note with its rendered body into a {note, body} + # tuple, so the identity renderer pairs each note with itself. + assert {:ok, %DnpEntryPage{mod_notes: notes}} = + DnpEntries.show_dnp_entry( + actor(moderator_user_fixture()), + entry.id, + & &1 + ) + + assert is_list(notes) + assert note.id in Enum.map(notes, fn {loaded, _body} -> loaded.id end) + end + + test "an assistant is permitted to read mod notes" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag, %{state: "listed"}) + + assert {:ok, %DnpEntryPage{mod_notes: notes}} = + DnpEntries.show_dnp_entry(actor(assistant_user_fixture()), entry.id, & &1) + + assert is_list(notes) + end + + test "a regular user gets nil" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, %DnpEntryPage{mod_notes: nil}} = + DnpEntries.show_dnp_entry(actor(user), entry.id, & &1) + end + + test "an anonymous viewer gets nil" do + {user, tag} = linked_user() + listed = dnp_entry_fixture(user, tag, %{state: "listed"}) + + assert {:ok, %DnpEntryPage{mod_notes: nil}} = + DnpEntries.show_dnp_entry(actor(), listed.id, & &1) + end + end + + describe "new_dnp_entry/2" do + test "a user with a linked tag gets a changeset and their selectable tags" do + {user, tag} = linked_user() + + assert {:ok, %DnpEntryForm{changeset: %Ecto.Changeset{}, selectable_tags: tags}} = + DnpEntries.new_dnp_entry(actor(user), %{}) + + assert tag.id in Enum.map(tags, & &1.id) + end + + test "a user cannot select arbitrary tags" do + {user, _tag} = linked_user() + unlinked_tag = tag_fixture() + + assert {:ok, %DnpEntryForm{selectable_tags: tags}} = + DnpEntries.new_dnp_entry(actor(user), %{"tag_id" => unlinked_tag.id}) + + refute unlinked_tag.id in Enum.map(tags, & &1.id) + end + + test "a banned actor is rejected before the tag check" do + {user, _tag} = linked_user() + + assert DnpEntries.new_dnp_entry(actor(user, ban: @ban), %{}) == {:error, :ban} + end + + test "an actor without a fingerprint is rejected before the tag check" do + assert DnpEntries.new_dnp_entry(actor(nil, fingerprint: nil), %{}) == + {:error, :unauthorized} + end + + test "an actor with no selectable tag is unauthorized" do + assert DnpEntries.new_dnp_entry(actor(confirmed_user_fixture()), %{}) == + {:error, :unauthorized} + end + + test "a moderator can select any tag" do + moderator = actor(moderator_user_fixture()) + tag = tag_fixture() + + assert {:ok, %DnpEntryForm{selectable_tags: tags}} = + DnpEntries.new_dnp_entry(moderator, %{"tag_id" => tag.id}) + + assert tag.id in Enum.map(tags, & &1.id) + end + end + + describe "create_dnp_entry/2" do + test "a user with a linked tag files a request against it" do + {user, tag} = linked_user() + + assert {:ok, %DnpEntry{} = entry} = + DnpEntries.create_dnp_entry(actor(user), dnp_entry_attrs(tag)) + + assert entry.tag_id == tag.id + assert entry.requesting_user_id == user.id + end + + test "a staff member files against arbitrary tags" do + moderator = moderator_user_fixture() + tag = artist_tag() + + assert {:ok, %DnpEntry{} = entry} = + DnpEntries.create_dnp_entry(actor(moderator), dnp_entry_attrs(tag)) + + assert entry.tag_id == tag.id + assert entry.requesting_user_id == moderator.id + end + + test "a banned actor is rejected" do + {user, tag} = linked_user() + + assert DnpEntries.create_dnp_entry(actor(user, ban: @ban), dnp_entry_attrs(tag)) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + {user, tag} = linked_user() + + assert DnpEntries.create_dnp_entry(actor(user, fingerprint: nil), dnp_entry_attrs(tag)) == + {:error, :unauthorized} + end + + test "an actor with no selectable tag is unauthorized" do + assert DnpEntries.create_dnp_entry(actor(confirmed_user_fixture()), %{}) == + {:error, :unauthorized} + end + + test "an invalid request re-renders with the changeset and selectable tags" do + {user, tag} = linked_user() + + assert {:error, + %DnpEntryForm{ + dnp_entry: %DnpEntry{}, + changeset: %Ecto.Changeset{} = changeset, + selectable_tags: tags + }} = + DnpEntries.create_dnp_entry(actor(user), dnp_entry_attrs(tag, %{"reason" => ""})) + + refute changeset.valid? + assert tag.id in Enum.map(tags, & &1.id) + end + + test "an unoffered tag is preserved as a form changeset error" do + {user, tag} = linked_user() + other_tag = artist_tag() + + assert {:error, %DnpEntryForm{changeset: changeset, selectable_tags: [selected]}} = + DnpEntries.create_dnp_entry(actor(user), dnp_entry_attrs(other_tag)) + + assert selected.id == tag.id + assert %{tag_id: ["must be one of your linked tags"]} = errors_on(changeset) + end + + test "a moderator's malformed tag is unauthorized" do + assert {:error, :unauthorized} = + DnpEntries.create_dnp_entry(actor(moderator_user_fixture()), %{ + "tag_id" => "not-a-number" + }) + end + end + + describe "edit_dnp_entry/2" do + test "a moderator loads the entry, a changeset, and the selectable tags" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, + %DnpEntryForm{ + dnp_entry: loaded, + changeset: %Ecto.Changeset{}, + selectable_tags: [_ | _] + }} = + DnpEntries.edit_dnp_entry( + actor(moderator_user_fixture()), + to_string(entry.id) + ) + + assert loaded.id == entry.id + end + + test "a moderator can load a bare edit URL using the entry's current tag" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, %DnpEntryForm{selectable_tags: [selected]}} = + DnpEntries.edit_dnp_entry( + actor(moderator_user_fixture()), + entry.id + ) + + assert selected.id == tag.id + end + + test "a banned moderator cannot load the form" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.edit_dnp_entry( + actor(moderator_user_fixture(), ban: @ban), + entry.id + ) == {:error, :ban} + end + + test "a regular user may not edit an entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.edit_dnp_entry(actor(user), to_string(entry.id)) == + {:error, :unauthorized} + end + + test "a non-integer id is not-found" do + assert DnpEntries.edit_dnp_entry( + actor(moderator_user_fixture()), + "not-a-number" + ) == {:error, :not_found} + end + end + + describe "update_dnp_entry/3" do + test "a moderator updates an entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, %DnpEntry{}} = + DnpEntries.update_dnp_entry( + actor(moderator_user_fixture()), + to_string(entry.id), + dnp_entry_attrs(tag, %{"reason" => "Updated reason"}) + ) + + assert Repo.reload!(entry).reason == "Updated reason" + end + + test "an invalid update re-renders with the entry, changeset, and selectable tags" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:error, + %DnpEntryForm{ + dnp_entry: %DnpEntry{}, + changeset: %Ecto.Changeset{} = changeset, + selectable_tags: [_ | _] + }} = + DnpEntries.update_dnp_entry( + actor(moderator_user_fixture()), + to_string(entry.id), + dnp_entry_attrs(tag, %{"reason" => ""}) + ) + + refute changeset.valid? + end + + test "a regular user may not update an entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.update_dnp_entry(actor(user), to_string(entry.id), dnp_entry_attrs(tag)) == + {:error, :unauthorized} + end + + test "a non-integer id is not-found" do + tag = artist_tag() + + assert DnpEntries.update_dnp_entry( + actor(moderator_user_fixture()), + "not-a-number", + dnp_entry_attrs(tag) + ) == {:error, :not_found} + end + + test "an unoffered replacement tag is preserved as a form changeset error" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + other_tag = artist_tag() + + assert {:error, %DnpEntryForm{changeset: changeset, selectable_tags: [selected]}} = + DnpEntries.update_dnp_entry( + actor(moderator_user_fixture()), + entry.id, + dnp_entry_attrs(other_tag) + ) + + assert selected.id == tag.id + assert %{tag_id: ["must be one of your linked tags"]} = errors_on(changeset) + assert Repo.reload!(entry).tag_id == tag.id + end + + test "a banned moderator cannot update" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.update_dnp_entry( + actor(moderator_user_fixture(), ban: @ban), + entry.id, + %{"dnp_entry" => dnp_entry_attrs(tag)} + ) == {:error, :ban} + end + end + + describe "list_admin_dnp_entries/3" do + test "an anonymous viewer is unauthorized" do + assert DnpEntries.list_admin_dnp_entries(actor(), %{}, @pagination) == + {:error, :unauthorized} + end + + test "a regular user is unauthorized" do + assert DnpEntries.list_admin_dnp_entries(actor(confirmed_user_fixture()), %{}, @pagination) == + {:error, :unauthorized} + end + + test "a moderator and an admin are authorized" do + for user <- [moderator_user_fixture(), admin_user_fixture()] do + assert {:ok, %Scrivener.Page{}, %Ecto.Changeset{valid?: true}} = + DnpEntries.list_admin_dnp_entries(actor(user), %{}, @pagination) + end + end + + test "the default view lists the active states and excludes listed entries" do + {user, tag} = linked_user() + requested = dnp_entry_fixture(user, tag) + + {other, other_tag} = linked_user() + listed = dnp_entry_fixture(other, other_tag, %{state: "listed"}) + + assert {:ok, page, %Ecto.Changeset{valid?: true}} = + DnpEntries.list_admin_dnp_entries( + actor(moderator_user_fixture()), + %{}, + @pagination + ) + + ids = Enum.map(page.entries, & &1.id) + assert requested.id in ids + refute listed.id in ids + end + + test "a states list restricts to those states" do + {user, tag} = linked_user() + requested = dnp_entry_fixture(user, tag) + + {other, other_tag} = linked_user() + listed = dnp_entry_fixture(other, other_tag, %{state: "listed"}) + + assert {:ok, page, %Ecto.Changeset{valid?: true}} = + DnpEntries.list_admin_dnp_entries( + actor(moderator_user_fixture()), + %{"states" => ["listed"]}, + @pagination + ) + + ids = Enum.map(page.entries, & &1.id) + assert listed.id in ids + refute requested.id in ids + end + + test "a text param filters by the tag name" do + {user, tag} = linked_user() + wanted = dnp_entry_fixture(user, tag) + + {other, other_tag} = linked_user() + unrelated = dnp_entry_fixture(other, other_tag) + + assert {:ok, page, %Ecto.Changeset{valid?: true}} = + DnpEntries.list_admin_dnp_entries( + actor(moderator_user_fixture()), + %{"text" => tag.name}, + @pagination + ) + + ids = Enum.map(page.entries, & &1.id) + assert wanted.id in ids + refute unrelated.id in ids + end + + test "states and text filters are applied together" do + {user, tag} = linked_user() + listed_match = dnp_entry_fixture(user, tag, %{state: "listed"}) + + {other, other_tag} = linked_user() + listed_other = dnp_entry_fixture(other, other_tag, %{state: "listed"}) + + assert {:ok, page, %Ecto.Changeset{valid?: true}} = + DnpEntries.list_admin_dnp_entries( + actor(moderator_user_fixture()), + %{"states" => ["listed"], "text" => tag.name}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [listed_match.id] + refute listed_other.id in Enum.map(page.entries, & &1.id) + end + end + + describe "create_dnp_entry_transition/3" do + test "accepts every declared DNP state" do + moderator = actor(moderator_user_fixture()) + + for state <- DnpEntry.states() do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, %DnpEntry{aasm_state: ^state}} = + DnpEntries.create_dnp_entry_transition(moderator, entry.id, state) + end + end + + test "a moderator transitions an entry and writes a moderation log" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + moderator = moderator_user_fixture() + + assert {:ok, %DnpEntry{aasm_state: "acknowledged"} = transitioned} = + DnpEntries.create_dnp_entry_transition( + actor(moderator), + to_string(entry.id), + "acknowledged" + ) + + assert transitioned.id == entry.id + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Admin.DnpEntry.Transition:create" + assert log.subject_path == "/dnp/#{entry.id}" + assert log.body == "Acknowledged DNP entry #{entry.id} on #{tag.name}" + end + + test "an admin transitions an entry" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:ok, %DnpEntry{aasm_state: "rescinded"}} = + DnpEntries.create_dnp_entry_transition( + actor(admin_user_fixture()), + to_string(entry.id), + "rescinded" + ) + end + + test "an anonymous actor is unauthorized" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.create_dnp_entry_transition(actor(), to_string(entry.id), "acknowledged") == + {:error, :unauthorized} + + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + test "a regular user is unauthorized" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.create_dnp_entry_transition( + actor(confirmed_user_fixture()), + to_string(entry.id), + "acknowledged" + ) == {:error, :unauthorized} + + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + test "an invalid target state is a rejected changeset and writes no log" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:error, %Ecto.Changeset{} = changeset} = + DnpEntries.create_dnp_entry_transition( + actor(moderator_user_fixture()), + to_string(entry.id), + "not-a-state" + ) + + refute changeset.valid? + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + test "a missing target state is a rejected changeset and writes no log" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert {:error, %Ecto.Changeset{} = changeset} = + DnpEntries.create_dnp_entry_transition( + actor(moderator_user_fixture()), + entry.id, + nil + ) + + refute changeset.valid? + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + test "a banned moderator is rejected before the transition" do + {user, tag} = linked_user() + entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.create_dnp_entry_transition( + actor(moderator_user_fixture(), ban: @ban), + entry.id, + "claimed" + ) == {:error, :ban} + + assert Repo.reload!(entry).aasm_state == "requested" + end + + test "an unknown well-formed id is not-found for an authorized actor" do + assert DnpEntries.create_dnp_entry_transition( + actor(moderator_user_fixture()), + "2147483647", + "acknowledged" + ) == + {:error, :not_found} + end + + test "a non-integer id is not-found for an authorized actor" do + assert DnpEntries.create_dnp_entry_transition( + actor(moderator_user_fixture()), + "not-a-number", + "acknowledged" + ) == {:error, :not_found} + end + end + + describe "count_dnp_entries/1" do + test "returns active count for a moderator and nil for a regular user" do + {user, tag} = linked_user() + _entry = dnp_entry_fixture(user, tag) + + assert DnpEntries.count_dnp_entries(actor(moderator_user_fixture())) == 1 + assert DnpEntries.count_dnp_entries(actor(confirmed_user_fixture())) == nil + end + end +end diff --git a/test/philomena/donations_test.exs b/test/philomena/donations_test.exs new file mode 100644 index 000000000..323964e4e --- /dev/null +++ b/test/philomena/donations_test.exs @@ -0,0 +1,144 @@ +defmodule Philomena.DonationsTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.Donations` functions: + the admin index (`load_donations/2`), the per-user listing (`load_user_donations/2`), + and `create_donation/2`. + + These pin the admin-only `:index, Donation` gate (moderators are rejected, + since no moderator rule covers donations), the newest-first ordering with the + user preloaded, the unknown-slug not-found shape, the foreign-key failure on a + bad `user_id`, and the all-fields-optional insert. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.DonationsFixtures + import Philomena.UsersFixtures + + alias Philomena.Donations + alias Philomena.Donations.Donation + + @pagination [page: 1, page_size: 25] + @ban %{reason: "Rule #0", valid_until: ~U[3000-01-01 00:00:00Z]} + + describe "list_donations/2" do + test "an anonymous viewer is unauthorized" do + assert Donations.list_donations(actor(), @pagination) == {:error, :unauthorized} + end + + test "a regular user is unauthorized" do + assert Donations.list_donations(actor(confirmed_user_fixture()), @pagination) == + {:error, :unauthorized} + end + + test "a moderator is unauthorized" do + assert Donations.list_donations(actor(moderator_user_fixture()), @pagination) == + {:error, :unauthorized} + end + + test "an admin gets the paginated listing with the user preloaded" do + user = confirmed_user_fixture() + donation = donation_fixture(user) + + assert {:ok, page} = Donations.list_donations(actor(admin_user_fixture()), @pagination) + + loaded = Enum.find(page.entries, &(&1.id == donation.id)) + assert loaded + refute match?(%Ecto.Association.NotLoaded{}, loaded.user) + assert loaded.user.id == user.id + end + end + + describe "show_user_donations/2" do + test "a regular user is unauthorized" do + user = confirmed_user_fixture() + + assert Donations.show_user_donations(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "a moderator is unauthorized" do + user = confirmed_user_fixture() + + assert Donations.show_user_donations(actor(moderator_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "an admin loads a user with their donations and an add-donation changeset" do + user = confirmed_user_fixture() + donation = donation_fixture(user) + + assert {:ok, {loaded, %Ecto.Changeset{data: %Donation{}}}} = + Donations.show_user_donations(actor(admin_user_fixture()), user.slug) + + assert loaded.id == user.id + assert donation.id in Enum.map(loaded.donations, & &1.id) + end + + test "an unknown slug is not-found for an admin" do + assert Donations.show_user_donations(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "create_donation/2" do + test "an anonymous actor is unauthorized" do + assert Donations.create_donation(actor(), %{"amount" => "5.00"}) == {:error, :unauthorized} + end + + test "a regular user is unauthorized" do + assert Donations.create_donation(actor(confirmed_user_fixture()), %{"amount" => "5.00"}) == + {:error, :unauthorized} + end + + test "a moderator is unauthorized" do + assert Donations.create_donation(actor(moderator_user_fixture()), %{"amount" => "5.00"}) == + {:error, :unauthorized} + end + + test "an admin creates a donation attributed to a user" do + user = confirmed_user_fixture() + + assert {:ok, %Donation{} = donation} = + Donations.create_donation(actor(admin_user_fixture()), %{ + "amount" => "10.00", + "email" => "donor@example.com", + "user_id" => user.id + }) + + assert donation.user_id == user.id + assert Decimal.equal?(donation.amount, Decimal.new("10.00")) + end + + test "an admin creates a donation from empty attrs, all fields being optional" do + assert {:ok, %Donation{}} = Donations.create_donation(actor(admin_user_fixture()), %{}) + end + + test "a user_id naming no user is a foreign-key changeset error" do + assert {:error, %Ecto.Changeset{} = changeset} = + Donations.create_donation(actor(admin_user_fixture()), %{ + "user_id" => 2_147_483_647 + }) + + assert %{user_id: ["does not exist"]} = errors_on(changeset) + end + end + + describe "write access prerequisite" do + test "the per-user form and create reject bans and missing fingerprints" do + admin = admin_user_fixture() + target = confirmed_user_fixture() + + operations = [ + &Donations.show_user_donations(&1, target.slug), + &Donations.create_donation(&1, %{}) + ] + + for operation <- operations do + assert operation.(actor(admin, ban: @ban)) == {:error, :ban} + assert operation.(actor(admin, fingerprint: nil)) == {:error, :unauthorized} + end + end + end +end diff --git a/test/philomena/duplicate_reports/comparison_test.exs b/test/philomena/duplicate_reports/comparison_test.exs new file mode 100644 index 000000000..17f57a888 --- /dev/null +++ b/test/philomena/duplicate_reports/comparison_test.exs @@ -0,0 +1,92 @@ +defmodule Philomena.DuplicateReports.ComparisonTest do + use ExUnit.Case, async: true + + alias Philomena.DuplicateReports.Comparison + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.Images.Image + alias Philomena.Images.Source + alias Philomena.Tags.Tag + + defp report(source_attrs \\ %{}, target_attrs \\ %{}) do + source = + struct!( + %Image{ + id: 1, + approved: true, + image_width: 100, + image_height: 100, + image_aspect_ratio: 1.0, + image_mime_type: "image/jpeg", + sources: [], + tags: [%Tag{name: "safe", category: "rating"}] + }, + source_attrs + ) + + target = + struct!( + %Image{ + id: 2, + approved: true, + image_width: 200, + image_height: 200, + image_aspect_ratio: 1.0, + image_mime_type: "image/png", + sources: [], + tags: [%Tag{name: "safe", category: "rating"}] + }, + target_attrs + ) + + %DuplicateReport{ + image_id: source.id, + duplicate_of_image_id: target.id, + image: source, + duplicate_of_image: target + } + end + + test "compares resolution, format, and merge direction" do + report = report() + + assert Comparison.forward_merge?(report) + assert Comparison.higher_res?(report) + refute Comparison.same_res?(report) + assert Comparison.better_format?(report) + assert Comparison.same_aspect_ratio?(report) + end + + test "compares source identity and host similarity" do + source = [%Source{source: "https://example.com/a"}] + same = [%Source{source: "https://example.com/a"}] + similar = [%Source{source: "https://example.com/b"}] + + assert Comparison.same_source?(report(%{sources: source}, %{sources: same})) + assert Comparison.similar_source?(report(%{sources: source}, %{sources: similar})) + assert Comparison.source_on_target?(report(%{sources: []}, %{sources: similar})) + end + + test "compares artist, rating, edit, and alternate-version tags" do + safe = %Tag{name: "safe", category: "rating"} + artist = %Tag{name: "artist:test", namespace: "artist"} + edit = %Tag{name: "edit"} + alternate = %Tag{name: "alternate version"} + + report = report(%{tags: [safe, artist]}, %{tags: [safe, artist, edit, alternate]}) + + assert Comparison.same_artist_tags?(report) + assert Comparison.same_rating_tags?(report) + assert Comparison.target_is_edit?(report) + assert Comparison.target_is_alternate_version?(report) + refute Comparison.source_is_edit?(report) + end + + test "requires matching ratings and two visible approved images for merging" do + assert Comparison.mergeable?(report()) + refute Comparison.mergeable?(report(%{approved: false})) + refute Comparison.mergeable?(report(%{hidden_from_users: true})) + + questionable = %Tag{name: "questionable", category: "rating"} + refute Comparison.mergeable?(report(%{}, %{tags: [questionable]})) + end +end diff --git a/test/philomena/duplicate_reports_concurrency_test.exs b/test/philomena/duplicate_reports_concurrency_test.exs new file mode 100644 index 000000000..6fbdba75f --- /dev/null +++ b/test/philomena/duplicate_reports_concurrency_test.exs @@ -0,0 +1,32 @@ +defmodule Philomena.DuplicateReportsConcurrencyTest do + use Philomena.ConcurrentDataCase + + alias Philomena.DuplicateReports + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + + import Philomena.AttributionFixtures + import Philomena.DuplicateReportsFixtures + import Philomena.ImagesFixtures + import Philomena.UsersFixtures + + test "concurrent claims assign the report once and write one audit log" do + report = duplicate_report_fixture(image_fixture(), image_fixture()) + + results = + concurrently( + for moderator <- [moderator_user_fixture(), moderator_user_fixture()] do + fn -> DuplicateReports.create_duplicate_report_claim(actor(moderator), report.id) end + end + ) + + assert Enum.count(results, &match?({:ok, %DuplicateReport{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, %Ecto.Changeset{}}, &1)) == 1 + assert Repo.aggregate(ModerationLog, :count) == 1 + + persisted = Repo.get!(DuplicateReport, report.id) + assert persisted.state == "claimed" + assert persisted.modifier_id + end +end diff --git a/test/philomena/duplicate_reports_test.exs b/test/philomena/duplicate_reports_test.exs new file mode 100644 index 000000000..7ea8f2f09 --- /dev/null +++ b/test/philomena/duplicate_reports_test.exs @@ -0,0 +1,504 @@ +defmodule Philomena.DuplicateReportsTest do + use Philomena.DataCase, async: true + + alias Philomena.DuplicateReports + alias Philomena.DuplicateReports.DuplicateReport + alias Philomena.DuplicateReports.SearchResult + alias Philomena.Images.Image + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + + import Philomena.AttributionFixtures + import Philomena.DuplicateReportsFixtures + import Philomena.ImagesFixtures + import Philomena.UsersFixtures + + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + @pagination %{page_number: 1, page_size: 25} + + defp only_moderation_log!, do: Repo.one!(ModerationLog) + + defp hide_image(image) do + image + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + end + + describe "list_duplicate_reports/3" do + test "authorizes the index and applies the state selection" do + open = duplicate_report_fixture(image_fixture(), image_fixture()) + rejected = duplicate_report_fixture(image_fixture(), image_fixture()) + moderator = actor(moderator_user_fixture()) + + {:ok, _rejected} = DuplicateReports.create_duplicate_report_reject(moderator, rejected.id) + + assert {:ok, page, changeset} = + DuplicateReports.list_duplicate_reports(moderator, %{}, @pagination) + + assert Enum.map(page.entries, & &1.id) == [open.id] + assert changeset.valid? + + assert {:ok, page, changeset} = + DuplicateReports.list_duplicate_reports( + moderator, + %{"states" => ["rejected"]}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [rejected.id] + assert changeset.valid? + + assert {:ok, _page, _changeset} = + DuplicateReports.list_duplicate_reports(actor(), %{}, @pagination) + + assistant = %{ + assistant_user_fixture() + | role_map: %{"DuplicateReport" => %{"moderator" => []}} + } + + assert {:ok, _page, _changeset} = + DuplicateReports.list_duplicate_reports(actor(assistant), %{}, @pagination) + end + + test "blank states use the default and invalid states match nothing" do + report = duplicate_report_fixture(image_fixture(), image_fixture()) + moderator = actor(moderator_user_fixture()) + + assert {:ok, blank_page, blank_changeset} = + DuplicateReports.list_duplicate_reports( + moderator, + %{"states" => ""}, + @pagination + ) + + assert Enum.map(blank_page.entries, & &1.id) == [report.id] + assert blank_changeset.valid? + + assert {:ok, invalid_page, invalid_changeset} = + DuplicateReports.list_duplicate_reports( + moderator, + %{"states" => ["bogus"]}, + @pagination + ) + + assert invalid_page.entries == [] + assert invalid_changeset.errors[:states] + end + + test "preloads the reporter, modifier, and both images" do + report = duplicate_report_fixture(image_fixture(), image_fixture()) + + assert {:ok, %{entries: [loaded]}, _changeset} = + DuplicateReports.list_duplicate_reports( + actor(moderator_user_fixture()), + %{}, + @pagination + ) + + assert loaded.id == report.id + assert Ecto.assoc_loaded?(loaded.user) + assert Ecto.assoc_loaded?(loaded.modifier) + assert Ecto.assoc_loaded?(loaded.image) + assert Ecto.assoc_loaded?(loaded.duplicate_of_image) + end + end + + describe "show_duplicate_report/2" do + test "loads a public report only when both images are visible" do + source = image_fixture() + target = image_fixture() + report = duplicate_report_fixture(source, target) + + assert {:ok, loaded} = DuplicateReports.show_duplicate_report(actor(), report.id) + assert loaded.image.id == source.id + assert loaded.duplicate_of_image.id == target.id + + hide_image(target) + + assert DuplicateReports.show_duplicate_report(actor(), report.id) == + {:error, :unauthorized} + + assert {:ok, _loaded} = + DuplicateReports.show_duplicate_report( + actor(moderator_user_fixture()), + report.id + ) + end + + test "normalizes malformed and missing IDs before authorization" do + for viewer <- [actor(), actor(confirmed_user_fixture()), actor(moderator_user_fixture())], + id <- ["not-an-id", "2147483647", "99999999999999999999"] do + assert DuplicateReports.show_duplicate_report(viewer, id) == {:error, :not_found} + end + end + end + + describe "new_duplicate_report/2" do + test "returns the image, all existing reports, and creation changeset" do + image = image_fixture() + visible_target = image_fixture() + hidden_target = image_fixture() + visible_report = duplicate_report_fixture(image, visible_target) + hidden_report = duplicate_report_fixture(image, hidden_target) + hide_image(hidden_target) + + assert {:ok, {loaded, reports, changeset}} = + DuplicateReports.new_duplicate_report(actor(), image.id) + + assert loaded.id == image.id + assert visible_report.id in Enum.map(reports, & &1.id) + assert hidden_report.id in Enum.map(reports, & &1.id) + assert changeset.data.image.id == image.id + end + + test "form and submission share write access and image visibility" do + image = image_fixture() + + assert DuplicateReports.new_duplicate_report( + actor(confirmed_user_fixture(), ban: @ban), + image.id + ) == {:error, :ban} + + assert DuplicateReports.new_duplicate_report( + actor(confirmed_user_fixture(), fingerprint: nil), + image.id + ) == {:error, :unauthorized} + + hidden = hide_image(image_fixture()) + + assert DuplicateReports.new_duplicate_report(actor(), hidden.id) == + {:error, :unauthorized} + + assert DuplicateReports.new_duplicate_report(actor(), "not-an-id") == + {:error, :not_found} + end + end + + describe "create_duplicate_report/4" do + test "records the reporter and returns both loaded image associations" do + user = confirmed_user_fixture() + source = image_fixture() + target = image_fixture() + + assert {:ok, report} = + DuplicateReports.create_duplicate_report( + actor(user), + source.id, + target.id, + %{"reason" => "same image"} + ) + + assert report.user_id == user.id + assert report.image.id == source.id + assert report.duplicate_of_image.id == target.id + assert report.reason == "same image" + end + + test "returns a changeset for a same-image or overlong-reason report" do + image = image_fixture() + + assert {:error, same_image} = + DuplicateReports.create_duplicate_report(actor(), image.id, image.id, %{}) + + assert same_image.errors[:image_id] == {"must be different from the target", []} + assert same_image.data.image.id == image.id + + assert {:error, too_long} = + DuplicateReports.create_duplicate_report( + actor(), + image.id, + image_fixture().id, + %{"reason" => String.duplicate("x", 251)} + ) + + assert too_long.errors[:reason] + end + + test "loads each image safely and enforces visibility" do + visible = image_fixture() + hidden = hide_image(image_fixture()) + + for {source_id, target_id} <- [ + {"not-an-id", visible.id}, + {visible.id, "not-an-id"}, + {"2147483647", visible.id}, + {visible.id, "2147483647"} + ] do + assert DuplicateReports.create_duplicate_report( + actor(), + source_id, + target_id, + %{} + ) == {:error, :not_found} + end + + assert DuplicateReports.create_duplicate_report(actor(), hidden.id, visible.id, %{}) == + {:error, :unauthorized} + + assert DuplicateReports.create_duplicate_report(actor(), visible.id, hidden.id, %{}) == + {:error, :unauthorized} + end + + test "checks a ban before locators and accepts anonymous attribution" do + source = image_fixture() + target = image_fixture() + + assert DuplicateReports.create_duplicate_report( + actor(confirmed_user_fixture(), ban: @ban, fingerprint: nil), + "not-an-id", + "not-an-id", + %{} + ) == {:error, :ban} + + assert {:ok, report} = + DuplicateReports.create_duplicate_report( + actor(), + source.id, + target.id, + %{} + ) + + assert report.user_id == nil + end + end + + describe "moderation locator and authorization contract" do + test "all transitions distinguish a forbidden row from malformed or missing IDs" do + report = duplicate_report_fixture(image_fixture(), image_fixture()) + + actions = [ + &DuplicateReports.create_duplicate_report_accept/2, + &DuplicateReports.create_duplicate_report_accept_reverse/2, + &DuplicateReports.create_duplicate_report_claim/2, + &DuplicateReports.delete_duplicate_report_claim/2, + &DuplicateReports.create_duplicate_report_reject/2 + ] + + for action <- actions do + assert action.(actor(confirmed_user_fixture()), report.id) == + {:error, :unauthorized} + + assert action.(actor(moderator_user_fixture()), "not-an-id") == + {:error, :not_found} + + assert action.(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + test "all transitions enforce write access before report loading" do + report = duplicate_report_fixture(image_fixture(), image_fixture()) + + for action <- [ + &DuplicateReports.create_duplicate_report_accept/2, + &DuplicateReports.create_duplicate_report_accept_reverse/2, + &DuplicateReports.create_duplicate_report_claim/2, + &DuplicateReports.delete_duplicate_report_claim/2, + &DuplicateReports.create_duplicate_report_reject/2 + ] do + assert action.(actor(moderator_user_fixture(), ban: @ban), report.id) == + {:error, :ban} + end + end + end + + describe "create_duplicate_report_accept/2" do + test "atomically accepts, rejects competing reports, merges, and logs" do + moderator = moderator_user_fixture() + source = image_fixture() + target = image_fixture() + report = duplicate_report_fixture(source, target) + other = duplicate_report_fixture(target, source) + + assert {:ok, result} = + DuplicateReports.create_duplicate_report_accept(actor(moderator), report.id) + + assert result.state == "accepted" + assert Repo.get!(DuplicateReport, other.id).state == "rejected" + + source = Repo.get!(Image, source.id) + assert source.hidden_from_users + assert source.duplicate_id == target.id + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "DuplicateReport.Accept:create" + assert log.subject_path == "/images/#{source.id}" + assert log.body == "Accepted duplicate report, merged #{source.id} into #{target.id}" + end + + test "an already accepted report returns a changeset and writes no second log" do + moderator = actor(moderator_user_fixture()) + report = duplicate_report_fixture(image_fixture(), image_fixture()) + + assert {:ok, _results} = + DuplicateReports.create_duplicate_report_accept(moderator, report.id) + + assert {:error, changeset} = + DuplicateReports.create_duplicate_report_accept(moderator, report.id) + + assert changeset.errors[:state] == {"must be open or claimed", []} + assert Repo.aggregate(ModerationLog, :count) == 1 + end + + test "a hidden source or target rejects the merge and rolls back the audit log" do + moderator = actor(moderator_user_fixture()) + + for hidden_side <- [:source, :target] do + source = image_fixture() + target = image_fixture() + report = duplicate_report_fixture(source, target) + + case hidden_side do + :source -> hide_image(source) + :target -> hide_image(target) + end + + assert {:error, %Ecto.Changeset{}} = + DuplicateReports.create_duplicate_report_accept(moderator, report.id) + + assert Repo.get!(DuplicateReport, report.id).state == "open" + end + + assert Repo.aggregate(ModerationLog, :count) == 0 + end + end + + describe "create_duplicate_report_accept_reverse/2" do + test "rejects the original, accepts the reverse report, merges, and logs" do + moderator = moderator_user_fixture() + source = image_fixture() + target = image_fixture() + original = duplicate_report_fixture(source, target) + + assert {:ok, result} = + DuplicateReports.create_duplicate_report_accept_reverse( + actor(moderator), + original.id + ) + + assert Repo.get!(DuplicateReport, original.id).state == "rejected" + assert result.image_id == target.id + assert result.duplicate_of_image_id == source.id + assert result.state == "accepted" + + target = Repo.get!(Image, target.id) + assert target.hidden_from_users + assert target.duplicate_id == source.id + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "DuplicateReport.AcceptReverse:create" + assert log.subject_path == "/images/#{target.id}" + + assert log.body == + "Reverse-accepted duplicate report, merged #{target.id} into #{source.id}" + end + + test "truncates a long reason before appending the reverse-accepted suffix" do + moderator = moderator_user_fixture() + source = image_fixture() + target = image_fixture() + reason = String.duplicate("x", 250) + original = duplicate_report_fixture(source, target, nil, %{"reason" => reason}) + + assert {:ok, reverse_report} = + DuplicateReports.create_duplicate_report_accept_reverse( + actor(moderator), + original.id + ) + + assert byte_size(reverse_report.reason) == 250 + assert String.ends_with?(reverse_report.reason, "\n(Reverse accepted)") + assert String.starts_with?(reverse_report.reason, String.duplicate("x", 231)) + end + end + + describe "claim and unclaim transitions" do + test "claim and unclaim validate state and commit their audit logs" do + moderator = moderator_user_fixture() + staff_actor = actor(moderator) + report = duplicate_report_fixture(image_fixture(), image_fixture()) + + assert {:ok, claimed} = + DuplicateReports.create_duplicate_report_claim(staff_actor, report.id) + + assert claimed.state == "claimed" + assert claimed.modifier_id == moderator.id + + assert {:error, already_claimed} = + DuplicateReports.create_duplicate_report_claim(staff_actor, report.id) + + assert already_claimed.errors[:state] == {"must be open", []} + + assert {:ok, released} = + DuplicateReports.delete_duplicate_report_claim(staff_actor, report.id) + + assert released.state == "open" + assert released.modifier_id == nil + + assert {:error, not_claimed} = + DuplicateReports.delete_duplicate_report_claim(staff_actor, report.id) + + assert not_claimed.errors[:state] == {"must be claimed", []} + + assert Repo.aggregate(ModerationLog, :count) == 2 + end + end + + describe "create_duplicate_report_reject/2" do + test "rejects an active report and logs its direction" do + moderator = moderator_user_fixture() + source = image_fixture() + target = image_fixture() + report = duplicate_report_fixture(source, target) + + assert {:ok, rejected} = + DuplicateReports.create_duplicate_report_reject(actor(moderator), report.id) + + assert rejected.state == "rejected" + assert rejected.modifier_id == moderator.id + + log = only_moderation_log!() + assert log.type == "DuplicateReport.Reject:create" + assert log.body == "Rejected duplicate report (#{source.id} -> #{target.id})" + + assert {:error, changeset} = + DuplicateReports.create_duplicate_report_reject(actor(moderator), report.id) + + assert changeset.errors[:state] == {"must be open or claimed", []} + assert Repo.aggregate(ModerationLog, :count) == 1 + end + end + + describe "reverse search boundary" do + test "returns a named empty result and explicit validation errors" do + assert {:ok, %SearchResult{images: nil, changeset: changeset}} = + DuplicateReports.create_reverse_search(actor()) + + assert changeset.valid? + + assert {:error, invalid} = + DuplicateReports.create_reverse_search(actor(), %{"distance" => "invalid"}, nil) + + refute invalid.valid? + assert invalid.errors[:distance] + assert invalid.errors[:uploaded_image] + end + end + + describe "count_duplicate_reports/1" do + test "returns the open count only to staff" do + duplicate_report_fixture(image_fixture(), image_fixture()) + + assert DuplicateReports.count_duplicate_reports(actor()) == nil + assert DuplicateReports.count_duplicate_reports(actor(confirmed_user_fixture())) == nil + assert DuplicateReports.count_duplicate_reports(actor(moderator_user_fixture())) == 1 + end + end +end diff --git a/test/philomena/filters_test.exs b/test/philomena/filters_test.exs new file mode 100644 index 000000000..b99a2c50d --- /dev/null +++ b/test/philomena/filters_test.exs @@ -0,0 +1,770 @@ +defmodule Philomena.FiltersTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.Filters` functions. + + These pin the index/search viewer-visibility scoping, the `FilterPage` struct + shape, the per-role authorization matrices on the form loaders and write + paths (owner vs unrelated user vs admin, uniform malformed/absent ID handling, + and banned and missing-fingerprint actors), explicit default selection, and + idempotent publication. + """ + + use Philomena.DataCase, async: false + + # query_filters/3 and delete_filter/2 (via unindex) touch OpenSearch, so this + # module follows the search rules: async: false, index cycled in setup. + @moduletag :search + + import Philomena.AttributionFixtures + import Philomena.FiltersFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + alias Philomena.Filters + alias Philomena.Filters.Filter + alias Philomena.Filters.FilterPage + alias Philomena.Filters.ImageFilter + alias Philomena.Images + alias Philomena.Images.Query + alias Philomena.Repo + alias Philomena.Users.User + alias PhilomenaQuery.Parse.String, as: QueryString + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to the write-access checks + # the tag toggles run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + @pagination %{page_number: 1, page_size: 25} + + setup do + Search.clear_index!(Filter) + :ok + end + + describe "compile_image_filter/3" do + test "compiles the effective search and display policy" do + image = image_fixture(tags: "safe") |> Repo.preload(tags: :aliases) + [safe] = image.tags + + current = %Filter{ + hidden_tag_ids: [], + spoilered_tag_ids: [safe.id], + hidden_complex_str: "score.lt:0", + spoilered_complex_str: "faves.gt:10" + } + + forced = %Filter{hidden_tag_ids: [safe.id], hidden_complex_str: "upvotes.lt:0"} + + assert %ImageFilter{} = + image_filter = + Filters.compile_image_filter(actor(), current, forced) + + assert safe.id in image_filter.display_tag_ids + assert Images.filter_or_spoiler_hits?(image, image_filter) + + assert %{bool: %{should: [%{terms: %{tag_ids: [safe_id]}}, _complex]}} = + image_filter.query + + assert safe_id == safe.id + end + + test "fails closed for invalid stored expressions" do + invalid_filters = [ + {%Filter{id: 42, hidden_complex_str: "("}, nil, :hidden_complex_str}, + {%Filter{id: 43, spoilered_complex_str: "("}, nil, :spoilered_complex_str}, + {nil, %Filter{id: 44, hidden_complex_str: "("}, :hidden_complex_str} + ] + + for {current_filter, forced_filter, field} <- invalid_filters do + assert %ImageFilter{} = + image_filter = + Filters.compile_image_filter(actor(), current_filter, forced_filter) + + assert {:error, message} = + Query.compile(QueryString.normalize("("), user: actor().user, filter: true) + + assert image_filter.query == %{match_all: %{}} + assert image_filter.display_query == %{match_all: %{}} + assert image_filter.display_tag_ids == [] + assert image_filter.errors == [{field, message}] + end + end + + test "an empty selection excludes and spoilers nothing" do + assert %ImageFilter{} = + image_filter = + Filters.compile_image_filter(actor(), nil, nil) + + assert image_filter.display_tag_ids == [] + + assert image_filter.query == %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + end + + describe "list_filters/1" do + test "an anonymous visitor gets no personal filters, only system filters" do + system = system_filter_fixture() + + assert {:ok, {nil, system_filters}} = Filters.list_filters(actor(), @pagination) + assert system.id in Enum.map(system_filters, & &1.id) + end + + test "a signed-in user gets their own filters and the system filters" do + user = confirmed_user_fixture() + mine = filter_fixture(user) + _theirs = filter_fixture(confirmed_user_fixture()) + system = system_filter_fixture() + + assert {:ok, {my_filters, system_filters}} = Filters.list_filters(actor(user), @pagination) + + my_ids = Enum.map(my_filters, & &1.id) + assert mine.id in my_ids + # Only the viewer's own filters land in the first list. + assert my_filters |> Enum.map(& &1.user_id) |> Enum.uniq() == [user.id] + + assert system.id in Enum.map(system_filters, & &1.id) + end + + test "the returned filters carry their :user preloaded" do + user = confirmed_user_fixture() + filter_fixture(user) + + {:ok, {mine, _system}} = Filters.list_filters(actor(user), @pagination) + refute match?(%Ecto.Association.NotLoaded{}, Enum.at(mine, 0).user) + end + end + + describe "query_filters/3" do + test "an anonymous viewer finds public and system filters but not private ones" do + public = filter_fixture(confirmed_user_fixture(), %{public: true}) + private = filter_fixture(confirmed_user_fixture()) + SearchHelpers.reindex_all!(Filter) + + assert {:ok, page} = Filters.query_filters(actor(), "*", @pagination) + + ids = Enum.map(page.entries, & &1.id) + assert public.id in ids + refute private.id in ids + end + + test "a signed-in user additionally finds their own private filters" do + user = confirmed_user_fixture() + mine = filter_fixture(user) + theirs = filter_fixture(confirmed_user_fixture()) + SearchHelpers.reindex_all!(Filter) + + assert {:ok, page} = Filters.query_filters(actor(user), "*", @pagination) + + ids = Enum.map(page.entries, & &1.id) + assert mine.id in ids + refute theirs.id in ids + end + + test "a moderator finds private filters consistently with their show grant" do + moderator = moderator_user_fixture() + theirs = filter_fixture(confirmed_user_fixture()) + SearchHelpers.reindex_all!(Filter) + + assert {:ok, page} = Filters.query_filters(actor(moderator), "*", @pagination) + assert theirs.id in Enum.map(page.entries, & &1.id) + end + + test "restricting the query to a name finds that filter" do + user = confirmed_user_fixture() + mine = filter_fixture(user) + SearchHelpers.reindex_all!(Filter) + + assert {:ok, page} = Filters.query_filters(actor(user), "name:#{mine.name}", @pagination) + assert mine.id in Enum.map(page.entries, & &1.id) + end + + test "a malformed query returns the compiler error" do + assert {:error, msg} = Filters.query_filters(actor(), "name:(", @pagination) + assert is_binary(msg) + end + end + + describe "indexing services" do + test "perform_reindex/2 makes the matching filter searchable" do + filter = filter_fixture(confirmed_user_fixture(), %{public: true}) + + assert :ok = Filters.perform_reindex(:id, [filter.id]) + :ok = Search.refresh_index!(Filter) + assert {:ok, page} = Filters.query_filters(actor(), "name:#{filter.name}", @pagination) + assert filter.id in Enum.map(page.entries, & &1.id) + end + + test "indexing_preloads/0 includes the filter owner" do + filter = filter_fixture(confirmed_user_fixture()) + + loaded = Repo.preload(filter, Filters.indexing_preloads()) + + refute match?(%Ecto.Association.NotLoaded{}, loaded.user) + end + end + + describe "show_filter_page/2" do + test "an anonymous viewer loads a system filter's page" do + system = system_filter_fixture() + + assert {:ok, %FilterPage{filter: filter, spoilered_tags: [], hidden_tags: []}} = + Filters.show_filter_page(actor(), "#{system.id}") + + assert filter.id == system.id + # The filter carries its :user preloaded for the page. + refute match?(%Ecto.Association.NotLoaded{}, filter.user) + end + + test "the owner loads their private filter's page with tags ordered by name" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + zed = tag_fixture(%{name: "zed tag"}) + abe = tag_fixture(%{name: "abe tag"}) + + {:ok, filter} = Filters.create_filter_hide(actor(user), filter, zed.slug) + {:ok, filter} = Filters.create_filter_hide(actor(user), filter, abe.slug) + + assert {:ok, %FilterPage{hidden_tags: hidden, spoilered_tags: []}} = + Filters.show_filter_page(actor(user), "#{filter.id}") + + # Tags come back ordered by name ascending. + assert Enum.map(hidden, & &1.name) == ["abe tag", "zed tag"] + end + + test "an anonymous viewer cannot load another user's private filter" do + filter = filter_fixture(confirmed_user_fixture()) + + assert Filters.show_filter_page(actor(), "#{filter.id}") == {:error, :unauthorized} + end + + test "a non-castable id is not-found" do + assert Filters.show_filter_page(actor(), "not-a-number") == {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Filters.show_filter_page(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Filters.show_filter_page(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + end + + describe "new_filter/2" do + test "a signed-in user with no base filter gets a blank changeset" do + assert {:ok, %Ecto.Changeset{data: %Filter{} = data}} = + Filters.new_filter(actor(confirmed_user_fixture()), nil) + + assert data.id == nil + end + + test "basing a new filter on a visible filter prefills its tag lists" do + owner = confirmed_user_fixture() + source = filter_fixture(owner, %{public: true}) + tag = tag_fixture() + {:ok, source} = Filters.create_filter_hide(actor(owner), source, tag.slug) + + assert {:ok, %Ecto.Changeset{data: %Filter{} = data}} = + Filters.new_filter(actor(confirmed_user_fixture()), "#{source.id}") + + assert tag.id in data.hidden_tag_ids + end + + test "basing a new filter on an unknown id yields a blank form" do + assert {:ok, %Ecto.Changeset{data: %Filter{hidden_tag_ids: []}}} = + Filters.new_filter(actor(confirmed_user_fixture()), "999999999") + end + + test "basing a new filter on a malformed id yields a blank form" do + assert {:ok, %Ecto.Changeset{data: %Filter{hidden_tag_ids: []}}} = + Filters.new_filter(actor(confirmed_user_fixture()), "not-an-id") + end + + test "an anonymous actor is unauthorized" do + assert Filters.new_filter(actor(), nil) == {:error, :unauthorized} + end + end + + describe "edit_filter/2" do + test "the owner loads their filter paired with an edit changeset" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert {:ok, {%Filter{} = loaded, %Ecto.Changeset{} = changeset}} = + Filters.edit_filter(actor(user), "#{filter.id}") + + assert loaded.id == filter.id + assert changeset.data.id == filter.id + end + + test "an unrelated user is unauthorized" do + filter = filter_fixture(confirmed_user_fixture()) + + assert Filters.edit_filter(actor(confirmed_user_fixture()), "#{filter.id}") == + {:error, :unauthorized} + end + + test "a non-castable id is not-found" do + assert Filters.edit_filter(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Filters.edit_filter(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Filters.edit_filter(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + end + + describe "update_filter/3" do + test "the owner renames their filter" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert {:ok, %Filter{}} = + Filters.update_filter(actor(user), "#{filter.id}", %{"name" => "Renamed Filter"}) + + assert Repo.reload!(filter).name == "Renamed Filter" + end + + test "an admin updates another user's filter" do + filter = filter_fixture(confirmed_user_fixture()) + + assert {:ok, %Filter{}} = + Filters.update_filter(actor(admin_user_fixture()), "#{filter.id}", %{ + "name" => "Admin Renamed" + }) + + assert Repo.reload!(filter).name == "Admin Renamed" + end + + test "an admin cannot rename the Default system filter" do + default = system_filter_fixture(%{name: "Default"}) + + assert {:error, %Ecto.Changeset{} = changeset} = + Filters.update_filter(actor(admin_user_fixture()), "#{default.id}", %{ + "name" => "Renamed Default" + }) + + refute changeset.valid? + assert {"cannot be changed for the system-wide default filter", _} = changeset.errors[:name] + assert Repo.reload!(default).name == "Default" + end + + test "an admin can update the Default system filter without renaming it" do + default = system_filter_fixture(%{name: "Default"}) + + assert {:ok, %Filter{} = updated} = + Filters.update_filter(actor(admin_user_fixture()), "#{default.id}", %{ + "description" => "Updated default filter" + }) + + assert updated.name == "Default" + assert updated.description == "Updated default filter" + end + + test "an invalid name is a rejected changeset" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert {:error, %Ecto.Changeset{} = changeset} = + Filters.update_filter(actor(user), "#{filter.id}", %{"name" => ""}) + + refute changeset.valid? + assert changeset.errors[:name] + end + + test "an unrelated user is unauthorized and leaves the row unchanged" do + filter = filter_fixture(confirmed_user_fixture()) + + assert Filters.update_filter(actor(confirmed_user_fixture()), "#{filter.id}", %{ + "name" => "Hijacked" + }) == {:error, :unauthorized} + + assert Repo.reload!(filter).name == filter.name + end + + test "a non-castable id is not-found" do + assert Filters.update_filter(actor(confirmed_user_fixture()), "abc", %{"name" => "x"}) == + {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Filters.update_filter(actor(confirmed_user_fixture()), "999999999", %{"name" => "x"}) == + {:error, :not_found} + + assert Filters.update_filter(actor(admin_user_fixture()), "999999999", %{"name" => "x"}) == + {:error, :not_found} + end + end + + describe "delete_filter/2" do + test "the owner deletes their filter" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert {:ok, %Filter{} = deleted} = Filters.delete_filter(actor(user), "#{filter.id}") + assert deleted.id == filter.id + assert Repo.reload(filter) == nil + end + + test "a filter used as a forced filter returns a rejected changeset" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + user + |> User.force_filter_changeset(%{"forced_filter_id" => filter.id}) + |> Repo.update!() + + assert {:error, %Ecto.Changeset{valid?: false}} = + Filters.delete_filter(actor(user), "#{filter.id}") + + assert Repo.reload!(filter).id == filter.id + end + + test "an unrelated user is unauthorized and leaves the row" do + filter = filter_fixture(confirmed_user_fixture()) + + assert Filters.delete_filter(actor(confirmed_user_fixture()), "#{filter.id}") == + {:error, :unauthorized} + + refute Repo.reload(filter) == nil + end + + test "a non-castable id is not-found" do + assert Filters.delete_filter(actor(confirmed_user_fixture()), "abc") == {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Filters.delete_filter(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Filters.delete_filter(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + end + + describe "create_filter_public/2" do + test "the owner makes their private filter public" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + refute filter.public + + assert {:ok, %Filter{public: true}} = + Filters.create_filter_public(actor(user), "#{filter.id}") + + assert Repo.reload!(filter).public + end + + test "making an already-public filter public again is an idempotent success" do + user = confirmed_user_fixture() + filter = filter_fixture(user, %{public: true}) + + assert {:ok, %Filter{public: true}} = + Filters.create_filter_public(actor(user), "#{filter.id}") + end + + test "an unrelated user is unauthorized" do + filter = filter_fixture(confirmed_user_fixture()) + + assert Filters.create_filter_public(actor(confirmed_user_fixture()), "#{filter.id}") == + {:error, :unauthorized} + end + + test "a non-castable id is not-found" do + assert Filters.create_filter_public(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Filters.create_filter_public(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Filters.create_filter_public(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + end + + describe "update_current_filter/2" do + test "a signed-in user switches to their own filter, persisting the choice" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert {:ok, %Filter{} = switched} = + Filters.update_current_filter(actor(user), "#{filter.id}") + + assert switched.id == filter.id + assert Repo.get!(User, user.id).current_filter_id == filter.id + end + + test "a signed-in user is not authorized to switch to an unowned private filter" do + user = confirmed_user_fixture() + others = filter_fixture(confirmed_user_fixture()) + + assert {:error, :unauthorized} = + Filters.update_current_filter(actor(user), "#{others.id}") + end + + test "an anonymous visitor gets the resolved filter back without persistence" do + public = filter_fixture(confirmed_user_fixture(), %{public: true}) + + assert {:ok, %Filter{} = switched} = Filters.update_current_filter(actor(), "#{public.id}") + assert switched.id == public.id + end + + test "an anonymous visitor is not authorized to switch to a private filter" do + private = filter_fixture(confirmed_user_fixture()) + + assert {:error, :unauthorized} = Filters.update_current_filter(actor(), "#{private.id}") + end + + test "a well-formed id naming no row is not-found" do + assert Filters.update_current_filter(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + end + + test "a non-castable id is not-found" do + assert Filters.update_current_filter(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a nil id explicitly switches to the default filter" do + default = system_filter_fixture(%{name: "Default"}) + user = confirmed_user_fixture() + + assert {:ok, %Filter{} = switched} = Filters.update_current_filter(actor(user), nil) + assert switched.id == default.id + assert Repo.get!(User, user.id).current_filter_id == default.id + end + + test "switching the current filter leaves the forced filter unchanged" do + user = confirmed_user_fixture() + forced = filter_fixture(user) + selected = filter_fixture(user) + + user = + user + |> User.force_filter_changeset(%{"forced_filter_id" => forced.id}) + |> Repo.update!() + + assert {:ok, %Filter{id: selected_id}} = + Filters.update_current_filter(actor(user), selected.id) + + assert selected_id == selected.id + assert Repo.get!(User, user.id).forced_filter_id == forced.id + end + end + + describe "load_selected_filters/2" do + test "an anonymous malformed cookie selection falls back to the default" do + default = system_filter_fixture(%{name: "Default"}) + + assert {:ok, %{current_filter: current, forced_filter: nil}} = + Filters.load_selected_filters(actor(), "not-an-id") + + assert current.id == default.id + end + + test "a signed-in user gets a persisted default and their forced filter" do + default = system_filter_fixture(%{name: "Default"}) + user = confirmed_user_fixture() + forced = filter_fixture(user) + + user = + user + |> User.force_filter_changeset(%{"forced_filter_id" => forced.id}) + |> Repo.update!() + + assert {:ok, %{current_filter: current, forced_filter: loaded_forced}} = + Filters.load_selected_filters(actor(user), nil) + + assert current.id == default.id + assert loaded_forced.id == forced.id + assert Repo.get!(User, user.id).current_filter_id == default.id + end + end + + describe "create_filter/2" do + test "a signed-in user creates a filter attributed to themselves" do + user = confirmed_user_fixture() + + assert {:ok, %Filter{} = filter} = + Filters.create_filter(actor(user), %{"name" => "My New Filter"}) + + assert filter.user_id == user.id + assert filter.name == "My New Filter" + end + + test "a blank name is a rejected changeset" do + assert {:error, %Ecto.Changeset{} = changeset} = + Filters.create_filter(actor(confirmed_user_fixture()), %{"name" => ""}) + + refute changeset.valid? + assert changeset.errors[:name] + end + + test "an anonymous actor is unauthorized" do + assert Filters.create_filter(actor(), %{"name" => "x"}) == {:error, :unauthorized} + end + end + + describe "create_filter_hide/3 and delete_filter_hide/3" do + test "the owner hides then unhides a tag by slug" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + tag = tag_fixture() + + assert {:ok, %Filter{} = hidden} = Filters.create_filter_hide(actor(user), filter, tag.slug) + assert tag.id in hidden.hidden_tag_ids + + assert {:ok, %Filter{} = shown} = Filters.delete_filter_hide(actor(user), hidden, tag.slug) + refute tag.id in shown.hidden_tag_ids + end + + test "an unknown tag slug is not-found" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert Filters.create_filter_hide(actor(user), filter, "no-such-tag") == + {:error, :not_found} + end + + test "an unrelated user is unauthorized" do + filter = filter_fixture(confirmed_user_fixture()) + tag = tag_fixture() + + assert Filters.create_filter_hide(actor(confirmed_user_fixture()), filter, tag.slug) == + {:error, :unauthorized} + end + + test "a banned actor is rejected before authorization, even with a good slug" do + # verify_write_access runs first and decides the ban before the filter + # authorization, so a banned owner is {:error, :ban}. + user = confirmed_user_fixture() + filter = filter_fixture(user) + tag = tag_fixture() + + assert Filters.create_filter_hide(actor(user, ban: @ban), filter, tag.slug) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + tag = tag_fixture() + + assert Filters.create_filter_hide(actor(user, fingerprint: nil), filter, tag.slug) == + {:error, :unauthorized} + end + + test "the ban wins over a missing fingerprint" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + tag = tag_fixture() + + assert Filters.create_filter_hide( + actor(user, ban: @ban, fingerprint: nil), + filter, + tag.slug + ) == + {:error, :ban} + end + end + + describe "create_filter_spoiler/3 and delete_filter_spoiler/3" do + test "the owner spoilers then unspoilers a tag by slug" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + tag = tag_fixture() + + assert {:ok, %Filter{} = spoilered} = + Filters.create_filter_spoiler(actor(user), filter, tag.slug) + + assert tag.id in spoilered.spoilered_tag_ids + + assert {:ok, %Filter{} = plain} = + Filters.delete_filter_spoiler(actor(user), spoilered, tag.slug) + + refute tag.id in plain.spoilered_tag_ids + end + + test "an unknown tag slug is not-found" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + assert Filters.create_filter_spoiler(actor(user), filter, "no-such-tag") == + {:error, :not_found} + end + + test "an unrelated user is unauthorized" do + filter = filter_fixture(confirmed_user_fixture()) + tag = tag_fixture() + + assert Filters.create_filter_spoiler(actor(confirmed_user_fixture()), filter, tag.slug) == + {:error, :unauthorized} + end + + test "a banned actor is rejected" do + user = confirmed_user_fixture() + filter = filter_fixture(user) + tag = tag_fixture() + + assert Filters.create_filter_spoiler(actor(user, ban: @ban), filter, tag.slug) == + {:error, :ban} + end + end + + describe "write access" do + setup do + user = confirmed_user_fixture() + filter = filter_fixture(user) + + operations = [ + new: fn actor -> Filters.new_filter(actor, nil) end, + edit: fn actor -> Filters.edit_filter(actor, filter.id) end, + create: fn actor -> Filters.create_filter(actor, %{"name" => "Created"}) end, + update: fn actor -> Filters.update_filter(actor, filter.id, %{"name" => "Updated"}) end, + publish: fn actor -> Filters.create_filter_public(actor, filter.id) end, + delete: fn actor -> Filters.delete_filter(actor, filter.id) end + ] + + %{user: user, operations: operations} + end + + test "all form and mutation entry points reject a banned actor first", context do + banned_actor = actor(context.user, ban: @ban) + + for {operation, invoke} <- context.operations do + assert invoke.(banned_actor) == {:error, :ban}, + "expected #{operation} to reject a banned actor" + end + end + + test "all form and mutation entry points reject a missing fingerprint", context do + unidentified_actor = actor(context.user, fingerprint: nil) + + for {operation, invoke} <- context.operations do + assert invoke.(unidentified_actor) == {:error, :unauthorized}, + "expected #{operation} to reject an actor without a fingerprint" + end + end + end +end diff --git a/test/philomena/fixtures_test.exs b/test/philomena/fixtures_test.exs index ea8bf4c54..7d29dce53 100644 --- a/test/philomena/fixtures_test.exs +++ b/test/philomena/fixtures_test.exs @@ -55,11 +55,12 @@ defmodule Philomena.FixturesTest do assert reply.body == "A reply" end - test "creates an anonymous-attribution topic" do + test "creates an anonymously displayed topic" do forum = ForumsFixtures.forum_fixture() topic = TopicsFixtures.topic_fixture(forum) - assert topic.user_id == nil + assert topic.user_id + assert topic.anonymous end end diff --git a/test/philomena/forum_hierarchy_concurrency_test.exs b/test/philomena/forum_hierarchy_concurrency_test.exs new file mode 100644 index 000000000..de02d47e5 --- /dev/null +++ b/test/philomena/forum_hierarchy_concurrency_test.exs @@ -0,0 +1,353 @@ +defmodule Philomena.ForumHierarchyConcurrencyTest do + use Philomena.ConcurrentDataCase + + import Ecto.Query + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.PostsFixtures + import Philomena.RulesFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.Posts + alias Philomena.Posts.Post + alias Philomena.Repo + alias Philomena.Topics + alias Philomena.Topics.Topic + alias Philomena.Users.User + + defp forum_post_ids(forum_id) do + Repo.all( + from post in Post, + join: topic in Topic, + on: topic.id == post.topic_id, + where: + topic.forum_id == ^forum_id and not topic.hidden_from_users and + not post.destroyed_content, + select: post.id + ) + end + + defp forum_visible_post_ids(forum_id) do + Repo.all( + from post in Post, + join: topic in Topic, + on: topic.id == post.topic_id, + where: + topic.forum_id == ^forum_id and not topic.hidden_from_users and + not post.hidden_from_users and not post.destroyed_content, + select: post.id + ) + end + + defp assert_forum_caches(forum) do + forum = Repo.reload!(forum) + + topic_count = + Repo.aggregate( + from(topic in Topic, where: topic.forum_id == ^forum.id and not topic.hidden_from_users), + :count + ) + + post_ids = forum_post_ids(forum.id) + visible_post_ids = forum_visible_post_ids(forum.id) + + assert forum.topic_count == topic_count + assert forum.post_count == length(post_ids) + assert forum.last_post_id == Enum.max(visible_post_ids, fn -> nil end) + end + + defp assert_topic_caches(topic) do + topic = Repo.reload!(topic) + + post_ids = + Repo.all( + from post in Post, + where: post.topic_id == ^topic.id and not post.destroyed_content, + select: post.id + ) + + visible_post_ids = + Repo.all( + from post in Post, + where: + post.topic_id == ^topic.id and not post.hidden_from_users and + not post.destroyed_content, + select: post.id + ) + + assert topic.post_count == length(post_ids) + assert topic.last_post_id == Enum.max(visible_post_ids, fn -> nil end) + end + + defp approval_rule! do + rule_fixture() + |> Ecto.Changeset.change(name: "Approval") + |> Repo.update!() + end + + test "concurrent replies preserve topic and forum counters and latest-post caches" do + forum = forum_fixture() + topic = topic_fixture(forum) + + functions = + for index <- 1..8 do + user = confirmed_user_fixture() + actor = actor(user, ip: random_ip()) + + fn -> + Posts.create_post(actor, forum.short_name, topic.slug, %{ + "body" => "Concurrent reply #{index}" + }) + end + end + + results = concurrently(functions) + + assert Enum.all?(results, &match?({:ok, %Post{}}, &1)) + assert_topic_caches(topic) + assert_forum_caches(forum) + end + + test "concurrent topic creation preserves forum counters and latest-post cache" do + forum = forum_fixture() + + functions = + for index <- 1..8 do + user = confirmed_user_fixture() + actor = actor(user, ip: random_ip()) + + fn -> + Topics.create_topic(actor, forum.short_name, %{ + "title" => "Concurrent topic #{index}", + "anonymous" => "false", + "posts" => %{"0" => %{"body" => "Concurrent topic body #{index}"}} + }) + end + end + + results = concurrently(functions) + + assert Enum.all?(results, &match?({:ok, %{topic: %Topic{}}}, &1)) + assert_forum_caches(forum) + + for topic <- Repo.all(from topic in Topic, where: topic.forum_id == ^forum.id) do + assert_topic_caches(topic) + end + end + + test "moving a topic to a restricted forum races post creation without stale caches" do + source = forum_fixture() + target = forum_fixture(access_level: "staff") + topic = topic_fixture(source) + moderator = actor(moderator_user_fixture(), ip: random_ip()) + author = actor(confirmed_user_fixture(), ip: random_ip()) + + [move_result, post_result] = + concurrently([ + fn -> + Topics.create_topic_move(moderator, source.short_name, topic.slug, %{ + "target_forum" => target.short_name + }) + end, + fn -> + Posts.create_post(author, source.short_name, topic.slug, %{ + "body" => "Reply racing a forum move" + }) + end + ]) + + assert move_result == {:error, :not_found} or match?({:ok, _}, move_result) + + assert post_result == {:error, :unauthorized} or + post_result == {:error, :not_found} or + match?({:ok, _}, post_result) + + assert_forum_caches(source) + assert_forum_caches(target) + assert_topic_caches(topic) + end + + test "opposite-direction forum moves use a consistent two-forum lock order" do + forum_a = forum_fixture(short_name: "a" <> unique_forum_short_name()) + forum_b = forum_fixture(short_name: "b" <> unique_forum_short_name()) + topic_a = topic_fixture(forum_a) + topic_b = topic_fixture(forum_b) + moderator = actor(moderator_user_fixture(), ip: random_ip()) + + [a_to_b_result, b_to_a_result] = + concurrently([ + fn -> + Topics.create_topic_move(moderator, forum_a.short_name, topic_a.slug, %{ + "target_forum" => forum_b.short_name + }) + end, + fn -> + Topics.create_topic_move(moderator, forum_b.short_name, topic_b.slug, %{ + "target_forum" => forum_a.short_name + }) + end + ]) + + assert match?({:ok, {%{id: _, short_name: _}, %{id: _, slug: _}}}, a_to_b_result) + assert match?({:ok, {%{id: _, short_name: _}, %{id: _, slug: _}}}, b_to_a_result) + assert Repo.reload!(topic_a).forum_id == forum_b.id + assert Repo.reload!(topic_b).forum_id == forum_a.id + assert_forum_caches(forum_a) + assert_forum_caches(forum_b) + assert_topic_caches(topic_a) + assert_topic_caches(topic_b) + end + + test "locking a topic races post creation without bypassing authorization or counters" do + forum = forum_fixture() + topic = topic_fixture(forum) + moderator = actor(moderator_user_fixture(), ip: random_ip()) + author = actor(confirmed_user_fixture(), ip: random_ip()) + + [lock_result, post_result] = + concurrently([ + fn -> + Topics.create_topic_lock(moderator, forum.short_name, topic.slug, %{ + "lock_reason" => "Concurrent lock" + }) + end, + fn -> + Posts.create_post(author, forum.short_name, topic.slug, %{ + "body" => "Reply racing a topic lock" + }) + end + ]) + + assert match?({:ok, _}, lock_result) + assert post_result == {:error, :unauthorized} or match?({:ok, _}, post_result) + + assert Repo.reload!(topic).locked_at + assert_topic_caches(topic) + assert_forum_caches(forum) + end + + test "destroying a post in a hidden topic keeps counters correct through restore" do + forum = forum_fixture() + topic = topic_fixture(forum) + post = hd(topic.posts) + moderator = actor(moderator_user_fixture(), ip: random_ip()) + + assert Repo.reload!(topic).post_count == 1 + assert Repo.reload!(forum).post_count == 1 + + assert {:ok, _} = + Topics.create_topic_hide(moderator, forum.short_name, topic.slug, %{ + "deletion_reason" => "Hidden for moderation" + }) + + assert Repo.reload!(topic).post_count == 1 + assert Repo.reload!(forum).post_count == 0 + + assert {:ok, _} = + Posts.create_post_hide( + moderator, + forum.short_name, + topic.slug, + post.id, + %{"deletion_reason" => "Destroyed post"} + ) + + assert {:ok, _} = Posts.create_post_delete(moderator, forum.short_name, topic.slug, post.id) + + assert_topic_caches(topic) + assert_forum_caches(forum) + + assert {:ok, _} = Topics.delete_topic_hide(moderator, forum.short_name, topic.slug) + + assert_topic_caches(topic) + assert_forum_caches(forum) + end + + test "approval racing destruction leaves one counter update and the correct latest post" do + forum = forum_fixture() + topic = topic_fixture(forum) + approval_rule!() + author = confirmed_user_fixture() + + post = + post_fixture(topic, author, %{ + "body" => "Pending post https://spam.example/" + }) + + refute post.approved + moderator = actor(moderator_user_fixture(), ip: random_ip()) + destroyer = actor(moderator_user_fixture(), ip: random_ip()) + + assert {:ok, %Post{hidden_from_users: true}} = + Posts.create_post_hide( + moderator, + forum.short_name, + topic.slug, + post.id, + %{"deletion_reason" => "Pending moderation"} + ) + + before_posts_count = Repo.get!(User, author.id).posts_count + + [approval_result, destroy_result] = + concurrently([ + fn -> Posts.create_post_approve(moderator, forum.short_name, topic.slug, post.id) end, + fn -> Posts.create_post_delete(destroyer, forum.short_name, topic.slug, post.id) end + ]) + + assert match?({:ok, %Post{}}, destroy_result) + + assert match?({:error, %Ecto.Changeset{}}, approval_result) or + match?({:ok, %Post{}}, approval_result) + + reloaded_post = Repo.reload!(post) + assert reloaded_post.destroyed_content + assert Repo.get!(User, author.id).posts_count == before_posts_count + assert_topic_caches(topic) + assert_forum_caches(forum) + end + + test "destruction racing unhiding serializes the hidden-post transition" do + forum = forum_fixture() + topic = topic_fixture(forum) + author = confirmed_user_fixture() + post = post_fixture(topic, author) + moderator = actor(moderator_user_fixture(), ip: random_ip()) + unhide_actor = actor(moderator_user_fixture(), ip: random_ip()) + + assert {:ok, %Post{hidden_from_users: true}} = + Posts.create_post_hide( + moderator, + forum.short_name, + topic.slug, + post.id, + %{"deletion_reason" => "Pending destruction"} + ) + + [destroy_result, unhide_result] = + concurrently([ + fn -> Posts.create_post_delete(moderator, forum.short_name, topic.slug, post.id) end, + fn -> Posts.delete_post_hide(unhide_actor, forum.short_name, topic.slug, post.id) end + ]) + + assert Enum.count([destroy_result, unhide_result], &match?({:ok, %Post{}}, &1)) == 1 + + assert match?({:ok, %Post{}}, destroy_result) or + match?({:error, %Ecto.Changeset{}}, destroy_result) + + assert match?({:ok, %Post{}}, unhide_result) or + match?({:error, %Ecto.Changeset{}}, unhide_result) + + reloaded_post = Repo.reload!(post) + + if reloaded_post.destroyed_content do + assert reloaded_post.hidden_from_users + else + refute reloaded_post.hidden_from_users + end + + assert_topic_caches(topic) + assert_forum_caches(forum) + end +end diff --git a/test/philomena/forums_test.exs b/test/philomena/forums_test.exs new file mode 100644 index 000000000..c58c1ef01 --- /dev/null +++ b/test/philomena/forums_test.exs @@ -0,0 +1,359 @@ +defmodule Philomena.ForumsTest do + @moduledoc """ + Context tests for actor-visible forum discovery, subscription toggles, and + staff management. They cover malformed/missing/forbidden result precedence, + write-access parity, idempotent subscription changes, and actor-specific + forum/topic counts. + """ + + use Philomena.DataCase, async: true + + import Ecto.Query + + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.Repo + alias Philomena.Forums + alias Philomena.Forums.Forum + alias Philomena.Forums.ForumIndex + alias Philomena.Forums.Subscription + + @pagination %{page_number: 1, page_size: 25} + + defp subscribed?(forum, user) do + Repo.exists?( + from s in Subscription, + where: s.forum_id == ^forum.id and s.user_id == ^user.id + ) + end + + defp subscription_count(forum, user) do + Repo.aggregate( + from(s in Subscription, where: s.forum_id == ^forum.id and s.user_id == ^user.id), + :count + ) + end + + describe "list_forums/1" do + test "forum and topic counts include hidden topics but not inaccessible forums" do + user = confirmed_user_fixture() + moderator = moderator_user_fixture() + public_forum = forum_fixture() + restricted_forum = forum_fixture(access_level: "staff") + _visible_topic = topic_fixture(public_forum) + hidden_topic = topic_fixture(public_forum) + _restricted_topic = topic_fixture(restricted_forum) + + {:ok, {_forum, _hidden_topic}} = + Philomena.Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + public_forum.short_name, + hidden_topic.slug, + %{"deletion_reason" => "Spam"} + ) + + assert %ForumIndex{forums: user_forums, topic_count: 1} = + Forums.list_forums(actor(user), @pagination) + + assert Enum.map(user_forums, & &1.id) == [public_forum.id] + + assert %ForumIndex{forums: moderator_forums, topic_count: 2} = + Forums.list_forums(actor(moderator), @pagination) + + assert Enum.sort(Enum.map(moderator_forums, & &1.id)) == + Enum.sort([public_forum.id, restricted_forum.id]) + end + end + + describe "create_forum_subscription/2" do + test "a regular user subscribes to a visible forum and the row is created" do + user = confirmed_user_fixture() + forum = forum_fixture() + + assert {:ok, loaded_forum} = Forums.create_forum_subscription(actor(user), forum.short_name) + + assert loaded_forum.id == forum.id + assert subscribed?(forum, user) + end + + test "subscribing twice is idempotent and leaves a single row" do + # create_subscription inserts with on_conflict: :nothing, so a repeat is a + # successful no-op rather than a changeset error. + user = confirmed_user_fixture() + forum = forum_fixture() + + assert {:ok, _} = Forums.create_forum_subscription(actor(user), forum.short_name) + assert {:ok, _} = Forums.create_forum_subscription(actor(user), forum.short_name) + + assert subscription_count(forum, user) == 1 + end + + test "a moderator subscribes to a visible forum" do + moderator = moderator_user_fixture() + forum = forum_fixture() + + assert {:ok, loaded_forum} = + Forums.create_forum_subscription(actor(moderator), forum.short_name) + + assert loaded_forum.id == forum.id + assert subscribed?(forum, moderator) + end + + test "an admin subscribes to a visible forum" do + admin = admin_user_fixture() + forum = forum_fixture() + + assert {:ok, _forum} = Forums.create_forum_subscription(actor(admin), forum.short_name) + assert subscribed?(forum, admin) + end + + test "an unknown forum slug is not found for a regular user" do + assert Forums.create_forum_subscription(actor(confirmed_user_fixture()), "nonexistent") == + {:error, :not_found} + end + + test "an unknown forum slug is not found for anonymous" do + assert Forums.create_forum_subscription(actor(), "nonexistent") == {:error, :not_found} + end + + test "a restricted forum is unauthorized for a regular user and no row is created" do + user = confirmed_user_fixture() + forum = forum_fixture(access_level: "staff") + + assert Forums.create_forum_subscription(actor(user), forum.short_name) == + {:error, :unauthorized} + + refute subscribed?(forum, user) + end + + test "a restricted forum is subscribable by a moderator" do + moderator = moderator_user_fixture() + forum = forum_fixture(access_level: "staff") + + assert {:ok, _forum} = Forums.create_forum_subscription(actor(moderator), forum.short_name) + assert subscribed?(forum, moderator) + end + + test "anonymous cannot create_forum_subscription to a visible forum" do + forum = forum_fixture() + + assert Forums.create_forum_subscription(actor(), forum.short_name) == + {:error, :unauthorized} + end + + test "an admin with an unknown forum gets not-found" do + assert Forums.create_forum_subscription(actor(admin_user_fixture()), "nonexistent") == + {:error, :not_found} + end + end + + describe "delete_forum_subscription/2" do + test "a regular user unsubscribes from a visible forum and the row is removed" do + user = confirmed_user_fixture() + forum = forum_fixture() + {:ok, _} = Forums.create_subscription(forum, user) + assert subscribed?(forum, user) + + assert {:ok, loaded_forum} = Forums.delete_forum_subscription(actor(user), forum.short_name) + + assert loaded_forum.id == forum.id + refute subscribed?(forum, user) + end + + test "unsubscribing with no existing subscription still succeeds" do + # delete_subscription runs an unconditional delete_all and hard-matches + # {:ok, _}, so the absence of a row is not an error. + user = confirmed_user_fixture() + forum = forum_fixture() + refute subscribed?(forum, user) + + assert {:ok, _forum} = Forums.delete_forum_subscription(actor(user), forum.short_name) + refute subscribed?(forum, user) + end + + test "a moderator unsubscribes from a visible forum" do + moderator = moderator_user_fixture() + forum = forum_fixture() + {:ok, _} = Forums.create_subscription(forum, moderator) + + assert {:ok, _forum} = Forums.delete_forum_subscription(actor(moderator), forum.short_name) + refute subscribed?(forum, moderator) + end + + test "an unknown forum slug is not found for a regular user" do + assert Forums.delete_forum_subscription(actor(confirmed_user_fixture()), "nonexistent") == + {:error, :not_found} + end + + test "a restricted forum is unauthorized for a regular user" do + user = confirmed_user_fixture() + forum = forum_fixture(access_level: "staff") + + assert Forums.delete_forum_subscription(actor(user), forum.short_name) == + {:error, :unauthorized} + end + end + + describe "list_admin_forums/1" do + test "an admin receives the forum list" do + forum = forum_fixture() + assert {:ok, forums} = Forums.list_admin_forums(actor(admin_user_fixture())) + assert Enum.any?(forums, &(&1.id == forum.id)) + end + + test "a plain moderator is not authorized" do + assert Forums.list_admin_forums(actor(moderator_user_fixture())) == {:error, :unauthorized} + end + + test "a regular user is not authorized" do + assert Forums.list_admin_forums(actor(confirmed_user_fixture())) == {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Forums.list_admin_forums(actor()) == {:error, :unauthorized} + end + end + + describe "new_forum/1" do + test "an admin gets a changeset" do + assert {:ok, %Ecto.Changeset{}} = Forums.new_forum(actor(admin_user_fixture())) + end + + test "a plain moderator is not authorized" do + assert Forums.new_forum(actor(moderator_user_fixture())) == {:error, :unauthorized} + end + + test "a regular user is not authorized" do + assert Forums.new_forum(actor(confirmed_user_fixture())) == {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Forums.new_forum(actor()) == {:error, :unauthorized} + end + end + + describe "create_forum/2" do + test "an admin creates a forum" do + assert {:ok, %Forum{} = forum} = + Forums.create_forum(actor(admin_user_fixture()), valid_attrs()) + + assert Repo.get(Forum, forum.id) + end + + test "invalid attributes return a changeset" do + assert {:error, %Ecto.Changeset{}} = + Forums.create_forum(actor(admin_user_fixture()), %{valid_attrs() | "name" => ""}) + end + + test "a plain moderator is not authorized and creates nothing" do + assert Forums.create_forum(actor(moderator_user_fixture()), valid_attrs()) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + assert Forums.create_forum(actor(confirmed_user_fixture()), valid_attrs()) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Forums.create_forum(actor(), valid_attrs()) == {:error, :unauthorized} + end + end + + describe "edit_forum/2" do + test "an admin loads a forum by short name" do + forum = forum_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + Forums.edit_forum(actor(admin_user_fixture()), forum.short_name) + + assert loaded.id == forum.id + end + + test "an unknown short name is not found for an admin" do + assert Forums.edit_forum(actor(admin_user_fixture()), "nonexistent") == + {:error, :not_found} + end + + test "an unknown short name is not found for a plain moderator" do + assert Forums.edit_forum(actor(moderator_user_fixture()), "nonexistent") == + {:error, :not_found} + end + + test "a real forum is unauthorized for a plain moderator" do + forum = forum_fixture() + + assert Forums.edit_forum(actor(moderator_user_fixture()), forum.short_name) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + forum = forum_fixture() + + assert Forums.edit_forum(actor(confirmed_user_fixture()), forum.short_name) == + {:error, :unauthorized} + end + end + + describe "update_forum/3" do + test "an admin updates a forum" do + forum = forum_fixture() + + assert {:ok, updated} = + Forums.update_forum(actor(admin_user_fixture()), forum.short_name, %{ + "name" => "Renamed" + }) + + assert updated.name == "Renamed" + end + + test "invalid attributes return a changeset" do + forum = forum_fixture() + + assert {:error, %Ecto.Changeset{}} = + Forums.update_forum(actor(admin_user_fixture()), forum.short_name, %{"name" => ""}) + end + + test "an unknown short name is not found for an admin" do + assert Forums.update_forum(actor(admin_user_fixture()), "nonexistent", %{"name" => "x"}) == + {:error, :not_found} + end + + test "an unknown short name is not found for a plain moderator" do + assert Forums.update_forum(actor(moderator_user_fixture()), "nonexistent", %{"name" => "x"}) == + {:error, :not_found} + end + + test "a real forum is unauthorized for a plain moderator" do + forum = forum_fixture() + + assert Forums.update_forum(actor(moderator_user_fixture()), forum.short_name, %{ + "name" => "x" + }) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + forum = forum_fixture() + + assert Forums.update_forum(actor(confirmed_user_fixture()), forum.short_name, %{ + "name" => "x" + }) == + {:error, :unauthorized} + end + end + + # Controller-shaped attrs (string keys) a forum insert requires; the short + # name must be lowercase letters only. + defp valid_attrs do + %{ + "name" => "Admin Test Forum", + "short_name" => unique_forum_short_name(), + "description" => "A forum created in an admin test", + "access_level" => "normal" + } + end +end diff --git a/test/philomena/galleries_concurrency_test.exs b/test/philomena/galleries_concurrency_test.exs new file mode 100644 index 000000000..b9882aae6 --- /dev/null +++ b/test/philomena/galleries_concurrency_test.exs @@ -0,0 +1,133 @@ +defmodule Philomena.GalleriesConcurrencyTest do + use Philomena.ConcurrentDataCase + + import Philomena.AttributionFixtures, only: [actor: 1] + import Philomena.GalleriesFixtures + import Philomena.ImagesFixtures + import Philomena.UsersFixtures + + alias Philomena.Galleries + alias Philomena.Galleries.Gallery + alias Philomena.Galleries.Interaction + alias Philomena.Repo + + test "concurrent additions of the same image create one membership" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + results = + concurrently([ + fn -> Galleries.create_gallery_image(actor(user), gallery.id, image.id) end, + fn -> Galleries.create_gallery_image(actor(user), gallery.id, image.id) end + ]) + + assert Enum.count(results, &match?({:ok, %Gallery{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, %Ecto.Changeset{}}, &1)) == 1 + assert Repo.aggregate(Interaction, :count) == 1 + assert Repo.get!(Gallery, gallery.id).image_count == 1 + end + + test "adding an image concurrently with deleting its gallery is serialized" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + [add_result, delete_result] = + concurrently([ + fn -> Galleries.create_gallery_image(actor(user), gallery.id, image.id) end, + fn -> Galleries.delete_gallery(actor(user), gallery.id) end + ]) + + assert match?({:ok, %Gallery{}}, delete_result) + assert add_result in [{:error, :not_found}] or match?({:ok, %Gallery{}}, add_result) + refute Repo.get(Gallery, gallery.id) + + refute Repo.exists?( + from interaction in Interaction, where: interaction.gallery_id == ^gallery.id + ) + end + + test "concurrent removals of the same image allow one removal" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + gallery_image_fixture(gallery, image) + + results = + concurrently([ + fn -> Galleries.delete_gallery_image(actor(user), gallery.id, image.id) end, + fn -> Galleries.delete_gallery_image(actor(user), gallery.id, image.id) end + ]) + + assert Enum.count(results, &match?({:ok, %Gallery{}}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :not_found})) == 1 + + refute Repo.exists?( + from interaction in Interaction, where: interaction.gallery_id == ^gallery.id + ) + + assert Repo.get!(Gallery, gallery.id).image_count == 0 + end + + test "removing an image concurrently with deleting its gallery is serialized" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + gallery_image_fixture(gallery, image) + + [remove_result, delete_result] = + concurrently([ + fn -> Galleries.delete_gallery_image(actor(user), gallery.id, image.id) end, + fn -> Galleries.delete_gallery(actor(user), gallery.id) end + ]) + + assert match?({:ok, %Gallery{}}, delete_result) + assert remove_result == {:error, :not_found} or match?({:ok, %Gallery{}}, remove_result) + refute Repo.get(Gallery, gallery.id) + + refute Repo.exists?( + from interaction in Interaction, where: interaction.gallery_id == ^gallery.id + ) + end + + test "concurrent additions assign strictly sequential ascending positions" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + images = Enum.map(1..8, fn _ -> image_fixture() end) + + results = + concurrently( + for image <- images do + fn -> Galleries.create_gallery_image(actor(user), gallery.id, image.id) end + end + ) + + assert Enum.all?(results, &match?({:ok, %Gallery{}}, &1)) + + positions = + Interaction + |> where(gallery_id: ^gallery.id) + |> order_by(:position) + |> select([interaction], interaction.position) + |> Repo.all() + + assert positions == Enum.to_list(0..(length(images) - 1)) + assert Repo.get!(Gallery, gallery.id).image_count == length(images) + end + + test "concurrent gallery deletions allow one deletion" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + + results = + concurrently([ + fn -> Galleries.delete_gallery(actor(user), gallery.id) end, + fn -> Galleries.delete_gallery(actor(user), gallery.id) end + ]) + + assert Enum.count(results, &match?({:ok, %Gallery{}}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :not_found})) == 1 + refute Repo.get(Gallery, gallery.id) + end +end diff --git a/test/philomena/galleries_test.exs b/test/philomena/galleries_test.exs index f4dd7fdd4..c3d904a1e 100644 --- a/test/philomena/galleries_test.exs +++ b/test/philomena/galleries_test.exs @@ -1,30 +1,258 @@ defmodule Philomena.GalleriesTest do + @moduledoc """ + Context-level tests for the actor-first `Philomena.Galleries` functions. + + These pin the authorization matrices on the write paths (ban, missing + fingerprint, owner vs unrelated user vs admin), the form loaders, the + add/remove/reorder image operations, the read-mark and subscription + helpers, and the search-backed page and index loaders. + """ + use Philomena.DataCase, async: false - # delete_gallery/2 unindexes the gallery from OpenSearch synchronously. @moduletag :search + import Philomena.AttributionFixtures + import Philomena.GalleriesFixtures + import Philomena.ImagesFixtures + import Philomena.UsersFixtures + import Philomena.ReportsFixtures + alias Philomena.Galleries - alias Philomena.Reports - alias Philomena.Reports.Report + alias Philomena.Galleries.Gallery + alias Philomena.Galleries.GalleryPage + alias Philomena.Galleries.Interaction + alias Philomena.Galleries.ReorderForm + alias Philomena.Images.Image + alias Philomena.Images.Search.Scope alias Philomena.Repo + alias Philomena.Reports.Report + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers - import Philomena.GalleriesFixtures - import Philomena.ReportsFixtures - import Philomena.UsersFixtures + # A truthy ban value in the shape production passes; only its presence + # matters to the write-access and not-banned checks the loaders run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + @pagination %{page_number: 1, page_size: 25} + + setup do + Search.clear_index!(Gallery) + Search.clear_index!(Image) + :ok + end + + # The compiled filter body for a viewer with no active filter: it excludes + # nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp scope do + Scope.new(default_filter(), @pagination) + end + + describe "new_gallery/1" do + test "an anonymous actor is unauthorized" do + assert Galleries.new_gallery(actor(nil)) == {:error, :unauthorized} + end + + test "a signed-in actor gets the new-gallery changeset" do + assert {:ok, %Ecto.Changeset{}} = Galleries.new_gallery(actor(confirmed_user_fixture())) + end + + test "a banned actor is rejected even while carrying a fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.new_gallery(actor) == {:error, :ban} + end + + test "an actor without a fingerprint may not reach the form" do + assert Galleries.new_gallery(actor(nil, fingerprint: nil)) == {:error, :unauthorized} + end + end + + describe "create_gallery/2 with an actor" do + test "a signed-in actor creates a gallery attributed to the user" do + user = confirmed_user_fixture() + thumbnail = image_fixture() + + assert {:ok, %Gallery{} = gallery} = + Galleries.create_gallery(actor(user), %{ + "title" => "A brand new gallery", + "thumbnail_id" => to_string(thumbnail.id) + }) + + assert gallery.user_id == user.id + assert gallery.title == "A brand new gallery" + assert Repo.get(Gallery, gallery.id) + end + + test "a blank title is a rejected changeset" do + thumbnail = image_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Galleries.create_gallery(actor(confirmed_user_fixture()), %{ + "title" => "", + "thumbnail_id" => to_string(thumbnail.id) + }) + + refute changeset.valid? + assert changeset.errors[:title] + end + + test "a banned actor is rejected" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.create_gallery(actor, %{"title" => "x"}) == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Galleries.create_gallery(actor, %{"title" => "x"}) == {:error, :unauthorized} + end + + test "the ban wins over a missing fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban, fingerprint: nil) + + assert Galleries.create_gallery(actor, %{"title" => "x"}) == {:error, :ban} + end + end + + describe "update_gallery/3" do + test "the owner updates their gallery" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + + assert {:ok, %Gallery{}} = + Galleries.update_gallery(actor(user), "#{gallery.id}", %{"title" => "Renamed"}) + + assert Repo.reload!(gallery).title == "Renamed" + end + + test "an admin updates another user's gallery" do + gallery = gallery_fixture(confirmed_user_fixture()) + + assert {:ok, %Gallery{}} = + Galleries.update_gallery(actor(admin_user_fixture()), "#{gallery.id}", %{ + "title" => "Admin renamed" + }) + + assert Repo.reload!(gallery).title == "Admin renamed" + end + + test "an unrelated user is unauthorized and leaves the row unchanged" do + gallery = gallery_fixture(confirmed_user_fixture()) + + assert Galleries.update_gallery(actor(confirmed_user_fixture()), "#{gallery.id}", %{ + "title" => "Hijacked" + }) == {:error, :unauthorized} + + assert Repo.reload!(gallery).title == gallery.title + end + + test "a banned actor is rejected before any loading, even with a garbage id" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.update_gallery(actor, "abc", %{"title" => "x"}) == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + gallery = gallery_fixture(confirmed_user_fixture()) + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Galleries.update_gallery(actor, "#{gallery.id}", %{"title" => "x"}) == + {:error, :unauthorized} + end + + test "the ban wins over a missing fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban, fingerprint: nil) + + assert Galleries.update_gallery(actor, "abc", %{"title" => "x"}) == {:error, :ban} + end + + test "a non-castable id is not-found" do + assert Galleries.update_gallery(actor(confirmed_user_fixture()), "abc", %{"title" => "x"}) == + {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Galleries.update_gallery(actor(confirmed_user_fixture()), "999999999", %{ + "title" => "x" + }) == {:error, :not_found} + + assert Galleries.update_gallery(actor(admin_user_fixture()), "999999999", %{"title" => "x"}) == + {:error, :not_found} + end + end describe "delete_gallery/2" do - test "closes the gallery's open reports and nulls the target FK while keeping the row" do + test "the owner deletes their gallery" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + + assert {:ok, %Gallery{} = deleted} = Galleries.delete_gallery(actor(user), "#{gallery.id}") + assert deleted.id == gallery.id + assert Repo.reload(gallery) == nil + end + + test "an unrelated user is unauthorized and leaves the row" do gallery = gallery_fixture(confirmed_user_fixture()) + + assert Galleries.delete_gallery(actor(confirmed_user_fixture()), "#{gallery.id}") == + {:error, :unauthorized} + + refute Repo.reload(gallery) == nil + end + + test "a banned actor is rejected even with a garbage id" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.delete_gallery(actor, "abc") == {:error, :ban} + end + + test "a non-castable id is not-found" do + assert Galleries.delete_gallery(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Galleries.delete_gallery(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Galleries.delete_gallery(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + end + + describe "sequential gallery erasure" do + test "deletes all owned galleries and closes their reports" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + other_gallery = gallery_fixture(user) report = report_fixture(gallery_id: gallery.id) admin = admin_user_fixture() assert report.open assert report.gallery_id == gallery.id - assert {:ok, _gallery} = Galleries.delete_gallery(gallery, admin) + assert {:ok, _deleted} = Galleries.delete_gallery(actor(admin), gallery.id) + assert {:ok, _deleted} = Galleries.delete_gallery(actor(admin), other_gallery.id) - closed = Reports.get_report!(report.id) + closed = Repo.get!(Report, report.id) refute closed.open assert closed.state == "closed" assert closed.admin_id == admin.id @@ -33,6 +261,474 @@ defmodule Philomena.GalleriesTest do assert Enum.all?(Report.target_columns(), &is_nil(Map.get(closed, &1))) refute Repo.get(Philomena.Galleries.Gallery, gallery.id) + refute Repo.get(Philomena.Galleries.Gallery, other_gallery.id) + end + end + + describe "edit_gallery/2" do + test "the owner loads the gallery paired with its edit changeset" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + + assert {:ok, {%Gallery{} = loaded, %Ecto.Changeset{} = changeset}} = + Galleries.edit_gallery(actor(user), "#{gallery.id}") + + assert loaded.id == gallery.id + assert changeset.data.id == gallery.id + end + + test "an unrelated user is unauthorized" do + gallery = gallery_fixture(confirmed_user_fixture()) + + assert Galleries.edit_gallery(actor(confirmed_user_fixture()), "#{gallery.id}") == + {:error, :unauthorized} + end + + test "a banned actor is rejected even while carrying a fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.edit_gallery(actor, "abc") == {:error, :ban} + end + + test "an actor without a fingerprint is rejected before loading" do + assert Galleries.edit_gallery(actor(nil, fingerprint: nil), "abc") == + {:error, :unauthorized} + end + + test "a non-castable id is not-found" do + assert Galleries.edit_gallery(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a well-formed id naming no row is not-found for every actor" do + assert Galleries.edit_gallery(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Galleries.edit_gallery(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + end + + describe "create_gallery_image/3" do + test "the owner adds an image at the last position" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + assert {:ok, %Gallery{} = result} = + Galleries.create_gallery_image(actor(user), "#{gallery.id}", "#{image.id}") + + assert %Gallery{} = result + assert result.image_count == 1 + assert Repo.reload!(gallery).image_count == 1 + end + + test "an unrelated user is unauthorized" do + gallery = gallery_fixture(confirmed_user_fixture()) + image = image_fixture() + + assert Galleries.create_gallery_image( + actor(confirmed_user_fixture()), + "#{gallery.id}", + "#{image.id}" + ) == {:error, :unauthorized} + end + + test "malformed and missing image ids are not-found" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + + assert Galleries.create_gallery_image(actor(user), gallery.id, "abc") == + {:error, :not_found} + + assert Galleries.create_gallery_image(actor(user), gallery.id, "999999999") == + {:error, :not_found} + end + + test "a hidden image is unauthorized for an ordinary owner" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture(hidden_from_users: true) + + assert Galleries.create_gallery_image(actor(user), gallery.id, image.id) == + {:error, :unauthorized} + + assert Repo.aggregate(Interaction, :count) == 0 + end + + test "a banned actor is rejected" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.create_gallery_image(actor, "abc", "abc") == {:error, :ban} + end + + test "adding an image already in the gallery returns a changeset error" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + {:ok, _} = Galleries.create_gallery_image(actor(user), "#{gallery.id}", "#{image.id}") + + assert {:error, %Ecto.Changeset{} = changeset} = + Galleries.create_gallery_image(actor(user), "#{gallery.id}", "#{image.id}") + + assert changeset.errors[:interactions] + assert Repo.reload!(gallery).image_count == 1 + assert Repo.aggregate(Interaction, :count) == 1 + end + end + + describe "delete_gallery_image/3" do + test "the owner removes an image in the gallery" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + {:ok, _} = Galleries.create_gallery_image(actor(user), "#{gallery.id}", "#{image.id}") + + assert {:ok, result} = + Galleries.delete_gallery_image(actor(user), "#{gallery.id}", "#{image.id}") + + assert result.image_count == 0 + assert Repo.reload!(gallery).image_count == 0 + end + + test "removing an image not in the gallery returns not-found" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + assert Galleries.delete_gallery_image(actor(user), "#{gallery.id}", "#{image.id}") == + {:error, :not_found} + end + + test "an unrelated user is unauthorized" do + gallery = gallery_fixture(confirmed_user_fixture()) + image = image_fixture() + + assert Galleries.delete_gallery_image( + actor(confirmed_user_fixture()), + "#{gallery.id}", + "#{image.id}" + ) == {:error, :unauthorized} + end + end + + describe "update_gallery_order/3" do + test "the owner reorders synchronously and gets the reorder form back" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + images = [image_fixture(), image_fixture(), image_fixture()] + Enum.each(images, &gallery_image_fixture(gallery, &1)) + + requested_ids = Enum.map(images, & &1.id) + + assert {:ok, %ReorderForm{} = returned} = + Galleries.update_gallery_order( + actor(user), + "#{gallery.id}", + %{image_ids: requested_ids} + ) + + assert returned.gallery.id == gallery.id + assert returned.image_ids == requested_ids + + assert %{position: 2} = + Repo.get_by!(Interaction, gallery_id: gallery.id, image_id: Enum.at(images, 0).id) + + assert %{position: 1} = + Repo.get_by!(Interaction, gallery_id: gallery.id, image_id: Enum.at(images, 1).id) + + assert %{position: 0} = + Repo.get_by!(Interaction, gallery_id: gallery.id, image_id: Enum.at(images, 2).id) + end + + test "an unrelated user is unauthorized" do + gallery = gallery_fixture(confirmed_user_fixture()) + image_a = image_fixture() + image_b = image_fixture() + + assert Galleries.update_gallery_order(actor(confirmed_user_fixture()), "#{gallery.id}", %{ + "image_ids" => [image_a.id, image_b.id] + }) == + {:error, :unauthorized} + end + + test "a banned actor is rejected" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Galleries.update_gallery_order(actor, "abc", [1]) == {:error, :ban} + end + + test "accepts a subset but rejects extra, duplicate, and malformed image ids" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + [image_a, image_b] = [image_fixture(), image_fixture()] + gallery_image_fixture(gallery, image_a) + gallery_image_fixture(gallery, image_b) + + assert {:ok, %ReorderForm{}} = + Galleries.update_gallery_order(actor(user), gallery.id, %{ + "image_ids" => [image_a.id] + }) + + for invalid_order <- [ + [image_a.id, image_b.id, 999_999_999], + [image_a.id, image_a.id], + [to_string(image_a.id), "not-an-id"] + ] do + assert {:error, %Ecto.Changeset{}} = + Galleries.update_gallery_order(actor(user), gallery.id, %{ + "image_ids" => invalid_order + }) + end + end + + test "accepts string ids in a subset" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + [image_a, image_b] = [image_fixture(), image_fixture()] + gallery_image_fixture(gallery, image_a) + gallery_image_fixture(gallery, image_b) + + assert {:ok, %ReorderForm{}} = + Galleries.update_gallery_order(actor(user), gallery.id, %{ + "image_ids" => [to_string(image_b.id)] + }) + end + end + + describe "ReorderForm" do + test "casts decimal string image ids to integers" do + changeset = ReorderForm.changeset(%ReorderForm{}, %{"image_ids" => ["12", "34"]}) + + assert {:ok, %ReorderForm{image_ids: [12, 34]}} = + Ecto.Changeset.apply_action(changeset, :create) + end + + test "rejects duplicate image ids" do + changeset = ReorderForm.changeset(%ReorderForm{}, %{image_ids: [12, 12]}) + + refute changeset.valid? + assert changeset.errors[:image_ids] + end + + test "rejects malformed image ids" do + changeset = ReorderForm.changeset(%ReorderForm{}, %{image_ids: [12, "not-an-id"]}) + + refute changeset.valid? + assert changeset.errors[:image_ids] + end + end + + describe "create_gallery_read/2" do + test "a known gallery is marked read for the user" do + user = confirmed_user_fixture() + gallery = gallery_fixture(confirmed_user_fixture()) + + assert {:ok, %Gallery{} = returned} = + Galleries.create_gallery_read(actor(user), "#{gallery.id}") + + assert returned.id == gallery.id + end + + # No authorization runs here, so an unknown id is not-found for everyone, + # admins included. + test "an unknown id is not-found for a user and for an admin" do + assert Galleries.create_gallery_read(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + + assert Galleries.create_gallery_read(actor(admin_user_fixture()), "999999999") == + {:error, :not_found} + end + + test "a non-castable id is not-found" do + assert Galleries.create_gallery_read(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "an anonymous actor is unauthorized for a real gallery" do + gallery = gallery_fixture(confirmed_user_fixture()) + + assert Galleries.create_gallery_read(actor(), gallery.id) == {:error, :unauthorized} + end + end + + describe "create_gallery_subscription/2 and delete_gallery_subscription/2" do + test "subscribing then unsubscribing toggles the subscription" do + user = confirmed_user_fixture() + gallery = gallery_fixture(confirmed_user_fixture()) + + assert {:ok, %Gallery{}} = + Galleries.create_gallery_subscription(actor(user), "#{gallery.id}") + + assert Galleries.subscribed?(gallery, user) + + assert {:ok, %Gallery{}} = + Galleries.delete_gallery_subscription(actor(user), "#{gallery.id}") + + refute Galleries.subscribed?(gallery, user) + end + + test "unsubscribing when not subscribed is an idempotent success" do + user = confirmed_user_fixture() + gallery = gallery_fixture(confirmed_user_fixture()) + + assert {:ok, %Gallery{}} = + Galleries.delete_gallery_subscription(actor(user), "#{gallery.id}") + + refute Galleries.subscribed?(gallery, user) + end + + test "subscribing to an unknown id is not-found" do + assert Galleries.create_gallery_subscription(actor(confirmed_user_fixture()), "999999999") == + {:error, :not_found} + end + + test "a non-castable id is not-found" do + assert Galleries.create_gallery_subscription(actor(confirmed_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a banned actor can subscribe or unsubscribe" do + user = confirmed_user_fixture() + gallery = gallery_fixture(confirmed_user_fixture()) + actor = actor(user, ban: @ban) + + assert {:ok, %Gallery{}} = Galleries.create_gallery_subscription(actor, "#{gallery.id}") + assert Galleries.subscribed?(gallery, user) + + assert {:ok, %Gallery{}} = Galleries.delete_gallery_subscription(actor, "#{gallery.id}") + refute Galleries.subscribed?(gallery, user) + end + end + + describe "show_gallery/2" do + test "the owner's scope gets a gallery page containing the gallery's image" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + + gallery_image_fixture(gallery, image) + SearchHelpers.reindex_all!(Image) + + assert {:ok, %GalleryPage{} = page} = + Galleries.show_gallery(actor(user), scope(), "#{gallery.id}") + + assert page.gallery.id == gallery.id + # The images page carries {image, hit} tuples, not bare image structs. + assert image.id in Enum.map(page.images, fn {img, _hit} -> img.id end) + assert is_boolean(page.watching) + assert is_boolean(page.gallery_prev) + assert is_boolean(page.gallery_next) + assert is_list(page.interactions) + end + + test "an anonymous viewer sees an empty gallery's page" do + gallery = gallery_fixture(confirmed_user_fixture()) + SearchHelpers.reindex_all!(Image) + + assert {:ok, %GalleryPage{} = page} = + Galleries.show_gallery(actor(), scope(), "#{gallery.id}") + + assert page.gallery.id == gallery.id + assert Enum.empty?(page.images) + end + + test "an unknown id is not-found for an anonymous viewer" do + assert Galleries.show_gallery(actor(), scope(), "999999999") == + {:error, :not_found} + end + + test "a non-castable id is not-found" do + assert Galleries.show_gallery(actor(), scope(), "abc") == {:error, :not_found} + end + end + + describe "list_galleries/3" do + test "a title filter finds a matching gallery and excludes others" do + user = confirmed_user_fixture() + wanted = gallery_fixture(user, title: "Test Wanted Gallery") + other = gallery_fixture(user, title: "Test Unrelated Gallery") + SearchHelpers.reindex_all!(Gallery) + + assert {:ok, page, _changeset} = + Galleries.list_galleries( + actor(), + %{"title" => "wanted"}, + @pagination + ) + + ids = Enum.map(page.entries, & &1.id) + assert wanted.id in ids + refute other.id in ids + end + + test "no filter returns the gallery with its thumbnail preloaded" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + SearchHelpers.reindex_all!(Gallery) + + assert {:ok, page, _changeset} = Galleries.list_galleries(actor(), %{}, @pagination) + + assert [%Gallery{} = loaded] = Enum.filter(page.entries, &(&1.id == gallery.id)) + # The thumbnail association is loaded, not left as a lazy placeholder. + refute match?(%Ecto.Association.NotLoaded{}, loaded.thumbnail) + end + + test "membership changes are reflected when the gallery worker reindexes" do + user = confirmed_user_fixture() + gallery = gallery_fixture(user) + image = image_fixture() + gallery_image_fixture(gallery, image) + + assert :ok = Galleries.perform_reindex(:id, [gallery.id]) + :ok = Search.refresh_index!(Gallery) + + params = %{"include_image" => to_string(image.id)} + assert {:ok, page, _changeset} = Galleries.list_galleries(actor(), params, @pagination) + assert Enum.any?(page.entries, &(&1.id == gallery.id)) + + assert {:ok, _gallery} = + Galleries.delete_gallery_image(actor(user), gallery.id, image.id) + + assert :ok = Galleries.perform_reindex(:id, [gallery.id]) + :ok = Search.refresh_index!(Gallery) + assert {:ok, page, _changeset} = Galleries.list_galleries(actor(), params, @pagination) + refute Enum.any?(page.entries, &(&1.id == gallery.id)) + end + end + + describe "gallery_choices_for_image/2" do + test "returns no choices for an anonymous actor" do + assert Galleries.gallery_choices_for_image(actor(), image_fixture()) == {:ok, []} + end + + test "limits the signed-in actor's selector to 100 galleries" do + user = confirmed_user_fixture() + thumbnail = image_fixture() + now = DateTime.utc_now(:second) + + rows = + Enum.map(1..101, fn number -> + %{ + user_id: user.id, + thumbnail_id: thumbnail.id, + title: "Selector gallery #{number}", + description: "", + spoiler_warning: "", + anonymous: false, + image_count: 0, + order_position_asc: false, + created_at: now, + updated_at: DateTime.add(now, number, :second) + } + end) + + {101, nil} = Repo.insert_all(Gallery, rows) + + assert {:ok, choices} = Galleries.gallery_choices_for_image(actor(user), thumbnail) + assert length(choices) == 100 end end end diff --git a/test/philomena/image_interactions_test.exs b/test/philomena/image_interactions_test.exs new file mode 100644 index 000000000..07a6235d2 --- /dev/null +++ b/test/philomena/image_interactions_test.exs @@ -0,0 +1,243 @@ +defmodule Philomena.ImageInteractionsTest do + use Philomena.DataCase, async: true + + alias Philomena.Multi + alias Philomena.ImageFaves + alias Philomena.ImageFaves.ImageFave + alias Philomena.ImageHides + alias Philomena.ImageHides.ImageHide + alias Philomena.ImageIntensities + alias Philomena.ImageIntensities.ImageIntensity + alias Philomena.ImageVotes + alias Philomena.ImageVotes.ImageVote + alias Philomena.Interactions + alias PhilomenaMedia.Intensities + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.ImagesFixtures + import Philomena.UsersFixtures + + defp transact(multi) do + assert {:ok, changes} = Multi.transact(multi) + changes + end + + test "favorite replacement and deletion keep image and user counters idempotent" do + image = image_fixture() + user = confirmed_user_fixture() + + put = fn -> + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> transact() + end + + put.() + put.() + + assert Repo.aggregate( + from(f in ImageFave, where: f.image_id == ^image.id and f.user_id == ^user.id), + :count + ) == 1 + + assert Repo.reload!(image).faves_count == 1 + assert Repo.reload!(user).image_faves_count == 1 + + delete = fn -> + Multi.new() + |> ImageFaves.delete_fave_for_loaded_image(image, user) + |> transact() + end + + assert %{unfave: {1, nil}} = delete.() + assert %{unfave: {0, nil}} = delete.() + assert Repo.reload!(image).faves_count == 0 + assert Repo.reload!(user).image_faves_count == 0 + end + + test "hide replacement and deletion keep the image counter idempotent" do + image = image_fixture() + user = confirmed_user_fixture() + + for _ <- 1..2 do + Multi.new() + |> ImageHides.put_hide_for_loaded_image(image, user) + |> transact() + end + + assert Repo.aggregate( + from(h in ImageHide, where: h.image_id == ^image.id and h.user_id == ^user.id), + :count + ) == 1 + + assert Repo.reload!(image).hides_count == 1 + + assert %{unhide: {1, nil}} = + Multi.new() + |> ImageHides.delete_hide_for_loaded_image(image, user) + |> transact() + + assert %{unhide: {0, nil}} = + Multi.new() + |> ImageHides.delete_hide_for_loaded_image(image, user) + |> transact() + + assert Repo.reload!(image).hides_count == 0 + end + + test "vote replacement handles retries, direction changes, and repeated deletion" do + image = image_fixture() + user = confirmed_user_fixture() + + vote = fn up -> + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, up) + |> transact() + end + + vote.(false) + assert %ImageVote{up: false} = Repo.get_by(ImageVote, image_id: image.id, user_id: user.id) + assert %{downvotes_count: 1, upvotes_count: 0, score: -1} = Repo.reload!(image) + + vote.(true) + vote.(true) + + assert %ImageVote{up: true} = Repo.get_by(ImageVote, image_id: image.id, user_id: user.id) + assert %{downvotes_count: 0, upvotes_count: 1, score: 1} = Repo.reload!(image) + assert Repo.reload!(user).image_votes_count == 1 + + delete = fn -> + Multi.new() + |> ImageVotes.delete_vote_for_loaded_image(image, user) + |> transact() + end + + assert %{unupvote: {1, nil}, undownvote: {0, nil}} = delete.() + assert %{unupvote: {0, nil}, undownvote: {0, nil}} = delete.() + assert %{downvotes_count: 0, upvotes_count: 0, score: 0} = Repo.reload!(image) + assert Repo.reload!(user).image_votes_count == 0 + end + + test "derived intensities replace the one row owned by an image" do + image = image_fixture() + + assert {:ok, %ImageIntensity{}} = + ImageIntensities.put_for_loaded_image( + image, + %Intensities{nw: 1.0, ne: 2.0, sw: 3.0, se: 4.0} + ) + + assert {:ok, %ImageIntensity{}} = + ImageIntensities.put_for_loaded_image( + image, + %Intensities{nw: 5.0, ne: 6.0, sw: 7.0, se: 8.0} + ) + + assert [%ImageIntensity{nw: 5.0, ne: 6.0, sw: 7.0, se: 8.0}] = + Repo.all(from(i in ImageIntensity, where: i.image_id == ^image.id)) + + Repo.delete!(image) + refute Repo.exists?(from i in ImageIntensity, where: i.image_id == ^image.id) + end + + test "actor reads normalize nested images and omit absent interactions" do + user = confirmed_user_fixture() + image = image_fixture() + other_image = image_fixture() + untouched = image_fixture() + + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> ImageVotes.put_vote_for_loaded_image(image, user, true) + |> ImageHides.put_hide_for_loaded_image(other_image, user) + |> transact() + + interactions = + Interactions.user_interactions(actor(user), [ + nil, + image, + image.id, + [{image, %{sort: []}}, [other_image, untouched]] + ]) + + assert interactions + |> Enum.map(&{&1.image_id, &1.interaction_type, &1.value}) + |> Enum.sort() == + [ + {image.id, "faved", ""}, + {image.id, "voted", "up"}, + {other_image.id, "hidden", ""} + ] + |> Enum.sort() + + assert Interactions.user_interactions(actor(), [image]) == [] + end + + test "merge migration keeps target collisions and applies inserted-row counter deltas" do + source = image_fixture() + target = image_fixture() + source_only = confirmed_user_fixture() + collision = confirmed_user_fixture() + downvote_only = confirmed_user_fixture() + + Multi.new() + |> ImageHides.put_hide_for_loaded_image(source, source_only) + |> ImageFaves.put_fave_for_loaded_image(source, source_only) + |> ImageVotes.put_vote_for_loaded_image(source, source_only, true) + |> transact() + + Multi.new() + |> ImageHides.put_hide_for_loaded_image(source, collision) + |> ImageFaves.put_fave_for_loaded_image(source, collision) + |> ImageVotes.put_vote_for_loaded_image(source, collision, true) + |> transact() + + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(source, downvote_only, false) + |> transact() + + Multi.new() + |> ImageHides.put_hide_for_loaded_image(target, collision) + |> ImageFaves.put_fave_for_loaded_image(target, collision) + |> ImageVotes.put_vote_for_loaded_image(target, collision, false) + |> transact() + + assert %{ + interaction_hides: 1, + interaction_faves: {1, _}, + interaction_upvotes: {1, _}, + interaction_downvotes: {1, _}, + interaction_image: 1 + } = + Multi.new() + |> Interactions.migrate_loaded_images(source, target) + |> transact() + + assert Repo.get_by(ImageHide, image_id: target.id, user_id: source_only.id) + assert Repo.get_by(ImageFave, image_id: target.id, user_id: source_only.id) + + assert %ImageVote{up: true} = + Repo.get_by(ImageVote, image_id: target.id, user_id: source_only.id) + + assert %ImageVote{up: false} = + Repo.get_by(ImageVote, image_id: target.id, user_id: collision.id) + + assert %ImageVote{up: false} = + Repo.get_by(ImageVote, image_id: target.id, user_id: downvote_only.id) + + assert %{ + hides_count: 2, + faves_count: 2, + upvotes_count: 1, + downvotes_count: 2, + score: -1 + } = Repo.reload!(target) + + assert Repo.get_by(ImageHide, image_id: source.id, user_id: source_only.id) + assert Repo.reload!(source_only).image_faves_count == 2 + assert Repo.reload!(source_only).image_votes_count == 2 + assert Repo.reload!(collision).image_faves_count == 2 + assert Repo.reload!(collision).image_votes_count == 2 + assert Repo.reload!(downvote_only).image_votes_count == 2 + end +end diff --git a/test/philomena/images/navigation_test.exs b/test/philomena/images/navigation_test.exs new file mode 100644 index 000000000..faf0532a2 --- /dev/null +++ b/test/philomena/images/navigation_test.exs @@ -0,0 +1,270 @@ +defmodule Philomena.Images.NavigationTest do + @moduledoc """ + Context-level tests for the image navigation loaders on `Philomena.Images`: + prev/next lookup, the search-index page number, related images, and the + random-image picker. + + All four run a search scoped to a viewer, so they are asserted against the + real OpenSearch index. Each parses and loads its subject image before `:show` + authorization, so malformed and missing ids are consistently not found for + every actor. + """ + + use Philomena.DataCase, async: false + + @moduletag :search + + import Philomena.ImagesFixtures + import Philomena.AttributionFixtures + import Philomena.UsersFixtures + + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Search.Scope + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + setup do + Search.clear_index!(Image) + :ok + end + + # The compiled filter body the web layer produces for a viewer with no active + # filter: an empty tag_ids exclusion plus a pair of match_none clauses, so it + # excludes nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp scope(overrides \\ []) do + Scope.new( + Keyword.get(overrides, :filter, default_filter()), + Keyword.get(overrides, :pagination, %{page_number: 1, page_size: 25}), + Keyword.get(overrides, :params, %{}) + ) + end + + defp hours_ago(hours) do + DateTime.utc_now() + |> DateTime.add(-hours * 3600, :second) + |> DateTime.truncate(:second) + end + + # Two images ordered by first_seen_at, the default descending sort; `newer` + # precedes `older` in the listing. + defp two_images do + older = image_fixture(first_seen_at: hours_ago(2)) + newer = image_fixture(first_seen_at: hours_ago(1)) + SearchHelpers.reindex_all!(Image) + + {older, newer} + end + + describe "list_image_navigation/2" do + test "rel=next finds the older image and carries a sort cursor" do + {older, newer} = two_images() + + assert {:ok, {image, {adjacent, hit}}} = + Images.list_image_navigation( + actor(), + scope(params: %{"rel" => "next"}), + to_string(newer.id) + ) + + assert image.id == newer.id + assert adjacent.id == older.id + assert Map.has_key?(hit, "sort") + end + + test "rel=prev finds the newer image" do + {older, newer} = two_images() + + assert {:ok, {image, {adjacent, _hit}}} = + Images.list_image_navigation( + actor(), + scope(params: %{"rel" => "prev"}), + to_string(older.id) + ) + + assert image.id == older.id + assert adjacent.id == newer.id + end + + test "returns a nil neighbor at the end of the sequence" do + {older, _newer} = two_images() + + assert {:ok, {image, nil}} = + Images.list_image_navigation( + actor(), + scope(params: %{"rel" => "next"}), + to_string(older.id) + ) + + assert image.id == older.id + end + + test "accepts an integer id" do + {older, newer} = two_images() + + assert {:ok, {image, {adjacent, _hit}}} = + Images.list_image_navigation(actor(), scope(params: %{"rel" => "next"}), newer.id) + + assert image.id == newer.id + assert adjacent.id == older.id + end + + test "an unknown well-formed id is not found for an anonymous viewer" do + # Missing image locators resolve to not-found before authorization. + assert Images.list_image_navigation( + actor(), + scope(params: %{"rel" => "next"}), + "2147483647" + ) == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + scope = scope(params: %{"rel" => "next"}) + + assert Images.list_image_navigation(actor(admin), scope, "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.list_image_navigation( + actor(), + scope(params: %{"rel" => "next"}), + "not-a-number" + ) == + {:error, :not_found} + end + end + + describe "list_image_index_page/2" do + test "returns the page number as an integer" do + {older, newer} = two_images() + + # Listed by descending id, the newer image has no images ahead of it, so + # it sits on page one; the older image trails it but still on page one at + # the default page size. + assert Images.list_image_index_page(actor(), scope(), to_string(newer.id)) == {:ok, 1} + assert Images.list_image_index_page(actor(), scope(), to_string(older.id)) == {:ok, 1} + end + + test "the scope's page size drives the page number" do + {older, _newer} = two_images() + + # One image precedes the older one; a page size of one puts it on page two. + scope = scope(pagination: %{page_number: 1, page_size: 1}) + + assert Images.list_image_index_page(actor(), scope, to_string(older.id)) == {:ok, 2} + end + + test "accepts an integer id" do + {_older, newer} = two_images() + + assert Images.list_image_index_page(actor(), scope(), newer.id) == {:ok, 1} + end + + test "an unknown well-formed id is not found for an anonymous viewer" do + assert Images.list_image_index_page(actor(), scope(), "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.list_image_index_page(actor(), scope(), "not-a-number") == + {:error, :not_found} + end + end + + describe "list_related_images/2" do + test "an image sharing a tag lists the related image" do + image = image_fixture(tags: "safe, test related subject") + related = image_fixture(tags: "safe, test related subject") + SearchHelpers.reindex_all!(Image) + + assert {:ok, {loaded, page}} = + Images.list_related_images(actor(), scope(), to_string(image.id)) + + assert loaded.id == image.id + assert related.id in Enum.map(page.entries, & &1.id) + # The subject image never appears among its own related results. + refute image.id in Enum.map(page.entries, & &1.id) + end + + test "an image with no shared tags still succeeds with an empty page" do + # Only the rating tag is present, and ratings are excluded from matching, + # so there is nothing to relate against. + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, {loaded, page}} = + Images.list_related_images(actor(), scope(), to_string(image.id)) + + assert loaded.id == image.id + assert page.entries == [] + end + + test "accepts an integer id" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, {loaded, _page}} = Images.list_related_images(actor(), scope(), image.id) + assert loaded.id == image.id + end + + test "an unknown well-formed id is not found for an anonymous viewer" do + assert Images.list_related_images(actor(), scope(), "2147483647") == {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + assert Images.list_related_images(actor(admin_user_fixture()), scope(), "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.list_related_images(actor(), scope(), "not-a-number") == {:error, :not_found} + end + end + + describe "list_random_images/1" do + test "returns the id of the only matching image" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert Images.list_random_images(actor(), scope()) == {:ok, image.id} + end + + test "returns nil when the index is empty" do + assert Images.list_random_images(actor(), scope()) == {:ok, nil} + end + + test "restricts the pool to the q parameter" do + _other = image_fixture(tags: "safe") + wanted = image_fixture(tags: "safe, test wanted tag") + SearchHelpers.reindex_all!(Image) + + assert Images.list_random_images(actor(), scope(params: %{"q" => "test wanted tag"})) == + {:ok, wanted.id} + end + + test "returns an explicit error for a malformed query string" do + # An unbalanced parenthesis fails to compile, and the picker treats a + # malformed query as an empty pool. + _image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:error, _message} = + Images.list_random_images(actor(), scope(params: %{"q" => "((("})) + end + end +end diff --git a/test/philomena/images/query_test.exs b/test/philomena/images/query_test.exs index 6a6531e9d..8a3799bd2 100644 --- a/test/philomena/images/query_test.exs +++ b/test/philomena/images/query_test.exs @@ -47,4 +47,19 @@ defmodule Philomena.Images.QueryTest do assert {:error, _} = Query.compile("my:watched", user: nil) end end + + describe "compile_with_tag_names/2" do + test "collects unique tags from nested positive and negative clauses" do + assert {:ok, %{query: query, tag_names: names}} = + Query.compile_with_tag_names("safe AND (cute OR -artist:Example) AND safe") + + assert is_map(query) + assert Enum.sort(names) == ["artist:example", "cute", "safe"] + end + + test "returns the normal parser error for malformed input" do + assert {:error, message} = Query.compile_with_tag_names("width.gte:abc") + assert is_binary(message) + end + end end diff --git a/test/philomena/images/search_test.exs b/test/philomena/images/search_test.exs new file mode 100644 index 000000000..9f4c8e0c3 --- /dev/null +++ b/test/philomena/images/search_test.exs @@ -0,0 +1,442 @@ +defmodule Philomena.Images.SearchTest do + @moduledoc """ + Context-level tests for `Philomena.Images.Search` and its `Scope` struct. + + The module builds OpenSearch definitions scoped to a viewer: the default + listing query with its upload delay, the deleted/hidden display switches + driven by the "del"/"hidden" params, the "sf"/"sd" sort mapping, and + prev/next navigation. Query builders return `{definition, tags}` where the + tags are the raw `Tag` records a tag search names. + + Search-backed behavior is asserted against the real OpenSearch index; + `parse_sort/2` and the `Scope` struct are pure and asserted directly. + """ + + use Philomena.DataCase, async: false + + @moduletag :search + + import Philomena.ImagesFixtures + import Philomena.AttributionFixtures + import Philomena.UsersFixtures + + alias Philomena.Images.Search + alias Philomena.Images.Search.Scope + alias Philomena.Images.Image + alias Philomena.Images.Query + alias Philomena.ImageHides.ImageHide + alias Philomena.Tags.Tag + alias Philomena.Repo + alias PhilomenaQuery.Search, as: SearchClient + alias PhilomenaQuery.SearchHelpers + + @pagination %{page_number: 1, page_size: 25} + + setup do + SearchClient.clear_index!(Image) + :ok + end + + # The compiled filter body the web layer's ImageFilterPlug produces for a + # viewer with no active filter: an empty tag_ids exclusion plus a pair of + # match_none complex clauses, so it excludes nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp scope(overrides \\ []) do + Scope.new( + Keyword.get(overrides, :filter, default_filter()), + Keyword.get(overrides, :pagination, @pagination), + Keyword.get(overrides, :params, %{}) + ) + end + + defp result_ids(definition) do + definition + |> Search.execute() + |> Map.fetch!(:entries) + |> Enum.map(& &1.id) + end + + defp hides_image!(image, user) do + Repo.insert!(%ImageHide{image_id: image.id, user_id: user.id}) + end + + defp seconds_ago(seconds) do + DateTime.utc_now() + |> DateTime.add(-seconds, :second) + |> DateTime.truncate(:second) + end + + defp hours_ago(hours), do: seconds_ago(hours * 3600) + + describe "default_query/2 and execute/2" do + test "excludes images created less than three minutes ago from an anonymous viewer" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.default_query(actor(), scope()) + + refute image.id in result_ids(definition) + end + + test "includes older images for an anonymous viewer" do + image = image_fixture(created_at: hours_ago(1)) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.default_query(actor(), scope()) + + assert image.id in result_ids(definition) + end + + test "includes recent images for a user who disabled the upload delay" do + user = confirmed_user_fixture() + + settings = + user.settings + |> Ecto.Changeset.change(delay_home_images: false) + |> Repo.update!() + + user = %{user | settings: settings} + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.default_query(actor(user), scope()) + + assert image.id in result_ids(definition) + end + + test "excludes recent images from a user with the default upload delay" do + user = confirmed_user_fixture() + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.default_query(actor(user), scope()) + + refute image.id in result_ids(definition) + end + end + + describe "deleted/hidden switches" do + test "an anonymous viewer never sees hidden images" do + image = image_fixture(hidden_from_users: true) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.query(actor(), scope(), %{match_all: %{}}) + + refute image.id in result_ids(definition) + end + + # The del switches only take effect for viewers who can hide images; a + # non-privileged viewer always gets the hidden_from_users exclusion no + # matter what del says. + test "an anonymous viewer passing del=1 still does not see hidden images" do + image = image_fixture(hidden_from_users: true) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(), scope(params: %{"del" => "1"}), %{match_all: %{}}) + + refute image.id in result_ids(definition) + end + + # NOTE: a viewer who can :hide images (admin here) still has hidden images + # excluded by default; only del=1/only/deleted reveal them. + test "an admin does not see hidden images by default" do + admin = admin_user_fixture() + image = image_fixture(hidden_from_users: true) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.query(actor(admin), scope(), %{match_all: %{}}) + + refute image.id in result_ids(definition) + end + + test "an admin passing del=1 sees hidden images" do + admin = admin_user_fixture() + hidden = image_fixture(hidden_from_users: true) + visible = image_fixture() + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(admin), scope(params: %{"del" => "1"}), %{match_all: %{}}) + + ids = result_ids(definition) + assert hidden.id in ids + assert visible.id in ids + end + + test "an admin passing del=only sees only hidden images" do + admin = admin_user_fixture() + hidden = image_fixture(hidden_from_users: true) + visible = image_fixture() + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(admin), scope(params: %{"del" => "only"}), %{match_all: %{}}) + + ids = result_ids(definition) + assert hidden.id in ids + refute visible.id in ids + end + + # NOTE: del=deleted places the duplicate_id existence check in must_not, so + # it excludes hidden images that have a duplicate and shows the hidden + # images that do not - the inverse of "images that have a duplicate". + test "an admin passing del=deleted sees hidden images without a duplicate" do + admin = admin_user_fixture() + original = image_fixture() + duplicate = image_fixture(hidden_from_users: true, duplicate_id: original.id) + hidden_non_dupe = image_fixture(hidden_from_users: true) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(admin), scope(params: %{"del" => "deleted"}), %{match_all: %{}}) + + ids = result_ids(definition) + assert hidden_non_dupe.id in ids + refute duplicate.id in ids + end + + # A moderator can hide images, so del=1 reveals them. + test "a moderator passing del=1 sees hidden images" do + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(moderator), scope(params: %{"del" => "1"}), %{match_all: %{}}) + + assert image.id in result_ids(definition) + end + + # A moderator can :hide images, so del=only shows only hidden ones. + test "a moderator passing del=only sees only hidden images" do + moderator = moderator_user_fixture() + hidden = image_fixture(hidden_from_users: true) + visible = image_fixture() + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(moderator), scope(params: %{"del" => "only"}), %{match_all: %{}}) + + ids = result_ids(definition) + assert hidden.id in ids + refute visible.id in ids + end + + test "unapproved images are excluded even for an admin passing del=1" do + admin = admin_user_fixture() + image = image_fixture(approved: false) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(admin), scope(params: %{"del" => "1"}), %{match_all: %{}}) + + refute image.id in result_ids(definition) + end + end + + describe "maybe_custom_hide" do + test "a user's own hidden images are excluded by default" do + user = confirmed_user_fixture() + image = image_fixture() + hides_image!(image, user) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = Search.query(actor(user), scope(), %{match_all: %{}}) + + refute image.id in result_ids(definition) + end + + test "a user's own hidden images are included with hidden=1" do + user = confirmed_user_fixture() + image = image_fixture() + hides_image!(image, user) + SearchHelpers.reindex_all!(Image) + + {definition, _tags} = + Search.query(actor(user), scope(params: %{"hidden" => "1"}), %{match_all: %{}}) + + assert image.id in result_ids(definition) + end + end + + describe "search_string/3" do + test "returns {:ok, {definition, tags}} for a valid query naming no tag" do + assert {:ok, {definition, tags}} = Search.search_string(actor(), scope(), "*") + + assert is_map(definition) + assert tags == [] + end + + test "returns the raw Tag record a single-tag query names" do + _image = image_fixture(tags: "safe") + + assert {:ok, {_definition, tags}} = Search.search_string(actor(), scope(), "safe") + + assert [%Tag{} = tag] = tags + assert tag.name == "safe" + # The raw schema struct is returned, not a rendered presentation map. + assert is_integer(tag.id) + end + + test "returns {:error, msg} for a malformed query" do + assert {:error, msg} = Search.search_string(actor(), scope(), "width.gte:abc") + assert is_binary(msg) + end + end + + describe "parse_sort/2" do + @query %{match_all: %{}} + + test "an allowed field sorts by that field and then id" do + assert %{query: @query, sorts: [%{"score" => "desc"}, %{"id" => "desc"}]} = + Search.parse_sort(%{"sf" => "score"}, @query) + end + + test "the sd parameter selects the direction" do + assert %{sorts: [%{"score" => "asc"}, %{"id" => "asc"}]} = + Search.parse_sort(%{"sf" => "score", "sd" => "asc"}, @query) + end + + test "the id field sorts by id alone" do + assert %{query: @query, sorts: [%{"id" => "desc"}]} = + Search.parse_sort(%{"sf" => "id"}, @query) + end + + test "an unknown field falls back to first_seen_at" do + assert %{sorts: [%{"first_seen_at" => "desc"}, %{"id" => "desc"}]} = + Search.parse_sort(%{"sf" => "bogus"}, @query) + end + + test "a missing field falls back to first_seen_at" do + assert %{sorts: [%{"first_seen_at" => "desc"}, %{"id" => "desc"}]} = + Search.parse_sort(%{}, @query) + end + + test "random:seed wraps the query in a seeded function_score" do + result = Search.parse_sort(%{"sf" => "random:12345"}, @query) + + assert %{ + function_score: %{ + query: @query, + random_score: %{seed: 12_345, field: :id}, + boost_mode: :replace + } + } = result.query + + assert result.sorts == [%{"_score" => "desc"}, %{"id" => "desc"}] + end + + test "random:seed is deterministic for a fixed seed" do + assert Search.parse_sort(%{"sf" => "random:42"}, @query) == + Search.parse_sort(%{"sf" => "random:42"}, @query) + end + + test "gallery_id:n produces a nested gallery-position sort" do + assert %{ + query: @query, + sorts: [ + %{ + "galleries.position" => %{ + order: "desc", + nested: %{path: :galleries, filter: %{term: %{"galleries.id" => 7}}} + } + }, + %{"id" => "desc"} + ] + } = Search.parse_sort(%{"sf" => "gallery_id:7"}, @query) + end + + test "an invalid gallery id yields empty sorts" do + assert %{query: @query, sorts: []} = + Search.parse_sort(%{"sf" => "gallery_id:abc"}, @query) + end + end + + describe "find_consecutive/3" do + setup do + {:ok, compiled} = Query.compile("*", user: nil) + + older = image_fixture(first_seen_at: seconds_ago(2 * 86_400)) + newer = image_fixture(first_seen_at: seconds_ago(86_400)) + SearchHelpers.reindex_all!(Image) + + %{compiled: compiled, older: older, newer: newer} + end + + test "rel=next finds the older image", %{compiled: compiled, older: older, newer: newer} do + result = + Search.find_consecutive(actor(), scope(params: %{"rel" => "next"}), newer, compiled) + + assert {image, hit} = result + assert image.id == older.id + assert is_map(hit) + assert Map.has_key?(hit, "sort") + end + + test "rel=prev finds the newer image", %{compiled: compiled, older: older, newer: newer} do + result = + Search.find_consecutive(actor(), scope(params: %{"rel" => "prev"}), older, compiled) + + assert {image, _hit} = result + assert image.id == newer.id + end + + test "returns nil at the end of the sequence", %{compiled: compiled, older: older} do + assert Search.find_consecutive(actor(), scope(params: %{"rel" => "next"}), older, compiled) == + nil + end + + test "uses a provided cursor for a non-default sort with tied sort values", %{ + compiled: compiled + } do + sort_scope = scope(params: %{"sf" => "score", "sd" => "desc"}) + {definition, _tags} = Search.query(actor(), sort_scope, %{match_all: %{}}) + + %{entries: [{first, first_hit}, {second, second_hit} | _]} = + Search.execute(definition, hits: true) + + assert first_hit["sort"] |> hd() == second_hit["sort"] |> hd() + + cursor = Enum.map(first_hit["sort"], &to_string/1) + navigation_scope = scope(params: %{"sf" => "score", "rel" => "next", "sort" => cursor}) + + assert {adjacent, _hit} = + Search.find_consecutive(actor(), navigation_scope, first, compiled) + + assert adjacent.id == second.id + end + end + + describe "Scope struct" do + test "new stores the filter and pagination" do + scope = Scope.new(%{match_all: %{}}, @pagination) + + assert scope.filter == %{match_all: %{}} + assert scope.pagination == @pagination + end + + test "casts the search_after sort cursor as a string array" do + scope = Scope.new(%{match_all: %{}}, @pagination, %{"sort" => ["123", "456"]}) + + assert scope.sort == ["123", "456"] + end + + test "defaults params and pagination" do + scope = Scope.new(%{match_all: %{}}, @pagination) + + assert scope.q == nil + assert scope.pagination == %{page_number: 1, page_size: 25} + end + end +end diff --git a/test/philomena/images_concurrency_test.exs b/test/philomena/images_concurrency_test.exs new file mode 100644 index 000000000..568079a30 --- /dev/null +++ b/test/philomena/images_concurrency_test.exs @@ -0,0 +1,302 @@ +defmodule Philomena.ImagesConcurrencyTest do + use Philomena.ConcurrentDataCase + + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + alias Philomena.SourceChanges.SourceChange + alias Philomena.TagChanges.TagChange + alias Philomena.Tags.Tag + + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + test "concurrent approvals transition once and increment uploader statistics once" do + uploader = confirmed_user_fixture() + moderator = moderator_user_fixture() + image = image_fixture(user_id: uploader.id, approved: false) + initial_count = Repo.reload!(uploader).images_count + + results = + concurrently([ + fn -> Images.create_image_approve(actor(moderator), image.id) end, + fn -> Images.create_image_approve(actor(moderator), image.id) end + ]) + + assert Enum.count(results, &match?({:ok, %Image{}}, &1)) == 1 + + assert Enum.count( + results, + &match?({:error, %{errors: [approved: {"must be false", []}]}}, &1) + ) == 1 + + assert Repo.get!(Image, image.id).approved + assert Repo.reload!(uploader).images_count == initial_count + 1 + assert Repo.aggregate(ModerationLog, :count) == 1 + end + + test "concurrent source additions merge against the locked image" do + image = image_fixture(tags: "safe") + actors = for _ <- 1..2, do: actor(admin_user_fixture()) + sources = ["https://example.com/concurrent-one", "https://example.com/concurrent-two"] + + results = + concurrently( + Enum.zip(actors, sources) + |> Enum.map(fn {actor, source} -> + fn -> Images.update_image_sources(actor, image.id, source_attrs(source)) end + end) + ) + + assert Enum.all?(results, &match?({:ok, %{}}, &1)) + assert source_urls(image) == Enum.sort(sources) + + assert Repo.aggregate( + from(change in SourceChange, where: change.image_id == ^image.id), + :count + ) == 2 + end + + test "concurrent source addition and removal merge against the locked image" do + base = "https://example.com/concurrent-base" + added = "https://example.com/concurrent-added" + image = image_fixture(tags: "safe", sources: [base]) + actors = for _ <- 1..2, do: actor(admin_user_fixture()) + + results = + concurrently([ + fn -> + Images.update_image_sources( + Enum.at(actors, 0), + image.id, + source_attrs([base], [base, added]) + ) + end, + fn -> + Images.update_image_sources(Enum.at(actors, 1), image.id, source_attrs([base], [])) + end + ]) + + assert Enum.all?(results, &match?({:ok, %{}}, &1)) + assert source_urls(image) == [added] + + assert Repo.aggregate( + from(change in SourceChange, where: change.image_id == ^image.id), + :count + ) == 2 + end + + test "concurrent source updates are limited per actor" do + user = confirmed_user_fixture() + images = for _ <- 1..3, do: image_fixture(tags: "safe") + actor = actor(user) + + results = + concurrently( + for {image, source} <- + Enum.zip(images, [ + "https://example.com/limited-one", + "https://example.com/limited-two", + "https://example.com/limited-three" + ]) do + fn -> Images.update_image_sources(actor, image.id, source_attrs(source)) end + end + ) + + assert Enum.count(results, &match?({:ok, %{}}, &1)) == 2 + assert Enum.count(results, &(&1 == {:error, :rate_limited})) == 1 + end + + test "concurrent tag additions merge against the locked image and update counts" do + image = image_fixture(tags: "safe, initial one, initial two") + actors = for _ <- 1..2, do: actor(admin_user_fixture()) + added_names = [unique_tag_name(), unique_tag_name()] + old_input = "safe, initial one, initial two" + + results = + concurrently( + Enum.zip(actors, added_names) + |> Enum.map(fn {actor, tag_name} -> + fn -> + Images.update_image_tags( + actor, + image.id, + %{ + "old_tag_input" => old_input, + "tag_input" => "#{old_input}, #{tag_name}" + } + ) + end + end) + ) + + assert Enum.all?(results, &match?({:ok, %{}}, &1)) + assert tag_names(image) == Enum.sort(["safe", "initial one", "initial two" | added_names]) + + added_tags = Repo.all(from(tag in Tag, where: tag.name in ^added_names)) + + assert Enum.map(added_tags, & &1.images_count) |> Enum.sort() == [1, 1] + + assert Repo.aggregate( + from(change in TagChange, where: change.image_id == ^image.id), + :count + ) == 2 + end + + test "concurrent tag addition and removal merge against the locked image" do + removed_name = unique_tag_name() + added_name = unique_tag_name() + old_input = "safe, keep, other, #{removed_name}" + image = image_fixture(tags: old_input) + + Repo.get_by!(Tag, name: removed_name) + |> change(images_count: 1) + |> Repo.update!() + + actors = for _ <- 1..2, do: actor(admin_user_fixture()) + + add = fn -> + Images.update_image_tags( + Enum.at(actors, 0), + image.id, + %{"old_tag_input" => old_input, "tag_input" => "#{old_input}, #{added_name}"} + ) + end + + remove = fn -> + Images.update_image_tags( + Enum.at(actors, 1), + image.id, + %{"old_tag_input" => old_input, "tag_input" => "safe, keep, other"} + ) + end + + results = concurrently([add, remove]) + + assert Enum.all?(results, &match?({:ok, %{}}, &1)) + assert tag_names(image) == ["keep", "other", "safe", added_name] + + assert Repo.get_by!(Tag, name: removed_name).images_count == 0 + assert Repo.get_by!(Tag, name: added_name).images_count == 1 + + assert Repo.aggregate( + from(change in TagChange, where: change.image_id == ^image.id), + :count + ) == 2 + end + + test "concurrent tag updates are limited per actor" do + user = confirmed_user_fixture() + + images = for _ <- 1..3, do: image_fixture(tags: "safe, initial one, initial two") + + actor = actor(user) + tag_names = for _ <- 1..3, do: unique_tag_name() + old_input = "safe, initial one, initial two" + + results = + concurrently( + for {image, tag_name} <- Enum.zip(images, tag_names) do + fn -> + Images.update_image_tags( + actor, + image.id, + %{ + "old_tag_input" => old_input, + "tag_input" => "#{old_input}, #{tag_name}" + } + ) + end + end + ) + + assert Enum.count(results, &match?({:ok, %{}}, &1)) == 2 + assert Enum.count(results, &(&1 == {:error, :rate_limited})) == 1 + end + + test "concurrent tag additions resolve an alias and its implications once" do + image = image_fixture(tags: "safe, initial one, initial two") + actors = for _ <- 1..2, do: actor(admin_user_fixture()) + alias_tag = tag_fixture(name: unique_tag_name()) + canonical_tag = tag_fixture(name: unique_tag_name()) + implied_tag = tag_fixture(name: unique_tag_name()) + + canonical_tag = + canonical_tag + |> Repo.preload(:implied_tags) + |> change() + |> put_assoc(:implied_tags, [implied_tag]) + |> Repo.update!() + + alias_tag = + alias_tag + |> change(aliased_tag_id: canonical_tag.id) + |> Repo.update!() + + old_input = "safe, initial one, initial two" + + results = + concurrently( + for actor <- actors do + fn -> + Images.update_image_tags( + actor, + image.id, + %{ + "old_tag_input" => old_input, + "tag_input" => "#{old_input}, #{alias_tag.name}" + } + ) + end + end + ) + + assert Enum.count(results, &match?({:ok, %{}}, &1)) == 2 + + assert tag_names(image) == + Enum.sort([ + "safe", + "initial one", + "initial two", + canonical_tag.name, + implied_tag.name + ]) + + assert Repo.aggregate(from(change in TagChange, where: change.image_id == ^image.id), :count) == + 1 + end + + defp source_attrs(source), do: source_attrs([], [source]) + + defp source_attrs(old_sources, sources) do + %{ + "old_sources" => source_params(old_sources), + "sources" => source_params(sources) + } + end + + defp source_params(sources) do + sources + |> Enum.with_index() + |> Map.new(fn {source, index} -> {to_string(index), %{"source" => source}} end) + end + + defp source_urls(image) do + image + |> Repo.preload(:sources, force: true) + |> Map.fetch!(:sources) + |> Enum.map(& &1.source) + |> Enum.sort() + end + + defp tag_names(image) do + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.name) + |> Enum.sort() + end +end diff --git a/test/philomena/images_test.exs b/test/philomena/images_test.exs index fdb97f1db..8e30bcaf1 100644 --- a/test/philomena/images_test.exs +++ b/test/philomena/images_test.exs @@ -1,14 +1,238 @@ defmodule Philomena.ImagesTest do use Philomena.DataCase, async: true - alias Philomena.Galleries + import Ecto.Query + + alias Ecto.Adapters.SQL.Sandbox + alias Phoenix.Socket.Broadcast + alias Philomena.Multi + alias Philomena.ImageFaves + alias Philomena.ImageFaves.ImageFave + alias Philomena.ImageFeatures.ImageFeature + alias Philomena.ImageHides + alias Philomena.ImageHides.ImageHide alias Philomena.Galleries.Interaction alias Philomena.Images + alias Philomena.ImageVotes + alias Philomena.ImageVotes.ImageVote + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Notifications + alias Philomena.Notifications.ImageCommentNotification + alias Philomena.Notifications.ImageMergeNotification + alias Philomena.Reports.Report + alias Philomena.SourceChanges.SourceChange + alias Philomena.TagChanges.Limits + alias Philomena.TagChanges.TagChange + alias Philomena.Images.Image + alias Philomena.Images.ImagePage + alias Philomena.Images.Search.Scope + alias Philomena.Tags.Tag + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + alias PhilomenaWeb.Endpoint import Philomena.GalleriesFixtures + import Philomena.FiltersFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures import Philomena.AttributionFixtures + import Philomena.CommentsFixtures + import Philomena.RulesFixtures + import Philomena.TagsFixtures + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to verify_write_access. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + @approval_pagination %{page_number: 1, page_size: 25} + + import Philomena.SourceChangesFixtures + + defp image_tag_names(image) do + Image + |> Repo.get(image.id) + |> Repo.preload(:tags) + |> Map.fetch!(:tags) + |> Enum.map(& &1.name) + end + + defp comment_notification?(image, user) do + Repo.exists?( + from n in ImageCommentNotification, + where: n.image_id == ^image.id and n.user_id == ^user.id + ) + end + + defp merge_notification?(image, user) do + Repo.exists?( + from n in ImageMergeNotification, + where: n.target_id == ^image.id and n.user_id == ^user.id + ) + end + + # Arranges a real unread image comment notification for `user`: subscribe the + # user to the image, then have another user comment so a notification lands. + defp arrange_comment_notification(image, user) do + author = confirmed_user_fixture() + {:ok, _} = Images.create_subscription(image, user) + comment = comment_fixture(image, author) + {:ok, _} = Notifications.broadcast_image_comment(author, image, comment) + :ok + end + + # Arranges a real unread image merge notification for `user`: subscribe the + # user to the target image, then merge a source image into it. + defp arrange_merge_notification(image, user) do + source = image_fixture() + {:ok, _} = Images.create_subscription(image, user) + {:ok, _} = Notifications.broadcast_image_merge(image, source) + :ok + end + + defp only_moderation_log!, do: Repo.one!(ModerationLog) + + defp moderation_log_count, do: Repo.aggregate(ModerationLog, :count) + + defp source_change_count(image) do + Repo.aggregate(from(s in SourceChange, where: s.image_id == ^image.id), :count) + end + + defp fave!(image, user) do + {:ok, _} = + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> Multi.transact() + end + + defp vote!(image, user, up) do + {:ok, _} = + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, up) + |> Multi.transact() + end + + defp hide!(image, user) do + {:ok, _} = + Multi.new() + |> ImageHides.put_hide_for_loaded_image(image, user) + |> Multi.transact() + end + + defp has_vote?(image, user) do + Repo.exists?(from v in ImageVote, where: v.image_id == ^image.id and v.user_id == ^user.id) + end + + defp image_hide_count(image, user) do + Repo.aggregate( + from(h in ImageHide, where: h.image_id == ^image.id and h.user_id == ^user.id), + :count + ) + end + + defp fave_count(image, user) do + Repo.aggregate( + from(f in ImageFave, where: f.image_id == ^image.id and f.user_id == ^user.id), + :count + ) + end + + defp vote_row(image, user) do + Repo.get_by(ImageVote, image_id: image.id, user_id: user.id) + end + + defp force_filter(user, attrs) do + filter = + system_filter_fixture() + |> Ecto.Changeset.change(attrs) + |> Repo.update!() + + user = + user + |> Ecto.Changeset.change(forced_filter_id: filter.id) + |> Repo.update!() + + {user, filter} + end + + defp source_change_row_count(image) do + Repo.aggregate(from(s in SourceChange, where: s.image_id == ^image.id), :count) + end + + defp source_urls(image) do + image + |> Repo.preload(:sources, force: true) + |> Map.fetch!(:sources) + |> Enum.map(& &1.source) + end + + # Controller-shaped attrs adding a single source with no prior sources. + defp add_source_attrs(url) do + %{"old_sources" => %{}, "sources" => %{"0" => %{"source" => url}}} + end + + describe "list_images_by_ids/1" do + test "loads matching images with rich-text representation associations" do + image = image_fixture(tags: "safe", sources: ["https://example.com/source"]) + + assert [loaded] = Images.list_images_by_ids([image.id, 2_147_483_647]) + assert loaded.id == image.id + assert Ecto.assoc_loaded?(loaded.sources) + assert Ecto.assoc_loaded?(loaded.tags) + assert Enum.all?(loaded.tags, &Ecto.assoc_loaded?(&1.aliases)) + end + end + + defp tag_names(image) do + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.name) + |> Enum.sort() + end + + defp tag_attrs(old_tag_input, tag_input) do + %{"old_tag_input" => old_tag_input, "tag_input" => tag_input} + end + + # Controller-shaped attrs adding `count` distinct sources. + defp many_source_attrs(count) do + sources = + Map.new(0..(count - 1), fn i -> + {to_string(i), %{"source" => "https://example.com/#{i}"}} + end) + + %{"old_sources" => %{}, "sources" => sources} + end + + defp hidden_image_fixture(reason \\ "Original reason") do + image = image_fixture() + moderator = moderator_user_fixture() + + {:ok, hidden} = + Images.create_image_hide(actor(moderator), image.id, %{"deletion_reason" => reason}) + + Repo.delete_all(ModerationLog) + + hidden + end + + defp feature_row_count(image) do + Repo.aggregate(from(f in ImageFeature, where: f.image_id == ^image.id), :count) + end + + defp locked_tag_names(image) do + image + |> Repo.reload!() + |> Repo.preload(:locked_tags) + |> Map.fetch!(:locked_tags) + |> Enum.map(& &1.name) + |> Enum.sort() + end describe "create_image/2 duplicate detection" do # image_changeset's prepare_changes rejects a new upload whose @@ -21,40 +245,44 @@ defmodule Philomena.ImagesTest do existing = image_fixture(image_orig_sha512_hash: png_upload_sha512()) user = user_fixture() - attrs = %{"image" => png_upload(), "tag_input" => "safe, solo, mare"} + attrs = %{"tag_input" => "safe, solo, mare"} + upload = media_png_upload() - assert {:error, :image, changeset, _changes} = - Images.create_image(attribution(user), attrs) + assert {:error, %Ecto.Changeset{} = changeset} = + Images.create_image(actor(user), attrs, upload) assert "has already been uploaded: it's image #{existing.id}" in errors_on(changeset).image end end - describe "hide_image/3 gallery cleanup" do + describe "create_image_hide/3 gallery cleanup" do # Hiding (deleting) an image removes it from every gallery containing it. # The gallery search document serializes image_count and image_ids, so the # transaction must surface the affected gallery ids for reindexing - the # galleries step returns them, and process_after_hide queues the reindex. test "removes the image from galleries and returns the affected gallery ids" do - moderator = user_fixture() + moderator = moderator_user_fixture() image = image_fixture() gallery = gallery_fixture(user_fixture()) - {:ok, _} = Galleries.add_image_to_gallery(gallery, image) + gallery_image_fixture(gallery, image) - assert {:ok, %{galleries: {1, [gallery_id]}}} = - Images.hide_image(image, moderator, %{"deletion_reason" => "Rule violation"}) + assert {:ok, _hidden} = + Images.create_image_hide(actor(moderator), image.id, %{ + "deletion_reason" => "Rule violation" + }) - assert gallery_id == gallery.id assert Repo.reload!(gallery).image_count == 0 refute Repo.get_by(Interaction, gallery_id: gallery.id) end test "returns no gallery ids when the image is in no gallery" do - moderator = user_fixture() + moderator = moderator_user_fixture() image = image_fixture() - assert {:ok, %{galleries: {0, []}}} = - Images.hide_image(image, moderator, %{"deletion_reason" => "Rule violation"}) + assert {:ok, _hidden} = + Images.create_image_hide(actor(moderator), image.id, %{ + "deletion_reason" => "Rule violation" + }) end end @@ -65,10 +293,13 @@ defmodule Philomena.ImagesTest do target = image_fixture() filler = image_fixture() gallery = gallery_fixture(user_fixture()) - {:ok, _} = Galleries.add_image_to_gallery(gallery, filler) - {:ok, _} = Galleries.add_image_to_gallery(gallery, source) + gallery_image_fixture(gallery, filler) + gallery_image_fixture(gallery, source) - assert {:ok, _result} = Images.merge_image(nil, source, target, moderator) + assert {:ok, _result} = + Multi.new() + |> Images.put_merge_image(source, target, moderator) + |> Multi.transact() # The source image's interaction was repointed in place. assert %{position: 1} = @@ -83,10 +314,13 @@ defmodule Philomena.ImagesTest do source = image_fixture() target = image_fixture() gallery = gallery_fixture(user_fixture()) - {:ok, _} = Galleries.add_image_to_gallery(gallery, source) - {:ok, _} = Galleries.add_image_to_gallery(gallery, target) + gallery_image_fixture(gallery, source) + gallery_image_fixture(gallery, target) - assert {:ok, _result} = Images.merge_image(nil, source, target, moderator) + assert {:ok, _result} = + Multi.new() + |> Images.put_merge_image(source, target, moderator) + |> Multi.transact() # The target keeps its own interaction; the source's is simply deleted. assert [%{image_id: target_id, position: 1}] = @@ -97,7 +331,40 @@ defmodule Philomena.ImagesTest do end end - describe "update_file/2 duplicate detection" do + describe "merge_image/4 target updates" do + test "combines the source image's sources into the target image" do + moderator = user_fixture() + source = image_fixture(sources: ["https://example.com/source"]) + target = image_fixture(sources: ["https://example.com/target"]) + + assert {:ok, _result} = + Multi.new() + |> Images.put_merge_image(source, target, moderator) + |> Multi.transact() + + assert Enum.sort(source_urls(target)) == [ + "https://example.com/source", + "https://example.com/target" + ] + end + + test "migrates the earliest first_seen_at to the target image" do + moderator = user_fixture() + source_first_seen_at = ~U[2020-01-01 00:00:00Z] + target_first_seen_at = ~U[2021-01-01 00:00:00Z] + source = image_fixture(first_seen_at: source_first_seen_at) + target = image_fixture(first_seen_at: target_first_seen_at) + + assert {:ok, _result} = + Multi.new() + |> Images.put_merge_image(source, target, moderator) + |> Multi.transact() + + assert Repo.reload!(target).first_seen_at == source_first_seen_at + end + end + + describe "update_image_file/3 duplicate detection" do # Root cause of the fixed bug: replacing an image's file with a # byte-identical copy. The image's own row still holds that file's # orig_sha512_hash, so the dedup lookup matches the image against itself; @@ -108,7 +375,10 @@ defmodule Philomena.ImagesTest do sha = png_upload_sha512() image = image_fixture(image_sha512_hash: sha, image_orig_sha512_hash: sha) - assert {:ok, updated} = Images.update_file(image, %{"image" => png_upload()}) + moderator = moderator_user_fixture() + + assert {:ok, updated} = + Images.update_image_file(actor(moderator), image.id, media_png_upload()) # The dedup fingerprint is still set - it is overwritten with the (same) # new file's hash, never nulled. @@ -117,17 +387,4834 @@ defmodule Philomena.ImagesTest do # A file matching a *different* image is still rejected - the self-exclusion # only spares the image being updated, not genuine cross-image duplicates. - # update_file returns the changeset error tuple unchanged. + # update_image_file returns the changeset error tuple unchanged. test "rejects replacing a file with one already uploaded as another image" do dup_sha = png_upload_sha512() other = image_fixture(image_sha512_hash: dup_sha, image_orig_sha512_hash: dup_sha) image = image_fixture() - assert {:error, changeset} = Images.update_file(image, %{"image" => png_upload()}) + moderator = moderator_user_fixture() + + assert {:error, changeset} = + Images.update_image_file(actor(moderator), image.id, media_png_upload()) assert "has already been uploaded: it's image #{other.id}" in errors_on(changeset).image # The target image keeps its own fingerprint. assert Repo.reload!(image).image_orig_sha512_hash == image.image_orig_sha512_hash end end + + describe "create_image_read/2" do + test "clears the actor's image comment notification and returns the image" do + user = confirmed_user_fixture() + image = image_fixture() + arrange_comment_notification(image, user) + assert comment_notification?(image, user) + + assert {:ok, marked} = Images.create_image_read(actor(user), to_string(image.id)) + assert marked.id == image.id + refute comment_notification?(image, user) + end + + test "clears the actor's image merge notification and returns the image" do + user = confirmed_user_fixture() + image = image_fixture() + arrange_merge_notification(image, user) + assert merge_notification?(image, user) + + assert {:ok, marked} = Images.create_image_read(actor(user), to_string(image.id)) + assert marked.id == image.id + refute merge_notification?(image, user) + end + + test "clears both comment and merge notifications at once" do + user = confirmed_user_fixture() + image = image_fixture() + arrange_comment_notification(image, user) + arrange_merge_notification(image, user) + assert comment_notification?(image, user) + assert merge_notification?(image, user) + + assert {:ok, marked} = Images.create_image_read(actor(user), to_string(image.id)) + assert marked.id == image.id + refute comment_notification?(image, user) + refute merge_notification?(image, user) + end + + test "clears only the actor's notifications, leaving another user's intact" do + # clear_image_notification filters on the actor's user_id, so a second + # subscriber's notification for the same image is untouched. + user = confirmed_user_fixture() + other = confirmed_user_fixture() + image = image_fixture() + arrange_comment_notification(image, user) + arrange_comment_notification(image, other) + assert comment_notification?(image, user) + assert comment_notification?(image, other) + + assert {:ok, _} = Images.create_image_read(actor(user), to_string(image.id)) + refute comment_notification?(image, user) + assert comment_notification?(image, other) + end + + test "succeeds with no notifications to clear" do + user = confirmed_user_fixture() + image = image_fixture() + refute comment_notification?(image, user) + refute merge_notification?(image, user) + + assert {:ok, marked} = Images.create_image_read(actor(user), to_string(image.id)) + assert marked.id == image.id + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, marked} = Images.create_image_read(actor(user), image.id) + assert marked.id == image.id + end + + test "an unknown well-formed id is not found" do + user = confirmed_user_fixture() + + assert Images.create_image_read(actor(user), "2147483647") == {:error, :not_found} + end + + test "a non-castable id is not found" do + user = confirmed_user_fixture() + + assert Images.create_image_read(actor(user), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, so it is a plain not found rather than a crash. + user = confirmed_user_fixture() + + assert Images.create_image_read(actor(user), "99999999999999999999") == {:error, :not_found} + end + end + + describe "delete_image_hash/2" do + test "a moderator clears the hash and gets the updated image" do + moderator = moderator_user_fixture() + image = image_fixture() + assert image.image_orig_sha512_hash != nil + + assert {:ok, cleared} = Images.delete_image_hash(actor(moderator), to_string(image.id)) + assert cleared.id == image.id + assert cleared.image_orig_sha512_hash == nil + assert Repo.reload!(image).image_orig_sha512_hash == nil + end + + test "an admin clears the hash" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, cleared} = Images.delete_image_hash(actor(admin), to_string(image.id)) + assert cleared.id == image.id + assert Repo.reload!(image).image_orig_sha512_hash == nil + end + + test "a regular user cannot clear the hash and it stays set" do + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.delete_image_hash(actor(user), to_string(image.id)) == {:error, :unauthorized} + assert Repo.reload!(image).image_orig_sha512_hash == image.image_orig_sha512_hash + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot clear the hash and it stays set" do + # A nil actor fails the :hide authorization on the loaded image, so this is + # a clean unauthorized rather than a crash. + image = image_fixture() + + assert Images.delete_image_hash(actor(), to_string(image.id)) == {:error, :unauthorized} + assert Repo.reload!(image).image_orig_sha512_hash == image.image_orig_sha512_hash + assert moderation_log_count() == 0 + end + + test "a successful clear writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = Images.delete_image_hash(actor(moderator), to_string(image.id)) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Hash:delete" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Cleared hash of image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, cleared} = Images.delete_image_hash(actor(moderator), image.id) + assert cleared.id == image.id + end + + test "a moderator with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.delete_image_hash(actor(moderator), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.delete_image_hash(actor(admin), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.delete_image_hash(actor(moderator), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, ahead of any authorization. + moderator = moderator_user_fixture() + + assert Images.delete_image_hash(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "create_image_repair/2" do + test "a moderator flags the image for reprocessing and gets the image" do + # The engine writes with update_all, so the returned struct still carries + # the pre-repair flags; the cleared flags show on reload. + moderator = moderator_user_fixture() + image = image_fixture(processed: true, thumbnails_generated: true) + + assert {:ok, repaired} = Images.create_image_repair(actor(moderator), to_string(image.id)) + assert repaired.id == image.id + + reloaded = Repo.reload!(image) + refute reloaded.processed + refute reloaded.thumbnails_generated + end + + test "an admin flags the image for reprocessing" do + admin = admin_user_fixture() + image = image_fixture(processed: true, thumbnails_generated: true) + + assert {:ok, repaired} = Images.create_image_repair(actor(admin), to_string(image.id)) + assert repaired.id == image.id + + reloaded = Repo.reload!(image) + refute reloaded.processed + refute reloaded.thumbnails_generated + end + + test "a regular user cannot repair and the flags stay set" do + user = confirmed_user_fixture() + image = image_fixture(processed: true, thumbnails_generated: true) + + assert Images.create_image_repair(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + reloaded = Repo.reload!(image) + assert reloaded.processed + assert reloaded.thumbnails_generated + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot repair and the flags stay set" do + # A nil actor fails the :hide authorization on the loaded image, so this is + # a clean unauthorized rather than a crash. + image = image_fixture(processed: true, thumbnails_generated: true) + + assert Images.create_image_repair(actor(), to_string(image.id)) == {:error, :unauthorized} + + reloaded = Repo.reload!(image) + assert reloaded.processed + assert reloaded.thumbnails_generated + assert moderation_log_count() == 0 + end + + test "a successful repair writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = Images.create_image_repair(actor(moderator), to_string(image.id)) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Repair:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Repaired image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, repaired} = Images.create_image_repair(actor(moderator), image.id) + assert repaired.id == image.id + end + + test "a moderator with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.create_image_repair(actor(moderator), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.create_image_repair(actor(admin), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_repair(actor(moderator), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, ahead of any authorization. + moderator = moderator_user_fixture() + + assert Images.create_image_repair(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "delete_image_source_history/2" do + test "a moderator clears the source history and source_url and gets the image" do + moderator = moderator_user_fixture() + image = image_fixture(source_url: "https://example.com/artwork") + source_change_fixture(image) + source_change_fixture(image) + assert source_change_count(image) == 2 + + assert {:ok, cleared} = + Images.delete_image_source_history(actor(moderator), to_string(image.id)) + + assert cleared.id == image.id + + reloaded = Repo.reload!(image) + assert reloaded.source_url == nil + assert source_change_count(image) == 0 + end + + test "an admin clears the source history and source_url" do + admin = admin_user_fixture() + image = image_fixture(source_url: "https://example.com/artwork") + source_change_fixture(image) + + assert {:ok, cleared} = + Images.delete_image_source_history(actor(admin), to_string(image.id)) + + assert cleared.id == image.id + + reloaded = Repo.reload!(image) + assert reloaded.source_url == nil + assert source_change_count(image) == 0 + end + + test "a regular user cannot clear the history and it stays intact" do + user = confirmed_user_fixture() + image = image_fixture(source_url: "https://example.com/artwork") + source_change_fixture(image) + + assert Images.delete_image_source_history(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + reloaded = Repo.reload!(image) + assert reloaded.source_url == "https://example.com/artwork" + assert source_change_count(image) == 1 + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot clear the history and it stays intact" do + # A nil actor fails the :hide authorization on the loaded image, so this is + # a clean unauthorized rather than a crash. + image = image_fixture(source_url: "https://example.com/artwork") + source_change_fixture(image) + + assert Images.delete_image_source_history(actor(), to_string(image.id)) == + {:error, :unauthorized} + + reloaded = Repo.reload!(image) + assert reloaded.source_url == "https://example.com/artwork" + assert source_change_count(image) == 1 + assert moderation_log_count() == 0 + end + + test "a successful clear writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = Images.delete_image_source_history(actor(moderator), to_string(image.id)) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.SourceHistory:delete" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Deleted source history for image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, cleared} = Images.delete_image_source_history(actor(moderator), image.id) + assert cleared.id == image.id + end + + test "a moderator with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.delete_image_source_history(actor(moderator), "2147483647") == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.delete_image_source_history(actor(admin), "2147483647") == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.delete_image_source_history(actor(moderator), "not-a-number") == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, ahead of any authorization. + moderator = moderator_user_fixture() + + assert Images.delete_image_source_history(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "list_image_faves/2" do + test "an anonymous actor gets the faves without vote data on a visible image" do + image = image_fixture() + + assert {:ok, {loaded, has_votes}} = Images.list_image_faves(actor(), to_string(image.id)) + assert loaded.id == image.id + refute has_votes + + # Faves are always preloaded; the tamper-only vote associations are not. + assert Ecto.assoc_loaded?(loaded.faves) + refute Ecto.assoc_loaded?(loaded.upvotes) + refute Ecto.assoc_loaded?(loaded.downvotes) + refute Ecto.assoc_loaded?(loaded.hides) + end + + test "a regular user gets the faves without vote data on a visible image" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, {loaded, has_votes}} = + Images.list_image_faves(actor(user), to_string(image.id)) + + assert loaded.id == image.id + refute has_votes + + assert Ecto.assoc_loaded?(loaded.faves) + refute Ecto.assoc_loaded?(loaded.upvotes) + refute Ecto.assoc_loaded?(loaded.downvotes) + refute Ecto.assoc_loaded?(loaded.hides) + end + + test "faves are preloaded with their user for any actor" do + faver = confirmed_user_fixture() + image = image_fixture() + fave!(image, faver) + + assert {:ok, {loaded, _has_votes}} = Images.list_image_faves(actor(), to_string(image.id)) + + [fave] = loaded.faves + assert fave.user.id == faver.id + end + + test "a moderator gets has_votes true with the vote associations preloaded" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, {loaded, has_votes}} = + Images.list_image_faves(actor(moderator), to_string(image.id)) + + assert loaded.id == image.id + assert has_votes + + assert Ecto.assoc_loaded?(loaded.faves) + assert Ecto.assoc_loaded?(loaded.upvotes) + assert Ecto.assoc_loaded?(loaded.downvotes) + assert Ecto.assoc_loaded?(loaded.hides) + end + + test "an admin gets has_votes true" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, {loaded, has_votes}} = + Images.list_image_faves(actor(admin), to_string(image.id)) + + assert loaded.id == image.id + assert has_votes + end + + test "a moderator's vote associations carry their users" do + moderator = moderator_user_fixture() + upvoter = confirmed_user_fixture() + downvoter = confirmed_user_fixture() + hider = confirmed_user_fixture() + image = image_fixture() + + vote!(image, upvoter, true) + vote!(image, downvoter, false) + hide!(image, hider) + + assert {:ok, {loaded, true}} = + Images.list_image_faves(actor(moderator), to_string(image.id)) + + assert [%{user: %{id: up_id}}] = loaded.upvotes + assert up_id == upvoter.id + assert [%{user: %{id: down_id}}] = loaded.downvotes + assert down_id == downvoter.id + assert [%{user: %{id: hide_id}}] = loaded.hides + assert hide_id == hider.id + end + + test "a hidden image is unauthorized for a regular user" do + user = confirmed_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert Images.list_image_faves(actor(user), to_string(image.id)) == {:error, :unauthorized} + end + + test "a hidden image is unauthorized for an anonymous actor" do + image = image_fixture(hidden_from_users: true) + + assert Images.list_image_faves(actor(), to_string(image.id)) == {:error, :unauthorized} + end + + test "a hidden image is listable by a moderator with has_votes true" do + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert {:ok, {loaded, true}} = + Images.list_image_faves(actor(moderator), to_string(image.id)) + + assert loaded.id == image.id + end + + test "accepts an integer id" do + image = image_fixture() + + assert {:ok, {loaded, false}} = Images.list_image_faves(actor(), image.id) + assert loaded.id == image.id + end + + test "an unknown well-formed id is not found for an anonymous actor" do + # Missing image locators resolve to not-found before authorization. + assert Images.list_image_faves(actor(), "2147483647") == {:error, :not_found} + end + + test "an unknown well-formed id is not found for a regular user" do + assert Images.list_image_faves(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for a moderator" do + assert Images.list_image_faves(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + assert Images.list_image_faves(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.list_image_faves(actor(), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, ahead of any authorization. + assert Images.list_image_faves(actor(), "99999999999999999999") == {:error, :not_found} + end + end + + describe "load_hidable_image/3" do + test "a moderator loads a known image" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, loaded} = Images.load_hidable_image(actor(moderator), to_string(image.id)) + assert loaded.id == image.id + end + + test "an admin loads a known image" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, loaded} = Images.load_hidable_image(actor(admin), to_string(image.id)) + assert loaded.id == image.id + end + + test "a regular user cannot load the image" do + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.load_hidable_image(actor(user), to_string(image.id)) == + {:error, :unauthorized} + end + + test "an anonymous actor cannot load the image" do + image = image_fixture() + + assert Images.load_hidable_image(actor(), to_string(image.id)) == + {:error, :unauthorized} + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, loaded} = Images.load_hidable_image(actor(moderator), image.id) + assert loaded.id == image.id + end + + test "a moderator with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.load_hidable_image(actor(moderator), "2147483647") == + {:error, :not_found} + end + + test "an admin with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.load_hidable_image(actor(admin), "2147483647") == {:error, :not_found} + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.load_hidable_image(actor(moderator), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.load_hidable_image(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "update_image_scratchpad/3" do + test "a moderator stores the scratchpad and gets the image" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, updated} = + Images.update_image_scratchpad(actor(moderator), to_string(image.id), %{ + "scratchpad" => "watch closely" + }) + + assert updated.id == image.id + assert updated.scratchpad == "watch closely" + assert Repo.reload!(image).scratchpad == "watch closely" + end + + test "an admin stores the scratchpad" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, _updated} = + Images.update_image_scratchpad(actor(admin), to_string(image.id), %{ + "scratchpad" => "noted" + }) + + assert Repo.reload!(image).scratchpad == "noted" + end + + test "a blank scratchpad clears the field to nil" do + moderator = moderator_user_fixture() + image = image_fixture(scratchpad: "existing note") + + assert {:ok, updated} = + Images.update_image_scratchpad(actor(moderator), to_string(image.id), %{ + "scratchpad" => "" + }) + + assert updated.scratchpad == nil + assert Repo.reload!(image).scratchpad == nil + end + + test "a successful update writes an exact moderation log with the new value" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.update_image_scratchpad(actor(moderator), to_string(image.id), %{ + "scratchpad" => "watch closely" + }) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Scratchpad:update" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Updated mod notes on image #{image.id} (watch closely)" + end + + test "clearing the scratchpad logs an empty value in the parentheses" do + moderator = moderator_user_fixture() + image = image_fixture(scratchpad: "existing note") + + assert {:ok, _} = + Images.update_image_scratchpad(actor(moderator), to_string(image.id), %{ + "scratchpad" => "" + }) + + log = only_moderation_log!() + assert log.body == "Updated mod notes on image #{image.id} ()" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, updated} = + Images.update_image_scratchpad(actor(moderator), image.id, %{ + "scratchpad" => "noted" + }) + + assert updated.scratchpad == "noted" + end + + test "a regular user cannot update and the scratchpad and log stay untouched" do + user = confirmed_user_fixture() + image = image_fixture(scratchpad: "existing note") + + assert Images.update_image_scratchpad(actor(user), to_string(image.id), %{ + "scratchpad" => "new" + }) == + {:error, :unauthorized} + + assert Repo.reload!(image).scratchpad == "existing note" + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot update and the scratchpad and log stay untouched" do + image = image_fixture(scratchpad: "existing note") + + assert Images.update_image_scratchpad(actor(), to_string(image.id), %{"scratchpad" => "new"}) == + {:error, :unauthorized} + + assert Repo.reload!(image).scratchpad == "existing note" + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + moderator = moderator_user_fixture() + + assert Images.update_image_scratchpad(actor(moderator), "2147483647", %{ + "scratchpad" => "new" + }) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + admin = admin_user_fixture() + + assert Images.update_image_scratchpad(actor(admin), "2147483647", %{"scratchpad" => "new"}) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_scratchpad(actor(moderator), "not-a-number", %{ + "scratchpad" => "new" + }) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_scratchpad(actor(moderator), "99999999999999999999", %{ + "scratchpad" => "new" + }) == + {:error, :not_found} + end + end + + describe "create_image_subscription/2" do + test "a regular user subscribes to a visible image and the row is created" do + # The :show authorization admits a regular user on a visible image, so + # subscribing is not staff-gated. + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, subscribed} = + Images.create_image_subscription(actor(user), to_string(image.id)) + + assert subscribed.id == image.id + assert Images.subscribed?(image, user) + end + + test "a moderator subscribes to a visible image" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = Images.create_image_subscription(actor(moderator), to_string(image.id)) + assert Images.subscribed?(image, moderator) + end + + test "subscribing twice is idempotent and stays subscribed" do + # create_subscription inserts with on_conflict: :nothing, so a repeat is a + # successful no-op rather than a changeset error. + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, _} = Images.create_image_subscription(actor(user), to_string(image.id)) + assert {:ok, _} = Images.create_image_subscription(actor(user), to_string(image.id)) + assert Images.subscribed?(image, user) + end + + test "a banned actor can subscribe" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.create_image_subscription(actor(user, ban: @ban), to_string(image.id)) + + assert Images.subscribed?(image, user) + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, subscribed} = Images.create_image_subscription(actor(user), image.id) + assert subscribed.id == image.id + end + + test "an unknown well-formed id is not found for an anonymous actor" do + # Missing image locators resolve to not-found before authorization. + assert Images.create_image_subscription(actor(), "2147483647") == {:error, :not_found} + end + + test "an unknown well-formed id is not found for a regular user" do + assert Images.create_image_subscription(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for a moderator" do + assert Images.create_image_subscription(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + assert Images.create_image_subscription(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.create_image_subscription(actor(confirmed_user_fixture()), "not-a-number") == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, ahead of any authorization. + assert Images.create_image_subscription( + actor(confirmed_user_fixture()), + "99999999999999999999" + ) == + {:error, :not_found} + end + end + + describe "delete_image_subscription/2" do + test "a regular user unsubscribes from a visible image and the row is removed" do + user = confirmed_user_fixture() + image = image_fixture() + {:ok, _} = Images.create_subscription(image, user) + assert Images.subscribed?(image, user) + + assert {:ok, unsubscribed} = + Images.delete_image_subscription(actor(user), to_string(image.id)) + + assert unsubscribed.id == image.id + refute Images.subscribed?(image, user) + end + + test "unsubscribing with no existing subscription still succeeds" do + # delete_subscription runs an unconditional delete and hard-matches {:ok, _}, + # so the absence of a row is not an error. + user = confirmed_user_fixture() + image = image_fixture() + refute Images.subscribed?(image, user) + + assert {:ok, unsubscribed} = + Images.delete_image_subscription(actor(user), to_string(image.id)) + + assert unsubscribed.id == image.id + refute Images.subscribed?(image, user) + end + + test "a banned actor can unsubscribe" do + user = confirmed_user_fixture() + image = image_fixture() + {:ok, _} = Images.create_subscription(image, user) + + assert {:ok, _} = + Images.delete_image_subscription(actor(user, ban: @ban), to_string(image.id)) + + refute Images.subscribed?(image, user) + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + {:ok, _} = Images.create_subscription(image, user) + + assert {:ok, unsubscribed} = Images.delete_image_subscription(actor(user), image.id) + assert unsubscribed.id == image.id + refute Images.subscribed?(image, user) + end + + test "an unknown well-formed id is not found for an anonymous actor" do + assert Images.delete_image_subscription(actor(), "2147483647") == {:error, :not_found} + end + + test "an unknown well-formed id is not found for a regular user" do + assert Images.delete_image_subscription(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for a moderator" do + assert Images.delete_image_subscription(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + assert Images.delete_image_subscription(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.delete_image_subscription(actor(confirmed_user_fixture()), "not-a-number") == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + assert Images.delete_image_subscription( + actor(confirmed_user_fixture()), + "99999999999999999999" + ) == + {:error, :not_found} + end + end + + describe "create_image_approve/2" do + test "a moderator approves an unapproved image and gets the image" do + moderator = moderator_user_fixture() + image = image_fixture(approved: false) + + assert {:ok, approved} = Images.create_image_approve(actor(moderator), to_string(image.id)) + assert approved.id == image.id + assert approved.approved + assert Repo.reload!(image).approved + end + + test "an admin approves an unapproved image" do + admin = admin_user_fixture() + image = image_fixture(approved: false) + + assert {:ok, _} = Images.create_image_approve(actor(admin), to_string(image.id)) + assert Repo.reload!(image).approved + end + + test "approving increments the uploader's image count" do + moderator = moderator_user_fixture() + uploader = confirmed_user_fixture() + image = image_fixture(approved: false, user_id: uploader.id) + assert Repo.reload!(uploader).images_count == 0 + + assert {:ok, _} = Images.create_image_approve(actor(moderator), to_string(image.id)) + assert Repo.reload!(uploader).images_count == 1 + end + + test "a successful approval writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(approved: false) + + assert {:ok, _} = Images.create_image_approve(actor(moderator), to_string(image.id)) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Approve:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Approved image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture(approved: false) + + assert {:ok, approved} = Images.create_image_approve(actor(moderator), image.id) + assert approved.id == image.id + end + + test "suggests verification when approval reaches the uploader's fifth image" do + moderator = moderator_user_fixture() + uploader = confirmed_user_fixture() + rule_fixture(name: "Verification") + + uploader + |> Ecto.Changeset.change(images_count: 4) + |> Repo.update!() + + image = image_fixture(approved: false, user_id: uploader.id) + + assert {:ok, approved} = Images.create_image_approve(actor(moderator), image.id) + assert approved.approved + assert Repo.reload!(uploader).images_count == 5 + + assert %Report{reported_user_id: uploader_id, reason: reason, system: true} = + Repo.one!(from report in Report, where: report.reported_user_id == ^uploader.id) + + assert uploader_id == uploader.id + + assert reason == + "User has uploaded enough approved images to be considered for verification." + end + + test "an already-approved image returns a changeset error with no log or state change" do + moderator = moderator_user_fixture() + image = image_fixture(approved: true) + + assert {:error, %{errors: [approved: {"must be false", []}]}} = + Images.create_image_approve(actor(moderator), to_string(image.id)) + + assert Repo.reload!(image).approved + assert moderation_log_count() == 0 + end + + test "a regular user on an already-approved image is unauthorized" do + # Authorization runs before the approved-state check, so a regular user + # fails :approve and never reaches the approved-state validation. + user = confirmed_user_fixture() + image = image_fixture(approved: true) + + assert Images.create_image_approve(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a regular user cannot approve an unapproved image and it stays unapproved" do + user = confirmed_user_fixture() + image = image_fixture(approved: false) + + assert Images.create_image_approve(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + refute Repo.reload!(image).approved + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot approve an unapproved image" do + image = image_fixture(approved: false) + + assert Images.create_image_approve(actor(), to_string(image.id)) == {:error, :unauthorized} + refute Repo.reload!(image).approved + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.create_image_approve(actor(moderator), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.create_image_approve(actor(admin), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_approve(actor(moderator), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_approve(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "update_image_comment_lock/3" do + test "a moderator locks comments, clearing commenting_allowed" do + moderator = moderator_user_fixture() + image = image_fixture(commenting_allowed: true) + + assert {:ok, locked} = + Images.update_image_comment_lock(actor(moderator), to_string(image.id), true) + + assert locked.id == image.id + refute locked.commenting_allowed + refute Repo.reload!(image).commenting_allowed + end + + test "an admin locks comments" do + admin = admin_user_fixture() + image = image_fixture(commenting_allowed: true) + + assert {:ok, _} = Images.update_image_comment_lock(actor(admin), to_string(image.id), true) + refute Repo.reload!(image).commenting_allowed + end + + test "a moderator unlocks comments, setting commenting_allowed" do + moderator = moderator_user_fixture() + image = image_fixture(commenting_allowed: false) + + assert {:ok, unlocked} = + Images.update_image_comment_lock(actor(moderator), to_string(image.id), false) + + assert unlocked.id == image.id + assert unlocked.commenting_allowed + assert Repo.reload!(image).commenting_allowed + end + + test "locking writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(commenting_allowed: true) + + assert {:ok, _} = + Images.update_image_comment_lock(actor(moderator), to_string(image.id), true) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.CommentLock:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Locked comments on image #{image.id}" + end + + test "unlocking writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(commenting_allowed: false) + + assert {:ok, _} = + Images.update_image_comment_lock(actor(moderator), to_string(image.id), false) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.CommentLock:delete" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Unlocked comments on image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture(commenting_allowed: true) + + assert {:ok, locked} = Images.update_image_comment_lock(actor(moderator), image.id, true) + assert locked.id == image.id + end + + test "a regular user cannot lock comments and the flag stays set" do + user = confirmed_user_fixture() + image = image_fixture(commenting_allowed: true) + + assert Images.update_image_comment_lock(actor(user), to_string(image.id), true) == + {:error, :unauthorized} + + assert Repo.reload!(image).commenting_allowed + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot lock comments and the flag stays set" do + image = image_fixture(commenting_allowed: true) + + assert Images.update_image_comment_lock(actor(), to_string(image.id), true) == + {:error, :unauthorized} + + assert Repo.reload!(image).commenting_allowed + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.update_image_comment_lock(actor(moderator), "2147483647", true) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.update_image_comment_lock(actor(admin), "2147483647", true) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_comment_lock(actor(moderator), "not-a-number", true) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_comment_lock(actor(moderator), "99999999999999999999", true) == + {:error, :not_found} + end + end + + describe "update_image_description_lock/3" do + test "a moderator locks description editing, clearing description_editing_allowed" do + moderator = moderator_user_fixture() + image = image_fixture(description_editing_allowed: true) + + assert {:ok, locked} = + Images.update_image_description_lock(actor(moderator), to_string(image.id), true) + + assert locked.id == image.id + refute locked.description_editing_allowed + refute Repo.reload!(image).description_editing_allowed + end + + test "an admin locks description editing" do + admin = admin_user_fixture() + image = image_fixture(description_editing_allowed: true) + + assert {:ok, _} = + Images.update_image_description_lock(actor(admin), to_string(image.id), true) + + refute Repo.reload!(image).description_editing_allowed + end + + test "a moderator unlocks description editing, setting description_editing_allowed" do + moderator = moderator_user_fixture() + image = image_fixture(description_editing_allowed: false) + + assert {:ok, unlocked} = + Images.update_image_description_lock(actor(moderator), to_string(image.id), false) + + assert unlocked.id == image.id + assert unlocked.description_editing_allowed + assert Repo.reload!(image).description_editing_allowed + end + + test "locking writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(description_editing_allowed: true) + + assert {:ok, _} = + Images.update_image_description_lock(actor(moderator), to_string(image.id), true) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.DescriptionLock:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Locked description editing on image #{image.id}" + end + + test "unlocking writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(description_editing_allowed: false) + + assert {:ok, _} = + Images.update_image_description_lock(actor(moderator), to_string(image.id), false) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.DescriptionLock:delete" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Unlocked description editing on image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture(description_editing_allowed: true) + + assert {:ok, locked} = + Images.update_image_description_lock(actor(moderator), image.id, true) + + assert locked.id == image.id + end + + test "a regular user cannot lock description editing and the flag stays set" do + user = confirmed_user_fixture() + image = image_fixture(description_editing_allowed: true) + + assert Images.update_image_description_lock(actor(user), to_string(image.id), true) == + {:error, :unauthorized} + + assert Repo.reload!(image).description_editing_allowed + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot lock description editing and the flag stays set" do + image = image_fixture(description_editing_allowed: true) + + assert Images.update_image_description_lock(actor(), to_string(image.id), true) == + {:error, :unauthorized} + + assert Repo.reload!(image).description_editing_allowed + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.update_image_description_lock(actor(moderator), "2147483647", true) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.update_image_description_lock(actor(admin), "2147483647", true) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_description_lock(actor(moderator), "not-a-number", true) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_description_lock(actor(moderator), "99999999999999999999", true) == + {:error, :not_found} + end + end + + describe "update_image_tag_lock/3" do + test "a moderator locks tags, clearing tag_editing_allowed" do + moderator = moderator_user_fixture() + image = image_fixture(tag_editing_allowed: true) + + assert {:ok, locked} = + Images.update_image_tag_lock(actor(moderator), to_string(image.id), true) + + assert locked.id == image.id + refute locked.tag_editing_allowed + refute Repo.reload!(image).tag_editing_allowed + end + + test "an admin locks tags" do + admin = admin_user_fixture() + image = image_fixture(tag_editing_allowed: true) + + assert {:ok, _} = Images.update_image_tag_lock(actor(admin), to_string(image.id), true) + refute Repo.reload!(image).tag_editing_allowed + end + + test "a moderator unlocks tags, setting tag_editing_allowed" do + moderator = moderator_user_fixture() + image = image_fixture(tag_editing_allowed: false) + + assert {:ok, unlocked} = + Images.update_image_tag_lock(actor(moderator), to_string(image.id), false) + + assert unlocked.id == image.id + assert unlocked.tag_editing_allowed + assert Repo.reload!(image).tag_editing_allowed + end + + test "locking writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(tag_editing_allowed: true) + + assert {:ok, _} = Images.update_image_tag_lock(actor(moderator), to_string(image.id), true) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.TagLock:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Locked tags on image #{image.id}" + end + + test "unlocking writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(tag_editing_allowed: false) + + assert {:ok, _} = Images.update_image_tag_lock(actor(moderator), to_string(image.id), false) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.TagLock:delete" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Unlocked tags on image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture(tag_editing_allowed: true) + + assert {:ok, locked} = Images.update_image_tag_lock(actor(moderator), image.id, true) + assert locked.id == image.id + end + + test "a regular user cannot lock tags and the flag stays set" do + user = confirmed_user_fixture() + image = image_fixture(tag_editing_allowed: true) + + assert Images.update_image_tag_lock(actor(user), to_string(image.id), true) == + {:error, :unauthorized} + + assert Repo.reload!(image).tag_editing_allowed + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot lock tags and the flag stays set" do + image = image_fixture(tag_editing_allowed: true) + + assert Images.update_image_tag_lock(actor(), to_string(image.id), true) == + {:error, :unauthorized} + + assert Repo.reload!(image).tag_editing_allowed + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.update_image_tag_lock(actor(moderator), "2147483647", true) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.update_image_tag_lock(actor(admin), "2147483647", true) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_tag_lock(actor(moderator), "not-a-number", true) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_tag_lock(actor(moderator), "99999999999999999999", true) == + {:error, :not_found} + end + end + + describe "load_hidable_image/3 with :preload" do + test "the option loads the named associations" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, loaded} = + Images.load_hidable_image(actor(moderator), to_string(image.id), + preload: :locked_tags + ) + + assert loaded.id == image.id + assert Ecto.assoc_loaded?(loaded.locked_tags) + end + + test "no associations are loaded by default" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, loaded} = Images.load_hidable_image(actor(moderator), image.id) + refute Ecto.assoc_loaded?(loaded.locked_tags) + end + end + + describe "update_image_locked_tags/3" do + test "a moderator replaces the locked-tags list" do + moderator = moderator_user_fixture() + image = image_fixture() + tag_fixture(name: "old lock") + tag_fixture(name: "cute") + + # Seed a starting locked tag, then replace it wholesale. + {:ok, _} = + Images.update_image_locked_tags(actor(moderator), image.id, %{"tag_input" => "old lock"}) + + assert locked_tag_names(image) == ["old lock"] + + assert {:ok, updated} = + Images.update_image_locked_tags(actor(moderator), to_string(image.id), %{ + "tag_input" => "safe, cute" + }) + + assert updated.id == image.id + assert locked_tag_names(image) == ["cute", "safe"] + end + + test "an empty tag_input clears the locked-tags list" do + moderator = moderator_user_fixture() + image = image_fixture() + tag_fixture(name: "cute") + + {:ok, _} = + Images.update_image_locked_tags(actor(moderator), image.id, %{"tag_input" => "safe, cute"}) + + assert locked_tag_names(image) == ["cute", "safe"] + + assert {:ok, _} = + Images.update_image_locked_tags(actor(moderator), to_string(image.id), %{ + "tag_input" => "" + }) + + assert locked_tag_names(image) == [] + end + + test "an admin replaces the locked-tags list" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.update_image_locked_tags(actor(admin), to_string(image.id), %{ + "tag_input" => "safe" + }) + + assert locked_tag_names(image) == ["safe"] + end + + test "only locks existing tags without expanding implications" do + moderator = moderator_user_fixture() + image = image_fixture() + implied = tag_fixture(name: "locked implied") + selected = tag_fixture(name: "locked selected") + + selected = + selected + |> Repo.preload(:implied_tags) + |> change() + |> put_assoc(:implied_tags, [implied]) + |> Repo.update!() + + missing = unique_tag_name() + + assert {:ok, _} = + Images.update_image_locked_tags(actor(moderator), image.id, %{ + "tag_input" => "#{selected.name}, #{missing}" + }) + + assert locked_tag_names(image) == [selected.name] + assert Repo.get_by(Tag, name: missing) == nil + end + + test "a successful update writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.update_image_locked_tags(actor(moderator), to_string(image.id), %{ + "tag_input" => "safe, cute" + }) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.TagLock:update" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Updated list of locked tags on image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, updated} = + Images.update_image_locked_tags(actor(moderator), image.id, %{ + "tag_input" => "safe" + }) + + assert updated.id == image.id + assert locked_tag_names(image) == ["safe"] + end + + test "a regular user cannot update and the list and log stay untouched" do + user = confirmed_user_fixture() + image = image_fixture() + + {:ok, _} = + Images.update_image_locked_tags(actor(moderator_user_fixture()), image.id, %{ + "tag_input" => "safe" + }) + + Repo.delete_all(ModerationLog) + + assert Images.update_image_locked_tags(actor(user), to_string(image.id), %{ + "tag_input" => "cute" + }) == + {:error, :unauthorized} + + assert locked_tag_names(image) == ["safe"] + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot update and the list and log stay untouched" do + image = image_fixture() + + {:ok, _} = + Images.update_image_locked_tags(actor(moderator_user_fixture()), image.id, %{ + "tag_input" => "safe" + }) + + Repo.delete_all(ModerationLog) + + assert Images.update_image_locked_tags(actor(), to_string(image.id), %{ + "tag_input" => "cute" + }) == + {:error, :unauthorized} + + assert locked_tag_names(image) == ["safe"] + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + moderator = moderator_user_fixture() + + assert Images.update_image_locked_tags(actor(moderator), "2147483647", %{ + "tag_input" => "safe" + }) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + admin = admin_user_fixture() + + assert Images.update_image_locked_tags(actor(admin), "2147483647", %{"tag_input" => "safe"}) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_locked_tags(actor(moderator), "not-a-number", %{ + "tag_input" => "safe" + }) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_locked_tags(actor(moderator), "99999999999999999999", %{ + "tag_input" => "safe" + }) == + {:error, :not_found} + end + end + + describe "create_image_feature/2" do + test "a moderator features a visible image, creating the feature row" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, %ImageFeature{} = feature} = + Images.create_image_feature(actor(moderator), to_string(image.id)) + + assert feature.image_id == image.id + assert feature.user_id == moderator.id + assert feature_row_count(image) == 1 + end + + test "an admin features a visible image" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, %ImageFeature{}} = + Images.create_image_feature(actor(admin), to_string(image.id)) + + assert feature_row_count(image) == 1 + end + + test "a successful feature writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = Images.create_image_feature(actor(moderator), to_string(image.id)) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Feature:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Featured image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, %ImageFeature{}} = Images.create_image_feature(actor(moderator), image.id) + assert feature_row_count(image) == 1 + end + + test "a hidden image is accepted" do + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert {:ok, %ImageFeature{}} = Images.create_image_feature(actor(moderator), image.id) + assert feature_row_count(image) == 1 + assert moderation_log_count() == 1 + end + + test "a regular user on a hidden image is unauthorized, not deleted" do + # Authorization runs before the hidden-state check, so a regular user fails + # :hide and never reaches the deleted branch. + user = confirmed_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert Images.create_image_feature(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + assert feature_row_count(image) == 0 + assert moderation_log_count() == 0 + end + + test "a regular user cannot feature a visible image" do + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.create_image_feature(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + assert feature_row_count(image) == 0 + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot feature a visible image" do + image = image_fixture() + + assert Images.create_image_feature(actor(), to_string(image.id)) == {:error, :unauthorized} + assert feature_row_count(image) == 0 + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.create_image_feature(actor(moderator), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.create_image_feature(actor(admin), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_feature(actor(moderator), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_feature(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "update_image_file/3" do + test "a moderator replaces the file and gets the image" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, updated} = + Images.update_image_file(actor(moderator), to_string(image.id), media_png_upload()) + + assert updated.id == image.id + assert Repo.reload!(image).image_sha512_hash == png_upload_sha512() + end + + test "an admin replaces the file" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.update_image_file(actor(admin), to_string(image.id), media_png_upload()) + + assert Repo.reload!(image).image_sha512_hash == png_upload_sha512() + end + + test "a successful replacement writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.update_image_file(actor(moderator), to_string(image.id), media_png_upload()) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.File:update" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Updated file of image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, updated} = + Images.update_image_file(actor(moderator), image.id, media_png_upload()) + + assert updated.id == image.id + end + + test "a file duplicating another image is a changeset error with no log" do + moderator = moderator_user_fixture() + dup_sha = png_upload_sha512() + _other = image_fixture(image_sha512_hash: dup_sha, image_orig_sha512_hash: dup_sha) + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_file(actor(moderator), to_string(image.id), media_png_upload()) + + assert moderation_log_count() == 0 + end + + test "a missing file is a changeset error with no log" do + # With no "image" key the upload analysis fails the required-file check, so + # the engine returns the changeset error the wrapper passes straight + # through without logging. + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_file(actor(moderator), to_string(image.id), nil) + + assert moderation_log_count() == 0 + end + + test "a moderator replaces a hidden image" do + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true, hidden_image_key: "hidden-key") + + assert {:ok, updated} = + Images.update_image_file(actor(moderator), to_string(image.id), media_png_upload()) + + assert updated.id == image.id + assert updated.hidden_from_users + assert Repo.reload!(image).image_sha512_hash == png_upload_sha512() + assert moderation_log_count() == 1 + end + + test "a regular user on a hidden image is unauthorized, not deleted" do + # Authorization runs before the hidden-state check, so a regular user fails + # :hide and never reaches the deleted branch. + user = confirmed_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert Images.update_image_file(actor(user), to_string(image.id), media_png_upload()) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a regular user cannot replace the file" do + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.update_image_file(actor(user), to_string(image.id), media_png_upload()) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot replace the file" do + image = image_fixture() + + assert Images.update_image_file(actor(), to_string(image.id), media_png_upload()) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + moderator = moderator_user_fixture() + + assert Images.update_image_file(actor(moderator), "2147483647", media_png_upload()) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.update_image_file(actor(admin), "2147483647", media_png_upload()) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_file(actor(moderator), "not-a-number", media_png_upload()) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_file( + actor(moderator), + "99999999999999999999", + media_png_upload() + ) == + {:error, :not_found} + end + end + + describe "update_image_anonymous/3" do + test "a moderator sets anonymity, flagging the image anonymous" do + moderator = moderator_user_fixture() + image = image_fixture(anonymous: false) + + assert {:ok, updated} = Images.update_anonymous(actor(moderator), to_string(image.id), true) + assert updated.id == image.id + assert updated.anonymous + assert Repo.reload!(image).anonymous + end + + test "an admin sets anonymity" do + admin = admin_user_fixture() + image = image_fixture(anonymous: false) + + assert {:ok, _} = Images.update_anonymous(actor(admin), to_string(image.id), true) + assert Repo.reload!(image).anonymous + end + + test "a moderator clears anonymity" do + moderator = moderator_user_fixture() + image = image_fixture(anonymous: true) + + assert {:ok, updated} = + Images.update_anonymous(actor(moderator), to_string(image.id), false) + + assert updated.id == image.id + refute updated.anonymous + refute Repo.reload!(image).anonymous + end + + test "setting anonymity writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(anonymous: false) + + assert {:ok, _} = Images.update_anonymous(actor(moderator), to_string(image.id), true) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Anonymous:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Updated anonymity of image #{image.id}" + end + + test "clearing anonymity writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture(anonymous: true) + + assert {:ok, _} = Images.update_anonymous(actor(moderator), to_string(image.id), false) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Anonymous:delete" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Updated anonymity of image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture(anonymous: false) + + assert {:ok, updated} = Images.update_anonymous(actor(moderator), image.id, true) + assert updated.id == image.id + assert Repo.reload!(image).anonymous + end + + test "a regular user is unauthorized on a real image and the flag stays put" do + # Authorization on :identity_metadata runs before the load, so a regular user is + # denied without the image ever being touched. + user = confirmed_user_fixture() + image = image_fixture(anonymous: false) + + assert Images.update_anonymous(actor(user), to_string(image.id), true) == + {:error, :unauthorized} + + refute Repo.reload!(image).anonymous + assert moderation_log_count() == 0 + end + + test "a regular user with a garbage id is still unauthorized, not not_found" do + # The :identity_metadata authorization precedes the id parse, so a non-castable id + # never reaches the not-found path for an unprivileged actor. + user = confirmed_user_fixture() + + assert Images.update_anonymous(actor(user), "not-a-number", true) == {:error, :unauthorized} + assert moderation_log_count() == 0 + end + + test "an anonymous actor is unauthorized on a real image" do + image = image_fixture(anonymous: false) + + assert Images.update_anonymous(actor(), to_string(image.id), true) == + {:error, :unauthorized} + + refute Repo.reload!(image).anonymous + assert moderation_log_count() == 0 + end + + test "an anonymous actor with a garbage id is still unauthorized" do + assert Images.update_anonymous(actor(), "not-a-number", true) == {:error, :unauthorized} + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + # missing image is a plain not-found rather than unauthorized. + moderator = moderator_user_fixture() + + assert Images.update_anonymous(actor(moderator), "2147483647", true) == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a moderator with a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_anonymous(actor(moderator), "not-a-number", true) == + {:error, :not_found} + end + + test "a moderator with an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_anonymous(actor(moderator), "99999999999999999999", true) == + {:error, :not_found} + end + end + + describe "create_image_destroy/2" do + test "an Image-admin role_map moderator destroys a hidden image, nulling the file" do + moderator = role_moderator_fixture("Image") + image = image_fixture(hidden_from_users: true) + + assert {:ok, destroyed} = Images.create_image_destroy(actor(moderator), to_string(image.id)) + assert destroyed.id == image.id + assert Repo.reload!(image).image == nil + end + + test "an admin destroys a hidden image" do + admin = admin_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert {:ok, _} = Images.create_image_destroy(actor(admin), to_string(image.id)) + assert Repo.reload!(image).image == nil + end + + test "a successful destroy writes an exact moderation log" do + admin = admin_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert {:ok, _} = Images.create_image_destroy(actor(admin), to_string(image.id)) + + log = only_moderation_log!() + assert log.user_id == admin.id + assert log.type == "Image.Destroy:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Hard-deleted image #{image.id}" + end + + test "accepts an integer id" do + admin = admin_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert {:ok, destroyed} = Images.create_image_destroy(actor(admin), image.id) + assert destroyed.id == image.id + assert Repo.reload!(image).image == nil + end + + test "a visible image is not deleted with the file intact and no log" do + # The precondition requires a hidden image; a still-visible one is refused + # before any change. + admin = admin_user_fixture() + image = image_fixture(hidden_from_users: false) + + assert {:error, %Ecto.Changeset{}} = + Images.create_image_destroy(actor(admin), to_string(image.id)) + + assert Repo.reload!(image).image == image.image + assert moderation_log_count() == 0 + end + + test "a plain moderator cannot destroy a hidden image and the file stays intact" do + # :destroy needs an Image-admin role_map grant, which a plain moderator + # lacks, so this is unauthorized even though the image is hidden. + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert Images.create_image_destroy(actor(moderator), to_string(image.id)) == + {:error, :unauthorized} + + assert Repo.reload!(image).image == image.image + assert moderation_log_count() == 0 + end + + test "a plain moderator on a visible image is unauthorized, not not_deleted" do + # Authorization runs before the hidden-state check, so a plain moderator + # fails :destroy and never reaches the not_deleted branch. + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: false) + + assert Images.create_image_destroy(actor(moderator), to_string(image.id)) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a regular user cannot destroy a hidden image" do + user = confirmed_user_fixture() + image = image_fixture(hidden_from_users: true) + + assert Images.create_image_destroy(actor(user), to_string(image.id)) == + {:error, :unauthorized} + + assert Repo.reload!(image).image == image.image + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot destroy a hidden image" do + image = image_fixture(hidden_from_users: true) + + assert Images.create_image_destroy(actor(), to_string(image.id)) == {:error, :unauthorized} + assert Repo.reload!(image).image == image.image + assert moderation_log_count() == 0 + end + + test "an Image-admin role_map moderator with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + moderator = role_moderator_fixture("Image") + + assert Images.create_image_destroy(actor(moderator), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + + assert Images.create_image_destroy(actor(admin), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + admin = admin_user_fixture() + + assert Images.create_image_destroy(actor(admin), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + admin = admin_user_fixture() + + assert Images.create_image_destroy(actor(admin), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "update_image_description/3" do + test "broadcasts the description and rendered image after persistence" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id, description: "Old") + :ok = Endpoint.subscribe("firehose") + + assert {:ok, {_image, "Old"}} = + Images.update_image_description(actor(uploader), image.id, %{ + "description" => "New" + }) + + assert_receive %Broadcast{ + event: "image:description_update", + payload: %{image_id: image_id, added: "New", removed: "Old"} + } + + assert image_id == image.id + assert_receive %Broadcast{event: "image:update"} + end + + test "the uploader edits its own image, persisting the new description" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id) + + assert {:ok, {updated, old_description}} = + Images.update_image_description(actor(uploader), to_string(image.id), %{ + "description" => "A fresh description" + }) + + assert updated.id == image.id + assert updated.description == "A fresh description" + assert Repo.reload!(image).description == "A fresh description" + # NOTE: a never-described image carries the column default "", so the + # returned prior value is the empty string, not nil. + assert old_description == "" + assert moderation_log_count() == 0 + end + + test "old_description carries the exact pre-update value" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id, description: "Original text") + + assert {:ok, {_updated, old_description}} = + Images.update_image_description(actor(uploader), to_string(image.id), %{ + "description" => "Replacement text" + }) + + assert old_description == "Original text" + assert Repo.reload!(image).description == "Replacement text" + end + + test "a moderator edits another user's image" do + moderator = moderator_user_fixture() + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id) + + assert {:ok, {updated, _old}} = + Images.update_image_description(actor(moderator), to_string(image.id), %{ + "description" => "Moderator edit" + }) + + assert updated.description == "Moderator edit" + assert Repo.reload!(image).description == "Moderator edit" + end + + test "accepts an integer id" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id) + + assert {:ok, {updated, _old}} = + Images.update_image_description(actor(uploader), image.id, %{ + "description" => "Via int" + }) + + assert updated.description == "Via int" + end + + test "a banned actor is rejected before any loading, even with a garbage id" do + # verify_write_access runs first, so a banned actor is {:error, :ban} even + # against an id that could never parse. + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Images.update_image_description(actor, "not-a-number", %{"description" => "x"}) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Images.update_image_description(actor, "not-a-number", %{"description" => "x"}) == + {:error, :unauthorized} + end + + test "the uploader cannot edit when description editing is locked" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id, description_editing_allowed: false) + + assert Images.update_image_description(actor(uploader), to_string(image.id), %{ + "description" => "blocked" + }) == {:error, :unauthorized} + + assert Repo.reload!(image).description == image.description + end + + test "a non-uploader regular user cannot edit the image" do + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert Images.update_image_description(actor(other), to_string(image.id), %{ + "description" => "not mine" + }) == {:error, :unauthorized} + + assert Repo.reload!(image).description == image.description + end + + test "an anonymous actor with a fingerprint cannot edit the image" do + # verify_write_access passes (fingerprint present, no ban) but the nil user + # fails :edit_description, so this is unauthorized rather than a write. + owner = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert Images.update_image_description(actor(), to_string(image.id), %{ + "description" => "anon" + }) == {:error, :unauthorized} + + assert Repo.reload!(image).description == image.description + end + + test "an over-long description is a changeset error with the image unchanged" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id, description: "Original") + too_long = String.duplicate("a", 50_001) + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_description(actor(uploader), to_string(image.id), %{ + "description" => too_long + }) + + assert Repo.reload!(image).description == "Original" + end + + test "an unknown well-formed id is not found for a non-admin actor" do + # The image loads as nil and a regular user fails :edit_description on the + # Missing image locators resolve to not-found before authorization. + assert Images.update_image_description(actor(confirmed_user_fixture()), "2147483647", %{ + "description" => "x" + }) == {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + # found. + assert Images.update_image_description(actor(admin_user_fixture()), "2147483647", %{ + "description" => "x" + }) == {:error, :not_found} + end + + test "a non-castable id is not found for a valid actor" do + assert Images.update_image_description(actor(confirmed_user_fixture()), "not-a-number", %{ + "description" => "x" + }) == {:error, :not_found} + end + + test "an out-of-range id is not found for a valid actor" do + assert Images.update_image_description( + actor(confirmed_user_fixture()), + "99999999999999999999", + %{ + "description" => "x" + } + ) == {:error, :not_found} + end + end + + describe "delete_user_vote/3" do + test "a moderator removes a target user's upvote, adjusting the score" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + image = image_fixture() + baseline_score = Repo.reload!(image).score + + vote!(image, target, true) + assert has_vote?(image, target) + assert Repo.reload!(image).score == baseline_score + 1 + + assert {:ok, returned} = + Images.delete_user_vote( + actor(moderator), + to_string(image.id), + to_string(target.id) + ) + + assert returned.id == image.id + refute has_vote?(image, target) + assert Repo.reload!(image).score == baseline_score + end + + test "removing an upvote writes an exact moderation log" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + image = image_fixture() + vote!(image, target, true) + + assert {:ok, _} = + Images.delete_user_vote( + actor(moderator), + to_string(image.id), + to_string(target.id) + ) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Tamper:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Deleted upvote by #{target.name} on image #{image.id}" + end + + test "removing a downvote writes a log naming a downvote" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + image = image_fixture() + vote!(image, target, false) + + assert {:ok, _} = + Images.delete_user_vote( + actor(moderator), + to_string(image.id), + to_string(target.id) + ) + + refute has_vote?(image, target) + + log = only_moderation_log!() + assert log.body == "Deleted downvote by #{target.name} on image #{image.id}" + end + + test "removing a vote the user never cast still succeeds, logging a plain vote" do + # With no upvote or downvote deleted, the type derivation falls through to + # the neutral "vote". + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + image = image_fixture() + refute has_vote?(image, target) + + assert {:ok, returned} = + Images.delete_user_vote( + actor(moderator), + to_string(image.id), + to_string(target.id) + ) + + assert returned.id == image.id + + log = only_moderation_log!() + assert log.body == "Deleted vote by #{target.name} on image #{image.id}" + end + + test "accepts bare integer ids" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + image = image_fixture() + vote!(image, target, true) + + assert {:ok, returned} = Images.delete_user_vote(actor(moderator), image.id, target.id) + assert returned.id == image.id + refute has_vote?(image, target) + end + + test "a regular user is unauthorized and the vote is left intact" do + # Image :tamper authorization runs before the user load, so a regular user + # is denied without the vote being touched. + user = confirmed_user_fixture() + target = confirmed_user_fixture() + image = image_fixture() + vote!(image, target, true) + + assert Images.delete_user_vote(actor(user), to_string(image.id), to_string(target.id)) == + {:error, :unauthorized} + + assert has_vote?(image, target) + assert moderation_log_count() == 0 + end + + test "a regular user with a garbage user_id is still unauthorized" do + # The :tamper check precedes the user load, so a non-castable user id never + # reaches the not-found path for an unprivileged actor. + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.delete_user_vote(actor(user), to_string(image.id), "not-a-number") == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "an anonymous actor with a garbage user_id is unauthorized" do + image = image_fixture() + + assert Images.delete_user_vote(actor(), to_string(image.id), "not-a-number") == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown user_id is not found and writes no log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert Images.delete_user_vote(actor(moderator), to_string(image.id), "2147483647") == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a moderator with a non-castable user_id is not found and writes no log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert Images.delete_user_vote(actor(moderator), to_string(image.id), "not-a-number") == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an unknown well-formed image_id is not found for a non-admin actor" do + # Missing image locators resolve to not-found before authorization. + user = confirmed_user_fixture() + target = confirmed_user_fixture() + + assert Images.delete_user_vote(actor(user), "2147483647", to_string(target.id)) == + {:error, :not_found} + end + + test "an unknown well-formed image_id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + admin = admin_user_fixture() + target = confirmed_user_fixture() + + assert Images.delete_user_vote(actor(admin), "2147483647", to_string(target.id)) == + {:error, :not_found} + end + + test "a non-castable image_id is not found" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + + assert Images.delete_user_vote(actor(moderator), "not-a-number", to_string(target.id)) == + {:error, :not_found} + end + end + + describe "update_image_uploader/3" do + test "a moderator reassigns the uploader, preloading the new user with awards" do + moderator = moderator_user_fixture() + owner = confirmed_user_fixture() + new_owner = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert {:ok, updated} = + Images.update_image_uploader(actor(moderator), to_string(image.id), %{ + "username" => new_owner.name + }) + + assert updated.id == image.id + assert Repo.reload!(image).user_id == new_owner.id + + assert Ecto.assoc_loaded?(updated.user) + assert updated.user.id == new_owner.id + assert Ecto.assoc_loaded?(updated.user.awards) + end + + test "an admin reassigns the uploader" do + admin = admin_user_fixture() + owner = confirmed_user_fixture() + new_owner = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert {:ok, _} = + Images.update_image_uploader(actor(admin), to_string(image.id), %{ + "username" => new_owner.name + }) + + assert Repo.reload!(image).user_id == new_owner.id + end + + test "an empty username clears the uploader to nil" do + moderator = moderator_user_fixture() + owner = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert {:ok, updated} = + Images.update_image_uploader(actor(moderator), to_string(image.id), %{ + "username" => "" + }) + + assert updated.id == image.id + assert Repo.reload!(image).user_id == nil + end + + test "reassigning writes an exact moderation log" do + moderator = moderator_user_fixture() + new_owner = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.update_image_uploader(actor(moderator), to_string(image.id), %{ + "username" => new_owner.name + }) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Uploader:update" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Changed uploader of image #{image.id}" + end + + test "clearing the uploader also succeeds and writes the same log" do + moderator = moderator_user_fixture() + owner = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert {:ok, _} = + Images.update_image_uploader(actor(moderator), to_string(image.id), %{ + "username" => "" + }) + + log = only_moderation_log!() + assert log.type == "Image.Uploader:update" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Changed uploader of image #{image.id}" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + new_owner = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, updated} = + Images.update_image_uploader(actor(moderator), image.id, %{ + "username" => new_owner.name + }) + + assert updated.id == image.id + assert Repo.reload!(image).user_id == new_owner.id + end + + test "an unknown username is a changeset error with the image untouched and no log" do + moderator = moderator_user_fixture() + owner = confirmed_user_fixture() + image = image_fixture(user_id: owner.id) + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_uploader(actor(moderator), to_string(image.id), %{ + "username" => "no-such-user" + }) + + assert Repo.reload!(image).user_id == owner.id + assert moderation_log_count() == 0 + end + + test "a regular user is unauthorized on a real image and params" do + # Authorization on :identity_metadata runs before the load, so a regular user is + # denied without the image ever being touched. + user = confirmed_user_fixture() + new_owner = confirmed_user_fixture() + image = image_fixture() + + assert Images.update_image_uploader(actor(user), to_string(image.id), %{ + "username" => new_owner.name + }) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a regular user with a garbage id and nil params is still unauthorized" do + # The :identity_metadata authorization precedes the id parse and the params check, + # so neither the not-found nor the invalid_params path is reached. + user = confirmed_user_fixture() + + assert Images.update_image_uploader(actor(user), "not-a-number", nil) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "an anonymous actor with a garbage id and nil params is unauthorized" do + assert Images.update_image_uploader(actor(), "not-a-number", nil) == {:error, :unauthorized} + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed image_id is not found and writes no log" do + # Missing image locators resolve to not-found before authorization. + # missing image is a plain not-found rather than unauthorized. + moderator = moderator_user_fixture() + new_owner = confirmed_user_fixture() + + assert Images.update_image_uploader(actor(moderator), "2147483647", %{ + "username" => new_owner.name + }) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a moderator with a non-castable image_id is not found" do + moderator = moderator_user_fixture() + new_owner = confirmed_user_fixture() + + assert Images.update_image_uploader(actor(moderator), "not-a-number", %{ + "username" => new_owner.name + }) == + {:error, :not_found} + end + + test "a moderator with an out-of-range image_id is not found" do + moderator = moderator_user_fixture() + new_owner = confirmed_user_fixture() + + assert Images.update_image_uploader(actor(moderator), "99999999999999999999", %{ + "username" => new_owner.name + }) == {:error, :not_found} + end + end + + describe "create_image_user_hide/2" do + test "a signed-in actor hides a visible image, recording a row and bumping the count" do + user = confirmed_user_fixture() + image = image_fixture() + baseline = Repo.reload!(image).hides_count + + assert {:ok, hidden} = Images.create_image_user_hide(actor(user), to_string(image.id)) + assert hidden.id == image.id + assert hidden.hides_count == baseline + 1 + assert image_hide_count(image, user) == 1 + end + + test "hiding again when already hidden leaves a single row" do + # create appends a delete before the insert, so a repeat replaces the row + # rather than stacking, and the count nets out unchanged. + user = confirmed_user_fixture() + image = image_fixture() + baseline = Repo.reload!(image).hides_count + + assert {:ok, _} = Images.create_image_user_hide(actor(user), to_string(image.id)) + assert {:ok, again} = Images.create_image_user_hide(actor(user), to_string(image.id)) + + assert again.hides_count == baseline + 1 + assert image_hide_count(image, user) == 1 + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, hidden} = Images.create_image_user_hide(actor(user), image.id) + assert hidden.id == image.id + assert image_hide_count(image, user) == 1 + end + + test "a banned actor is rejected before any loading, even with a garbage id" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Images.create_image_user_hide(actor, "not-a-number") == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Images.create_image_user_hide(actor, "not-a-number") == {:error, :unauthorized} + end + + test "a non-castable id is not found" do + assert Images.create_image_user_hide(actor(confirmed_user_fixture()), "not-a-number") == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + assert Images.create_image_user_hide( + actor(confirmed_user_fixture()), + "99999999999999999999" + ) == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for a regular actor" do + # Missing image locators resolve to not-found before authorization. + assert Images.create_image_user_hide(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for a moderator" do + assert Images.create_image_user_hide(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + assert Images.create_image_user_hide(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "delete_image_user_hide/2" do + test "a signed-in actor unhides an image, removing the row and decrementing" do + user = confirmed_user_fixture() + image = image_fixture() + baseline = Repo.reload!(image).hides_count + {:ok, _} = Images.create_image_user_hide(actor(user), to_string(image.id)) + assert image_hide_count(image, user) == 1 + + assert {:ok, unhidden} = Images.delete_image_user_hide(actor(user), to_string(image.id)) + assert unhidden.id == image.id + assert unhidden.hides_count == baseline + assert image_hide_count(image, user) == 0 + end + + test "unhiding when no row exists still succeeds" do + user = confirmed_user_fixture() + image = image_fixture() + baseline = Repo.reload!(image).hides_count + assert image_hide_count(image, user) == 0 + + assert {:ok, unhidden} = Images.delete_image_user_hide(actor(user), to_string(image.id)) + assert unhidden.id == image.id + assert unhidden.hides_count == baseline + assert image_hide_count(image, user) == 0 + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + {:ok, _} = Images.create_image_user_hide(actor(user), image.id) + + assert {:ok, unhidden} = Images.delete_image_user_hide(actor(user), image.id) + assert unhidden.id == image.id + assert image_hide_count(image, user) == 0 + end + + test "a banned actor is rejected before any loading, even with a garbage id" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Images.delete_image_user_hide(actor, "not-a-number") == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Images.delete_image_user_hide(actor, "not-a-number") == {:error, :unauthorized} + end + + test "a non-castable id is not found" do + assert Images.delete_image_user_hide(actor(confirmed_user_fixture()), "not-a-number") == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + assert Images.delete_image_user_hide( + actor(confirmed_user_fixture()), + "99999999999999999999" + ) == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for a regular actor" do + assert Images.delete_image_user_hide(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + assert Images.delete_image_user_hide(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "image interaction prerequisites" do + test "normalizes write-access and image-loading failures in the owning actions" do + assert Images.create_image_fave( + actor(confirmed_user_fixture(), ban: @ban), + "not-a-number" + ) == {:error, :ban} + + assert Images.create_image_fave( + actor(confirmed_user_fixture(), fingerprint: nil), + "not-a-number" + ) == {:error, :unauthorized} + + assert Images.create_image_fave(actor(confirmed_user_fixture()), "not-a-number") == + {:error, :not_found} + + assert Images.create_image_fave(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Images.create_image_fave(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "the Images-owned service enforces complex forced filters" do + image = image_fixture() + other_image = image_fixture() + + {user, _filter} = + force_filter(confirmed_user_fixture(), hidden_complex_str: "id:#{image.id}") + + assert Images.verify_forced_filter_access(actor(user), image) == + {:error, :forced_filter} + + assert Images.verify_forced_filter_access(actor(user), other_image) == :ok + end + + test "fave and vote creates and deletes all enforce forced hidden tags" do + image = image_fixture(tags: "safe") + [tag] = image.tags + user = confirmed_user_fixture() + + fave!(image, user) + vote!(image, user, true) + {user, _filter} = force_filter(user, hidden_tag_ids: [tag.id]) + actor = actor(user) + + assert Images.create_image_fave(actor, image.id) == {:error, :forced_filter} + assert Images.delete_image_fave(actor, image.id) == {:error, :forced_filter} + assert Images.create_image_vote(actor, image.id, %{up: false}) == {:error, :forced_filter} + assert Images.delete_image_vote(actor, image.id) == {:error, :forced_filter} + + assert fave_count(image, user) == 1 + assert %ImageVote{up: true} = vote_row(image, user) + end + end + + describe "create_image_fave/2" do + test "records a fave and an implicit upvote, bumping faves_count and score" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + base_faves = Repo.reload!(image).faves_count + + assert {:ok, faved} = Images.create_image_fave(actor(user), image.id) + assert faved.id == image.id + assert faved.faves_count == base_faves + 1 + assert faved.score == base_score + 1 + + assert fave_count(image, user) == 1 + assert %ImageVote{up: true} = vote_row(image, user) + end + + test "replaces an existing downvote with the fave's upvote" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + + vote!(image, user, false) + assert %ImageVote{up: false} = vote_row(image, user) + + assert {:ok, faved} = Images.create_image_fave(actor(user), image.id) + assert %ImageVote{up: true} = vote_row(image, user) + assert faved.score == base_score + 1 + end + + test "faving again when already faved stays at a single fave row" do + user = confirmed_user_fixture() + image = image_fixture() + base_faves = Repo.reload!(image).faves_count + + assert {:ok, _} = Images.create_image_fave(actor(user), image.id) + assert {:ok, again} = Images.create_image_fave(actor(user), image.id) + + assert fave_count(image, user) == 1 + assert again.faves_count == base_faves + 1 + end + end + + describe "delete_image_fave/2" do + test "removes the fave but keeps the upvote, dropping faves_count and leaving score" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + base_faves = Repo.reload!(image).faves_count + + {:ok, faved} = Images.create_image_fave(actor(user), image.id) + assert fave_count(image, user) == 1 + + assert {:ok, unfaved} = Images.delete_image_fave(actor(user), image.id) + assert unfaved.id == image.id + assert fave_count(image, user) == 0 + assert unfaved.faves_count == base_faves + # The implicit upvote survives, so the score stays where the fave put it. + assert unfaved.score == base_score + 1 + assert %ImageVote{up: true} = vote_row(image, user) + assert faved.score == unfaved.score + end + + test "unfaving when no fave exists still succeeds" do + user = confirmed_user_fixture() + image = image_fixture() + base_faves = Repo.reload!(image).faves_count + assert fave_count(image, user) == 0 + + assert {:ok, unfaved} = Images.delete_image_fave(actor(user), image.id) + assert unfaved.id == image.id + assert fave_count(image, user) == 0 + assert unfaved.faves_count == base_faves + end + end + + describe "create_image_vote/3" do + test "an upvote records the row and bumps score and upvotes_count" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + base_upvotes = Repo.reload!(image).upvotes_count + + assert {:ok, voted} = Images.create_image_vote(actor(user), image.id, %{up: true}) + assert voted.id == image.id + assert voted.score == base_score + 1 + assert voted.upvotes_count == base_upvotes + 1 + assert %ImageVote{up: true} = vote_row(image, user) + end + + test "a downvote records the row and drops score" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + base_downvotes = Repo.reload!(image).downvotes_count + + assert {:ok, voted} = Images.create_image_vote(actor(user), image.id, %{up: false}) + assert voted.score == base_score - 1 + assert voted.downvotes_count == base_downvotes + 1 + assert %ImageVote{up: false} = vote_row(image, user) + end + + test "revoting flips an existing downvote to an upvote in a single row" do + # From the downvoted state the score swings up by two (the downvote is + # removed and an upvote added), ending one above the baseline. + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + + vote!(image, user, false) + assert %ImageVote{up: false} = vote_row(image, user) + assert Repo.reload!(image).score == base_score - 1 + + assert {:ok, voted} = Images.create_image_vote(actor(user), image.id, %{up: true}) + # get_by raises on more than one row, so a returned struct confirms a + # single vote row survived the flip. + assert %ImageVote{up: true} = vote_row(image, user) + assert voted.score == base_score + 1 + end + end + + describe "delete_image_vote/2" do + test "removing an upvote restores the score" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + {:ok, _} = Images.create_image_vote(actor(user), image.id, %{up: true}) + + assert {:ok, unvoted} = Images.delete_image_vote(actor(user), image.id) + assert unvoted.id == image.id + assert vote_row(image, user) == nil + assert unvoted.score == base_score + end + + test "removing a downvote restores the score" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + vote!(image, user, false) + + assert {:ok, unvoted} = Images.delete_image_vote(actor(user), image.id) + assert vote_row(image, user) == nil + assert unvoted.score == base_score + end + + test "unvoting when no vote exists still succeeds" do + user = confirmed_user_fixture() + image = image_fixture() + base_score = Repo.reload!(image).score + refute has_vote?(image, user) + + assert {:ok, unvoted} = Images.delete_image_vote(actor(user), image.id) + assert unvoted.id == image.id + assert unvoted.score == base_score + refute has_vote?(image, user) + end + end + + describe "create_image_hide/3" do + test "a moderator hides the image, persisting the reason" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, hidden} = + Images.create_image_hide(actor(moderator), to_string(image.id), %{ + "deletion_reason" => "Rule #0" + }) + + assert hidden.id == image.id + assert hidden.hidden_from_users + + reloaded = Repo.reload!(image) + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Rule #0" + end + + test "an admin hides the image" do + admin = admin_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.create_image_hide(actor(admin), to_string(image.id), %{ + "deletion_reason" => "Rule #0" + }) + + assert Repo.reload!(image).hidden_from_users + end + + test "hiding writes an exact moderation log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, _} = + Images.create_image_hide(actor(moderator), to_string(image.id), %{ + "deletion_reason" => "Rule #0" + }) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Delete:create" + assert log.subject_path == "/images/#{image.id}" + assert log.body == "Deleted image #{image.id} (Rule #0)" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:ok, hidden} = + Images.create_image_hide(actor(moderator), image.id, %{ + "deletion_reason" => "Rule #0" + }) + + assert hidden.id == image.id + end + + test "a blank reason fails with the image left visible and no log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.create_image_hide(actor(moderator), to_string(image.id), %{ + "deletion_reason" => "" + }) + + refute Repo.reload!(image).hidden_from_users + assert moderation_log_count() == 0 + end + + test "a regular user cannot hide the image and it stays visible" do + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.create_image_hide(actor(user), to_string(image.id), %{ + "deletion_reason" => "Rule #0" + }) == + {:error, :unauthorized} + + refute Repo.reload!(image).hidden_from_users + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot hide the image" do + image = image_fixture() + + assert Images.create_image_hide(actor(), to_string(image.id), %{ + "deletion_reason" => "Rule #0" + }) == + {:error, :unauthorized} + + refute Repo.reload!(image).hidden_from_users + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + moderator = moderator_user_fixture() + + assert Images.create_image_hide(actor(moderator), "2147483647", %{ + "deletion_reason" => "Rule #0" + }) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + admin = admin_user_fixture() + + assert Images.create_image_hide(actor(admin), "2147483647", %{ + "deletion_reason" => "Rule #0" + }) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_hide(actor(moderator), "not-a-number", %{ + "deletion_reason" => "Rule #0" + }) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.create_image_hide(actor(moderator), "99999999999999999999", %{ + "deletion_reason" => "Rule #0" + }) == {:error, :not_found} + end + end + + describe "update_image_hide/3" do + test "a moderator updates the reason on a hidden image" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture("Original reason") + + assert {:ok, updated} = + Images.update_image_hide(actor(moderator), to_string(hidden.id), %{ + "deletion_reason" => "Better reason" + }) + + assert updated.id == hidden.id + assert Repo.reload!(hidden).deletion_reason == "Better reason" + end + + test "updating the reason writes an exact moderation log with the new reason" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture("Original reason") + + assert {:ok, _} = + Images.update_image_hide(actor(moderator), to_string(hidden.id), %{ + "deletion_reason" => "Better reason" + }) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Delete:update" + assert log.subject_path == "/images/#{hidden.id}" + assert log.body == "Changed deletion reason of #{hidden.id} (Better reason)" + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture() + + assert {:ok, updated} = + Images.update_image_hide(actor(moderator), hidden.id, %{ + "deletion_reason" => "New" + }) + + assert updated.id == hidden.id + end + + test "a visible image is fails with no log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_hide(actor(moderator), to_string(image.id), %{ + "deletion_reason" => "New" + }) + + assert moderation_log_count() == 0 + end + + test "a regular user on a visible image is unauthorized, not not_deleted" do + # Authorization runs before the hidden-state check, so a regular user fails + # :hide and never reaches the not_deleted branch. + user = confirmed_user_fixture() + image = image_fixture() + + assert Images.update_image_hide(actor(user), to_string(image.id), %{ + "deletion_reason" => "New" + }) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a blank reason on a hidden image is a changeset error with the reason unchanged" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture("Keep me") + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_hide(actor(moderator), to_string(hidden.id), %{ + "deletion_reason" => "" + }) + + assert Repo.reload!(hidden).deletion_reason == "Keep me" + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + moderator = moderator_user_fixture() + + assert Images.update_image_hide(actor(moderator), "2147483647", %{ + "deletion_reason" => "New" + }) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + admin = admin_user_fixture() + + assert Images.update_image_hide(actor(admin), "2147483647", %{"deletion_reason" => "New"}) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_hide(actor(moderator), "not-a-number", %{ + "deletion_reason" => "New" + }) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.update_image_hide(actor(moderator), "99999999999999999999", %{ + "deletion_reason" => "New" + }) == {:error, :not_found} + end + end + + describe "delete_image_hide/2" do + test "a moderator restores a hidden image" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture() + + assert {:ok, restored} = Images.delete_image_hide(actor(moderator), to_string(hidden.id)) + assert restored.id == hidden.id + refute restored.hidden_from_users + refute Repo.reload!(hidden).hidden_from_users + end + + test "an admin restores a hidden image" do + admin = admin_user_fixture() + hidden = hidden_image_fixture() + + assert {:ok, _} = Images.delete_image_hide(actor(admin), to_string(hidden.id)) + refute Repo.reload!(hidden).hidden_from_users + end + + test "restoring writes an exact moderation log" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture() + + assert {:ok, _} = Images.delete_image_hide(actor(moderator), to_string(hidden.id)) + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Image.Delete:delete" + assert log.subject_path == "/images/#{hidden.id}" + assert log.body == "Restored image #{hidden.id}" + end + + test "restoring an already-visible image fails and writes no log" do + moderator = moderator_user_fixture() + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.delete_image_hide(actor(moderator), to_string(image.id)) + + refute Repo.reload!(image).hidden_from_users + assert moderation_log_count() == 0 + end + + test "accepts an integer id" do + moderator = moderator_user_fixture() + hidden = hidden_image_fixture() + + assert {:ok, restored} = Images.delete_image_hide(actor(moderator), hidden.id) + assert restored.id == hidden.id + end + + test "a regular user cannot restore a hidden image and it stays hidden" do + user = confirmed_user_fixture() + hidden = hidden_image_fixture() + + assert Images.delete_image_hide(actor(user), to_string(hidden.id)) == + {:error, :unauthorized} + + assert Repo.reload!(hidden).hidden_from_users + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot restore a hidden image" do + hidden = hidden_image_fixture() + + assert Images.delete_image_hide(actor(), to_string(hidden.id)) == {:error, :unauthorized} + assert Repo.reload!(hidden).hidden_from_users + assert moderation_log_count() == 0 + end + + test "a moderator with an unknown well-formed id is not found and writes no log" do + moderator = moderator_user_fixture() + + assert Images.delete_image_hide(actor(moderator), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "an admin with an unknown well-formed id is not found and writes no log" do + admin = admin_user_fixture() + + assert Images.delete_image_hide(actor(admin), "2147483647") == {:error, :not_found} + assert moderation_log_count() == 0 + end + + test "a non-castable id is not found" do + moderator = moderator_user_fixture() + + assert Images.delete_image_hide(actor(moderator), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + moderator = moderator_user_fixture() + + assert Images.delete_image_hide(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "update_image_sources/3" do + test "broadcasts source and rendered image updates after persistence" do + user = confirmed_user_fixture() + image = image_fixture() + :ok = Endpoint.subscribe("firehose") + + assert {:ok, _result} = + Images.update_image_sources( + actor(user), + image.id, + add_source_attrs("https://example.test/source") + ) + + assert_receive %Broadcast{ + event: "image:source_update", + payload: %{image_id: image_id} + } + + assert image_id == image.id + assert_receive %Broadcast{event: "image:update"} + end + + test "a signed-in actor adds a source, recording an attributed change and bumping stats" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, result} = + Images.update_image_sources( + actor(user), + to_string(image.id), + add_source_attrs("https://example.com/art") + ) + + assert result.image.id == image.id + assert result.source_change_count == 1 + + change = Repo.one(from s in SourceChange, where: s.image_id == ^image.id) + assert change.source_url == "https://example.com/art" + assert change.user_id == user.id + assert change.added == true + + assert Repo.reload!(user).metadata_updates_count == 1 + end + + test "an anonymous fingerprinted actor records a change with no user" do + image = image_fixture() + + assert {:ok, _result} = + Images.update_image_sources( + actor(), + to_string(image.id), + add_source_attrs("https://example.com/anon") + ) + + change = Repo.one(from s in SourceChange, where: s.image_id == ^image.id) + assert change.source_url == "https://example.com/anon" + assert change.user_id == nil + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, result} = + Images.update_image_sources( + actor(user), + image.id, + add_source_attrs("https://example.com/i") + ) + + assert result.image.id == image.id + end + + test "a banned actor is rejected before any loading, even with a garbage id" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Images.update_image_sources( + actor, + "not-a-number", + add_source_attrs("https://x.test") + ) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Images.update_image_sources( + actor, + "not-a-number", + add_source_attrs("https://x.test") + ) == + {:error, :unauthorized} + end + + test "a hidden image is unauthorized and records no change" do + # edit_metadata requires a non-hidden image, so a hidden one fails + # authorization for a signed-in actor. + user = confirmed_user_fixture() + hidden = hidden_image_fixture() + + assert Images.update_image_sources( + actor(user), + to_string(hidden.id), + add_source_attrs("https://x.test") + ) == {:error, :unauthorized} + + assert source_change_row_count(hidden) == 0 + end + + test "more than 15 sources is a changeset error with no change recorded" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_sources( + actor(user), + to_string(image.id), + many_source_attrs(16) + ) + + assert source_change_row_count(image) == 0 + end + + test "an invalid source URL returns an image-backed changeset" do + user = confirmed_user_fixture() + image = image_fixture(sources: ["https://example.com/existing"]) + + assert {:error, %Ecto.Changeset{data: %Image{} = changeset_image} = changeset} = + Images.update_image_sources( + actor(user), + image.id, + %{ + "old_sources" => %{}, + "sources" => %{"0" => %{"source" => "not-a-url"}} + } + ) + + assert changeset_image.id == image.id + source_changeset = Enum.find(get_change(changeset, :sources), &(not &1.valid?)) + assert %Ecto.Changeset{} = source_changeset + assert "has invalid format" in errors_on(source_changeset).source + assert source_urls(image) == ["https://example.com/existing"] + assert source_change_row_count(image) == 0 + end + + test "an unknown well-formed id is not found for a regular actor" do + # The image loads as nil and a regular actor fails :edit_metadata on the nil + # Missing image locators resolve to not-found before authorization. + assert Images.update_image_sources( + actor(confirmed_user_fixture()), + "2147483647", + add_source_attrs("https://x.test") + ) == {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + assert Images.update_image_sources( + actor(admin_user_fixture()), + "2147483647", + add_source_attrs("https://x.test") + ) == {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.update_image_sources( + actor(confirmed_user_fixture()), + "not-a-number", + add_source_attrs("https://x.test") + ) == {:error, :not_found} + end + + test "an out-of-range id is not found" do + assert Images.update_image_sources( + actor(confirmed_user_fixture()), + "99999999999999999999", + add_source_attrs("https://x.test") + ) == {:error, :not_found} + end + + test "an over-limit actor is rate limited and records no source change" do + # The :source_update counter is primed past the limit, so the rate check + # (after write-access, before the id parse and load) refuses the write. + image = image_fixture() + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :source_update) + + assert Images.update_image_sources( + actor, + to_string(image.id), + add_source_attrs("https://x.test") + ) == {:error, :rate_limited} + + assert source_change_row_count(image) == 0 + end + + test "a successful update records the counter" do + image = image_fixture() + actor = actor(confirmed_user_fixture()) + track_rate_limit(actor, :source_update) + + assert {:ok, _result} = + Images.update_image_sources( + actor, + to_string(image.id), + add_source_attrs("https://x.test") + ) + + assert rate_limit_count(actor, :source_update) == "1" + end + + test "a non-castable id does not consume the rate limit" do + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :source_update) + + assert Images.update_image_sources( + actor, + "not-a-number", + add_source_attrs("https://x.test") + ) == + {:error, :not_found} + end + end + + describe "update_image_tags/3" do + setup do + # The shared attribution fixture's anonymous identity (i:) is not rolled + # back by the SQL sandbox and accumulates across runs, so clear it before + # each test. Signed-in tests use fresh users, whose u: bucket starts + # empty on its own. + reset_tag_change_limits() + :ok + end + + test "broadcasts tag and rendered image updates after persistence" do + user = confirmed_user_fixture() + image = image_fixture() + :ok = Endpoint.subscribe("firehose") + + assert {:ok, _result} = + Images.update_image_tags( + actor(user), + image.id, + tag_attrs("safe", "safe, broadcast tag, another broadcast tag") + ) + + assert_receive %Broadcast{ + event: "image:tag_update", + payload: %{image_id: image_id, added: added} + } + + assert image_id == image.id + assert Enum.sort(added) == ["another broadcast tag", "broadcast tag"] + assert_receive %Broadcast{event: "image:update"} + end + + test "a signed-in actor changes tags, recording an attributed change and bumping stats" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, result} = + Images.update_image_tags( + actor(user), + to_string(image.id), + tag_attrs("safe", "safe, added test tag, other added tag") + ) + + assert result.image.id == image.id + assert result.tag_change_count >= 1 + assert result.tag_change_tag_count >= 1 + + assert tag_names(image) == ["added test tag", "other added tag", "safe"] + + change = Repo.one(from tc in TagChange, where: tc.image_id == ^image.id) + assert change.user_id == user.id + + assert Repo.reload!(user).metadata_updates_count == 1 + end + + test "an anonymous fingerprinted actor records a change with no user" do + image = image_fixture() + + assert {:ok, _result} = + Images.update_image_tags( + actor(), + to_string(image.id), + tag_attrs("safe", "safe, added test tag, other added tag") + ) + + change = Repo.one(from tc in TagChange, where: tc.image_id == ^image.id) + assert change.user_id == nil + end + + test "accepts an integer id" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:ok, result} = + Images.update_image_tags( + actor(user), + image.id, + tag_attrs("safe", "safe, added test tag, other added tag") + ) + + assert result.image.id == image.id + end + + test "a banned actor is rejected before any loading, even with a garbage id" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Images.update_image_tags(actor, "not-a-number", tag_attrs("safe", "safe, a, b")) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Images.update_image_tags(actor, "not-a-number", tag_attrs("safe", "safe, a, b")) == + {:error, :unauthorized} + end + + test "an image with tag editing disabled is unauthorized and records no change" do + user = confirmed_user_fixture() + image = image_fixture(tag_editing_allowed: false) + + assert Images.update_image_tags( + actor(user), + to_string(image.id), + tag_attrs("safe", "safe, added test tag, other added tag") + ) == {:error, :unauthorized} + + refute Repo.exists?(from tc in TagChange, where: tc.image_id == ^image.id) + end + + test "reducing below the minimum tag count is a changeset error with tags unchanged" do + user = confirmed_user_fixture() + image = image_fixture() + + assert {:error, %Ecto.Changeset{}} = + Images.update_image_tags( + actor(user), + to_string(image.id), + tag_attrs("safe", "safe, one more") + ) + + assert tag_names(image) == ["safe"] + refute Repo.exists?(from tc in TagChange, where: tc.image_id == ^image.id) + end + + test "exceeding the tag-change rate limit rolls the transaction back" do + user = confirmed_user_fixture() + image = image_fixture() + ip = %Postgrex.INET{address: {203, 0, 113, 1}, netmask: 32} + + # Fill the user's tag bucket to the 50-change limit so the next multi-tag + # update trips check_limits. The counter carries a 10-minute TTL that the + # SQL sandbox does not roll back, so clear it afterward. + :ok = Limits.record_action(user, ip, 50, 0) + on_exit(fn -> reset_tag_change_limits(user: user, ip: ip) end) + + assert Images.update_image_tags( + actor(user), + to_string(image.id), + tag_attrs("safe", "safe, added test tag, other added tag") + ) == {:error, :rate_limited} + + assert tag_names(image) == ["safe"] + refute Repo.exists?(from tc in TagChange, where: tc.image_id == ^image.id) + end + + test "an unknown well-formed id is not found for a regular actor" do + # The image loads as nil and a regular actor fails :edit_metadata on the nil + # Missing image locators resolve to not-found before authorization. + assert Images.update_image_tags( + actor(confirmed_user_fixture()), + "2147483647", + tag_attrs("safe", "safe, a, b") + ) == {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + # Missing image locators resolve to not-found before authorization. + assert Images.update_image_tags( + actor(admin_user_fixture()), + "2147483647", + tag_attrs("safe", "safe, a, b") + ) == {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.update_image_tags( + actor(confirmed_user_fixture()), + "not-a-number", + tag_attrs("safe", "safe, a, b") + ) == {:error, :not_found} + end + + test "an out-of-range id is not found" do + assert Images.update_image_tags( + actor(confirmed_user_fixture()), + "99999999999999999999", + tag_attrs("safe", "safe, a, b") + ) == {:error, :not_found} + end + + test "an over-limit once-per-window actor is rate limited and records no change" do + # This is the new once-per-window counter (rl:tag_update:*), distinct from + # the tag-count limiter (rltcn:/rltcr:) the test above exercises. Priming it + # over the limit makes the rate check (after write-access, before the load) + # refuse the write. + image = image_fixture() + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :tag_update) + + assert Images.update_image_tags( + actor, + to_string(image.id), + tag_attrs("safe", "safe, added test tag, other added tag") + ) == {:error, :rate_limited} + + assert tag_names(image) == ["safe"] + refute Repo.exists?(from tc in TagChange, where: tc.image_id == ^image.id) + end + + test "a successful update records the once-per-window counter" do + image = image_fixture() + actor = actor(confirmed_user_fixture()) + track_rate_limit(actor, :tag_update) + + assert {:ok, _result} = + Images.update_image_tags( + actor, + to_string(image.id), + tag_attrs("safe", "safe, added test tag, other added tag") + ) + + assert rate_limit_count(actor, :tag_update) == "1" + end + + test "a non-castable id does not consume the rate limit" do + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :tag_update) + + assert Images.update_image_tags(actor, "not-a-number", tag_attrs("safe", "safe, a, b")) == + {:error, :not_found} + end + end + + # Records one tag change against `image` (adding two tags), returning the + # image. Produces a single tag_changes row carrying two tag_change_tags. + defp record_tag_change(image) do + user = confirmed_user_fixture() + reset_tag_change_limits() + + {:ok, _} = + Images.update_image_tags( + actor(user), + to_string(image.id), + tag_attrs("safe", "safe, alpha, beta") + ) + + image + end + + # The compiled filter body the web layer produces for a viewer with no active + # filter: an empty tag_ids exclusion plus a pair of match_none clauses, so it + # excludes nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp index_scope do + Scope.new(default_filter(), %{page_number: 1, page_size: 25}) + end + + defp search_scope(params) do + Scope.new(default_filter(), %{page_number: 1, page_size: 25}, params) + end + + defp minutes_ago(minutes) do + DateTime.utc_now() + |> DateTime.add(-minutes * 60, :second) + |> DateTime.truncate(:second) + end + + # Waits for the background upload process a successful upload spawns to exit. + # It shares the test process's sandbox connection, so letting it outlive the + # test leaves it retrying against a dead owner. The spawned process is our + # direct child. + defp await_async_upload do + test_pid = self() + + for pid <- Process.list(), Process.info(pid, :parent) == {:parent, test_pid} do + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, _reason} -> :ok + after + 5_000 -> raise "async upload process #{inspect(pid)} did not exit" + end + end + + :ok + end + + describe "show_image/2" do + test "an anonymous viewer loads a visible image with zero change counts" do + image = image_fixture() + + assert {:ok, loaded} = Images.show_image(actor(), to_string(image.id)) + assert loaded.id == image.id + assert loaded.tag_change_count == 0 + assert loaded.tag_change_tag_count == 0 + assert loaded.source_change_count == 0 + end + + test "the change counts reflect recorded tag and source changes" do + image = image_fixture() + record_tag_change(image) + source_change_fixture(image) + source_change_fixture(image) + + assert {:ok, loaded} = Images.show_image(actor(), to_string(image.id)) + assert loaded.tag_change_count == 1 + assert loaded.tag_change_tag_count == 2 + assert loaded.source_change_count == 2 + end + + test "the show preloads are populated on the loaded image" do + image = image_fixture(sources: ["https://example.com/a"]) + + assert {:ok, loaded} = Images.show_image(actor(), to_string(image.id)) + assert Ecto.assoc_loaded?(loaded.tags) + assert Ecto.assoc_loaded?(loaded.sources) + assert Ecto.assoc_loaded?(loaded.locked_tags) + end + + test "accepts an integer id" do + image = image_fixture() + + assert {:ok, loaded} = Images.show_image(actor(), image.id) + assert loaded.id == image.id + end + + test "a hidden image is returned to an anonymous viewer" do + image = image_fixture(hidden_from_users: true) + + assert {:ok, %Image{}} = Images.show_image(actor(), to_string(image.id)) + end + + test "a hidden duplicate is redirected for an anonymous viewer" do + # A merged image is hidden from users; a viewer who cannot :show the + # hidden image is redirected to its duplicate rather than shown the notice. + original = image_fixture() + duplicate = image_fixture(duplicate_id: original.id, hidden_from_users: true) + + assert {:duplicate_of, target_id} = + Images.show_image(actor(), to_string(duplicate.id)) + + assert target_id == original.id + end + + test "a non-hidden duplicate is shown to an anonymous viewer" do + # The duplicate branch only fires when the viewer cannot :show the image; + # a duplicate that is not hidden is still viewable, so it loads normally. + original = image_fixture() + duplicate = image_fixture(duplicate_id: original.id) + + assert {:ok, loaded} = Images.show_image(actor(), to_string(duplicate.id)) + + assert loaded.id == duplicate.id + end + + test "a hidden duplicate loads normally for a moderator who can show it" do + moderator = moderator_user_fixture() + original = image_fixture() + duplicate = image_fixture(duplicate_id: original.id, hidden_from_users: true) + + assert {:ok, loaded} = + Images.show_image(actor(moderator), to_string(duplicate.id)) + + assert loaded.id == duplicate.id + end + + test "an unknown well-formed id is not found for an anonymous viewer" do + # Missing images are resolved before the viewer's :show permission. + assert Images.show_image(actor(), "2147483647") == {:error, :not_found} + end + + test "an unknown well-formed id is not found for an admin" do + assert Images.show_image(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-castable id is not found" do + assert Images.show_image(actor(), "not-a-number") == {:error, :not_found} + end + + test "an out-of-range id is not found" do + assert Images.show_image(actor(), "99999999999999999999") == {:error, :not_found} + end + end + + describe "show_image_page/3" do + test "assembles the page struct for a signed-in viewer" do + user = confirmed_user_fixture() + image = image_fixture() + + page = Images.show_image_page(actor(user), image, page: 1, page_size: 25) + + assert %ImagePage{} = page + assert page.image.id == image.id + assert %Scrivener.Page{} = page.comments + assert is_boolean(page.watching) + assert is_list(page.user_galleries) + assert is_list(page.interactions) + assert %Ecto.Changeset{} = page.comment_changeset + assert %Ecto.Changeset{} = page.tag_changeset + assert %Ecto.Changeset{} = page.source_changeset + refute page.description_changeset + refute page.hide_changeset + refute page.file_changeset + refute page.feature_changeset + refute page.repair_changeset + refute page.hash_changeset + refute page.uploader_changeset + end + + test "assembles the page struct for an anonymous viewer" do + image = image_fixture() + + page = Images.show_image_page(actor(), image, page: 1, page_size: 25) + + assert %ImagePage{} = page + refute page.watching + assert page.user_galleries == [] + assert page.interactions == [] + refute page.can_interact + end + + test "a banned viewer gets no write controls or mutation changesets" do + user = confirmed_user_fixture() + image = image_fixture() + + page = Images.show_image_page(actor(user, ban: @ban), image, page: 1, page_size: 25) + + refute page.can_interact + assert page.interactions == [] + assert page.comment_changeset == nil + assert page.description_changeset == nil + assert page.tag_changeset == nil + assert page.source_changeset == nil + assert page.file_changeset == nil + assert page.hide_changeset == nil + assert page.feature_changeset == nil + assert page.repair_changeset == nil + assert page.hash_changeset == nil + assert page.uploader_changeset == nil + end + + test "a forced-filtered image gets no write controls or mutation changesets" do + image = image_fixture() + + {user, _filter} = + force_filter(confirmed_user_fixture(), hidden_complex_str: "id:#{image.id}") + + page = Images.show_image_page(actor(user), image, page: 1, page_size: 25) + + refute page.can_interact + assert page.interactions == [] + assert page.comment_changeset == nil + assert page.description_changeset == nil + assert page.tag_changeset == nil + assert page.source_changeset == nil + assert page.file_changeset == nil + assert page.hide_changeset == nil + assert page.feature_changeset == nil + assert page.repair_changeset == nil + assert page.hash_changeset == nil + assert page.uploader_changeset == nil + end + + test "a hidden image gets no interaction controls even for a moderator" do + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true) + + page = Images.show_image_page(actor(moderator), image, page: 1, page_size: 25) + + refute page.can_interact + assert page.interactions == [] + assert page.comment_changeset == nil + assert %Ecto.Changeset{} = page.description_changeset + assert %Ecto.Changeset{} = page.tag_changeset + assert %Ecto.Changeset{} = page.source_changeset + assert %Ecto.Changeset{} = page.file_changeset + assert %Ecto.Changeset{} = page.hide_changeset + assert %Ecto.Changeset{} = page.feature_changeset + assert %Ecto.Changeset{} = page.repair_changeset + assert %Ecto.Changeset{} = page.hash_changeset + assert %Ecto.Changeset{} = page.uploader_changeset + end + + test "an image uploader can edit the description" do + uploader = confirmed_user_fixture() + image = image_fixture(user_id: uploader.id) + + page = Images.show_image_page(actor(uploader), image, page: 1, page_size: 25) + + assert %Ecto.Changeset{} = page.description_changeset + assert %Ecto.Changeset{} = page.tag_changeset + assert %Ecto.Changeset{} = page.source_changeset + refute page.hide_changeset + end + + test "staff gets changesets for image management actions" do + staff = moderator_user_fixture() + image = image_fixture() + + page = Images.show_image_page(actor(staff), image, page: 1, page_size: 25) + + assert %Ecto.Changeset{} = page.description_changeset + assert %Ecto.Changeset{} = page.tag_changeset + assert %Ecto.Changeset{} = page.source_changeset + assert %Ecto.Changeset{} = page.file_changeset + assert %Ecto.Changeset{} = page.hide_changeset + assert %Ecto.Changeset{} = page.feature_changeset + assert %Ecto.Changeset{} = page.repair_changeset + assert %Ecto.Changeset{} = page.hash_changeset + assert %Ecto.Changeset{} = page.uploader_changeset + end + + test "watching is true once the viewer is subscribed" do + user = confirmed_user_fixture() + image = image_fixture() + {:ok, _} = Images.create_subscription(image, user) + + page = Images.show_image_page(actor(user), image, page: 1, page_size: 25) + + assert page.watching + end + + test "user_galleries pairs each of the viewer's galleries with image membership" do + user = confirmed_user_fixture() + image = image_fixture() + containing = gallery_fixture(user) + empty = gallery_fixture(user) + gallery_image_fixture(containing, image) + + page = Images.show_image_page(actor(user), image, page: 1, page_size: 25) + + memberships = + Map.new(page.user_galleries, fn {gallery, member?} -> {gallery.id, member?} end) + + assert memberships[containing.id] == true + assert memberships[empty.id] == false + end + + test "loading the page clears the viewer's image notification" do + user = confirmed_user_fixture() + image = image_fixture() + arrange_comment_notification(image, user) + assert comment_notification?(image, user) + + Images.show_image_page(actor(user), image, page: 1, page_size: 25) + + refute comment_notification?(image, user) + end + + test "an oldest-first jump-to-last viewer lands on the final comment page" do + user = confirmed_user_fixture() + + settings = + user.settings + |> Ecto.Changeset.change(comments_newest_first: false, comments_always_jump_to_last: true) + |> Repo.update!() + + user = %{user | settings: settings} + image = image_fixture() + for _ <- 1..3, do: comment_fixture(image, confirmed_user_fixture()) + + page = Images.show_image_page(actor(user), image, page: 1, page_size: 2) + + # Three comments over a page size of two put the newest on the second page. + assert page.comments.page_number == 2 + end + + test "a viewer without the jump preference stays on the requested page" do + user = confirmed_user_fixture() + image = image_fixture() + for _ <- 1..3, do: comment_fixture(image, confirmed_user_fixture()) + + page = Images.show_image_page(actor(user), image, page: 1, page_size: 2) + + assert page.comments.page_number == 1 + end + end + + describe "new_image/1" do + test "a normal actor gets the upload form changeset" do + assert {:ok, %Ecto.Changeset{}} = Images.new_image(actor(confirmed_user_fixture())) + end + + test "an anonymous actor gets the upload form changeset" do + assert {:ok, %Ecto.Changeset{}} = Images.new_image(actor()) + end + + test "a banned actor may not reach the form" do + assert Images.new_image(actor(confirmed_user_fixture(), ban: @ban)) == {:error, :ban} + end + + test "a banned actor is rejected even with a fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban, fingerprint: "d015c342859dde3") + + assert Images.new_image(actor) == {:error, :ban} + end + + test "an actor without a fingerprint may not reach the form" do + assert Images.new_image(actor(nil, fingerprint: nil)) == {:error, :unauthorized} + end + end + + describe "create_image/3" do + test "a normal actor uploads an image and the row exists" do + actor = actor(confirmed_user_fixture()) + :ok = Endpoint.subscribe("firehose") + + assert {:ok, %{image: %Image{} = image, upload_pid: pid}} = + Images.create_image( + actor, + %{"tag_input" => "safe, solo, pony"}, + media_png_upload() + ) + + # The background upload process finishes the persist/repair work against + # the Repo; in an async case it owns no sandbox connection, so grant it the + # test's before awaiting its exit. + Sandbox.allow(Repo, self(), pid) + + assert Repo.get(Image, image.id) + assert source_urls(image) == [] + + assert_receive %Broadcast{ + event: "image:create", + payload: %{image: %{id: image_id}} + } + + assert image_id == image.id + await_async_upload() + end + + test "uploads valid nested source params and persists the source rows" do + actor = actor(confirmed_user_fixture()) + sources = ["https://example.com/first", "https://example.com/second"] + + assert {:ok, %{image: image, upload_pid: pid}} = + Images.create_image( + actor, + %{ + "tag_input" => "safe, solo, pony", + "sources" => + sources + |> Enum.with_index() + |> Map.new(fn {source, index} -> + {to_string(index), %{"source" => source}} + end) + }, + media_png_upload() + ) + + Sandbox.allow(Repo, self(), pid) + assert source_urls(image) == Enum.sort(sources) + await_async_upload() + end + + test "ignores blank source rows during upload" do + actor = actor(confirmed_user_fixture()) + + assert {:ok, %{image: image, upload_pid: pid}} = + Images.create_image( + actor, + %{ + "tag_input" => "safe, solo, pony", + "sources" => %{ + "0" => %{"source" => ""}, + "1" => %{"source" => "https://example.com/source"}, + "2" => %{"source" => ""} + } + }, + media_png_upload() + ) + + Sandbox.allow(Repo, self(), pid) + assert source_urls(image) == ["https://example.com/source"] + await_async_upload() + end + + test "invalid upload source URLs return an image-backed changeset" do + actor = actor(confirmed_user_fixture()) + + assert {:error, %Ecto.Changeset{data: %Image{} = image} = changeset} = + Images.create_image( + actor, + %{ + "tag_input" => "safe, solo, pony", + "sources" => %{"0" => %{"source" => "not-a-url"}} + }, + media_png_upload() + ) + + assert image.id == nil + assert [%Ecto.Changeset{} = source_changeset] = get_change(changeset, :sources) + assert "has invalid format" in errors_on(source_changeset).source + end + + test "a trusted actor's approved upload increments the count and suggests verification" do + user = admin_user_fixture() + rule_fixture(name: "Verification") + + user + |> Ecto.Changeset.change(images_count: 4) + |> Repo.update!() + + assert {:ok, %{image: image, upload_pid: pid}} = + Images.create_image( + actor(user), + %{"tag_input" => "safe, solo, pony"}, + media_png_upload() + ) + + assert image.approved + assert Repo.reload!(user).images_count == 5 + + assert %Report{reported_user_id: user_id, system: true} = + Repo.one!(from report in Report, where: report.reported_user_id == ^user.id) + + assert user_id == user.id + + Sandbox.allow(Repo, self(), pid) + await_async_upload() + end + + test "a banned actor may not upload" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Images.create_image(actor, %{"tag_input" => "safe"}, media_png_upload()) == + {:error, :ban} + end + + test "an actor with no fingerprint may not upload" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Images.create_image(actor, %{"tag_input" => "safe"}, media_png_upload()) == + {:error, :unauthorized} + end + + test "a ban outranks a missing fingerprint" do + actor = actor(confirmed_user_fixture(), ban: @ban, fingerprint: nil) + + assert Images.create_image(actor, %{"tag_input" => "safe"}, media_png_upload()) == + {:error, :ban} + end + + test "an over-limit actor is rate limited and no image is created" do + # The :image_create counter is primed past the limit, so the rate check + # (after write-access, before create_image) refuses the upload, spawning no + # background process. + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :image_create) + + assert Images.create_image(actor, %{"tag_input" => "safe"}, media_png_upload()) == + {:error, :rate_limited} + + assert Repo.aggregate(Image, :count) == 0 + end + + test "a successful upload records the counter" do + actor = actor(confirmed_user_fixture()) + track_rate_limit(actor, :image_create) + + assert {:ok, %{image: %Image{}, upload_pid: pid}} = + Images.create_image( + actor, + %{"tag_input" => "safe, solo, pony"}, + media_png_upload() + ) + + # Recording happens synchronously once create_image succeeds. + assert rate_limit_count(actor, :image_create) == "1" + + # Let the background upload process finish against the test's sandbox + # connection before the test exits. + Sandbox.allow(Repo, self(), pid) + await_async_upload() + end + + test "the rate check precedes create_image: over-limit with empty params is still rate limited" do + # create_image would reject empty params, but the rate check runs first, so + # an over-limit actor gets :rate_limited rather than a create failure. + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :image_create) + + assert Images.create_image(actor, %{}, nil) == {:error, :rate_limited} + end + end + + describe "list_approval_queue/2" do + test "a moderator gets the unapproved images, oldest first, with tags preloaded" do + approved = image_fixture(approved: true) + first = image_fixture(approved: false) + second = image_fixture(approved: false) + + assert {:ok, page} = + Images.list_approval_queue(actor(moderator_user_fixture()), @approval_pagination) + + assert %Scrivener.Page{} = page + + ids = Enum.map(page.entries, & &1.id) + assert first.id in ids + assert second.id in ids + refute approved.id in ids + + # Oldest first: the ids come back ascending. + assert ids == Enum.sort(ids) + + entry = Enum.find(page.entries, &(&1.id == first.id)) + assert Ecto.assoc_loaded?(entry.tags) + end + + test "an admin gets the approval queue" do + image = image_fixture(approved: false) + + assert {:ok, page} = + Images.list_approval_queue(actor(admin_user_fixture()), @approval_pagination) + + assert image.id in Enum.map(page.entries, & &1.id) + end + + test "a regular user is not authorized" do + assert Images.list_approval_queue(actor(confirmed_user_fixture()), @approval_pagination) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert Images.list_approval_queue(actor(), @approval_pagination) == {:error, :unauthorized} + end + end + + describe "update_batch_tags/2" do + test "an admin adds a tag to matched images and logs against their own profile" do + # A letters-only name keeps the profile subject_path identical to + # "/profiles/" with no percent-encoding. + admin = admin_user_fixture(%{name: "batchtagger"}) + actor = actor(admin) + tag_fixture(%{name: "batchadd"}) + image = image_fixture(tags: "safe") + + assert {:ok, result} = + Images.update_batch_tags(actor, %{tag_list: "batchadd", image_ids: [image.id]}) + + assert result == %{succeeded: 1, failed: 0} + assert "batchadd" in image_tag_names(image) + + :ok = Endpoint.subscribe("firehose") + + assert {:ok, _result} = + Images.update_batch_tags(actor, %{tag_list: "-batchadd", image_ids: [image.id]}) + + assert_receive %Broadcast{ + event: "image:batch_tag_update", + payload: %{image_ids: [image_id], added: [], removed: ["batchadd"]} + } + + assert image_id == image.id + + log = Repo.one!(from log in ModerationLog, order_by: [desc: log.id], limit: 1) + assert log.user_id == admin.id + assert log.type == "Admin.Batch.Tag:update" + assert log.subject_path == "/profiles/#{admin.slug}" + assert log.body == "Batch tagged '-batchadd' on 1 images" + end + + test "an admin removes a tag from matched images" do + actor = actor(admin_user_fixture()) + image = image_fixture(tags: "safe, removeme") + + assert {:ok, result} = + Images.update_batch_tags(actor, %{tag_list: "-removeme", image_ids: [image.id]}) + + assert result == %{succeeded: 1, failed: 0} + refute "removeme" in image_tag_names(image) + end + + test "canonicalizes aliases for additions and removals" do + actor = actor(admin_user_fixture()) + canonical = tag_fixture(name: unique_tag_name()) + + alias_tag = + tag_fixture(name: unique_tag_name()) + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + image = image_fixture() + + assert {:ok, result} = + Images.update_batch_tags(actor, %{tag_list: alias_tag.name, image_ids: [image.id]}) + + assert result == %{succeeded: 1, failed: 0} + assert canonical.name in image_tag_names(image) + refute alias_tag.name in image_tag_names(image) + + assert {:ok, result} = + Images.update_batch_tags(actor, %{ + tag_list: "-#{alias_tag.name}", + image_ids: [image.id] + }) + + assert result == %{succeeded: 1, failed: 0} + refute canonical.name in image_tag_names(image) + end + + test "a moderator with a Tag batch_update grant is authorized" do + user = %{confirmed_user_fixture() | role_map: %{"Tag" => %{"batch_update" => []}}} + tag_fixture(%{name: "batchadd"}) + image = image_fixture(tags: "safe") + + assert {:ok, result} = + Images.update_batch_tags(actor(user), %{ + tag_list: "batchadd", + image_ids: [image.id] + }) + + assert result == %{succeeded: 1, failed: 0} + end + + test "a plain moderator is not authorized" do + image = image_fixture(tags: "safe") + + assert Images.update_batch_tags( + actor(moderator_user_fixture()), + %{tag_list: "batchadd", image_ids: [image.id]} + ) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + image = image_fixture(tags: "safe") + + assert Images.update_batch_tags( + actor(confirmed_user_fixture()), + %{tag_list: "batchadd", image_ids: [image.id]} + ) == + {:error, :unauthorized} + end + + test "an anonymous actor is not authorized" do + image = image_fixture(tags: "safe") + + assert Images.update_batch_tags(actor(), %{tag_list: "batchadd", image_ids: [image.id]}) == + {:error, :unauthorized} + end + + test "an unknown-but-castable id lands in failed, not succeeded" do + actor = actor(admin_user_fixture()) + tag_fixture(%{name: "batchadd"}) + image = image_fixture(tags: "safe") + + assert {:ok, result} = + Images.update_batch_tags(actor, %{ + tag_list: "batchadd", + image_ids: [image.id, 2_147_483_647] + }) + + assert result == %{succeeded: 1, failed: 1} + end + + test "processes image IDs in chunks of 1,000" do + actor = actor(admin_user_fixture()) + tag_fixture(%{name: "batchadd"}) + :ok = Endpoint.subscribe("firehose") + + image_ids = Enum.to_list(2_000_000_000..2_000_001_000) + + assert {:ok, result} = + Images.update_batch_tags(actor, %{tag_list: "batchadd", image_ids: image_ids}) + + assert result == %{succeeded: 0, failed: 1_001} + assert moderation_log_count() == 2 + assert_receive %Broadcast{event: "image:batch_tag_update", payload: %{image_ids: []}} + assert_receive %Broadcast{event: "image:batch_tag_update", payload: %{image_ids: []}} + end + + test "a non-castable id returns the form changeset" do + actor = actor(admin_user_fixture()) + tag_fixture(%{name: "batchadd"}) + image = image_fixture(tags: "safe") + + assert {:error, %Ecto.Changeset{} = changeset} = + Images.update_batch_tags(actor, %{ + tag_list: "batchadd", + image_ids: [image.id, "abc"] + }) + + assert changeset.errors[:image_ids] + end + + test "the log counts only the images the batch matched" do + actor = actor(admin_user_fixture()) + tag_fixture(%{name: "batchadd"}) + image = image_fixture(tags: "safe") + + assert {:ok, %{succeeded: 1, failed: 1}} = + Images.update_batch_tags(actor, %{ + tag_list: "batchadd", + image_ids: [image.id, 2_147_483_647] + }) + + log = only_moderation_log!() + assert log.body == "Batch tagged 'batchadd' on 1 images" + end + + test "an admin updates hidden images without including them in tag image counts" do + actor = actor(admin_user_fixture()) + visible = image_fixture() + hidden = image_fixture(hidden_from_users: true) + tag_fixture(%{name: "batchhiddentag"}) + + assert {:ok, result} = + Images.update_batch_tags(actor, %{ + tag_list: "batchhiddentag", + image_ids: [hidden.id, visible.id] + }) + + assert result == %{succeeded: 2, failed: 0} + assert "batchhiddentag" in image_tag_names(hidden) + assert "batchhiddentag" in image_tag_names(visible) + assert Repo.get_by!(Tag, name: "batchhiddentag").images_count == 1 + + assert {:ok, result} = + Images.update_batch_tags(actor, %{ + tag_list: "-batchhiddentag", + image_ids: [hidden.id] + }) + + assert result == %{succeeded: 1, failed: 0} + refute "batchhiddentag" in image_tag_names(hidden) + assert "batchhiddentag" in image_tag_names(visible) + assert Repo.get_by!(Tag, name: "batchhiddentag").images_count == 1 + end + end + + describe "list_images/1" do + @describetag :search + + setup do + Search.clear_index!(Image) + :ok + end + + test "a visible older image appears for an anonymous scope, with tags preloaded" do + image = image_fixture(created_at: minutes_ago(4)) + SearchHelpers.reindex_all!(Image) + + page = Images.list_images(actor(), index_scope()) + + assert %Scrivener.Page{} = page + ids = Enum.map(page.entries, & &1.id) + assert image.id in ids + + entry = Enum.find(page.entries, &(&1.id == image.id)) + assert Ecto.assoc_loaded?(entry.tags) + end + + test "a recent image is held back by the front-page upload delay" do + image = image_fixture(created_at: minutes_ago(1)) + SearchHelpers.reindex_all!(Image) + + page = Images.list_images(actor(), index_scope()) + + refute image.id in Enum.map(page.entries, & &1.id) + end + end + + describe "query_images/1" do + @describetag :search + + setup do + Search.clear_index!(Image) + :ok + end + + test "a wildcard query returns the record page and an empty sidebar tag list" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, %{images: page, tags: []}} = + Images.query_images(actor(), search_scope(%{"q" => "*"})) + + assert %Scrivener.Page{} = page + assert image.id in Enum.map(page.entries, & &1.id) + end + + test "a single-tag query returns the raw Tag record it names in the sidebar list" do + _image = image_fixture(tags: "safe") + SearchHelpers.reindex_all!(Image) + + assert {:ok, %{tags: tags}} = Images.query_images(actor(), search_scope(%{"q" => "safe"})) + + assert [%Tag{} = tag] = tags + assert tag.name == "safe" + assert is_integer(tag.id) + end + + test "a malformed query returns the compiler error tuple" do + assert {:error, msg} = + Images.query_images(actor(), search_scope(%{"q" => "width.gte:abc"})) + + assert is_binary(msg) + end + + # A custom sort field (anything under "sf" other than id/first_seen_at) + # needs its sort cursor, so the page is loaded with hits and each entry is + # a {record, hit} tuple. + test "a custom sort field pairs each entry with its raw hit" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, %{images: page}} = + Images.query_images(actor(), search_scope(%{"q" => "*", "sf" => "score"})) + + assert Enum.all?(page.entries, &match?({%Image{}, hit} when is_map(hit), &1)) + assert {%Image{id: id}, _hit} = Enum.find(page.entries, &(elem(&1, 0).id == image.id)) + assert id == image.id + end + + test "the default sort returns plain records" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, %{images: page}} = Images.query_images(actor(), search_scope(%{"q" => "*"})) + + assert Enum.all?(page.entries, &match?(%Image{}, &1)) + assert image.id in Enum.map(page.entries, & &1.id) + end + + test "sf=id returns plain records" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, %{images: page}} = + Images.query_images(actor(), search_scope(%{"q" => "*", "sf" => "id"})) + + assert Enum.all?(page.entries, &match?(%Image{}, &1)) + assert image.id in Enum.map(page.entries, & &1.id) + end + + test "sf=first_seen_at returns plain records" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + assert {:ok, %{images: page}} = + Images.query_images(actor(), search_scope(%{"q" => "*", "sf" => "first_seen_at"})) + + assert Enum.all?(page.entries, &match?(%Image{}, &1)) + assert image.id in Enum.map(page.entries, & &1.id) + end + end end diff --git a/test/philomena/loader_test.exs b/test/philomena/loader_test.exs new file mode 100644 index 000000000..c739bf596 --- /dev/null +++ b/test/philomena/loader_test.exs @@ -0,0 +1,81 @@ +defmodule Philomena.LoaderTest do + use Philomena.DataCase, async: true + + import Ecto.Query + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.SiteNoticesFixtures + import Philomena.UsersFixtures + + alias Philomena.Loader + alias Philomena.SiteNotices.SiteNotice + + describe "parse_id/1" do + test "normalizes malformed IDs to not found" do + assert Loader.parse_id("42") == {:ok, 42} + assert Loader.parse_id("not-an-id") == {:error, :not_found} + end + end + + describe "fetch_and_authorize/5" do + setup do + notice = site_notice_fixture() + + actors = [ + anonymous: actor(), + user: actor(confirmed_user_fixture()), + moderator: actor(moderator_user_fixture()), + admin: actor(admin_user_fixture()) + ] + + %{actors: actors, notice: notice} + end + + test "malformed and missing IDs are not found for every actor", %{actors: actors} do + for {_role, actor} <- actors do + assert Loader.fetch_and_authorize(SiteNotice, actor, :edit, "not-an-id") == + {:error, :not_found} + + assert Loader.fetch_and_authorize(SiteNotice, actor, :edit, 2_147_483_647) == + {:error, :not_found} + end + end + + test "authorization is evaluated only for a real record", %{actors: actors, notice: notice} do + for {role, actor} <- actors do + expected = + if role == :admin do + {:ok, notice} + else + {:error, :unauthorized} + end + + assert Loader.fetch_and_authorize(SiteNotice, actor, :edit, notice.id) == expected + end + end + end + + describe "query-based loaders" do + test "one/1 normalizes an empty result" do + assert Loader.one(from notice in SiteNotice, where: notice.id == -1) == + {:error, :not_found} + end + + test "one_and_authorize/3 preserves missing before forbidden precedence" do + user = actor(confirmed_user_fixture()) + + assert Loader.one_and_authorize( + from(notice in SiteNotice, where: notice.id == -1), + user, + :edit + ) == {:error, :not_found} + + notice = site_notice_fixture() + + assert Loader.one_and_authorize( + from(candidate in SiteNotice, where: candidate.id == ^notice.id), + user, + :edit + ) == {:error, :unauthorized} + end + end +end diff --git a/test/philomena/mod_notes_test.exs b/test/philomena/mod_notes_test.exs index 9b0184970..c9b6bc636 100644 --- a/test/philomena/mod_notes_test.exs +++ b/test/philomena/mod_notes_test.exs @@ -1,23 +1,473 @@ defmodule Philomena.ModNotesTest do - use Philomena.DataCase, async: true + @moduledoc """ + Context-level tests for the controller-facing `Philomena.ModNotes` functions: + the admin index (`load_mod_note_index/4`), the new/create form and insert + (`new_mod_note/2`, `create_mod_note/2`), and the edit/update/delete actions. - alias Philomena.ModNotes - alias Philomena.ModNotes.ModNote - alias Philomena.Repo + These pin the staff authorization matrix (assistants and moderators may index, + create, and touch their own notes; a moderator may not touch another + moderator's note; an admin may touch any), the moderator attribution on + create, the notable-filter branch of the index, and uniform not-found results + for absent IDs. + """ - import Philomena.UsersFixtures + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.DnpEntriesFixtures + import Philomena.ModNotesFixtures import Philomena.ReportsFixtures import Philomena.ImagesFixtures - import Philomena.DnpEntriesFixtures + import Philomena.UsersFixtures import Philomena.TagsFixtures - describe "create_mod_note/3 against a target column" do + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.ModNotes + alias Philomena.ModNotes.ModNote + + @pagination [page: 1, page_size: 25] + + # Note params in the shape the admin form posts. + defp note_attrs(attrs \\ %{}) do + Enum.into(attrs, %{ + "user_id" => confirmed_user_fixture().id, + "body" => "Watching this one" + }) + end + + describe "list_mod_notes/4" do + test "an anonymous viewer is unauthorized" do + assert ModNotes.list_mod_notes(actor(), %{}, & &1, @pagination) == + {:error, :unauthorized} + end + + test "a regular user is unauthorized" do + assert ModNotes.list_mod_notes(actor(confirmed_user_fixture()), %{}, & &1, @pagination) == + {:error, :unauthorized} + end + + test "an assistant, a moderator, and an admin are all authorized" do + for user <- [assistant_user_fixture(), moderator_user_fixture(), admin_user_fixture()] do + assert {:ok, page} = ModNotes.list_mod_notes(actor(user), %{}, & &1, @pagination) + assert %Scrivener.Page{} = page + end + end + + test "the default view lists all notes newest first as {note, rendered} tuples" do + moderator = moderator_user_fixture() + note = mod_note_fixture(moderator) + + assert {:ok, page} = ModNotes.list_mod_notes(actor(moderator), %{}, & &1, @pagination) + + # The identity renderer pairs each note with itself. + assert note.id in Enum.map(page.entries, fn {loaded, _rendered} -> loaded.id end) + end + + test "the notable filter restricts to the matching notable" do + moderator = moderator_user_fixture() + wanted = mod_note_fixture(moderator) + other = mod_note_fixture(moderator) + + assert {:ok, page} = + ModNotes.list_mod_notes( + actor(moderator), + %{"user_id" => wanted.user_id}, + & &1, + @pagination + ) + + ids = Enum.map(page.entries, fn {loaded, _rendered} -> loaded.id end) + assert wanted.id in ids + refute other.id in ids + end + + test "target filters ignore malformed, missing, and multiple targets" do + moderator = actor(moderator_user_fixture()) + target = confirmed_user_fixture() + + {:ok, index} = + ModNotes.list_mod_notes( + moderator, + %{"user_id" => "not-an-id"}, + & &1, + @pagination + ) + + assert Enum.empty?(index) + + {:ok, index} = + ModNotes.list_mod_notes( + moderator, + %{"user_id" => "not-an-id"}, + & &1, + @pagination + ) + + assert Enum.empty?(index) + + {:ok, index} = + ModNotes.list_mod_notes( + moderator, + %{"user_id" => "2147483647"}, + & &1, + @pagination + ) + + assert Enum.empty?(index) + + {:ok, index} = + ModNotes.list_mod_notes( + moderator, + %{"user_id" => target.id, "report_id" => "2147483647"}, + & &1, + @pagination + ) + + assert Enum.empty?(index) + end + end + + describe "list_for_target/3" do + test "loads each supported target kind newest first" do + author = moderator_user_fixture() + user = confirmed_user_fixture() + report = report_fixture(image_id: image_fixture().id) + dnp_entry = dnp_entry_fixture(confirmed_user_fixture(), tag_fixture()) + + {:ok, user_note} = + ModNotes.create_mod_note(actor(author), %{"body" => "user note", "user_id" => user.id}) + + {:ok, report_note} = + ModNotes.create_mod_note(actor(author), %{ + "body" => "report note", + "report_id" => report.id + }) + + {:ok, dnp_note} = + ModNotes.create_mod_note(actor(author), %{ + "body" => "dnp note", + "dnp_entry_id" => dnp_entry.id + }) + + renderer = fn notes -> Enum.map(notes, & &1.body) end + + assert {:ok, [{loaded, "user note"}]} = + ModNotes.list_for_target(actor(author), {:user, user.id}, renderer) + + assert loaded.id == user_note.id + + assert {:ok, [{loaded, "report note"}]} = + ModNotes.list_for_target(actor(author), {:report, report.id}, renderer) + + assert loaded.id == report_note.id + + assert {:ok, [{loaded, "dnp note"}]} = + ModNotes.list_for_target(actor(author), {:dnp_entry, dnp_entry.id}, renderer) + + assert loaded.id == dnp_note.id + end + + test "rejects unauthorized viewers and missing target IDs" do + target = confirmed_user_fixture() + renderer = fn notes -> notes end + + assert ModNotes.list_for_target( + actor(confirmed_user_fixture()), + {:user, target.id}, + renderer + ) == {:error, :unauthorized} + + assert ModNotes.list_for_target( + actor(moderator_user_fixture()), + {:user, "2147483647"}, + renderer + ) == {:error, :not_found} + + assert ModNotes.list_for_target( + actor(moderator_user_fixture()), + {:user, "not-an-id"}, + renderer + ) == {:error, :not_found} + end + end + + describe "new_mod_note/2" do + test "a moderator gets a changeset seeded from the params" do + report = report_fixture(image_id: image_fixture().id) + + assert {:ok, changeset} = + ModNotes.new_mod_note(actor(moderator_user_fixture()), %{ + "report_id" => to_string(report.id) + }) + + assert Ecto.Changeset.get_field(changeset, :report_id) == report.id + end + + test "an assistant is authorized" do + author = assistant_user_fixture() + + assert {:ok, _changeset} = + ModNotes.new_mod_note(actor(author), %{"user_id" => author.id}) + end + + test "a regular user is unauthorized" do + assert ModNotes.new_mod_note(actor(confirmed_user_fixture()), %{}) == + {:error, :unauthorized} + end + + test "an anonymous actor is unauthorized" do + assert ModNotes.new_mod_note(actor(), %{}) == {:error, :unauthorized} + end + + test "a malformed or missing selected target is not found" do + moderator = actor(moderator_user_fixture()) + + assert ModNotes.new_mod_note(moderator, %{"user_id" => "not-an-id"}) == + {:error, :not_found} + + assert ModNotes.new_mod_note(moderator, %{"user_id" => "2147483647"}) == + {:error, :not_found} + end + end + + describe "create_mod_note/2" do + test "a moderator creates a note attributed to themselves" do + moderator = moderator_user_fixture() + + assert {:ok, %ModNote{} = note} = ModNotes.create_mod_note(actor(moderator), note_attrs()) + assert note.moderator_id == moderator.id + assert note.body == "Watching this one" + + assert %ModerationLog{user_id: user_id, type: "ModNote:create"} = + Repo.get_by!(ModerationLog, subject_path: "/admin/mod_notes") + + assert user_id == moderator.id + end + + test "an assistant creates a note" do + assistant = assistant_user_fixture() + + assert {:ok, %ModNote{} = note} = ModNotes.create_mod_note(actor(assistant), note_attrs()) + assert note.moderator_id == assistant.id + end + + test "a regular user is unauthorized" do + assert ModNotes.create_mod_note(actor(confirmed_user_fixture()), note_attrs()) == + {:error, :unauthorized} + end + + test "an anonymous actor is unauthorized" do + assert ModNotes.create_mod_note(actor(), note_attrs()) == {:error, :unauthorized} + end + + test "a blank body is a rejected changeset" do + assert {:error, %Ecto.Changeset{} = changeset} = + ModNotes.create_mod_note( + actor(moderator_user_fixture()), + note_attrs(%{"body" => ""}) + ) + + assert %{body: ["can't be blank"]} = errors_on(changeset) + refute Repo.get_by(ModerationLog, type: "ModNote:create") + end + + test "a missing target is not found without attempting the insert" do + assert ModNotes.create_mod_note(actor(moderator_user_fixture()), %{ + "body" => "missing target", + "user_id" => "2147483647" + }) == {:error, :not_found} + + refute Repo.get_by(ModNote, body: "missing target") + end + + test "the global write prerequisite rejects banned and unattributed actors" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + attrs = %{"body" => "blocked", "user_id" => target.id} + + assert ModNotes.create_mod_note(actor(moderator, ban: %{}), attrs) == {:error, :ban} + + assert ModNotes.create_mod_note(actor(moderator, fingerprint: nil), attrs) == + {:error, :unauthorized} + + refute Repo.get_by(ModNote, body: "blocked") + end + end + + describe "edit_mod_note/2" do + test "a moderator loads their own note with a changeset" do + moderator = moderator_user_fixture() + note = mod_note_fixture(moderator) + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + ModNotes.edit_mod_note(actor(moderator), to_string(note.id)) + + assert loaded.id == note.id + end + + test "a moderator may not edit another moderator's note" do + note = mod_note_fixture(moderator_user_fixture()) + + assert ModNotes.edit_mod_note(actor(moderator_user_fixture()), to_string(note.id)) == + {:error, :unauthorized} + end + + test "an admin may edit any note" do + note = mod_note_fixture(moderator_user_fixture()) + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + ModNotes.edit_mod_note(actor(admin_user_fixture()), to_string(note.id)) + + assert loaded.id == note.id + end + + test "a regular user is unauthorized" do + note = mod_note_fixture(moderator_user_fixture()) + + assert ModNotes.edit_mod_note(actor(confirmed_user_fixture()), to_string(note.id)) == + {:error, :unauthorized} + end + + test "an unknown well-formed id is not-found for every actor" do + assert ModNotes.edit_mod_note(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + + assert ModNotes.edit_mod_note(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-integer id is not-found" do + assert ModNotes.edit_mod_note(actor(admin_user_fixture()), "not-a-number") == + {:error, :not_found} + end + end + + describe "update_mod_note/3" do + test "a moderator updates their own note" do + moderator = moderator_user_fixture() + note = mod_note_fixture(moderator) + + assert {:ok, %ModNote{} = updated} = + ModNotes.update_mod_note(actor(moderator), to_string(note.id), %{ + "body" => "Edited body" + }) + + assert updated.body == "Edited body" + + assert Repo.get_by!(ModerationLog, type: "ModNote:update").user_id == moderator.id + end + + test "an admin updates any note" do + note = mod_note_fixture(moderator_user_fixture()) + + assert {:ok, %ModNote{body: "Edited by admin"}} = + ModNotes.update_mod_note(actor(admin_user_fixture()), to_string(note.id), %{ + "body" => "Edited by admin" + }) + end + + test "an invalid update is a rejected changeset" do + moderator = moderator_user_fixture() + note = mod_note_fixture(moderator) + + assert {:error, %Ecto.Changeset{} = changeset} = + ModNotes.update_mod_note(actor(moderator), to_string(note.id), %{"body" => ""}) + + assert %{body: ["can't be blank"]} = errors_on(changeset) + refute Repo.get_by(ModerationLog, type: "ModNote:update") + end + + test "a moderator may not update another moderator's note" do + note = mod_note_fixture(moderator_user_fixture()) + + assert ModNotes.update_mod_note(actor(moderator_user_fixture()), to_string(note.id), %{ + "body" => "Edited body" + }) == {:error, :unauthorized} + end + + test "a regular user is unauthorized" do + note = mod_note_fixture(moderator_user_fixture()) + + assert ModNotes.update_mod_note(actor(confirmed_user_fixture()), to_string(note.id), %{ + "body" => "Edited body" + }) == {:error, :unauthorized} + end + + test "an unknown well-formed id is not-found for every actor" do + assert ModNotes.update_mod_note(actor(moderator_user_fixture()), "2147483647", %{ + "body" => "x" + }) == + {:error, :not_found} + + assert ModNotes.update_mod_note(actor(admin_user_fixture()), "2147483647", %{"body" => "x"}) == + {:error, :not_found} + end + + test "a non-integer id is not-found" do + assert ModNotes.update_mod_note(actor(admin_user_fixture()), "not-a-number", %{ + "body" => "x" + }) == + {:error, :not_found} + end + end + + describe "delete_mod_note/2" do + test "a moderator deletes their own note" do + moderator = moderator_user_fixture() + note = mod_note_fixture(moderator) + + assert {:ok, %ModNote{}} = ModNotes.delete_mod_note(actor(moderator), to_string(note.id)) + refute Repo.get(ModNote, note.id) + + assert Repo.get_by!(ModerationLog, type: "ModNote:delete").user_id == moderator.id + end + + test "an admin deletes any note" do + note = mod_note_fixture(moderator_user_fixture()) + + assert {:ok, %ModNote{}} = + ModNotes.delete_mod_note(actor(admin_user_fixture()), to_string(note.id)) + + refute Repo.get(ModNote, note.id) + end + + test "a moderator may not delete another moderator's note" do + note = mod_note_fixture(moderator_user_fixture()) + + assert ModNotes.delete_mod_note(actor(moderator_user_fixture()), to_string(note.id)) == + {:error, :unauthorized} + + assert Repo.get(ModNote, note.id) + end + + test "a regular user is unauthorized" do + note = mod_note_fixture(moderator_user_fixture()) + + assert ModNotes.delete_mod_note(actor(confirmed_user_fixture()), to_string(note.id)) == + {:error, :unauthorized} + end + + test "an unknown well-formed id is not-found for every actor" do + assert ModNotes.delete_mod_note(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + + assert ModNotes.delete_mod_note(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + + test "a non-integer id is not-found" do + assert ModNotes.delete_mod_note(actor(admin_user_fixture()), "not-a-number") == + {:error, :not_found} + end + end + + describe "create_mod_note/2 against a target column" do test "a user_id note sets user_id" do author = moderator_user_fixture() target = confirmed_user_fixture() {:ok, note} = - ModNotes.create_mod_note(author, %{"body" => "watching"}, user_id: target.id) + ModNotes.create_mod_note(actor(author), %{"body" => "watching", "user_id" => target.id}) assert note.user_id == target.id assert note.report_id == nil @@ -30,7 +480,10 @@ defmodule Philomena.ModNotesTest do report = report_fixture(image_id: image.id) {:ok, note} = - ModNotes.create_mod_note(author, %{"body" => "watching report"}, report_id: report.id) + ModNotes.create_mod_note(actor(author), %{ + "body" => "watching report", + "report_id" => report.id + }) assert note.report_id == report.id assert note.user_id == nil @@ -43,36 +496,40 @@ defmodule Philomena.ModNotesTest do dnp_entry = dnp_entry_fixture(requester, tag) {:ok, note} = - ModNotes.create_mod_note(author, %{"body" => "watching dnp"}, dnp_entry_id: dnp_entry.id) + ModNotes.create_mod_note(actor(author), %{ + "body" => "watching dnp", + "dnp_entry_id" => dnp_entry.id + }) assert note.dnp_entry_id == dnp_entry.id assert note.user_id == nil end end - describe "create_mod_note/3 validation" do + describe "create_mod_note/2 validation" do test "rejects a note with no target" do author = moderator_user_fixture() - assert {:error, changeset} = - ModNotes.create_mod_note(author, %{"body" => "orphan attempt"}, []) + assert {:error, :not_found} = + ModNotes.create_mod_note(actor(author), %{"body" => "orphan attempt"}) - assert errors_on(changeset)[:target] == ["must reference exactly one target"] + refute Repo.get_by(ModNote, body: "orphan attempt") end - test "rejects a note referencing two targets" do + test "rejects two targets instead of silently choosing one" do author = moderator_user_fixture() target = confirmed_user_fixture() image = image_fixture() report = report_fixture(image_id: image.id) - assert {:error, changeset} = - ModNotes.create_mod_note(author, %{"body" => "two targets"}, - user_id: target.id, - report_id: report.id - ) + assert {:error, :not_found} = + ModNotes.create_mod_note(actor(author), %{ + "body" => "two targets", + "user_id" => target.id, + "report_id" => report.id + }) - assert errors_on(changeset)[:target] == ["must reference exactly one target"] + refute Repo.get_by(ModNote, body: "two targets") end end @@ -124,15 +581,17 @@ defmodule Philomena.ModNotesTest do report = report_fixture(image_id: image.id) {:ok, _note} = - ModNotes.create_mod_note(author, %{"body" => "note on image report"}, - report_id: report.id - ) + ModNotes.create_mod_note(actor(author), %{ + "body" => "note on image report", + "report_id" => report.id + }) - [{note, _body}] = - ModNotes.list_all_mod_notes_for_target( - fn notes -> Enum.map(notes, & &1.body) end, - report_id: report.id - ) + assert {:ok, [{note, _body}]} = + ModNotes.list_for_target( + actor(author), + {:report, report.id}, + fn notes -> Enum.map(notes, & &1.body) end + ) assert %Philomena.Reports.Report{} = note.report assert note.report.id == report.id diff --git a/test/philomena/moderation_logs/paths_test.exs b/test/philomena/moderation_logs/paths_test.exs index 3d993e906..438c57f08 100644 --- a/test/philomena/moderation_logs/paths_test.exs +++ b/test/philomena/moderation_logs/paths_test.exs @@ -25,6 +25,7 @@ defmodule Philomena.ModerationLogs.PathsTest do alias Philomena.Forums.Forum alias Philomena.Topics.Topic alias Philomena.Posts.Post + alias Philomena.Reports.Report alias Philomena.DnpEntries.DnpEntry alias Philomena.ArtistLinks.ArtistLink @@ -116,6 +117,14 @@ defmodule Philomena.ModerationLogs.PathsTest do end end + describe "admin_report_path/1" do + test "matches ~p for a report and a raw integer id" do + report = %Report{id: 321} + assert Paths.admin_report_path(report) == ~p"/admin/reports/#{report}" + assert Paths.admin_report_path(321) == "/admin/reports/321" + end + end + describe "artist_link_path/1 and artist_link_path/2" do test "artist_link_path/2 matches ~p (User → :slug, ArtistLink → :id)" do user = %User{slug: interesting_user_slug()} diff --git a/test/philomena/moderation_logs_test.exs b/test/philomena/moderation_logs_test.exs index 012f5e8ff..5b2d48f6e 100644 --- a/test/philomena/moderation_logs_test.exs +++ b/test/philomena/moderation_logs_test.exs @@ -1,13 +1,15 @@ defmodule Philomena.ModerationLogsTest do use Philomena.DataCase, async: true + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.UsersFixtures + + alias Philomena.Multi alias Philomena.ModerationLogs + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo describe "moderation_logs" do - alias Philomena.ModerationLogs.ModerationLog - - import Philomena.UsersFixtures - test "create_moderation_log/4 with valid data creates a moderation_log" do user = user_fixture() @@ -27,4 +29,157 @@ defmodule Philomena.ModerationLogsTest do ModerationLogs.create_moderation_log(user, nil, nil, nil) end end + + describe "put_log/6" do + test "composes an audit insert with actor attribution" do + user = admin_user_fixture() + + assert {:ok, %{audit: %ModerationLog{} = log}} = + Multi.new() + |> ModerationLogs.put_log( + :audit, + actor(user), + "User:update", + "/profiles/example", + "Updated user" + ) + |> Multi.transact() + + assert log.user_id == user.id + end + + test "an invalid audit record rolls back preceding database steps" do + user = admin_user_fixture() + deleted_actor = admin_user_fixture() + Repo.delete!(deleted_actor) + + marker = + ModerationLog.changeset( + %ModerationLog{user_id: user.id}, + %{type: "Marker:create", subject_path: "/marker", body: "must roll back"} + ) + + assert {:error, :audit, %Ecto.Changeset{}, %{action: %ModerationLog{}}} = + Multi.new() + |> Multi.insert(:action, marker) + |> ModerationLogs.put_log( + :audit, + actor(deleted_actor), + "User:update", + "/profiles/deleted", + "Must fail attribution" + ) + |> Multi.transact() + + refute Repo.get_by(ModerationLog, body: "must roll back") + end + end + + describe "list_moderation_logs/2" do + alias Scrivener.Page + + @pagination [page: 1, page_size: 25] + + defp logged_entry do + {:ok, log} = + ModerationLogs.create_moderation_log( + admin_user_fixture(), + "User:update", + "/path/to/subject", + "Updated user" + ) + + log + end + + test "a moderator gets the paginated logs" do + log = logged_entry() + + assert {:ok, %Page{} = page} = + ModerationLogs.list_moderation_logs(actor(moderator_user_fixture()), @pagination) + + assert log.id in Enum.map(page.entries, & &1.id) + end + + test "an admin gets the paginated logs" do + assert {:ok, %Page{}} = + ModerationLogs.list_moderation_logs(actor(admin_user_fixture()), @pagination) + end + + test "a regular user is unauthorized" do + assert ModerationLogs.list_moderation_logs(actor(confirmed_user_fixture()), @pagination) == + {:error, :unauthorized} + end + + test "an anonymous viewer is unauthorized" do + assert ModerationLogs.list_moderation_logs(actor(), @pagination) == {:error, :unauthorized} + end + + test "orders equal-timestamp entries by newest ID and excludes expired logs" do + user = admin_user_fixture() + timestamp = DateTime.utc_now(:second) + + older = + Repo.insert!(%ModerationLog{ + user_id: user.id, + type: "Order:test", + subject_path: "/older", + body: "older id", + created_at: timestamp + }) + + newer = + Repo.insert!(%ModerationLog{ + user_id: user.id, + type: "Order:test", + subject_path: "/newer", + body: "newer id", + created_at: timestamp + }) + + Repo.insert!(%ModerationLog{ + user_id: user.id, + type: "Order:test", + subject_path: "/expired", + body: "expired", + created_at: DateTime.add(timestamp, -15, :day) + }) + + assert {:ok, page} = + ModerationLogs.list_moderation_logs(actor(moderator_user_fixture()), @pagination) + + ids = Enum.map(page.entries, & &1.id) + assert Enum.take(ids, 2) == [newer.id, older.id] + refute Enum.any?(page.entries, &(&1.body == "expired")) + end + end + + describe "cleanup!/0" do + test "deletes only records older than the retention window" do + user = admin_user_fixture() + now = DateTime.utc_now(:second) + + recent = + Repo.insert!(%ModerationLog{ + user_id: user.id, + type: "Cleanup:test", + subject_path: "/recent", + body: "recent", + created_at: now + }) + + expired = + Repo.insert!(%ModerationLog{ + user_id: user.id, + type: "Cleanup:test", + subject_path: "/expired", + body: "expired", + created_at: DateTime.add(now, -15, :day) + }) + + assert {1, nil} = ModerationLogs.cleanup!() + assert Repo.get(ModerationLog, recent.id) + refute Repo.get(ModerationLog, expired.id) + end + end end diff --git a/test/philomena/multi_test.exs b/test/philomena/multi_test.exs new file mode 100644 index 000000000..0ce279236 --- /dev/null +++ b/test/philomena/multi_test.exs @@ -0,0 +1,126 @@ +defmodule Philomena.MultiTest do + use Philomena.DataCase, async: false + + alias Ecto.Adapters.SQL.Sandbox + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.Tags.Tag + + defp await_semaphore(parent, ready, proceed) do + send(parent, ready) + + receive do + ^proceed -> {:ok, :released} + end + end + + test "restarts the transaction after a transient conflict" do + attempts = start_supervised!({Agent, fn -> 0 end}) + + multi = + Multi.new() + |> Multi.run(:result, fn _repo, _changes -> + attempt = Agent.get_and_update(attempts, fn count -> {count, count + 1} end) + + case attempt do + 0 -> {:error, :conflict} + 1 -> {:ok, :retried} + end + end) + + assert {:ok, %{result: :retried}} = Multi.transact_with_automatic_retry(multi) + assert Agent.get(attempts, & &1) == 2 + end + + test "restarts the transaction after a serialization failure" do + attempts = start_supervised!({Agent, fn -> 0 end}) + + Sandbox.unboxed_run(Repo, fn -> + tag_names = + for suffix <- ["first", "second"] do + "multi-serialization-#{suffix}-#{System.unique_integer([:positive])}" + end + + {:ok, %{rows: [[first_tag_id], [second_tag_id]]}} = + Repo.query( + """ + INSERT INTO tags (name, slug, created_at, updated_at) + VALUES ($1, $1, NOW(), NOW()), ($2, $2, NOW(), NOW()) + RETURNING id + """, + tag_names + ) + + tag_ids = [first_tag_id, second_tag_id] + + try do + parent = self() + read_query = from(tag in Tag, where: tag.id in ^tag_ids, select: tag.id) + first_update_query = from(tag in Tag, where: tag.id == ^first_tag_id) + second_update_query = from(tag in Tag, where: tag.id == ^second_tag_id) + + competing_transaction = + Task.async(fn -> + Sandbox.unboxed_run(Repo, fn -> + Multi.new() + |> Multi.run(:start_semaphore, fn _repo, _changes -> + await_semaphore(parent, :competing_transaction_ready, :start) + end) + |> Multi.all(:read_tags, read_query) + |> Multi.run(:read_semaphore, fn _repo, _changes -> + await_semaphore(parent, :competing_transaction_read, :update) + end) + |> Multi.update_all(:update_tag, first_update_query, inc: [images_count: 1]) + |> Multi.transact(isolation: :serializable) + end) + end) + + assert_receive :competing_transaction_ready + + multi = + Multi.new() + |> Multi.run(:start_semaphore, fn _repo, _changes -> + attempt = Agent.get_and_update(attempts, fn count -> {count, count + 1} end) + + if attempt == 0 do + await_semaphore(parent, :multi_ready, :start) + else + {:ok, :retry} + end + end) + |> Multi.all(:read_tags, read_query) + |> Multi.run(:read_semaphore, fn _repo, %{start_semaphore: attempt} -> + if attempt == :retry do + {:ok, :retry} + else + await_semaphore(parent, :multi_read, :update) + end + end) + |> Multi.update_all(:update_tag, second_update_query, inc: [images_count: 1]) + + transaction = + Task.async(fn -> + Sandbox.unboxed_run(Repo, fn -> + Multi.transact_with_automatic_retry(multi, isolation: :serializable) + end) + end) + + assert_receive :multi_ready + send(competing_transaction.pid, :start) + send(transaction.pid, :start) + + assert_receive :competing_transaction_read + assert_receive :multi_read + + send(competing_transaction.pid, :update) + assert {:ok, _} = Task.await(competing_transaction) + + send(transaction.pid, :update) + assert {:ok, %{update_tag: {1, nil}}} = Task.await(transaction) + assert Agent.get(attempts, & &1) == 2 + after + Repo.query!("DELETE FROM tags WHERE id IN ($1, $2)", tag_ids) + end + end) + end +end diff --git a/test/philomena/notifications_test.exs b/test/philomena/notifications_test.exs new file mode 100644 index 000000000..7c686045e --- /dev/null +++ b/test/philomena/notifications_test.exs @@ -0,0 +1,211 @@ +defmodule Philomena.NotificationsTest do + use Philomena.DataCase, async: true + + alias Philomena.Channels + alias Philomena.Galleries + alias Philomena.Images + alias Philomena.Notifications + alias Philomena.Notifications.ChannelLiveNotification + alias Philomena.Notifications.ForumPostNotification + alias Philomena.Notifications.ForumTopicNotification + alias Philomena.Notifications.GalleryImageNotification + alias Philomena.Notifications.ImageCommentNotification + alias Philomena.Notifications.ImageMergeNotification + alias Philomena.Topics + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.ChannelsFixtures + import Philomena.CommentsFixtures + import Philomena.ForumsFixtures + import Philomena.GalleriesFixtures + import Philomena.ImagesFixtures + import Philomena.PostsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + @categories ~w(channel_live forum_post forum_topic gallery_image image_comment image_merge) + @pagination %{page_number: 1, page_size: 10} + + defp event_cases(subscriber, author) do + channel = channel_fixture() + {:ok, _subscription} = Channels.create_subscription(channel, subscriber) + + forum = forum_fixture() + forum_topic = topic_fixture(forum, author) + {:ok, _subscription} = Philomena.Forums.create_subscription(forum, subscriber) + + post_topic = forum_fixture() |> topic_fixture(author) + post = post_fixture(post_topic, author) + {:ok, _subscription} = Topics.create_subscription(post_topic, subscriber) + + gallery = gallery_fixture(author) + {:ok, _subscription} = Galleries.create_subscription(gallery, subscriber) + + comment_image = image_fixture() + comment = comment_fixture(comment_image, author) + {:ok, _subscription} = Images.create_subscription(comment_image, subscriber) + + merge_target = image_fixture() + merge_source = image_fixture() + {:ok, _subscription} = Images.create_subscription(merge_target, subscriber) + + [ + %{ + category: :channel_live, + schema: ChannelLiveNotification, + broadcast: fn -> Notifications.broadcast_channel_live(channel) end, + clear: fn -> Notifications.clear_channel_live(channel, subscriber) end + }, + %{ + category: :forum_post, + schema: ForumPostNotification, + broadcast: fn -> Notifications.broadcast_forum_post(author, post_topic, post) end, + clear: fn -> Notifications.clear_forum_post(post_topic, subscriber) end + }, + %{ + category: :forum_topic, + schema: ForumTopicNotification, + broadcast: fn -> Notifications.broadcast_forum_topic(author, forum_topic) end, + clear: fn -> Notifications.clear_forum_topic(forum_topic, subscriber) end + }, + %{ + category: :gallery_image, + schema: GalleryImageNotification, + broadcast: fn -> Notifications.broadcast_gallery_image(gallery) end, + clear: fn -> Notifications.clear_gallery_image(gallery, subscriber) end + }, + %{ + category: :image_comment, + schema: ImageCommentNotification, + broadcast: fn -> Notifications.broadcast_image_comment(author, comment_image, comment) end, + clear: fn -> Notifications.clear_image_comment(comment_image, subscriber) end + }, + %{ + category: :image_merge, + schema: ImageMergeNotification, + broadcast: fn -> Notifications.broadcast_image_merge(merge_target, merge_source) end, + clear: fn -> Notifications.clear_image_merge(merge_target, subscriber) end + } + ] + end + + describe "parse_category/1" do + test "normalizes every recognized route parameter" do + for category <- @categories do + assert Notifications.parse_category(category) == {:ok, String.to_existing_atom(category)} + end + end + + test "rejects unknown and non-string route parameters" do + for param <- ["bogus", "", nil, 42] do + assert Notifications.parse_category(param) == {:error, :not_found} + end + end + end + + describe "actor-scoped unread reads" do + test "returns every category and never surfaces another user's rows" do + subscriber = confirmed_user_fixture() + other = confirmed_user_fixture() + channel = channel_fixture() + {:ok, _subscription} = Channels.create_subscription(channel, subscriber) + assert {:ok, 1} = Notifications.broadcast_channel_live(channel) + + assert {:ok, notifications} = + Notifications.list_unread_notifications(actor(subscriber), @pagination) + + assert Keyword.keys(notifications) == Enum.map(@categories, &String.to_existing_atom/1) + assert [%ChannelLiveNotification{}] = notifications[:channel_live].entries + assert Notifications.total_unread_count(actor(subscriber)) == 1 + + assert {:ok, other_notifications} = + Notifications.list_unread_notifications(actor(other), @pagination) + + assert Enum.all?(other_notifications, fn {_category, page} -> page.entries == [] end) + assert Notifications.total_unread_count(actor(other)) == 0 + end + + test "defines anonymous behavior explicitly" do + assert Notifications.total_unread_count(actor()) == 0 + + assert Notifications.list_unread_notifications(actor(), @pagination) == + {:error, :unauthorized} + + assert Notifications.show_unread_notification_category(actor(), "forum_post", @pagination) == + {:error, :unauthorized} + end + + test "parses and paginates a single category" do + subscriber = confirmed_user_fixture() + + for _ <- 1..2 do + channel = channel_fixture() + {:ok, _subscription} = Channels.create_subscription(channel, subscriber) + assert {:ok, 1} = Notifications.broadcast_channel_live(channel) + end + + assert {:ok, {:channel_live, page}} = + Notifications.show_unread_notification_category( + actor(subscriber), + "channel_live", + page: 2, + page_size: 1 + ) + + assert length(page.entries) == 1 + assert page.page_number == 2 + + assert Notifications.show_unread_notification_category( + actor(subscriber), + "unknown", + @pagination + ) == + {:error, :not_found} + end + end + + describe "event services" do + test "every broadcast is duplicate-safe and every clear is idempotent" do + subscriber = confirmed_user_fixture() + author = confirmed_user_fixture() + + for event <- event_cases(subscriber, author) do + assert {:ok, 1} = event.broadcast.(), "first #{event.category} broadcast" + assert {:ok, 1} = event.broadcast.(), "duplicate #{event.category} broadcast" + assert Repo.aggregate(event.schema, :count) == 1 + assert {:ok, 1} = event.clear.() + assert {:ok, 0} = event.clear.() + end + end + + test "every broadcast joins and rolls back with an ambient transaction" do + subscriber = confirmed_user_fixture() + author = confirmed_user_fixture() + + for event <- event_cases(subscriber, author) do + assert Repo.transact(fn -> + assert {:ok, 1} = event.broadcast.() + {:error, :forced_rollback} + end) == {:error, :forced_rollback} + + assert Repo.aggregate(event.schema, :count) == 0 + end + end + + test "forum authors do not notify themselves" do + author = confirmed_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum, author) + {:ok, _subscription} = Philomena.Forums.create_subscription(forum, author) + + assert Notifications.broadcast_forum_topic(author, topic) == {:ok, 0} + assert Repo.aggregate(ForumTopicNotification, :count) == 0 + end + + test "anonymous clear paths are successful no-ops" do + channel = channel_fixture() + + assert Notifications.clear_channel_live(channel, nil) == {:ok, 0} + end + end +end diff --git a/test/philomena/poll_concurrency_test.exs b/test/philomena/poll_concurrency_test.exs new file mode 100644 index 000000000..d5a3f4fdc --- /dev/null +++ b/test/philomena/poll_concurrency_test.exs @@ -0,0 +1,219 @@ +defmodule Philomena.PollConcurrencyTest do + use Philomena.ConcurrentDataCase + + import Ecto.Query + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.PollOptions.PollOption + alias Philomena.PollVotes + alias Philomena.PollVotes.PollVote + alias Philomena.Polls + alias Philomena.Polls.Poll + alias Philomena.Repo + + defp poll_fixture do + forum = forum_fixture() + {topic, poll} = topic_with_poll_fixture(forum) + poll = Repo.preload(poll, :options) + [option_a, option_b] = Enum.sort_by(poll.options, & &1.label) + + %{forum: forum, topic: topic, poll: poll, option_a: option_a, option_b: option_b} + end + + defp vote(actor, fixture, option) do + PollVotes.create_votes(actor, fixture.forum.short_name, fixture.topic.slug, %{ + "option_ids" => [to_string(option.id)] + }) + end + + defp assert_consistent_poll(fixture, expected_total) do + poll = Repo.reload!(fixture.poll) + options = Repo.all(from option in PollOption, where: option.poll_id == ^poll.id) + + assert poll.total_votes == expected_total + + assert Repo.aggregate( + from(vote in PollVote, + join: option in assoc(vote, :poll_option), + where: option.poll_id == ^poll.id + ), + :count + ) == expected_total + + for option <- options do + assert option.vote_count == + Repo.aggregate( + from(vote in PollVote, where: vote.poll_option_id == ^option.id), + :count + ) + end + end + + test "concurrent ballots preserve poll and option counters" do + fixture = poll_fixture() + + functions = + for option <- Enum.take(Stream.cycle([fixture.option_a, fixture.option_b]), 8) do + user = confirmed_user_fixture() + + fn -> vote(actor(user), fixture, option) end + end + + results = concurrently(functions) + + assert Enum.all?(results, &match?({:ok, _}, &1)) + assert_consistent_poll(fixture, 8) + end + + test "concurrent ballots by one user cast exactly one ballot" do + fixture = poll_fixture() + user = confirmed_user_fixture() + + results = + concurrently( + for option <- List.duplicate(fixture.option_a, 8) do + fn -> vote(actor(user), fixture, option) end + end + ) + + assert Enum.count(results, &match?({:ok, _}, &1)) == 1 + assert Enum.count(results, &match?({:error, %Ecto.Changeset{}}, &1)) == 7 + assert_consistent_poll(fixture, 1) + assert Repo.aggregate(from(vote in PollVote, where: vote.user_id == ^user.id), :count) == 1 + end + + test "concurrent deletion of ballots preserves poll and option counters" do + fixture = poll_fixture() + moderator = actor(moderator_user_fixture()) + + votes = + for option <- [fixture.option_a, fixture.option_b, fixture.option_a, fixture.option_b] do + user = confirmed_user_fixture() + {:ok, _} = vote(actor(user), fixture, option) + + Repo.one!( + from vote in PollVote, + where: vote.user_id == ^user.id and vote.poll_option_id == ^option.id + ) + end + + assert_consistent_poll(fixture, 4) + + results = + concurrently( + for poll_vote <- votes do + fn -> + PollVotes.delete_vote( + moderator, + fixture.forum.short_name, + fixture.topic.slug, + poll_vote.id + ) + end + end + ) + + assert Enum.all?(results, &match?({:ok, %Poll{}}, &1)) + assert_consistent_poll(fixture, 0) + end + + test "concurrent moderators deleting the same ballot return one not-found" do + fixture = poll_fixture() + voter = confirmed_user_fixture() + moderators = [moderator_user_fixture(), moderator_user_fixture()] + {:ok, _} = vote(actor(voter), fixture, fixture.option_a) + + poll_vote = + Repo.one!( + from vote in PollVote, + where: vote.user_id == ^voter.id and vote.poll_option_id == ^fixture.option_a.id + ) + + results = + concurrently( + for moderator <- moderators do + fn -> + PollVotes.delete_vote( + actor(moderator), + fixture.forum.short_name, + fixture.topic.slug, + poll_vote.id + ) + end + end + ) + + assert Enum.count(results, &match?({:ok, %Poll{}}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :not_found})) == 1 + assert_consistent_poll(fixture, 0) + end + + test "a ballot racing poll edits cannot change vote meaning after voting starts" do + fixture = poll_fixture() + moderator = actor(moderator_user_fixture()) + voter = confirmed_user_fixture() + + [vote_result, update_result] = + concurrently([ + fn -> vote(actor(voter), fixture, fixture.option_a) end, + fn -> + Polls.update_poll(moderator, fixture.forum.short_name, fixture.topic.slug, %{ + "vote_method" => "multiple", + "options" => %{ + "0" => %{"id" => fixture.option_a.id, "label" => "Changed A"}, + "1" => %{"id" => fixture.option_b.id, "label" => "Changed B"} + } + }) + end + ]) + + assert match?({:ok, _}, vote_result) + + case update_result do + {:ok, _poll} -> + # The edit acquired the poll lock first, so it was a valid pre-vote edit. + assert Repo.reload!(fixture.option_a).label == "Changed A" + assert Repo.reload!(fixture.poll).vote_method == "multiple" + + {:error, %Ecto.Changeset{} = changeset} -> + assert "cannot be changed after voting has started" in errors_on(changeset).options + assert "cannot be changed after voting has started" in errors_on(changeset).vote_method + assert Repo.reload!(fixture.option_a).label == "Option A" + assert Repo.reload!(fixture.poll).vote_method == "single" + end + + assert_consistent_poll(fixture, 1) + end + + test "concurrent edits after voting starts preserve the poll configuration" do + fixture = poll_fixture() + voter = confirmed_user_fixture() + moderator = actor(moderator_user_fixture()) + {:ok, _} = vote(actor(voter), fixture, fixture.option_a) + + results = + concurrently( + for title <- ["First", "Second", "Third", "Fourth"] do + fn -> + Polls.update_poll(moderator, fixture.forum.short_name, fixture.topic.slug, %{ + "title" => title, + "vote_method" => "multiple", + "options" => %{ + "0" => %{"id" => fixture.option_a.id, "label" => title <> " A"}, + "1" => %{"id" => fixture.option_b.id, "label" => title <> " B"} + } + }) + end + end + ) + + assert Enum.all?(results, &match?({:error, %Ecto.Changeset{}}, &1)) + assert Repo.reload!(fixture.poll).vote_method == "single" + assert Repo.reload!(fixture.option_a).label == "Option A" + assert Repo.reload!(fixture.option_b).label == "Option B" + assert_consistent_poll(fixture, 1) + end +end diff --git a/test/philomena/poll_votes_test.exs b/test/philomena/poll_votes_test.exs new file mode 100644 index 000000000..fd8356130 --- /dev/null +++ b/test/philomena/poll_votes_test.exs @@ -0,0 +1,399 @@ +defmodule Philomena.PollVotesTest do + @moduledoc """ + Context-level tests for the actor-first `Philomena.PollVotes` API: + `list_votes/3`, `create_votes/4`, and `delete_vote/4`. + + These pin the shared load-and-authorize chain the vote routes reuse (forum + `:show`, topic visibility, poll existence) and where each function diverges + from it: `list_votes/3` and `delete_vote/4` additionally gate on the topic + `:hide` permission, while `create_votes/4` runs `verify_write_access/1` first + (before any loading) and never checks `:hide`. The corresponding controller + characterization tests pin the HTTP behavior on top of these results. + """ + + use Philomena.DataCase, async: true + + import Ecto.Query + + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.PollVotes + alias Philomena.PollVotes.PollVote + alias Philomena.Repo + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to verify_write_access. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + # A visible topic (in a normal, publicly readable forum) carrying a two-option + # poll, with the options preloaded and sorted by label. This is the common + # shape all three functions load through. + defp forum_topic_poll_options do + forum = forum_fixture() + {topic, poll} = topic_with_poll_fixture(forum) + poll = Repo.preload(poll, :options) + [option_a, option_b] = Enum.sort_by(poll.options, & &1.label) + + %{forum: forum, topic: topic, poll: poll, option_a: option_a, option_b: option_b} + end + + # Records a vote for `option` by a fresh confirmed voter through the engine, + # returning the persisted PollVote row. + defp record_vote(forum, topic, option) do + voter = confirmed_user_fixture() + + {:ok, _ballot} = + PollVotes.create_votes(actor(voter), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option.id)] + }) + + Repo.one!( + from pv in PollVote, + where: pv.poll_option_id == ^option.id and pv.user_id == ^voter.id + ) + end + + describe "list_votes/3" do + test "a regular user is unauthorized even though the poll exists" do + # The topic is visible, so the forum :show and topic visibility checks pass + # and the poll load succeeds; the block on the topic :hide permission is + # what denies a regular user. + user = confirmed_user_fixture() + %{forum: forum, topic: topic} = forum_topic_poll_options() + + assert PollVotes.list_votes(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + + test "a moderator gets only options with votes, with voters preloaded" do + moderator = moderator_user_fixture() + + %{forum: forum, topic: topic, option_a: option_a, option_b: option_b} = + forum_topic_poll_options() + + vote = record_vote(forum, topic, option_a) + + assert {:ok, [option]} = + PollVotes.list_votes(actor(moderator), forum.short_name, topic.slug) + + # Only the option that carries a vote is returned; the zero-vote option is + # dropped so the index view renders only options someone voted for. + assert option.id == option_a.id + refute option.id == option_b.id + + # The votes and their voters are preloaded (a loaded list of PollVote rows, + # each with a loaded %User{}), not unloaded associations. + assert [%PollVote{} = loaded_vote] = option.poll_votes + assert loaded_vote.id == vote.id + assert %Philomena.Users.User{} = loaded_vote.user + end + + test "an unknown forum is not found" do + assert PollVotes.list_votes(actor(moderator_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert PollVotes.list_votes( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + end + + test "a topic without a poll is not found for a moderator" do + # A topic that carries no poll is not_found, and this check runs before + # the :hide authorization. + forum = forum_fixture() + topic = topic_fixture(forum) + + assert PollVotes.list_votes(actor(moderator_user_fixture()), forum.short_name, topic.slug) == + {:error, :not_found} + end + end + + describe "create_votes/4" do + test "a banned actor is rejected before any loading" do + # verify_write_access runs first, so a banned actor is {:error, :ban} even + # against a forum slug that does not exist: had loading run first, a missing + # forum would surface as :unauthorized. Getting :ban pins that the ban check + # precedes the forum/topic/poll load. + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert PollVotes.create_votes(actor, "nonexistent", "whatever", %{"option_ids" => ["1"]}) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading, signed in or not" do + # The fingerprint requirement applies regardless of whether a user is signed + # in, and (like the ban check) precedes loading, so a missing forum still + # answers unauthorized from the write-access gate rather than the loader. + signed_in = actor(confirmed_user_fixture(), fingerprint: nil) + anonymous = actor(nil, fingerprint: nil) + + assert PollVotes.create_votes(signed_in, "nonexistent", "whatever", %{ + "option_ids" => ["1"] + }) == {:error, :unauthorized} + + assert PollVotes.create_votes(anonymous, "nonexistent", "whatever", %{ + "option_ids" => ["1"] + }) == {:error, :unauthorized} + end + + test "a valid signed-in actor records the vote" do + user = confirmed_user_fixture() + %{forum: forum, topic: topic, poll: poll, option_a: option_a} = forum_topic_poll_options() + + assert {:ok, ballot} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id)] + }) + + assert ballot.poll.id == poll.id + + assert Repo.exists?( + from pv in PollVote, + where: pv.poll_option_id == ^option_a.id and pv.user_id == ^user.id + ) + + assert Repo.reload!(option_a).vote_count == 1 + assert Repo.reload!(poll).total_votes == 1 + end + + test "an empty poll parameter records nothing and reports failure with the topic" do + user = confirmed_user_fixture() + %{forum: forum, topic: topic, poll: poll} = forum_topic_poll_options() + + assert {:error, %Ecto.Changeset{data: ballot} = changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{}) + + assert ballot.poll.id == poll.id + assert %{option_ids: ["must select a choice"]} = errors_on(changeset) + assert Repo.aggregate(PollVote, :count) == 0 + end + + test "duplicate choices reject the entire selection" do + user = confirmed_user_fixture() + %{forum: forum, topic: topic, option_a: option} = forum_topic_poll_options() + option_id = to_string(option.id) + + assert {:error, changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [option_id, option_id] + }) + + assert %{option_ids: ["contains duplicate choices"]} = errors_on(changeset) + assert Repo.aggregate(PollVote, :count) == 0 + end + + test "malformed and wrong-poll choices reject the entire selection" do + user = confirmed_user_fixture() + + %{forum: forum, topic: topic, option_a: valid_option} = forum_topic_poll_options() + %{option_a: wrong_option} = forum_topic_poll_options() + + for option_ids <- [ + [to_string(wrong_option.id)], + [to_string(valid_option.id), to_string(wrong_option.id)] + ] do + assert {:error, changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => option_ids + }) + + assert %{option_ids: ["contains an invalid choice"]} = errors_on(changeset) + end + + assert {:error, changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => ["not-an-id"] + }) + + assert %{option_ids: ["is invalid"]} = errors_on(changeset) + + assert Repo.aggregate(PollVote, :count) == 0 + end + + test "a single-choice poll rejects multiple distinct choices" do + user = confirmed_user_fixture() + + %{forum: forum, topic: topic, option_a: option_a, option_b: option_b} = + forum_topic_poll_options() + + assert {:error, changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id), to_string(option_b.id)] + }) + + assert %{option_ids: ["must select exactly one choice"]} = errors_on(changeset) + assert Repo.aggregate(PollVote, :count) == 0 + end + + test "an expired poll is rejected" do + # The engine's :ended step bails when the poll is no longer active, so a + # closed poll surfaces as {:error, forum, topic} (both carried so the + # controller can redirect back) with nothing recorded. + user = confirmed_user_fixture() + %{forum: forum, topic: topic, poll: poll, option_a: option_a} = forum_topic_poll_options() + + poll + |> Ecto.Changeset.change(active_until: DateTime.add(DateTime.utc_now(:second), -1, :day)) + |> Repo.update!() + + assert {:error, %Ecto.Changeset{data: ballot} = changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id)] + }) + + assert ballot.poll.id == poll.id + assert %{option_ids: ["poll is closed"]} = errors_on(changeset) + assert Repo.aggregate(PollVote, :count) == 0 + end + + test "a multiple-choice poll accepts one or more choices" do + user = confirmed_user_fixture() + forum = forum_fixture() + {topic, poll} = topic_with_poll_fixture(forum, nil, %{"vote_method" => "multiple"}) + [option_a | _options] = poll |> Repo.preload(:options) |> Map.fetch!(:options) + + assert {:ok, _ballot} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id)] + }) + + assert Repo.aggregate(PollVote, :count) == 1 + end + + test "a repeat vote by the same user is rejected" do + user = confirmed_user_fixture() + + %{forum: forum, topic: topic, poll: poll, option_a: option_a, option_b: option_b} = + forum_topic_poll_options() + + assert {:ok, _ballot} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id)] + }) + + assert {:error, %Ecto.Changeset{data: ballot} = changeset} = + PollVotes.create_votes(actor(user), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_b.id)] + }) + + assert ballot.poll.id == poll.id + assert %{option_ids: ["has already voted"]} = errors_on(changeset) + assert Repo.aggregate(from(pv in PollVote, where: pv.user_id == ^user.id), :count) == 1 + end + + test "an anonymous but fingerprinted actor is unauthorized" do + %{forum: forum, topic: topic, option_a: option_a} = forum_topic_poll_options() + + assert PollVotes.create_votes(actor(nil), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id)] + }) == {:error, :unauthorized} + + assert Repo.aggregate(PollVote, :count) == 0 + end + end + + describe "delete_vote/4" do + test "a regular user is unauthorized and the vote survives" do + # The :hide check runs before the vote is even looked up, so a regular user + # is denied regardless of the vote id. + user = confirmed_user_fixture() + %{forum: forum, topic: topic, option_a: option_a} = forum_topic_poll_options() + vote = record_vote(forum, topic, option_a) + + assert PollVotes.delete_vote(actor(user), forum.short_name, topic.slug, to_string(vote.id)) == + {:error, :unauthorized} + + assert Repo.get(PollVote, vote.id) + end + + test "a moderator with an unknown vote id gets not-found" do + moderator = moderator_user_fixture() + %{forum: forum, topic: topic} = forum_topic_poll_options() + + assert PollVotes.delete_vote( + actor(moderator), + forum.short_name, + topic.slug, + "999999999" + ) == {:error, :not_found} + end + + test "a moderator with a non-integer vote id gets not-found" do + # The id is parsed with IntegerId first, so an unparsable id takes the same + # nil path as an unknown one rather than raising. + moderator = moderator_user_fixture() + %{forum: forum, topic: topic} = forum_topic_poll_options() + + assert PollVotes.delete_vote( + actor(moderator), + forum.short_name, + topic.slug, + "not-a-number" + ) == {:error, :not_found} + end + + test "a vote from another poll is not found and survives" do + moderator = moderator_user_fixture() + %{forum: forum, topic: topic} = forum_topic_poll_options() + + %{ + forum: other_forum, + topic: other_topic, + poll: other_poll, + option_a: other_option + } = forum_topic_poll_options() + + other_vote = record_vote(other_forum, other_topic, other_option) + + assert PollVotes.delete_vote( + actor(moderator), + forum.short_name, + topic.slug, + to_string(other_vote.id) + ) == {:error, :not_found} + + assert Repo.get(PollVote, other_vote.id) + assert Repo.reload!(other_option).vote_count == 1 + assert Repo.reload!(other_poll).total_votes == 1 + end + + test "a moderator removes the vote and decrements the cached tallies" do + moderator = moderator_user_fixture() + %{forum: forum, topic: topic, poll: poll, option_a: option_a} = forum_topic_poll_options() + vote = record_vote(forum, topic, option_a) + + assert Repo.reload!(option_a).vote_count == 1 + assert Repo.reload!(poll).total_votes == 1 + + assert {:ok, poll} = + PollVotes.delete_vote( + actor(moderator), + forum.short_name, + topic.slug, + to_string(vote.id) + ) + + assert poll.topic.id == topic.id + assert poll.topic.forum.id == forum.id + refute Repo.get(PollVote, vote.id) + + assert Repo.reload!(option_a).vote_count == 0 + assert Repo.reload!(poll).total_votes == 0 + end + end +end diff --git a/test/philomena/polls_test.exs b/test/philomena/polls_test.exs new file mode 100644 index 000000000..c5e2a4db8 --- /dev/null +++ b/test/philomena/polls_test.exs @@ -0,0 +1,270 @@ +defmodule Philomena.PollsTest do + @moduledoc """ + Context-level tests for the actor-first poll editing APIs on `Philomena.Polls`: + `load_poll_for_edit/3` and `update_poll/4`. + + These pin the shared load-and-authorize chain both functions reuse (forum + `:show`, topic visibility, poll existence, then topic `:hide`) across the + anonymous / regular user / moderator matrix, including the ordering quirk + where the poll-existence check runs before the `:hide` authorization: a + regular user on a poll-less topic answers not-found rather than unauthorized. + The corresponding controller characterization tests pin the HTTP behavior on + top of these results. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.Polls + alias Philomena.Polls.Poll + alias Philomena.PollVotes + alias Philomena.Repo + + # A visible topic (in a normal, publicly readable forum) that carries a poll, + # the common shape both functions load through. + defp forum_topic_poll do + forum = forum_fixture() + {topic, poll} = topic_with_poll_fixture(forum) + {forum, topic, poll} + end + + describe "edit_poll/3" do + test "a moderator loads the poll, preloaded options, and a changeset for it" do + moderator = moderator_user_fixture() + {forum, topic, poll} = forum_topic_poll() + + assert {:ok, %Ecto.Changeset{data: loaded_poll}} = + Polls.edit_poll(actor(moderator), forum.short_name, topic.slug) + + assert loaded_poll.topic.forum.id == forum.id + assert loaded_poll.topic.id == topic.id + assert loaded_poll.id == poll.id + + # The options are preloaded so the edit form can render existing choices: + # a loaded list, not an unloaded association. + assert is_list(loaded_poll.options) + assert length(loaded_poll.options) == 2 + end + + test "an admin loads the poll" do + admin = admin_user_fixture() + {forum, topic, poll} = forum_topic_poll() + + assert {:ok, %Ecto.Changeset{data: loaded_poll}} = + Polls.edit_poll(actor(admin), forum.short_name, topic.slug) + + assert loaded_poll.id == poll.id + end + + test "a regular user is unauthorized even though the poll exists" do + # The topic is visible, so the forum :show and topic visibility checks pass + # and the poll load succeeds; the block on the topic :hide permission is + # what denies a regular user. + user = confirmed_user_fixture() + {forum, topic, _poll} = forum_topic_poll() + + assert Polls.edit_poll(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + + test "an anonymous actor is unauthorized" do + # nil clears forum :show and topic visibility on normal content and the + # poll load succeeds, but fails the topic :hide permission, so this is a + # clean unauthorized rather than a crash on the nil actor. + {forum, topic, _poll} = forum_topic_poll() + + assert Polls.edit_poll(actor(), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + + test "an unknown forum is not found for a regular user" do + assert Polls.edit_poll(actor(confirmed_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Polls.edit_poll( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == {:error, :not_found} + end + + test "a topic without a poll is not found for a moderator" do + # A topic that carries no poll is not_found. + forum = forum_fixture() + topic = topic_fixture(forum) + + assert Polls.edit_poll( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug + ) == + {:error, :not_found} + end + + test "a topic without a poll is unauthorized for a regular user" do + user = confirmed_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum) + + assert Polls.edit_poll(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + end + + describe "update_poll/4" do + test "a moderator updates the poll title and redirect data is returned" do + moderator = moderator_user_fixture() + {forum, topic, poll} = forum_topic_poll() + + assert {:ok, %Poll{} = loaded_poll} = + Polls.update_poll(actor(moderator), forum.short_name, topic.slug, %{ + "title" => "Moderator updated title" + }) + + assert loaded_poll.topic.forum.id == forum.id + assert loaded_poll.topic.id == topic.id + assert Repo.reload!(poll).title == "Moderator updated title" + end + + test "an admin updates the poll title" do + admin = admin_user_fixture() + {forum, topic, poll} = forum_topic_poll() + + assert {:ok, %Poll{}} = + Polls.update_poll(actor(admin), forum.short_name, topic.slug, %{ + "title" => "Admin updated title" + }) + + assert Repo.reload!(poll).title == "Admin updated title" + end + + test "options can be edited before voting starts" do + moderator = moderator_user_fixture() + {forum, topic, poll} = forum_topic_poll() + [option_a, option_b] = poll |> Repo.preload(:options) |> Map.fetch!(:options) + + assert {:ok, %Poll{}} = + Polls.update_poll(actor(moderator), forum.short_name, topic.slug, %{ + "options" => %{ + "0" => %{"id" => option_a.id, "label" => "Renamed option"}, + "1" => %{"id" => option_b.id, "label" => option_b.label} + } + }) + + assert Repo.reload!(option_a).label == "Renamed option" + end + + test "options and vote method are immutable after voting starts" do + moderator = moderator_user_fixture() + voter = confirmed_user_fixture() + {forum, topic, poll} = forum_topic_poll() + [option_a, option_b] = poll |> Repo.preload(:options) |> Map.fetch!(:options) + + assert {:ok, _ballot} = + PollVotes.create_votes(actor(voter), forum.short_name, topic.slug, %{ + "option_ids" => [to_string(option_a.id)] + }) + + assert {:error, %Ecto.Changeset{} = changeset} = + Polls.update_poll(actor(moderator), forum.short_name, topic.slug, %{ + "vote_method" => "multiple", + "options" => %{ + "0" => %{"id" => option_a.id, "label" => "Rewritten choice"}, + "1" => %{"id" => option_b.id, "label" => option_b.label} + } + }) + + assert "cannot be changed after voting has started" in errors_on(changeset).options + assert "cannot be changed after voting has started" in errors_on(changeset).vote_method + assert Repo.reload!(option_a).label == option_a.label + assert Repo.reload!(poll).vote_method == "single" + end + + test "a rejected changeset leaves the poll unchanged" do + # Poll.changeset requires title; a blank title with another real change + # (active_until) produces an invalid changeset with non-empty changes, so + # it surfaces as {:error, changeset} (data containing both the loaded forum + # and topic so the controller can re-render the edit form) rather than the + # no-op :ignore path. + moderator = moderator_user_fixture() + {forum, topic, poll} = forum_topic_poll() + + assert {:error, %Ecto.Changeset{data: loaded_poll} = changeset} = + Polls.update_poll(actor(moderator), forum.short_name, topic.slug, %{ + "title" => "", + "active_until" => + DateTime.utc_now(:second) |> DateTime.add(3, :day) |> DateTime.to_iso8601(), + "vote_method" => "single" + }) + + assert loaded_poll.topic.forum.id == forum.id + assert loaded_poll.topic.id == topic.id + refute changeset.valid? + assert Repo.reload!(poll).title == "Best test option?" + end + + test "a regular user is unauthorized and the poll is unchanged" do + user = confirmed_user_fixture() + {forum, topic, poll} = forum_topic_poll() + + assert Polls.update_poll(actor(user), forum.short_name, topic.slug, %{"title" => "Hijacked"}) == + {:error, :unauthorized} + + assert Repo.reload!(poll).title == "Best test option?" + end + + test "an anonymous actor is unauthorized and the poll is unchanged" do + {forum, topic, poll} = forum_topic_poll() + + assert Polls.update_poll(actor(), forum.short_name, topic.slug, %{"title" => "Hijacked"}) == + {:error, :unauthorized} + + assert Repo.reload!(poll).title == "Best test option?" + end + + test "an unknown forum is not found for a regular user" do + assert Polls.update_poll(actor(confirmed_user_fixture()), "nonexistent", "whatever", %{ + "title" => "New title" + }) == {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Polls.update_poll( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic", + %{"title" => "New title"} + ) == {:error, :not_found} + end + + test "a topic without a poll is not found for a moderator" do + forum = forum_fixture() + topic = topic_fixture(forum) + + assert Polls.update_poll(actor(moderator_user_fixture()), forum.short_name, topic.slug, %{ + "title" => "New title" + }) == {:error, :not_found} + end + + test "a topic without a poll is unauthorized for a regular user" do + user = confirmed_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum) + + assert Polls.update_poll(actor(user), forum.short_name, topic.slug, %{ + "title" => "New title" + }) == + {:error, :unauthorized} + end + end +end diff --git a/test/philomena/posts_test.exs b/test/philomena/posts_test.exs new file mode 100644 index 000000000..89351bccd --- /dev/null +++ b/test/philomena/posts_test.exs @@ -0,0 +1,1135 @@ +defmodule Philomena.PostsTest do + @moduledoc """ + Context-level tests for the actor-first `Philomena.Posts` API. + + These pin the authorization matrix (anonymous/user/moderator), the two + global error shapes routed through the id guard, and the moderation log + entry - type string, body, and subject path byte-for-byte - that + `approve_post/2`, `hide_post/3`, and `unhide_post/2` write on success. The + corresponding controller characterization tests pin the HTTP behavior on top + of these results. + """ + + use Philomena.DataCase, async: true + + import Ecto.Query + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.PostsFixtures + import Philomena.RulesFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Posts + alias Philomena.Posts.Post + alias Philomena.Posts.PostVersion + alias Philomena.Reports.Report + alias Philomena.Forums.Forum + alias Philomena.Repo + alias Philomena.Users.User + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to the write-access and + # not-banned checks the report loaders run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + setup do + forum = forum_fixture() + topic = topic_fixture(forum) + + %{forum: forum, topic: topic} + end + + # A post authored by a fresh (untrusted) user containing an external link is + # not auto-approved on creation (see Philomena.Schema.Approval); returns the + # post together with its author so the posts_count bump can be checked. + defp unapproved_post(topic) do + approval_rule!() + author = confirmed_user_fixture() + + post = + post_fixture(topic, author, %{ + "body" => "check this out https://spam.example/" + }) + + refute post.approved + {post, author} + end + + defp approval_rule! do + rule_fixture() + |> Ecto.Changeset.change(name: "Approval") + |> Repo.update!() + end + + defp no_moderation_logs! do + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + defp route_parent(post_id) do + post = + case Philomena.IntegerId.parse(post_id) do + {:ok, id} -> Repo.get(Post, id) + :error -> nil + end + + case post && Repo.preload(post, topic: :forum) do + %Post{topic: topic} -> + {topic.forum.short_name, topic.slug} + + nil -> + forum = forum_fixture() + topic = topic_fixture(forum) + {forum.short_name, topic.slug} + end + end + + defp approve_post(actor, post_id) do + {forum_slug, topic_slug} = route_parent(post_id) + Posts.create_post_approve(actor, forum_slug, topic_slug, post_id) + end + + defp hide_post(actor, post_id, attrs) do + {forum_slug, topic_slug} = route_parent(post_id) + Posts.create_post_hide(actor, forum_slug, topic_slug, post_id, attrs) + end + + defp unhide_post(actor, post_id) do + {forum_slug, topic_slug} = route_parent(post_id) + Posts.delete_post_hide(actor, forum_slug, topic_slug, post_id) + end + + defp destroy_post(actor, post_id) do + {forum_slug, topic_slug} = route_parent(post_id) + Posts.create_post_delete(actor, forum_slug, topic_slug, post_id) + end + + describe "communication visibility" do + test "a pending post is visible only to its author, matching IP, and staff", %{ + forum: forum, + topic: topic + } do + {post, author} = unapproved_post(topic) + other = confirmed_user_fixture() + + assert {:ok, loaded} = + Posts.show_topic_post(actor(author), forum.short_name, topic.slug, post.id) + + assert loaded.id == post.id + + assert Posts.show_topic_post(actor(other), forum.short_name, topic.slug, post.id) == + {:error, :not_found} + + assert {:ok, _loaded} = + Posts.show_topic_post( + actor(other, ip: post.ip), + forum.short_name, + topic.slug, + post.id + ) + + assert {:ok, _loaded} = + Posts.show_topic_post( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id + ) + end + + test "a destroyed post is unavailable to ordinary viewers but remains visible to staff", %{ + forum: forum, + topic: topic + } do + post = + post_fixture(topic, confirmed_user_fixture()) + |> Ecto.Changeset.change(destroyed_content: true) + |> Repo.update!() + + assert Posts.show_topic_post(actor(), forum.short_name, topic.slug, post.id) == + {:error, :not_found} + + assert {:ok, loaded} = + Posts.show_topic_post( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id + ) + + assert loaded.id == post.id + end + end + + describe "parent scoping" do + test "moderation actions cannot address a post through another topic", %{ + forum: forum, + topic: topic + } do + moderator = moderator_user_fixture() + wrong_topic = topic_fixture(forum) + {unapproved, _author} = unapproved_post(topic) + visible = visible_post(topic) + hidden = already_hidden_post(topic) + + assert Posts.create_post_approve( + actor(moderator), + forum.short_name, + wrong_topic.slug, + unapproved.id + ) == {:error, :not_found} + + assert Posts.create_post_hide( + actor(moderator), + forum.short_name, + wrong_topic.slug, + visible.id, + %{"deletion_reason" => "Spam"} + ) == {:error, :not_found} + + assert Posts.delete_post_hide( + actor(moderator), + forum.short_name, + wrong_topic.slug, + hidden.id + ) == {:error, :not_found} + + assert Posts.create_post_delete( + actor(moderator), + forum.short_name, + wrong_topic.slug, + visible.id + ) == {:error, :not_found} + + refute Repo.reload!(unapproved).approved + refute Repo.reload!(visible).hidden_from_users + refute Repo.reload!(visible).destroyed_content + assert Repo.reload!(hidden).hidden_from_users + no_moderation_logs!() + end + end + + describe "create_post_approve/2" do + test "denies an anonymous actor", %{topic: topic} do + {post, _author} = unapproved_post(topic) + + assert approve_post(actor(), "#{post.id}") == {:error, :unauthorized} + refute Repo.reload!(post).approved + no_moderation_logs!() + end + + test "denies a regular user", %{topic: topic} do + {post, _author} = unapproved_post(topic) + + assert approve_post(actor(confirmed_user_fixture()), "#{post.id}") == + {:error, :unauthorized} + + refute Repo.reload!(post).approved + no_moderation_logs!() + end + + test "a moderator approves the post, which is returned with topic and forum preloaded", + %{forum: forum, topic: topic} do + {post, _author} = unapproved_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, %Post{} = approved} = approve_post(actor(moderator), "#{post.id}") + + assert approved.id == post.id + assert approved.approved + assert %{topic: %{forum: %Forum{}}} = approved + assert approved.topic.id == topic.id + assert approved.topic.forum.id == forum.id + + assert Repo.reload!(post).approved + end + + test "the moderation log names the post and topic byte-for-byte", + %{forum: forum, topic: topic} do + {post, _author} = unapproved_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, _} = approve_post(actor(moderator), "#{post.id}") + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Topic.Post.Approve:create" + assert log.body == "Approved forum post ##{post.id} in topic '#{topic.title}'" + + assert log.subject_path == + "/forums/#{forum.short_name}/topics/#{topic.slug}?post_id=#{post.id}#post_#{post.id}" + end + + test "approving increments the author's forum posts_count by one", %{topic: topic} do + {post, author} = unapproved_post(topic) + before = Repo.get!(User, author.id).posts_count + + assert {:ok, _} = approve_post(actor(moderator_user_fixture()), "#{post.id}") + + assert Repo.get!(User, author.id).posts_count == before + 1 + end + + test "a well-formed id naming no row is not found" do + assert approve_post(actor(moderator_user_fixture()), "999999999") == + {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found" do + assert approve_post(actor(moderator_user_fixture()), "abc") == {:error, :not_found} + no_moderation_logs!() + end + end + + # A visible reply authored by a fresh user, ready to be hidden. + defp visible_post(topic) do + post_fixture(topic, confirmed_user_fixture(), %{"body" => "Rule-breaking post"}) + end + + # An already-hidden reply, set up through the auth-free/log-free engine so no + # moderation log exists before the restore under test runs. + defp already_hidden_post(topic) do + moderator = moderator_user_fixture() + + {:ok, hidden} = + hide_post(actor(moderator), visible_post(topic).id, %{"deletion_reason" => "Spam"}) + + Repo.delete_all(ModerationLog) + + hidden + end + + describe "create_post_hide/3" do + test "denies an anonymous actor", %{topic: topic} do + post = visible_post(topic) + + assert hide_post(actor(), "#{post.id}", %{"deletion_reason" => "Spam"}) == + {:error, :unauthorized} + + refute Repo.reload!(post).hidden_from_users + no_moderation_logs!() + end + + test "denies a regular user, leaving the post unchanged", %{topic: topic} do + post = visible_post(topic) + + assert hide_post(actor(confirmed_user_fixture()), "#{post.id}", %{ + "deletion_reason" => "Spam" + }) == + {:error, :unauthorized} + + reloaded = Repo.reload!(post) + refute reloaded.hidden_from_users + assert reloaded.deletion_reason == "" + no_moderation_logs!() + end + + test "a moderator hides the post, which is returned with topic and forum preloaded", + %{forum: forum, topic: topic} do + post = visible_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, %Post{} = hidden} = + hide_post(actor(moderator), "#{post.id}", %{"deletion_reason" => "Spam"}) + + assert hidden.id == post.id + assert hidden.hidden_from_users + assert hidden.deletion_reason == "Spam" + assert %{topic: %{forum: %Forum{}}} = hidden + assert hidden.topic.id == topic.id + assert hidden.topic.forum.id == forum.id + + reloaded = Repo.reload!(post) + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + end + + test "the moderation log names the post, topic, and reason byte-for-byte", + %{forum: forum, topic: topic} do + post = visible_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, _} = + hide_post(actor(moderator), "#{post.id}", %{"deletion_reason" => "Spam"}) + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Topic.Post.Hide:create" + assert log.body == "Deleted forum post ##{post.id} in topic '#{topic.title}' (Spam)" + + assert log.subject_path == + "/forums/#{forum.short_name}/topics/#{topic.slug}?post_id=#{post.id}#post_#{post.id}" + end + + test "a blank deletion reason is a rejected changeset carrying the loaded post", + %{topic: topic} do + post = visible_post(topic) + + assert {:error, %Ecto.Changeset{data: %Post{} = returned}} = + hide_post(actor(moderator_user_fixture()), "#{post.id}", %{ + "deletion_reason" => "" + }) + + assert returned.id == post.id + refute Repo.reload!(post).hidden_from_users + no_moderation_logs!() + end + + test "a well-formed id naming no row is not found" do + assert hide_post(actor(moderator_user_fixture()), "999999999", %{ + "deletion_reason" => "Spam" + }) == + {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found" do + assert hide_post(actor(moderator_user_fixture()), "abc", %{ + "deletion_reason" => "Spam" + }) == + {:error, :not_found} + + no_moderation_logs!() + end + end + + describe "delete_post_hide/2" do + test "denies an anonymous actor", %{topic: topic} do + post = already_hidden_post(topic) + + assert unhide_post(actor(), "#{post.id}") == {:error, :unauthorized} + assert Repo.reload!(post).hidden_from_users + no_moderation_logs!() + end + + test "denies a regular user, leaving the post hidden", %{topic: topic} do + post = already_hidden_post(topic) + + assert unhide_post(actor(confirmed_user_fixture()), "#{post.id}") == + {:error, :unauthorized} + + reloaded = Repo.reload!(post) + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + no_moderation_logs!() + end + + test "a moderator restores the post, which is returned with topic and forum preloaded", + %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, %Post{} = restored} = unhide_post(actor(moderator), "#{post.id}") + + assert restored.id == post.id + refute restored.hidden_from_users + assert restored.deletion_reason == "" + assert %{topic: %{forum: %Forum{}}} = restored + assert restored.topic.id == topic.id + assert restored.topic.forum.id == forum.id + + reloaded = Repo.reload!(post) + refute reloaded.hidden_from_users + assert reloaded.deletion_reason == "" + end + + test "the moderation log names the post and topic byte-for-byte", + %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, _} = unhide_post(actor(moderator), "#{post.id}") + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Topic.Post.Hide:delete" + assert log.body == "Restored forum post ##{post.id} in topic '#{topic.title}'" + + assert log.subject_path == + "/forums/#{forum.short_name}/topics/#{topic.slug}?post_id=#{post.id}#post_#{post.id}" + end + + test "a well-formed id naming no row is not found" do + assert unhide_post(actor(moderator_user_fixture()), "999999999") == + {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found" do + assert unhide_post(actor(moderator_user_fixture()), "abc") == {:error, :not_found} + no_moderation_logs!() + end + end + + describe "create_post_delete/2" do + test "denies an anonymous actor, leaving the body intact", %{topic: topic} do + post = already_hidden_post(topic) + + assert destroy_post(actor(), "#{post.id}") == {:error, :unauthorized} + + reloaded = Repo.reload!(post) + assert reloaded.body == "Rule-breaking post" + refute reloaded.destroyed_content + no_moderation_logs!() + end + + test "denies a regular user, leaving the body intact", %{topic: topic} do + post = already_hidden_post(topic) + + assert destroy_post(actor(confirmed_user_fixture()), "#{post.id}") == + {:error, :unauthorized} + + reloaded = Repo.reload!(post) + assert reloaded.body == "Rule-breaking post" + refute reloaded.destroyed_content + no_moderation_logs!() + end + + test "a moderator destroys the post, which is returned with topic and forum preloaded", + %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, %Post{} = destroyed} = destroy_post(actor(moderator), "#{post.id}") + + assert destroyed.id == post.id + assert %{topic: %{forum: %Forum{}}} = destroyed + assert destroyed.topic.id == topic.id + assert destroyed.topic.forum.id == forum.id + + # The destroy engine blanks the body and marks the content destroyed. It + # requires the post to already be hidden and preserves its hidden state. + reloaded = Repo.reload!(post) + assert reloaded.body == "" + assert reloaded.destroyed_content + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + end + + # The engine authorizes :hide and never inspects hidden_from_users, so an + # already-hidden post is destroyable too; it keeps its hidden flag and reason + # while the text is wiped. + test "destroys an already-hidden post, keeping its hidden flag and reason", %{topic: topic} do + post = already_hidden_post(topic) + + # Set up through the log-free engine, so no log exists before the destroy. + no_moderation_logs!() + + assert {:ok, %Post{}} = destroy_post(actor(moderator_user_fixture()), "#{post.id}") + + reloaded = Repo.reload!(post) + assert reloaded.body == "" + assert reloaded.destroyed_content + assert reloaded.hidden_from_users + assert reloaded.deletion_reason == "Spam" + end + + test "destroying an approved post decrements the author's posts_count", + %{topic: topic} do + author = confirmed_user_fixture() + post = post_fixture(topic, author, %{"body" => "An approved post"}) + before = Repo.get!(User, author.id).posts_count + + assert post.approved + + assert {:ok, _} = + Posts.create_post_hide( + actor(moderator_user_fixture()), + topic.forum.short_name, + topic.slug, + post.id, + %{"deletion_reason" => "Spam"} + ) + + assert {:ok, _} = destroy_post(actor(moderator_user_fixture()), "#{post.id}") + assert Repo.get!(User, author.id).posts_count == before - 1 + end + + test "destroying a withheld post does not decrement the author's posts_count", + %{topic: topic} do + {post, author} = unapproved_post(topic) + before = Repo.get!(User, author.id).posts_count + + refute post.approved + + assert {:ok, _} = + Posts.create_post_hide( + actor(moderator_user_fixture()), + topic.forum.short_name, + topic.slug, + post.id, + %{"deletion_reason" => "Spam"} + ) + + assert {:ok, _} = destroy_post(actor(moderator_user_fixture()), "#{post.id}") + assert Repo.get!(User, author.id).posts_count == before + end + + test "the moderation log names the post and topic byte-for-byte", + %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + moderator = moderator_user_fixture() + + assert {:ok, _} = destroy_post(actor(moderator), "#{post.id}") + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Topic.Post.Delete:create" + assert log.body == "Destroyed forum post ##{post.id} in topic '#{topic.title}'" + + assert log.subject_path == + "/forums/#{forum.short_name}/topics/#{topic.slug}?post_id=#{post.id}#post_#{post.id}" + end + + test "a well-formed id naming no row is not found" do + assert destroy_post(actor(moderator_user_fixture()), "999999999") == + {:error, :not_found} + + no_moderation_logs!() + end + + test "an id that cannot name a row is not found" do + assert destroy_post(actor(moderator_user_fixture()), "abc") == {:error, :not_found} + no_moderation_logs!() + end + end + + describe "list_post_history/4" do + # Unlike the moderation actions above, list_post_history is a public read routed + # by forum short name and topic slug (not a bare post id), so it takes the + # loaded topic's addressing rather than a raw "#{post.id}" string. + + test "an anonymous actor reads the history of a visible post", + %{forum: forum, topic: topic} do + [post] = topic.posts + + assert {:ok, {loaded_topic, %Post{} = loaded_post, versions}} = + Posts.list_post_history(actor(), forum.short_name, topic.slug, "#{post.id}") + + assert loaded_topic.id == topic.id + assert loaded_post.id == post.id + + # The post comes back with the associations the history page renders. + assert %{topic: %{forum: %Forum{}}} = loaded_post + assert loaded_post.topic.id == topic.id + assert loaded_post.topic.forum.id == forum.id + + # A never-edited post has recorded no versions. + assert versions == [] + end + + test "an unknown forum is not found", %{topic: topic} do + [post] = topic.posts + + assert Posts.list_post_history(actor(), "nonexistent", topic.slug, "#{post.id}") == + {:error, :not_found} + end + + test "an unknown topic in a real forum is not found", %{forum: forum} do + assert Posts.list_post_history(actor(), forum.short_name, "nonexistent", "1") == + {:error, :not_found} + end + + test "an unknown post id in a real topic is not found", %{forum: forum, topic: topic} do + assert Posts.list_post_history(actor(), forum.short_name, topic.slug, "999999999") == + {:error, :not_found} + end + + test "an anonymous actor cannot read the history of a hidden post", + %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + + assert Posts.list_post_history(actor(), forum.short_name, topic.slug, "#{post.id}") == + {:error, :unauthorized} + end + + test "a regular user cannot read the history of a hidden post", + %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + + assert Posts.list_post_history( + actor(confirmed_user_fixture()), + forum.short_name, + topic.slug, + "#{post.id}" + ) == + {:error, :unauthorized} + end + + test "a moderator reads the history of a hidden post", %{forum: forum, topic: topic} do + post = already_hidden_post(topic) + + assert {:ok, {_topic, %Post{} = loaded_post, versions}} = + Posts.list_post_history( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + "#{post.id}" + ) + + assert loaded_post.id == post.id + assert loaded_post.hidden_from_users + assert is_list(versions) + end + + test "an edited post reports the recorded version, its author, and the pre-edit body", + %{forum: forum, topic: topic} do + author = confirmed_user_fixture() + post = post_fixture(topic, author, %{"body" => "Original post body"}) + + {:ok, _} = + Posts.update_post(actor(author), forum.short_name, topic.slug, post.id, %{ + "body" => "Original post body plus an edit", + "edit_reason" => "typo fix" + }) + + assert {:ok, {_topic, _post, [%PostVersion{} = version]}} = + Posts.list_post_history(actor(), forum.short_name, topic.slug, "#{post.id}") + + # previous_body records the body as it stood before the edit, so the + # single version carries the original text and names its editor. + assert version.previous_body == "Original post body" + assert version.user.id == author.id + end + + test "the history is capped at the most recent 25 versions", + %{forum: forum, topic: topic} do + author = confirmed_user_fixture() + post = post_fixture(topic, author, %{"body" => "edit 0"}) + + # Each update records one version, so 26 edits record 26 versions; the + # query limits the result to 25. Database ids break same-second timestamp + # ties, so the most recently serialized edit is first. + Enum.reduce(1..26, post, fn n, current -> + {:ok, updated} = + Posts.update_post(actor(author), forum.short_name, topic.slug, current.id, %{ + "body" => "edit #{n}" + }) + + updated + end) + + assert {:ok, {_topic, _post, versions}} = + Posts.list_post_history(actor(), forum.short_name, topic.slug, "#{post.id}") + + assert length(versions) == 25 + end + end + + describe "load_report_target/4" do + test "loads a visible post through its forum and topic parents" do + forum = forum_fixture() + topic = topic_fixture(forum) + post = hd(topic.posts) + + assert {:ok, loaded} = + Posts.load_report_target(actor(), forum.short_name, topic.slug, post.id) + + assert loaded.id == post.id + assert loaded.topic.id == topic.id + assert loaded.topic.forum.id == forum.id + end + + test "normalizes malformed, missing, and mismatched route locators" do + first_forum = forum_fixture() + second_forum = forum_fixture() + topic = topic_fixture(first_forum) + post = hd(topic.posts) + + assert Posts.load_report_target( + actor(), + first_forum.short_name, + topic.slug, + "not-an-id" + ) == {:error, :not_found} + + assert Posts.load_report_target( + actor(), + second_forum.short_name, + topic.slug, + post.id + ) == {:error, :not_found} + + assert Posts.load_report_target( + actor(), + first_forum.short_name, + "missing-topic", + post.id + ) == {:error, :not_found} + end + + test "rejects a hidden post for a regular user" do + forum = forum_fixture() + topic = topic_fixture(forum) + post = hd(topic.posts) + + hidden = + post + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + assert Posts.load_report_target( + actor(confirmed_user_fixture()), + forum.short_name, + topic.slug, + hidden.id + ) == {:error, :unauthorized} + end + end + + describe "create_post/4" do + # This is a write, so it runs verify_write_access first (ban -> :ban, + # missing fingerprint -> :unauthorized), both before any loading, then the + # forum/topic load-and-authorize chain and finally the insert engine. + + test "a banned actor is rejected before any loading, even with an unknown forum" do + # verify_write_access runs first, so a banned actor is {:error, :ban} even + # against a forum slug that does not exist (a missing forum would otherwise + # surface as :unauthorized). Getting :ban pins that the ban check precedes + # the load. + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Posts.create_post(actor, "nonexistent", "whatever", %{"body" => "Hi"}) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + # The fingerprint requirement precedes loading, so a missing forum still + # answers unauthorized from the write-access gate rather than the loader. + anonymous = actor(nil, fingerprint: nil) + + assert Posts.create_post(anonymous, "nonexistent", "whatever", %{"body" => "Hi"}) == + {:error, :unauthorized} + end + + test "a valid anonymous fingerprinted actor creates a post with no author", + %{forum: forum, topic: topic} do + # actor(nil) carries the shared fingerprint, so it clears verify_write_access + # and reaches the public forum/topic create; the engine records the post + # with a nil user (anonymous attribution). + assert {:ok, %Post{} = post} = + Posts.create_post(actor(nil), forum.short_name, topic.slug, %{ + "body" => "An anonymous reply" + }) + + assert post.user_id == nil + assert post.body == "An anonymous reply" + assert post.topic.id == topic.id + assert post.topic.forum.id == forum.id + + # The topic carries its author preloaded for the firehose broadcast. + assert %{user: _} = post.topic + end + + test "a signed-in actor creates a post attributed to the user", + %{forum: forum, topic: topic} do + user = confirmed_user_fixture() + + assert {:ok, %Post{} = post} = + Posts.create_post(actor(user), forum.short_name, topic.slug, %{ + "body" => "A logged-in reply" + }) + + assert post.user_id == user.id + assert post.body == "A logged-in reply" + end + + test "a regular actor cannot post in a locked topic", %{forum: forum, topic: topic} do + # authorize(:create_post, topic) permits no rule on a locked topic, so a + # regular actor is unauthorized after the load. + moderator = moderator_user_fixture() + + {:ok, {_forum, _topic}} = + Philomena.Topics.create_topic_lock( + actor(moderator), + forum.short_name, + topic.slug, + %{"lock_reason" => "Test lock"} + ) + + assert Posts.create_post(actor(confirmed_user_fixture()), forum.short_name, topic.slug, %{ + "body" => "Reply to a locked topic" + }) == + {:error, :unauthorized} + end + + test "an unknown topic in a real forum is not found", %{forum: forum} do + assert Posts.create_post( + actor(confirmed_user_fixture()), + forum.short_name, + "nonexistent-topic", + %{"body" => "Reply to nothing"} + ) == + {:error, :not_found} + end + + test "a blank body is a rejected insert carrying the forum and topic", + %{forum: forum, topic: topic} do + assert {:error, %Ecto.Changeset{data: %Post{} = returned}} = + Posts.create_post(actor(confirmed_user_fixture()), forum.short_name, topic.slug, %{ + "body" => "" + }) + + assert returned.topic.id == topic.id + assert returned.topic.forum.id == forum.id + + # No reply was inserted beyond the topic's own first post. + assert Repo.aggregate(from(p in Post, where: p.topic_id == ^topic.id), :count) == 1 + end + + test "an approved post increments the author's forum posts_count by one", + %{forum: forum, topic: topic} do + # A fresh confirmed user's plain (link-free) reply is auto-approved, so the + # post-insert bookkeeping bumps the author's forum post total. + author = confirmed_user_fixture() + before = Repo.get!(User, author.id).posts_count + + assert {:ok, post} = + Posts.create_post(actor(author), forum.short_name, topic.slug, %{ + "body" => "A trustworthy reply" + }) + + assert post.approved + assert Repo.get!(User, author.id).posts_count == before + 1 + end + + test "a withheld post does not increment the author's forum posts_count and is reported", + %{forum: forum, topic: topic} do + author = confirmed_user_fixture() + before = Repo.get!(User, author.id).posts_count + approval_rule!() + + assert {:ok, post} = + Posts.create_post(actor(author), forum.short_name, topic.slug, %{ + "body" => "A reply containing https://spam.example/" + }) + + refute post.approved + assert Repo.get!(User, author.id).posts_count == before + assert Repo.aggregate(from(r in Report, where: r.post_id == ^post.id), :count) == 1 + end + + test "an over-limit actor is rate limited and no post is created", + %{forum: forum, topic: topic} do + # The :post_create counter is primed past the limit, so the rate check + # (after write-access, before the topic load and insert) refuses the write. + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :post_create) + + assert Posts.create_post(actor, forum.short_name, topic.slug, %{"body" => "A reply"}) == + {:error, :rate_limited} + + # Only the topic's own first post remains. + assert Repo.aggregate(from(p in Post, where: p.topic_id == ^topic.id), :count) == 1 + end + + test "a successful create records the counter", %{forum: forum, topic: topic} do + actor = actor(confirmed_user_fixture()) + track_rate_limit(actor, :post_create) + + assert {:ok, %Post{}} = + Posts.create_post(actor, forum.short_name, topic.slug, %{"body" => "A reply"}) + + assert rate_limit_count(actor, :post_create) == "1" + end + + test "the rate check precedes the topic load: over-limit against an unknown forum is still rate limited" do + # show_forum_topic runs after the rate check, so an over-limit actor gets + # :rate_limited rather than the :unauthorized a missing forum yields. + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :post_create) + + assert Posts.create_post(actor, "nonexistent", "whatever", %{"body" => "Hi"}) == + {:error, :rate_limited} + end + end + + describe "edit_post/4" do + # This backs the edit write, so it runs the global write prerequisite and + # then the same load-and-authorize chain update_post/5 uses. + + test "a banned actor is rejected before any loading, even with an unknown forum" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Posts.edit_post(actor, "nonexistent", "whatever", "1") == + {:error, :ban} + end + + test "an actor without a fingerprint is rejected before loading" do + assert Posts.edit_post( + actor(nil, fingerprint: nil), + "nonexistent", + "whatever", + "1" + ) == {:error, :unauthorized} + end + + test "the post's author loads the form", %{forum: forum, topic: topic} do + author = confirmed_user_fixture() + post = post_fixture(topic, author) + + assert {:ok, %Ecto.Changeset{data: %Post{} = loaded} = changeset} = + Posts.edit_post(actor(author), forum.short_name, topic.slug, "#{post.id}") + + assert loaded.id == post.id + + # The changeset is over the loaded post, driving the edit form. + assert %Post{} = changeset.data + assert changeset.data.id == post.id + end + + test "another regular user cannot load the form", %{forum: forum, topic: topic} do + post = post_fixture(topic, confirmed_user_fixture()) + + assert Posts.edit_post( + actor(confirmed_user_fixture()), + forum.short_name, + topic.slug, + "#{post.id}" + ) == + {:error, :unauthorized} + end + + test "a moderator loads the form", %{forum: forum, topic: topic} do + post = post_fixture(topic, confirmed_user_fixture()) + + assert {:ok, %Ecto.Changeset{data: %Post{} = loaded}} = + Posts.edit_post( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + "#{post.id}" + ) + + assert loaded.id == post.id + end + + test "an unknown post id in a real topic is not found", %{forum: forum, topic: topic} do + assert Posts.edit_post( + actor(confirmed_user_fixture()), + forum.short_name, + topic.slug, + "999999999" + ) == + {:error, :not_found} + end + end + + describe "update_post/5" do + # This is a write, so it runs verify_write_access first (ban -> :ban), then + # the same load-and-authorize chain edit_post/4 uses, then the edit + # engine which records a version. + + test "a banned actor is rejected before any loading, even with an unknown forum" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Posts.update_post(actor, "nonexistent", "whatever", "1", %{"body" => "Edited"}) == + {:error, :ban} + end + + test "the author edits the body and a version is recorded", + %{forum: forum, topic: topic} do + author = confirmed_user_fixture() + post = post_fixture(topic, author, %{"body" => "Original reply body"}) + + assert {:ok, %Post{} = updated} = + Posts.update_post(actor(author), forum.short_name, topic.slug, "#{post.id}", %{ + "body" => "Original reply body plus an edit", + "edit_reason" => "typo" + }) + + assert updated.body == "Original reply body plus an edit" + assert Repo.reload!(post).body == "Original reply body plus an edit" + assert Repo.exists?(from v in PostVersion, where: v.post_id == ^post.id) + + assert {:ok, {_topic, _post, [%PostVersion{} = version]}} = + Posts.list_post_history(actor(), forum.short_name, topic.slug, "#{post.id}") + + assert version.previous_body == "Original reply body" + end + + test "editing an approved post into a withheld one decrements its count once and reports it", + %{forum: forum, topic: topic} do + approval_rule!() + author = confirmed_user_fixture() + post = post_fixture(topic, author, %{"body" => "An ordinary reply"}) + before = Repo.get!(User, author.id).posts_count + + assert {:ok, %Post{approved: false}} = + Posts.update_post(actor(author), forum.short_name, topic.slug, post.id, %{ + "body" => "Now containing https://spam.example/" + }) + + assert Repo.get!(User, author.id).posts_count == before - 1 + assert Repo.aggregate(from(r in Report, where: r.post_id == ^post.id), :count) == 1 + + assert {:ok, %Post{approved: false}} = + Posts.update_post(actor(author), forum.short_name, topic.slug, post.id, %{ + "body" => "Still containing https://spam.example/" + }) + + assert Repo.get!(User, author.id).posts_count == before - 1 + assert Repo.aggregate(from(r in Report, where: r.post_id == ^post.id), :count) == 1 + + assert {:ok, %Post{approved: true}} = + Posts.create_post_approve( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id + ) + + assert Repo.get!(User, author.id).posts_count == before + end + + test "another regular user cannot edit, leaving the body unchanged", + %{forum: forum, topic: topic} do + post = post_fixture(topic, confirmed_user_fixture(), %{"body" => "Original reply body"}) + + assert Posts.update_post( + actor(confirmed_user_fixture()), + forum.short_name, + topic.slug, + "#{post.id}", + %{"body" => "Hijacked"} + ) == + {:error, :unauthorized} + + assert Repo.reload!(post).body == "Original reply body" + end + + test "a blank body is a rejected changeset carrying the loaded post", + %{forum: forum, topic: topic} do + author = confirmed_user_fixture() + post = post_fixture(topic, author, %{"body" => "Original reply body"}) + + assert {:error, %Ecto.Changeset{data: %Post{} = returned}} = + Posts.update_post(actor(author), forum.short_name, topic.slug, "#{post.id}", %{ + "body" => "" + }) + + assert returned.id == post.id + assert Repo.reload!(post).body == "Original reply body" + end + + test "an unknown post id in a real topic is not found", %{forum: forum, topic: topic} do + assert Posts.update_post( + actor(confirmed_user_fixture()), + forum.short_name, + topic.slug, + "999999999", + %{"body" => "Edited"} + ) == + {:error, :not_found} + end + end +end diff --git a/test/philomena/profiles_test.exs b/test/philomena/profiles_test.exs new file mode 100644 index 000000000..e57a3af99 --- /dev/null +++ b/test/philomena/profiles_test.exs @@ -0,0 +1,367 @@ +defmodule Philomena.ProfilesTest do + @moduledoc """ + Context-level tests for `Philomena.Profiles`: the assembled profile page and + the admin-only history views, each scoped to a viewer. + + `load_profile_page/4` is search-backed (the recent uploads/faves/artwork, + comments, and posts strips come from a single multi-search), so the module is + `async: false` and reindexes explicitly. The remaining functions are + Postgres-only, but share the module. + + These pin the typed result shapes, the `:show_details` and owning-context + authorization gates, stable missing/deactivated results, and pagination of + sensitive histories. + """ + + use Philomena.DataCase, async: false + + @moduletag :search + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.BansFixtures + import Philomena.CommentsFixtures + import Philomena.ImagesFixtures + import Philomena.UserFingerprintsFixtures + import Philomena.UserIpsFixtures + import Philomena.UsersFixtures + + alias Philomena.Comments.Comment + alias Philomena.Images.Image + alias Philomena.Images.Search.Scope + alias Philomena.Posts.Post + alias Philomena.Profiles + alias Philomena.Profiles.AdminMetadata + alias Philomena.Profiles.FingerprintHistory + alias Philomena.Profiles.IpHistory + alias Philomena.Profiles.ProfilePage + alias Philomena.Repo + alias Philomena.UserNameChanges.UserNameChange + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + @pagination %{page_number: 1, page_size: 25} + @scrivener [page: 1, page_size: 25] + + # The compiled filter body for a viewer with no active filter: it excludes + # nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp scope do + Scope.new(default_filter(), @pagination) + end + + # The %Filter{} the profile page scopes its recent comments strip against; a + # fresh system filter mirrors what ConnCase hands the web layer. + defp current_filter do + Philomena.FiltersFixtures.system_filter_fixture() + end + + describe "show_profile/4" do + setup do + Search.clear_index!(Image) + Search.clear_index!(Comment) + Search.clear_index!(Post) + :ok + end + + test "assembles the full profile page with every struct key populated" do + user = confirmed_user_fixture() + upload = image_fixture(%{user_id: user.id}) + commented_image = image_fixture() + comment = comment_fixture(commented_image, user) + ban = user_ban_fixture(user) + + SearchHelpers.reindex_all!(Image) + SearchHelpers.reindex_all!(Comment) + + assert {:ok, %ProfilePage{} = page} = + Profiles.show_profile(actor(), scope(), current_filter(), user.slug) + + assert page.user.id == user.id + + # NOTE: the image strips are %Scrivener.Page{} (msearch_records returns named + # page per definition), not bare lists, though the struct type doc says + # list(). They are Enumerable, so mapping over them yields the records. + assert %Scrivener.Page{} = page.recent_uploads + assert %Scrivener.Page{} = page.recent_faves + assert %Scrivener.Page{} = page.recent_artwork + assert upload.id in Enum.map(page.recent_uploads, & &1.id) + + # recent_comments holds only comments whose images the viewer may see; the + # comment is on a visible image, so it is present. It and recent_posts are + # plain lists (both pass through Enum.filter). + assert is_list(page.recent_comments) + assert comment.id in Enum.map(page.recent_comments, & &1.id) + assert is_list(page.recent_posts) + assert is_list(page.recent_galleries) + assert is_list(page.interactions) + assert is_list(page.tags) + + # The 90-day statistics series carries one list of 90 daily values per + # tracked counter. + assert Map.keys(page.statistics) |> Enum.sort() == + Enum.sort([ + :images_count, + :image_faves_count, + :comments_count, + :image_votes_count, + :metadata_updates_count, + :posts_count + ]) + + for {_key, series} <- page.statistics do + assert length(series) == 90 + end + + assert is_map(page.watcher_counts) + + # The user's bans are listed newest first. + assert ban.id in Enum.map(page.bans, & &1.id) + end + + test "excludes a comment on a hidden image from recent_comments" do + user = confirmed_user_fixture() + hidden_image = image_fixture(%{hidden_from_users: true}) + comment = comment_fixture(hidden_image, moderator_user_fixture()) + + SearchHelpers.reindex_all!(Image) + SearchHelpers.reindex_all!(Comment) + + assert {:ok, %ProfilePage{} = page} = + Profiles.show_profile(actor(), scope(), current_filter(), user.slug) + + # The comment itself is not hidden, so it matches the search, but an + # anonymous viewer cannot see the hidden image, so it is dropped from the + # strip the viewer receives. + refute comment.id in Enum.map(page.recent_comments, & &1.id) + end + + test "an unknown slug is not found for every viewer" do + for viewer <- [actor(), actor(confirmed_user_fixture()), actor(admin_user_fixture())] do + assert Profiles.show_profile( + viewer, + scope(), + current_filter(), + "no-such-user" + ) == {:error, :not_found} + end + end + + test "a deactivated profile is not found for every viewer" do + user = confirmed_user_fixture() + + user + |> Ecto.Changeset.change(deleted_at: DateTime.utc_now(:second)) + |> Repo.update!() + + assert Profiles.show_profile( + actor(admin_user_fixture()), + scope(), + current_filter(), + user.slug + ) == {:error, :not_found} + end + end + + describe "load_admin_metadata/2" do + test "a moderator sees the user's current filter and latest IP and fingerprint" do + user = confirmed_user_fixture() + user_ip_fixture(user, "203.0.113.55") + user_fingerprint_fixture(user, "metadatafp") + + assert {:ok, + %AdminMetadata{ + filter: _filter, + last_ip: last_ip, + last_fingerprint: last_fingerprint + }} = Profiles.load_admin_metadata(actor(moderator_user_fixture()), user) + + assert to_string(last_ip.ip) == "203.0.113.55" + assert last_fingerprint.fingerprint == "metadatafp" + end + + test "a regular user sees no metadata" do + assert Profiles.load_admin_metadata( + actor(confirmed_user_fixture()), + confirmed_user_fixture() + ) == {:error, :unauthorized} + end + + test "an anonymous viewer sees no metadata" do + assert Profiles.load_admin_metadata(actor(), confirmed_user_fixture()) == + {:error, :unauthorized} + end + end + + describe "load_mod_notes/3" do + # The renderer is handed the raw note list and returns one body per note; the + # result pairs each preloaded note with its rendered body. + defp renderer, do: fn notes -> Enum.map(notes, & &1.body) end + + test "a moderator sees the notes on the user, paired with rendered bodies" do + moderator = moderator_user_fixture() + user = confirmed_user_fixture() + + {:ok, note} = + Philomena.ModNotes.create_mod_note(actor(moderator), %{ + "user_id" => user.id, + "body" => "Watching this account" + }) + + assert {:ok, [{loaded_note, body}]} = + Profiles.load_mod_notes(actor(moderator), user, renderer()) + + assert loaded_note.id == note.id + assert body == "Watching this account" + end + + test "a regular user sees no notes" do + assert Profiles.load_mod_notes( + actor(confirmed_user_fixture()), + confirmed_user_fixture(), + renderer() + ) == {:error, :unauthorized} + end + + test "the profile-details gate applies before the mod-note permission" do + assert Profiles.load_mod_notes( + actor(assistant_user_fixture()), + confirmed_user_fixture(), + renderer() + ) == {:error, :unauthorized} + end + end + + describe "load_name_changes/2" do + test "a moderator sees the user's name changes, newest id first" do + user = confirmed_user_fixture() + older = Repo.insert!(%UserNameChange{user_id: user.id, name: "oldname"}) + newer = Repo.insert!(%UserNameChange{user_id: user.id, name: "newername"}) + + assert {:ok, changes} = Profiles.load_name_changes(actor(moderator_user_fixture()), user) + assert Enum.map(changes, & &1.id) == [newer.id, older.id] + end + + test "a regular user sees no name changes" do + assert Profiles.load_name_changes( + actor(confirmed_user_fixture()), + confirmed_user_fixture() + ) == {:error, :unauthorized} + end + end + + describe "list_profile_ip_history/3" do + test "a moderator loads the user's IPs and the other users on them" do + subject = confirmed_user_fixture() + alias_user = confirmed_user_fixture() + user_ip_fixture(subject, "203.0.113.40") + user_ip_fixture(alias_user, "203.0.113.40") + + assert {:ok, %IpHistory{user: loaded, user_ips: user_ips, other_users: other_users}} = + Profiles.list_profile_ip_history( + actor(moderator_user_fixture()), + subject.slug, + @scrivener + ) + + assert loaded.id == subject.id + assert length(user_ips.entries) == 1 + + shared_ip = hd(user_ips.entries).ip + other_user_ids = other_users[shared_ip] |> Enum.map(& &1.user_id) + assert alias_user.id in other_user_ids + assert subject.id in other_user_ids + end + + test "a regular user may not load IP history" do + assert Profiles.list_profile_ip_history( + actor(confirmed_user_fixture()), + confirmed_user_fixture().slug, + @scrivener + ) == + {:error, :unauthorized} + end + + test "an anonymous viewer may not load IP history" do + assert Profiles.list_profile_ip_history(actor(), confirmed_user_fixture().slug, @scrivener) == + {:error, :unauthorized} + end + + test "an unknown slug is not found before authorization for every viewer" do + for viewer <- [actor(), actor(confirmed_user_fixture()), actor(moderator_user_fixture())] do + assert Profiles.list_profile_ip_history(viewer, "no-such-user", @scrivener) == + {:error, :not_found} + end + end + + test "paginates the subject history" do + subject = confirmed_user_fixture() + user_ip_fixture(subject, "203.0.113.41") + user_ip_fixture(subject, "203.0.113.42") + + assert {:ok, %IpHistory{user_ips: page}} = + Profiles.list_profile_ip_history( + actor(moderator_user_fixture()), + subject.slug, + page: 1, + page_size: 1 + ) + + assert length(page.entries) == 1 + assert page.total_entries == 2 + end + end + + describe "list_profile_fingerprint_history/3" do + test "a moderator loads the user's fingerprints and the other users on them" do + subject = confirmed_user_fixture() + alias_user = confirmed_user_fixture() + user_fingerprint_fixture(subject, "sharedfp") + user_fingerprint_fixture(alias_user, "sharedfp") + + assert {:ok, + %FingerprintHistory{ + user: loaded, + user_fingerprints: user_fingerprints, + other_users: other_users + }} = + Profiles.list_profile_fingerprint_history( + actor(moderator_user_fixture()), + subject.slug, + @scrivener + ) + + assert loaded.id == subject.id + assert length(user_fingerprints.entries) == 1 + + other_user_ids = other_users["sharedfp"] |> Enum.map(& &1.user_id) + assert alias_user.id in other_user_ids + assert subject.id in other_user_ids + end + + test "a regular user may not load fingerprint history" do + assert Profiles.list_profile_fingerprint_history( + actor(confirmed_user_fixture()), + confirmed_user_fixture().slug, + @scrivener + ) == + {:error, :unauthorized} + end + + test "an unknown slug is not found before authorization for every viewer" do + for viewer <- [actor(), actor(confirmed_user_fixture()), actor(moderator_user_fixture())] do + assert Profiles.list_profile_fingerprint_history(viewer, "no-such-user", @scrivener) == + {:error, :not_found} + end + end + end +end diff --git a/test/philomena/rate_limiter_test.exs b/test/philomena/rate_limiter_test.exs new file mode 100644 index 000000000..9331e1ec9 --- /dev/null +++ b/test/philomena/rate_limiter_test.exs @@ -0,0 +1,102 @@ +defmodule Philomena.RateLimiterTest do + use Philomena.DataCase, async: false + + alias Philomena.Attribution.Actor + alias Philomena.Multi + alias Philomena.RateLimiter + alias Philomena.Users.User + + defp user_actor(user_attrs \\ []) do + user = struct!(%User{id: System.unique_integer([:positive])}, user_attrs) + actor = %Actor{ip: unique_ip(), fingerprint: "d015c342859dde3", user: user} + + on_exit(fn -> Redix.command!(:redix, ["DEL", "rl:post_create:u:#{user.id}"]) end) + actor + end + + defp anonymous_actor do + actor = %Actor{ip: unique_ip(), fingerprint: "d015c342859dde3", user: nil} + + on_exit(fn -> Redix.command!(:redix, ["DEL", "rl:post_create:i:#{actor.ip}"]) end) + actor + end + + defp unique_ip do + n = System.unique_integer([:positive]) + %Postgrex.INET{address: {203, 0, rem(div(n, 254), 254) + 1, rem(n, 254) + 1}, netmask: 32} + end + + test "an action reserves one slot and rollback releases it" do + actor = user_actor() + + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + assert RateLimiter.rollback_action(actor, :post_create) == :ok + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + end + + test "a failed transaction rolls back its reservation" do + actor = user_actor() + + result = + Multi.new() + |> Multi.reserve_action( + fn -> RateLimiter.record_action(actor, :post_create, 60) end, + fn -> RateLimiter.rollback_action(actor, :post_create) end + ) + |> Multi.run(:failure, fn _repo, _changes -> {:error, :boom} end) + |> Multi.transact() + + assert {:error, :failure, :boom, _changes} = result + assert Redix.command!(:redix, ["GET", "rl:post_create:u:#{actor.user.id}"]) == "0" + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + end + + test "concurrent reservations above the limit are rejected immediately" do + actor = anonymous_actor() + + results = + for _ <- 1..10 do + Task.async(fn -> RateLimiter.record_action(actor, :post_create, 60) end) + end + |> Enum.map(&Task.await(&1, 5_000)) + + assert Enum.count(results, &(&1 == :ok)) == 2 + assert Enum.count(results, &(&1 == {:error, :rate_limited})) == 8 + assert Redix.command!(:redix, ["GET", "rl:post_create:i:#{actor.ip}"]) == "10" + end + + describe "scoping and exemptions" do + test "signed-in actors are scoped by user id" do + actor = user_actor() + other = %Actor{actor | user: %User{id: System.unique_integer([:positive])}} + + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + assert RateLimiter.record_action(other, :post_create, 60) == :ok + end + + for role <- ~w(admin moderator assistant) do + test "a #{role} records no counter" do + actor = user_actor(role: unquote(role)) + + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + assert Redix.command!(:redix, ["GET", "rl:post_create:u:#{actor.user.id}"]) == nil + end + end + + test "a bypass_rate_limits user records no counter" do + actor = user_actor(bypass_rate_limits: true) + + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + assert Redix.command!(:redix, ["GET", "rl:post_create:u:#{actor.user.id}"]) == nil + end + end + + test "recording sets a TTL on the counter" do + actor = user_actor() + + assert RateLimiter.record_action(actor, :post_create, 60) == :ok + ttl = Redix.command!(:redix, ["TTL", "rl:post_create:u:#{actor.user.id}"]) + assert ttl > 0 and ttl <= 60 + end +end diff --git a/test/philomena/reports_concurrency_test.exs b/test/philomena/reports_concurrency_test.exs new file mode 100644 index 000000000..36aaaef85 --- /dev/null +++ b/test/philomena/reports_concurrency_test.exs @@ -0,0 +1,97 @@ +defmodule Philomena.ReportsConcurrencyTest do + use Philomena.ConcurrentDataCase + + @moduletag :search + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2, random_ip: 0] + import Philomena.ImagesFixtures + import Philomena.ReportsFixtures + import Philomena.RulesFixtures + import Philomena.UsersFixtures + + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo + alias Philomena.Reports + alias Philomena.Reports.Report + alias PhilomenaQuery.Search + + setup do + Search.clear_index!(Report) + %{report: report_fixture(image_id: image_fixture().id)} + end + + test "a second or racing claim cannot reassign the report", %{report: report} do + results = + concurrently( + for moderator <- [moderator_user_fixture(), moderator_user_fixture()] do + fn -> Reports.create_report_claim(actor(moderator), report.id) end + end + ) + + assert Enum.count(results, &match?({:ok, %Report{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, %Ecto.Changeset{}}, &1)) == 1 + assert Repo.aggregate(ModerationLog, :count) == 1 + end + + test "racing anonymous report creation cannot exceed the open report limit" do + image = image_fixture() + + # The setup report uses the shared fixture IP, so leave one slot for the + # racing requests after accounting for it. + for _ <- 1..(Reports.max_open_reports() - 2) do + report_fixture(image_id: image.id) + end + + params = %{ + "reason" => "Concurrent test report", + "user_agent" => "Test Browser/1.0", + "rule_id" => rule_fixture().id + } + + results = + concurrently([ + fn -> Reports.create_report(actor(), {:image, image.id}, params) end, + fn -> Reports.create_report(actor(), {:image, image.id}, params) end + ]) + + assert Enum.count(results, &match?({:ok, %Report{}}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :too_many_reports})) == 1 + assert Repo.aggregate(Report, :count) == Reports.max_open_reports() + end + + test "racing authenticated report creation cannot exceed the user limit" do + user = confirmed_user_fixture() + ip = random_ip() + image = image_fixture() + + for _ <- 1..(Reports.max_open_reports() - 1) do + {:ok, _report} = + Reports.create_report( + actor(user, ip: ip), + {:image, image.id}, + %{ + "reason" => "Concurrent test report", + "user_agent" => "Test Browser/1.0", + "rule_id" => rule_fixture().id + } + ) + end + + params = %{ + "reason" => "Concurrent test report", + "user_agent" => "Test Browser/1.0", + "rule_id" => rule_fixture().id + } + + results = + concurrently( + for racing_ip <- [random_ip(), random_ip()] do + fn -> Reports.create_report(actor(user, ip: racing_ip), {:image, image.id}, params) end + end + ) + + assert Enum.count(results, &match?({:ok, %Report{}}, &1)) == 1 + assert Enum.count(results, &(&1 == {:error, :too_many_reports})) == 1 + assert Repo.aggregate(Report, :count) == Reports.max_open_reports() + 1 + end +end diff --git a/test/philomena/reports_test.exs b/test/philomena/reports_test.exs index 6617d49d0..18f2e81ee 100644 --- a/test/philomena/reports_test.exs +++ b/test/philomena/reports_test.exs @@ -1,262 +1,500 @@ defmodule Philomena.ReportsTest do - use Philomena.DataCase, async: true + use Philomena.DataCase, async: false - alias Philomena.Reports - alias Philomena.Reports.Report - alias Philomena.Reports.SearchIndex - alias Philomena.Repo + @moduletag :search - import Philomena.ReportsFixtures import Philomena.AttributionFixtures - import Philomena.ImagesFixtures - import Philomena.UsersFixtures - import Philomena.GalleriesFixtures + import Philomena.CommentsFixtures import Philomena.CommissionsFixtures + import Philomena.ConversationsFixtures + import Philomena.ForumsFixtures + import Philomena.GalleriesFixtures + import Philomena.ImagesFixtures + import Philomena.ModNotesFixtures + import Philomena.ReportsFixtures import Philomena.RulesFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures - describe "Report.target_columns/0" do - test "target_columns lists all seven columns" do - assert Report.target_columns() == [ - :image_id, - :comment_id, - :post_id, - :reported_user_id, - :commission_id, - :conversation_id, - :gallery_id - ] - end + alias Philomena.Comments.Comment + alias Philomena.Commissions.Commission + alias Philomena.Conversations.Conversation + alias Philomena.Galleries.Gallery + alias Philomena.Images.Image + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Posts.Post + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.Reports + alias Philomena.Reports.Report + alias Philomena.Reports.ReportForm + alias Philomena.Reports.ReportPage + alias Philomena.Reports.SearchIndex + alias Philomena.Users.User + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + @pagination %{page_number: 1, page_size: 25} + + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + setup do + Search.clear_index!(Report) + :ok end - describe "create_report/3 single-target acceptance" do - test "accepts an image report and sets image_id" do - image = image_fixture() - report = report_fixture(image_id: image.id) + defp report_params(attrs \\ %{}) do + attrs + |> Enum.into(%{ + "reason" => "Test report reason", + "user_agent" => "Test Browser/1.0" + }) + |> Map.put_new_lazy("rule_id", fn -> rule_fixture().id end) + end + + defp target_matrix do + image = image_fixture() + comment = comment_fixture(image) + profile = confirmed_user_fixture() + commission_owner = confirmed_user_fixture() + commission = commission_fixture(commission_owner) + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + gallery = gallery_fixture(confirmed_user_fixture()) + forum = forum_fixture() + topic = topic_fixture(forum, confirmed_user_fixture()) + post = hd(topic.posts) + + [ + {{:image, image.id}, Image, :image_id, image.id}, + {{:comment, image.id, comment.id}, Comment, :comment_id, comment.id}, + {{:post, forum.short_name, topic.slug, post.id}, Post, :post_id, post.id}, + {{:user, profile.slug}, User, :reported_user_id, profile.id}, + {{:commission, commission_owner.slug}, Commission, :commission_id, commission.id}, + {{:conversation, conversation.slug}, Conversation, :conversation_id, conversation.id}, + {{:gallery, gallery.id}, Gallery, :gallery_id, gallery.id} + ] + end - assert report.image_id == image.id + describe "report form boundary" do + test "new_report/2 returns a typed form for every reportable locator" do + actor = actor(moderator_user_fixture()) + reportable_rule = rule_fixture() + + for {locator, schema, foreign_key, target_id} <- target_matrix() do + assert {:ok, %ReportForm{target: target, changeset: changeset, rules: rules}} = + Reports.new_report(actor, locator) + + assert target.__struct__ == schema + assert target.id == target_id + assert %Report{} = changeset.data + assert Map.fetch!(changeset.data, foreign_key) == target_id + assert reportable_rule.id in Enum.map(rules, & &1.id) + refute Enum.any?(rules, & &1.internal) + end end - test "accepts a user report and sets reported_user_id" do - target = confirmed_user_fixture() - report = report_fixture(reported_user_id: target.id) + test "the form and create paths share write-access precedence" do + locator = {:image, "not-an-id"} + banned = actor(confirmed_user_fixture(), ban: @ban) + no_fingerprint = actor(nil, fingerprint: nil) - assert report.reported_user_id == target.id + assert Reports.new_report(banned, locator) == {:error, :ban} + assert Reports.create_report(banned, locator, report_params()) == {:error, :ban} + + assert Reports.new_report(no_fingerprint, locator) == {:error, :unauthorized} + + assert Reports.create_report(no_fingerprint, locator, report_params()) == + {:error, :unauthorized} end - test "accepts a gallery report and sets gallery_id" do - gallery = gallery_fixture(confirmed_user_fixture()) - report = report_fixture(gallery_id: gallery.id) + test "malformed and missing locators are not-found for every actor" do + for actor <- [actor(nil), actor(confirmed_user_fixture()), actor(admin_user_fixture())] do + assert Reports.new_report(actor, {:image, "invalid"}) == {:error, :not_found} + assert Reports.new_report(actor, {:image, "2147483647"}) == {:error, :not_found} + assert Reports.new_report(actor, {:gallery, "2147483647"}) == {:error, :not_found} + assert Reports.new_report(actor, {:user, "missing-profile"}) == {:error, :not_found} + + assert Reports.new_report(actor, {:conversation, "missing-conversation"}) == + {:error, :not_found} + + assert Reports.new_report(actor, {:commission, "missing-profile"}) == + {:error, :not_found} - assert report.gallery_id == gallery.id + assert Reports.new_report(actor, {:post, "missing-forum", "missing-topic", "1"}) == + {:error, :not_found} + end end - test "accepts a commission report and sets commission_id" do - commission = commission_fixture(confirmed_user_fixture()) - report = report_fixture(commission_id: commission.id) + test "forbidden real targets are unauthorized" do + hidden = image_fixture(%{hidden_from_users: true}) - assert report.commission_id == commission.id + assert Reports.new_report(actor(confirmed_user_fixture()), {:image, hidden.id}) == + {:error, :unauthorized} + + conversation = + conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + + assert Reports.new_report( + actor(confirmed_user_fixture()), + {:conversation, conversation.slug} + ) == {:error, :unauthorized} end - end - describe "create_report/3 target-count rejection" do - test "rejects a report with zero targets" do - attrs = %{ - "reason" => "no target", - "user_agent" => "TB/1.0", - "rule_id" => rule_fixture().id - } + test "comment and post locators enforce their route parents" do + first_image = image_fixture() + second_image = image_fixture() + comment = comment_fixture(first_image) + + assert Reports.new_report(actor(), {:comment, second_image.id, comment.id}) == + {:error, :not_found} + + first_forum = forum_fixture() + second_forum = forum_fixture() + topic = topic_fixture(first_forum) + post = hd(topic.posts) - assert {:error, changeset} = - Reports.create_report(attribution(), attrs, []) + assert Reports.new_report( + actor(), + {:post, second_forum.short_name, topic.slug, post.id} + ) == {:error, :not_found} - assert %{target: ["must reference exactly one target"]} = errors_on(changeset) + assert Reports.new_report( + actor(), + {:post, first_forum.short_name, topic.slug, "not-an-id"} + ) == {:error, :not_found} end end - describe "creation_changeset/4 exactly-one validation" do - test "rejects a report referencing two targets" do + describe "create_report/3" do + test "creates and attributes every reportable target" do + moderator = moderator_user_fixture() + + for {locator, _schema, foreign_key, target_id} <- target_matrix() do + assert {:ok, %Report{} = report} = + Reports.create_report(actor(moderator), locator, report_params()) + + assert report.user_id == moderator.id + assert Map.fetch!(report, foreign_key) == target_id + end + end + + test "an anonymous report records IP and fingerprint attribution" do image = image_fixture() - target = confirmed_user_fixture() - changeset = - Report.creation_changeset( - %Report{image_id: image.id, reported_user_id: target.id}, - %{"reason" => "two targets", "user_agent" => "TB/1.0"}, - attribution(), - rule_fixture() - ) + assert {:ok, report} = + Reports.create_report(actor(nil), {:image, image.id}, report_params()) - refute changeset.valid? - assert %{target: ["must reference exactly one target"]} = errors_on(changeset) + assert is_nil(report.user_id) + assert report.ip + assert report.fingerprint end - test "rejects a report referencing no target" do - changeset = - Report.creation_changeset( - %Report{}, - %{"reason" => "no target", "user_agent" => "TB/1.0"}, - attribution(), - rule_fixture() - ) + test "validation returns the loaded target in a ReportForm" do + image = image_fixture() + + assert {:error, + %ReportForm{target: %Image{id: image_id}, changeset: changeset, rules: rules}} = + Reports.create_report( + actor(), + {:image, image.id}, + report_params(%{"reason" => ""}) + ) + assert image_id == image.id refute changeset.valid? - assert %{target: ["must reference exactly one target"]} = errors_on(changeset) + assert changeset.errors[:reason] + assert Enum.any?(rules) end - end - describe "reports_reportable_association_null DB constraint" do - test "allows an all-NULL (orphan) report row" do - assert {:ok, report} = - %Report{} - |> Ecto.Changeset.change(%{ - ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, - fingerprint: "ffff", - reason: "orphan" - }) - |> Repo.insert() - - assert Enum.all?(Report.target_columns(), &is_nil(Map.get(report, &1))) + test "the target gate runs before the open-report limit" do + user = confirmed_user_fixture() + image = image_fixture() + + for _ <- 1..Reports.max_open_reports() do + report_fixture(user, image_id: image.id) + end + + assert Reports.create_report(actor(user), {:image, "missing"}, report_params()) == + {:error, :not_found} + + assert Reports.create_report(actor(user), {:image, image.id}, report_params()) == + {:error, :too_many_reports} end - test "rejects a report row with two non-NULL columns" do + test "the anonymous limit is keyed by IP and staff use a named bypass ability" do image = image_fixture() - gallery = gallery_fixture(confirmed_user_fixture()) - - assert {:error, changeset} = - %Report{} - |> Ecto.Changeset.change(%{ - ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, - fingerprint: "ffff", - reason: "two targets", - image_id: image.id, - gallery_id: gallery.id - }) - |> Ecto.Changeset.check_constraint(:target, - name: "reports_reportable_association_null" - ) - |> Repo.insert() - assert %{target: ["is invalid"]} = errors_on(changeset) + for _ <- 1..Reports.max_open_reports() do + report_fixture(image_id: image.id) + end + + assert Reports.create_report(actor(), {:image, image.id}, report_params()) == + {:error, :too_many_reports} + + moderator = moderator_user_fixture() + + for _ <- 1..Reports.max_open_reports() do + report_fixture(moderator, image_id: image.id) + end + + assert {:ok, %Report{}} = + Reports.create_report(actor(moderator), {:image, image.id}, report_params()) end end - describe "orphaned report helpers" do - setup do - {:ok, orphan} = - %Report{} - |> Ecto.Changeset.change(%{ - ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, - fingerprint: "ffff", - reason: "orphan" - }) - |> Repo.insert() + describe "user and staff indexes" do + test "list_user_reports/2 is actor-scoped" do + user = confirmed_user_fixture() + other = confirmed_user_fixture() + image = image_fixture() + own = report_fixture(user, image_id: image.id) + _other = report_fixture(other, image_id: image.id) + + assert {:ok, page} = Reports.list_user_reports(actor(user), @pagination) + assert Enum.map(page.entries, & &1.id) == [own.id] + assert Reports.list_user_reports(actor(), @pagination) == {:error, :unauthorized} + end + + test "count_open_reports/1 authorizes before querying" do + assert Reports.count_open_reports(actor()) == nil + assert Reports.count_open_reports(actor(confirmed_user_fixture())) == nil + assert Reports.count_open_reports(actor(moderator_user_fixture())) == 0 + end - %{orphan: orphan} + test "list_reports/3 returns the assembled default page" do + report = report_fixture(image_id: image_fixture().id) + SearchHelpers.reindex_all!(Report) + + assert {:ok, %ReportPage{reports: reports, my_reports: [], system_reports: []}, + _query_changeset} = + Reports.list_reports(actor(admin_user_fixture()), %{}, @pagination) + + assert report.id in Enum.map(reports.entries, & &1.id) end - test "all target columns are nil", %{orphan: orphan} do - assert Enum.all?(Report.target_columns(), &is_nil(Map.get(orphan, &1))) + test "the search branch empties auxiliary lists and rejects malformed queries" do + report_fixture(image_id: image_fixture().id) + SearchHelpers.reindex_all!(Report) + admin = actor(admin_user_fixture()) + + assert {:ok, %ReportPage{reports: reports, my_reports: [], system_reports: []}, + _query_changeset} = + Reports.list_reports(admin, %{"query" => "*"}, @pagination) + + assert length(reports.entries) == 1 + + assert {:error, %Ecto.Changeset{}} = + Reports.list_reports(admin, %{"query" => "("}, @pagination) + + assert {:error, %Ecto.Changeset{}} = + Reports.list_reports(admin, %{"query" => ["open:true"]}, @pagination) end - test "preload_targets/1 leaves every target association nil", %{orphan: orphan} do - preloaded = Reports.preload_targets(orphan) + test "the index is unauthorized for regular users" do + assert Reports.list_reports(actor(), %{}, @pagination) == {:error, :unauthorized} - assert preloaded.image == nil - assert preloaded.comment == nil - assert preloaded.post == nil - assert preloaded.reported_user == nil - assert preloaded.commission == nil - assert preloaded.conversation == nil - assert preloaded.gallery == nil + assert Reports.list_reports( + actor(confirmed_user_fixture()), + %{}, + @pagination + ) == {:error, :unauthorized} end end - describe "close_reports/2 via the target-column API" do - test "closes open reports for an image" do + describe "show_report/2" do + test "loads a real report with its target and normalizes missing IDs" do image = image_fixture() report = report_fixture(image_id: image.id) - admin = admin_user_fixture() - assert report.open + assert {:ok, loaded} = Reports.show_report(actor(moderator_user_fixture()), report.id) + assert loaded.image.id == image.id - assert {:ok, {1, _ids}} = Reports.close_reports(admin, image_id: image.id) + for actor <- [actor(), actor(confirmed_user_fixture()), actor(moderator_user_fixture())] do + assert Reports.show_report(actor, "not-an-id") == {:error, :not_found} + assert Reports.show_report(actor, "2147483647") == {:error, :not_found} + end - closed = Reports.get_report!(report.id) - refute closed.open - assert closed.state == "closed" - assert closed.admin_id == admin.id + assert Reports.show_report(actor(confirmed_user_fixture()), report.id) == + {:error, :unauthorized} end + end - test "closes open reports for a user" do - target = confirmed_user_fixture() - report = report_fixture(reported_user_id: target.id) - admin = admin_user_fixture() + describe "report transition changesets" do + test "claim requires an open, unclaimed report" do + moderator = moderator_user_fixture() - assert {:ok, {1, _ids}} = Reports.close_reports(admin, reported_user_id: target.id) + closed = Report.claim_changeset(%Report{open: false}, moderator) + claimed = Report.claim_changeset(%Report{open: true, admin_id: moderator.id}, moderator) - closed = Reports.get_report!(report.id) + refute closed.valid? + assert closed.errors[:state] == {"must be open", []} + refute claimed.valid? + assert claimed.errors[:admin_id] == {"has already been claimed", []} + end + + test "unclaim requires an open report and errors when not claimed" do + moderator = moderator_user_fixture() + + closed = Report.unclaim_changeset(%Report{open: false, admin_id: moderator.id}, moderator) + unclaimed = Report.unclaim_changeset(%Report{open: true, state: "open"}, moderator) + + refute closed.valid? + assert closed.errors[:state] == {"must be open", []} + refute unclaimed.valid? + assert unclaimed.errors[:admin_id] == {"was not claimed", []} + end + + test "close is invalid when the report is already closed" do + report = %Report{open: false, state: "closed", admin_id: moderator_user_fixture().id} + + changeset = Report.close_changeset(report, moderator_user_fixture()) + + refute changeset.valid? + end + end + + describe "staff transitions" do + setup do + %{report: report_fixture(image_id: image_fixture().id)} + end + + test "claim commits its moderation log with the report", %{report: report} do + moderator = moderator_user_fixture() + assert {:ok, claimed} = Reports.create_report_claim(actor(moderator), report.id) + assert claimed.state == "in_progress" + assert claimed.admin_id == moderator.id + + log = Repo.one!(ModerationLog) + assert log.user_id == moderator.id + assert log.type == "Report.Claim:create" + assert log.subject_path == "/admin/reports/#{report.id}" + end + + test "unclaim releases a claim", %{report: report} do + moderator = actor(moderator_user_fixture()) + assert {:ok, _claimed} = Reports.create_report_claim(moderator, report.id) + assert {:ok, released} = Reports.delete_report_claim(moderator, report.id) + assert released.state == "open" + assert is_nil(released.admin_id) + assert {:error, %Ecto.Changeset{}} = Reports.delete_report_claim(moderator, report.id) + assert Repo.aggregate(ModerationLog, :count) == 2 + end + + test "close cannot be undone by unclaim", %{report: report} do + moderator = actor(moderator_user_fixture()) + assert {:ok, closed} = Reports.create_report_close(moderator, report.id) refute closed.open assert closed.state == "closed" + assert {:error, %Ecto.Changeset{}} = Reports.create_report_close(moderator, report.id) + assert Repo.aggregate(ModerationLog, :count) == 1 + + assert {:error, changeset} = Reports.delete_report_claim(moderator, report.id) + assert changeset.errors[:state] + end + + test "each action has a distinct authorization and stable ID contract", %{report: report} do + user = actor(confirmed_user_fixture()) + + for action <- [ + &Reports.create_report_claim/2, + &Reports.delete_report_claim/2, + &Reports.create_report_close/2 + ] do + assert action.(user, report.id) == {:error, :unauthorized} + assert action.(actor(moderator_user_fixture()), "not-an-id") == {:error, :not_found} + + assert action.(actor(moderator_user_fixture()), "2147483647") == + {:error, :not_found} + end end end - describe "SearchIndex.as_json/1" do - defp indexed_report(report) do - report - |> Repo.preload([:user, :admin]) - |> Reports.preload_targets() + describe "mod notes" do + test "sensitive notes are returned only after the note authorization gate" do + report = report_fixture(image_id: image_fixture().id) + note = mod_note_fixture_for(moderator_user_fixture(), %{"report_id" => report.id}) + + notes = Reports.mod_notes(actor(moderator_user_fixture()), report, & &1) + assert note.id in Enum.map(notes, fn {loaded, _rendered} -> loaded.id end) + assert Reports.mod_notes(actor(confirmed_user_fixture()), report, & &1) == nil + assert Reports.mod_notes(actor(), report, & &1) == nil end + end - test "image report carries legacy reportable_type, reportable_id and image_id" do - owner = confirmed_user_fixture() - image = image_fixture(%{user_id: owner.id}) + describe "trusted cross-context services" do + test "bulk close returns IDs for after-commit indexing" do + image = image_fixture() report = report_fixture(image_id: image.id) + moderator = moderator_user_fixture() - json = SearchIndex.as_json(indexed_report(report)) + {:ok, _} = + Multi.new() + |> Reports.put_close_reports(:reports, moderator, image_id: image.id) + |> Multi.transact() - assert json.reportable_type == "Image" - assert json.reportable_id == image.id - assert json.image_id == image.id - assert String.downcase(owner.name) in json.related_users + closed = Repo.get!(Report, report.id) + refute closed.open + assert closed.admin_id == moderator.id end - test "user report carries legacy reportable_type and reportable_id" do - target = confirmed_user_fixture() - report = report_fixture(reported_user_id: target.id) - - json = SearchIndex.as_json(indexed_report(report)) + test "system reports require a real rule" do + image = image_fixture() + rule = rule_fixture() - assert json.reportable_type == "User" - assert json.reportable_id == target.id - assert String.downcase(target.name) in json.related_users - end + {:ok, _} = + Multi.new() + |> Reports.put_create_system_report(rule.name, "Automated review", :image_id, image.id) + |> Multi.transact() - test "gallery report includes the gallery owner in related_users" do - owner = confirmed_user_fixture() - gallery = gallery_fixture(owner) - report = report_fixture(gallery_id: gallery.id) + assert Repo.aggregate(where(Report, system: true), :count) == 1 - json = SearchIndex.as_json(indexed_report(report)) + assert_raise(MatchError, fn -> + Multi.new() + |> Reports.put_create_system_report("missing rule", "reason", :image_id, image.id) + |> Multi.transact() + end) + end + end - assert json.reportable_type == "Gallery" - assert json.reportable_id == gallery.id - assert json.related_users == [String.downcase(owner.name)] - assert json.related_user_ids == [owner.id] + describe "target invariants and indexing" do + test "target_columns/0 lists all reportable foreign keys" do + assert Report.target_columns() == [ + :image_id, + :comment_id, + :post_id, + :reported_user_id, + :commission_id, + :conversation_id, + :gallery_id + ] end - test "commission report carries legacy reportable_type and reportable_id" do - owner = confirmed_user_fixture() - commission = commission_fixture(owner) - report = report_fixture(commission_id: commission.id) + test "the creation changeset rejects zero or multiple targets" do + image = image_fixture() + user = confirmed_user_fixture() + attrs = %{"reason" => "bad target count", "user_agent" => "test"} + rule = rule_fixture() + + zero = Report.creation_changeset(%Report{}, attrs, actor(), rule) - json = SearchIndex.as_json(indexed_report(report)) + two = + Report.creation_changeset( + %Report{image_id: image.id, reported_user_id: user.id}, + attrs, + actor(), + rule + ) - assert json.reportable_type == "Commission" - assert json.reportable_id == commission.id - assert json.related_users == [String.downcase(owner.name)] + assert %{target: ["must reference exactly one target"]} = errors_on(zero) + assert %{target: ["must reference exactly one target"]} = errors_on(two) end - test "orphan report serializes without crashing" do + test "orphaned reports preload and serialize without crashing" do {:ok, orphan} = %Report{} |> Ecto.Changeset.change(%{ @@ -266,13 +504,19 @@ defmodule Philomena.ReportsTest do }) |> Repo.insert() - json = SearchIndex.as_json(indexed_report(orphan)) + preloaded = Repo.preload(orphan, Reports.indexing_preloads()) + assert Enum.all?(Report.target_columns(), &is_nil(Map.get(preloaded, &1))) + assert SearchIndex.as_json(preloaded).reportable_type == nil + end + + test "indexed targets retain their legacy reportable fields" do + image = image_fixture() + report = report_fixture(image_id: image.id) + indexed = Repo.preload(report, Reports.indexing_preloads()) |> SearchIndex.as_json() - assert json.reportable_type == nil - assert json.reportable_id == nil - assert json.image_id == nil - assert json.related_users == [] - assert json.related_user_ids == [] + assert indexed.reportable_type == "Image" + assert indexed.reportable_id == image.id + assert indexed.image_id == image.id end end end diff --git a/test/philomena/rules_test.exs b/test/philomena/rules_test.exs new file mode 100644 index 000000000..b77ca5a84 --- /dev/null +++ b/test/philomena/rules_test.exs @@ -0,0 +1,235 @@ +defmodule Philomena.RulesTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.Rules` functions. + + These pin the edit-gated index visibility (staff see hidden and internal rules, + everyone else only the visible ones), ability-based hidden-rule visibility, + position parsing, missing-before-forbidden precedence, and the admin-only + create/edit/update authorization matrix. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.RulesFixtures + import Philomena.UsersFixtures + + alias Philomena.Rules + alias Philomena.Rules.Rule + + @ban %{reason: "Rule #0", valid_until: ~U[3000-01-01 00:00:00Z]} + + describe "safe rule service lookups" do + test "malformed IDs and absent names do not raise" do + assert Rules.fetch_rule("not-an-id") == {:error, :not_found} + assert Rules.fetch_rule_by_name("No such rule") == {:error, :not_found} + end + + test "loads an existing rule by name" do + rule = rule_fixture() + assert Rules.fetch_rule_by_name(rule.name) == {:ok, rule} + end + end + + describe "list_rules_for/1" do + test "an admin sees hidden and internal rules alongside visible ones" do + visible = rule_fixture() + hidden = rule_fixture(%{hidden: true}) + internal = rule_fixture(%{internal: true}) + + ids = Enum.map(Rules.list_rules_for(actor(admin_user_fixture())), & &1.id) + assert visible.id in ids + assert hidden.id in ids + assert internal.id in ids + end + + test "a regular user sees only the visible rules" do + visible = rule_fixture() + hidden = rule_fixture(%{hidden: true}) + internal = rule_fixture(%{internal: true}) + + ids = Enum.map(Rules.list_rules_for(actor(confirmed_user_fixture())), & &1.id) + assert visible.id in ids + refute hidden.id in ids + refute internal.id in ids + end + + test "an anonymous viewer sees only the visible rules" do + visible = rule_fixture() + hidden = rule_fixture(%{hidden: true}) + + ids = Enum.map(Rules.list_rules_for(actor()), & &1.id) + assert visible.id in ids + refute hidden.id in ids + end + end + + describe "show_rule/2" do + test "loads a visible rule by position for an anonymous viewer" do + rule = rule_fixture() + + assert {:ok, loaded} = Rules.show_rule(actor(), to_string(rule.position)) + assert loaded.id == rule.id + end + + test "a hidden rule is unauthorized for a viewer who may not edit it" do + rule = rule_fixture(%{hidden: true}) + + assert Rules.show_rule(actor(confirmed_user_fixture()), to_string(rule.position)) == + {:error, :unauthorized} + end + + test "an internal rule is unauthorized for a viewer who may not edit it" do + rule = rule_fixture(%{internal: true}) + + assert Rules.show_rule(actor(), to_string(rule.position)) == + {:error, :unauthorized} + end + + test "an admin may show a hidden rule" do + rule = rule_fixture(%{hidden: true}) + + assert {:ok, loaded} = + Rules.show_rule(actor(admin_user_fixture()), to_string(rule.position)) + + assert loaded.id == rule.id + end + + test "a non-integer position is not-found" do + assert Rules.show_rule(actor(), "not-a-number") == {:error, :not_found} + end + + test "an unknown well-formed position is not found for every actor" do + assert Rules.show_rule(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Rules.show_rule(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "new_rule/1" do + test "an admin gets a blank changeset" do + assert {:ok, %Ecto.Changeset{data: %Rule{}}} = + Rules.new_rule(actor(admin_user_fixture())) + end + + test "a regular user is unauthorized" do + assert Rules.new_rule(actor(confirmed_user_fixture())) == {:error, :unauthorized} + end + + test "an anonymous viewer is unauthorized" do + assert Rules.new_rule(actor()) == {:error, :unauthorized} + end + end + + describe "create_rule/2" do + test "an admin creates a rule and its initial version" do + unique = System.unique_integer([:positive]) + + assert {:ok, [%Rule{} = rule, _version]} = + Rules.create_rule(actor(admin_user_fixture()), %{ + name: "New Rule ##{unique}", + position: unique + }) + + assert {:ok, %Rule{}} = Rules.fetch_rule(rule.id) + end + + test "invalid attrs are a rejected changeset" do + assert {:error, %Ecto.Changeset{} = changeset} = + Rules.create_rule(actor(admin_user_fixture()), %{name: ""}) + + refute changeset.valid? + end + + test "a regular user is unauthorized" do + assert Rules.create_rule(actor(confirmed_user_fixture()), %{name: "x", position: 1}) == + {:error, :unauthorized} + end + end + + describe "edit_rule/2" do + test "an admin loads a rule and a changeset" do + rule = rule_fixture() + + assert {:ok, {%Rule{} = loaded, %Ecto.Changeset{}}} = + Rules.edit_rule(actor(admin_user_fixture()), to_string(rule.position)) + + assert loaded.id == rule.id + end + + test "a regular user is unauthorized" do + rule = rule_fixture() + + assert Rules.edit_rule(actor(confirmed_user_fixture()), to_string(rule.position)) == + {:error, :unauthorized} + end + + test "an unknown well-formed position is not found for every actor" do + assert Rules.edit_rule(actor(confirmed_user_fixture()), "2147483647") == + {:error, :not_found} + + assert Rules.edit_rule(actor(admin_user_fixture()), "2147483647") == + {:error, :not_found} + end + end + + describe "update_rule/3" do + test "an admin updates a rule and stores a new version" do + rule = rule_fixture() + + assert {:ok, [%Rule{} = updated, _version]} = + Rules.update_rule(actor(admin_user_fixture()), to_string(rule.position), %{ + title: "Updated Title" + }) + + assert updated.title == "Updated Title" + assert {:ok, %Rule{title: "Updated Title"}} = Rules.fetch_rule(rule.id) + end + + test "an invalid update carries the unchanged rule for re-rendering" do + rule = rule_fixture() + + assert {:error, {%Rule{} = carried, %Ecto.Changeset{} = changeset}} = + Rules.update_rule(actor(admin_user_fixture()), to_string(rule.position), %{ + name: "" + }) + + assert carried.id == rule.id + refute changeset.valid? + end + + test "a regular user is unauthorized" do + rule = rule_fixture() + + assert Rules.update_rule(actor(confirmed_user_fixture()), to_string(rule.position), %{ + title: "Hijacked" + }) == {:error, :unauthorized} + end + + test "a non-integer position is not-found" do + assert Rules.update_rule(actor(admin_user_fixture()), "not-a-number", %{title: "x"}) == + {:error, :not_found} + end + end + + describe "write access prerequisite" do + test "form and mutation paths reject bans and missing fingerprints" do + admin = admin_user_fixture() + rule = rule_fixture() + + operations = [ + &Rules.new_rule/1, + &Rules.create_rule(&1, %{name: "Blocked", position: -1}), + &Rules.edit_rule(&1, rule.position), + &Rules.update_rule(&1, rule.position, %{title: "Blocked"}) + ] + + for operation <- operations do + assert operation.(actor(admin, ban: @ban)) == {:error, :ban} + assert operation.(actor(admin, fingerprint: nil)) == {:error, :unauthorized} + end + end + end +end diff --git a/test/philomena/site_notices_test.exs b/test/philomena/site_notices_test.exs new file mode 100644 index 000000000..78f197594 --- /dev/null +++ b/test/philomena/site_notices_test.exs @@ -0,0 +1,367 @@ +defmodule Philomena.SiteNoticesTest do + @moduledoc """ + Context-level tests for the admin site-notice management functions on + `Philomena.SiteNotices`: `load_site_notices/2`, `new_site_notice/1`, + `create_site_notice/2`, `load_site_notice_for_edit/2`, `update_site_notice/3`, + and `delete_site_notice/2`. + + These pin the per-role authorization matrix (admin and a moderator holding the + SiteNotice admin grant pass; a plain moderator, a regular user, and an + anonymous visitor are rejected), the write prerequisite, and the shared + loader contract: malformed and absent IDs are not found for every actor, + while a real forbidden notice is unauthorized. No moderation logs are written + here. + + The actor is a `Philomena.Attribution.Actor`, matching what the controller + hands in as `conn.assigns.actor`. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.SiteNoticesFixtures + import Philomena.UsersFixtures + + alias Philomena.SiteNotices + alias Philomena.SiteNotices.SiteNotice + + @pagination %{page_number: 1, page_size: 25} + @ban %{reason: "Rule #0", valid_until: ~U[3000-01-01 00:00:00Z]} + + # A moderator granted the SiteNotice admin role_map, the shape a request-loaded + # actor carries; site-notice management admits this moderator but not a plain + # one. + defp notice_moderator do + %{moderator_user_fixture() | role_map: %{"SiteNotice" => %{"admin" => []}}} + end + + # Controller-shaped attrs (string keys) a site-notice insert requires; + # start_date/finish_date are RelativeDate fields a plain DateTime casts fine. + defp valid_attrs do + %{ + "title" => "Scheduled maintenance", + "text" => "The site will be down.", + "start_date" => DateTime.utc_now(:second), + "finish_date" => DateTime.add(DateTime.utc_now(:second), 365, :day) + } + end + + describe "active_site_notices/0" do + test "returns only live notices inside their active UTC window" do + now = DateTime.utc_now(:second) + active = site_notice_fixture(%{"start_date" => DateTime.add(now, -1, :day)}) + _not_live = site_notice_fixture(%{"live" => false}) + + _not_started = + site_notice_fixture(%{ + "start_date" => DateTime.add(now, 1, :day), + "finish_date" => DateTime.add(now, 2, :day) + }) + + _finished = + site_notice_fixture(%{ + "start_date" => DateTime.add(now, -2, :day), + "finish_date" => DateTime.add(now, -1, :day) + }) + + assert Enum.map(SiteNotices.active_site_notices(), & &1.id) == [active.id] + end + end + + describe "list_site_notices/2" do + test "an admin gets the paginated site notices" do + admin = admin_user_fixture() + notice = site_notice_fixture() + + assert {:ok, page} = SiteNotices.list_site_notices(actor(admin), @pagination) + assert %Scrivener.Page{} = page + assert notice.id in Enum.map(page.entries, & &1.id) + end + + test "a moderator with the site-notice grant is authorized" do + notice = site_notice_fixture() + + assert {:ok, page} = SiteNotices.list_site_notices(actor(notice_moderator()), @pagination) + assert notice.id in Enum.map(page.entries, & &1.id) + end + + test "a plain moderator is not authorized" do + assert SiteNotices.list_site_notices(actor(moderator_user_fixture()), @pagination) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + assert SiteNotices.list_site_notices(actor(confirmed_user_fixture()), @pagination) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert SiteNotices.list_site_notices(actor(), @pagination) == {:error, :unauthorized} + end + end + + describe "new_site_notice/1" do + test "an admin gets a changeset" do + assert {:ok, %Ecto.Changeset{}} = SiteNotices.new_site_notice(actor(admin_user_fixture())) + end + + test "a moderator with the site-notice grant is authorized" do + assert {:ok, %Ecto.Changeset{}} = SiteNotices.new_site_notice(actor(notice_moderator())) + end + + test "a plain moderator is not authorized" do + assert SiteNotices.new_site_notice(actor(moderator_user_fixture())) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + assert SiteNotices.new_site_notice(actor(confirmed_user_fixture())) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert SiteNotices.new_site_notice(actor()) == {:error, :unauthorized} + end + end + + describe "create_site_notice/2" do + test "an admin creates a notice authored by them" do + admin = admin_user_fixture() + + assert {:ok, %SiteNotice{} = notice} = + SiteNotices.create_site_notice(actor(admin), valid_attrs()) + + assert notice.user_id == admin.id + end + + test "a moderator with the site-notice grant creates a notice" do + moderator = notice_moderator() + + assert {:ok, %SiteNotice{} = notice} = + SiteNotices.create_site_notice(actor(moderator), valid_attrs()) + + assert notice.user_id == moderator.id + end + + test "invalid attributes return a changeset" do + assert {:error, %Ecto.Changeset{}} = + SiteNotices.create_site_notice(actor(admin_user_fixture()), %{ + valid_attrs() + | "title" => "" + }) + end + + test "a plain moderator is not authorized and creates nothing" do + assert SiteNotices.create_site_notice(actor(moderator_user_fixture()), valid_attrs()) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + assert SiteNotices.create_site_notice(actor(confirmed_user_fixture()), valid_attrs()) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + assert SiteNotices.create_site_notice(actor(), valid_attrs()) == {:error, :unauthorized} + end + end + + describe "edit_site_notice/2" do + test "an admin loads the notice with a changeset" do + admin = admin_user_fixture() + notice = site_notice_fixture() + + assert {:ok, {loaded, %Ecto.Changeset{}}} = + SiteNotices.edit_site_notice(actor(admin), notice.id) + + assert loaded.id == notice.id + end + + test "a moderator with the site-notice grant loads the notice" do + notice = site_notice_fixture() + + assert {:ok, {loaded, _}} = + SiteNotices.edit_site_notice(actor(notice_moderator()), notice.id) + + assert loaded.id == notice.id + end + + test "a well-formed unknown id is not found for an admin" do + assert SiteNotices.edit_site_notice(actor(admin_user_fixture()), 2_147_483_647) == + {:error, :not_found} + end + + test "a well-formed unknown id is not found for a plain moderator" do + assert SiteNotices.edit_site_notice(actor(moderator_user_fixture()), 2_147_483_647) == + {:error, :not_found} + end + + test "a well-formed unknown id is not found for a regular user" do + assert SiteNotices.edit_site_notice(actor(confirmed_user_fixture()), 2_147_483_647) == + {:error, :not_found} + end + + test "a non-castable id is not found for an admin" do + assert SiteNotices.edit_site_notice(actor(admin_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a non-castable id is not found for a plain moderator" do + # The id parse fails before authorization runs, so a non-castable id is + # not_found even for an actor who could never see the record. + assert SiteNotices.edit_site_notice(actor(moderator_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a real notice is unauthorized for a plain moderator" do + notice = site_notice_fixture() + + assert SiteNotices.edit_site_notice(actor(moderator_user_fixture()), notice.id) == + {:error, :unauthorized} + end + + test "a real notice is unauthorized for a regular user" do + notice = site_notice_fixture() + + assert SiteNotices.edit_site_notice(actor(confirmed_user_fixture()), notice.id) == + {:error, :unauthorized} + end + end + + describe "update_site_notice/3" do + test "an admin updates the notice" do + notice = site_notice_fixture() + + assert {:ok, updated} = + SiteNotices.update_site_notice(actor(admin_user_fixture()), notice.id, %{ + "title" => "Rescheduled" + }) + + assert updated.title == "Rescheduled" + end + + test "a moderator with the site-notice grant updates the notice" do + notice = site_notice_fixture() + + assert {:ok, _} = + SiteNotices.update_site_notice(actor(notice_moderator()), notice.id, %{ + "title" => "x" + }) + end + + test "invalid attributes return a changeset" do + notice = site_notice_fixture() + + assert {:error, %Ecto.Changeset{}} = + SiteNotices.update_site_notice(actor(admin_user_fixture()), notice.id, %{ + "title" => "" + }) + end + + test "a well-formed unknown id is not found for an admin" do + assert SiteNotices.update_site_notice(actor(admin_user_fixture()), 2_147_483_647, %{ + "title" => "x" + }) == + {:error, :not_found} + end + + test "a well-formed unknown id is not found for a plain moderator" do + assert SiteNotices.update_site_notice(actor(moderator_user_fixture()), 2_147_483_647, %{ + "title" => "x" + }) == {:error, :not_found} + end + + test "a real notice is unauthorized for a plain moderator" do + notice = site_notice_fixture() + + assert SiteNotices.update_site_notice(actor(moderator_user_fixture()), notice.id, %{ + "title" => "x" + }) == + {:error, :unauthorized} + end + + test "a regular user is not authorized" do + notice = site_notice_fixture() + + assert SiteNotices.update_site_notice(actor(confirmed_user_fixture()), notice.id, %{ + "title" => "x" + }) == + {:error, :unauthorized} + end + end + + describe "delete_site_notice/2" do + test "an admin deletes the notice" do + notice = site_notice_fixture() + + assert {:ok, deleted} = + SiteNotices.delete_site_notice(actor(admin_user_fixture()), notice.id) + + assert deleted.id == notice.id + refute Repo.get(SiteNotice, notice.id) + end + + test "a moderator with the site-notice grant deletes the notice" do + notice = site_notice_fixture() + + assert {:ok, _} = SiteNotices.delete_site_notice(actor(notice_moderator()), notice.id) + refute Repo.get(SiteNotice, notice.id) + end + + test "a well-formed unknown id is not found for an admin" do + assert SiteNotices.delete_site_notice(actor(admin_user_fixture()), 2_147_483_647) == + {:error, :not_found} + end + + test "a well-formed unknown id is not found for a plain moderator" do + assert SiteNotices.delete_site_notice(actor(moderator_user_fixture()), 2_147_483_647) == + {:error, :not_found} + end + + test "a non-castable id is not found for an admin" do + assert SiteNotices.delete_site_notice(actor(admin_user_fixture()), "abc") == + {:error, :not_found} + end + + test "a real notice is unauthorized for a plain moderator" do + notice = site_notice_fixture() + + assert SiteNotices.delete_site_notice(actor(moderator_user_fixture()), notice.id) == + {:error, :unauthorized} + + assert Repo.get(SiteNotice, notice.id) + end + + test "a regular user is not authorized" do + notice = site_notice_fixture() + + assert SiteNotices.delete_site_notice(actor(confirmed_user_fixture()), notice.id) == + {:error, :unauthorized} + end + + test "an anonymous visitor is not authorized" do + notice = site_notice_fixture() + assert SiteNotices.delete_site_notice(actor(), notice.id) == {:error, :unauthorized} + end + end + + describe "write access prerequisite" do + test "form and mutation paths consistently reject bans and missing fingerprints" do + admin = admin_user_fixture() + notice = site_notice_fixture() + + operations = [ + &SiteNotices.new_site_notice/1, + &SiteNotices.create_site_notice(&1, valid_attrs()), + &SiteNotices.edit_site_notice(&1, notice.id), + &SiteNotices.update_site_notice(&1, notice.id, %{"title" => "Changed"}), + &SiteNotices.delete_site_notice(&1, notice.id) + ] + + for operation <- operations do + assert operation.(actor(admin, ban: @ban)) == {:error, :ban} + assert operation.(actor(admin, fingerprint: nil)) == {:error, :unauthorized} + end + end + end +end diff --git a/test/philomena/site_statistics_test.exs b/test/philomena/site_statistics_test.exs new file mode 100644 index 000000000..d1b8ab2c4 --- /dev/null +++ b/test/philomena/site_statistics_test.exs @@ -0,0 +1,49 @@ +defmodule Philomena.SiteStatisticsTest do + use Philomena.DataCase, async: false + + @moduletag :search + + alias Philomena.Comments.Comment + alias Philomena.Images.Image + alias Philomena.SiteStatistics + alias Philomena.StaticPages.StaticPage + alias Philomena.Repo + alias PhilomenaQuery.Search + alias PhilomenaWeb.StatsUpdater + + setup do + Search.clear_index!(Image) + Search.clear_index!(Comment) + :ok + end + + test "calculates an empty sitewide snapshot" do + assert %SiteStatistics{} = statistics = SiteStatistics.calculate() + + assert statistics.forums_count == 0 + assert statistics.topics_count == 0 + assert statistics.posts_count == 0 + assert statistics.users_count == 0 + assert statistics.users_24h == 0 + assert statistics.open_commissions == 0 + assert statistics.commission_items == 0 + assert statistics.open_reports == 0 + assert statistics.report_stat_count == 0 + assert statistics.response_time == 0 + assert statistics.gallery_count == 0 + assert statistics.gallery_size == 0 + assert statistics.distinct_creators == 0 + assert statistics.images_in_galleries == 0 + assert statistics.image_aggs["aggregations"]["non_deleted"]["doc_count"] == 0 + assert statistics.comment_aggs["hits"]["total"]["value"] == 0 + end + + test "the web updater renders and persists the domain snapshot" do + assert {1, nil} = StatsUpdater.update_stats!() + + page = Repo.get_by!(StaticPage, slug: "stats") + assert page.title == "Statistics" + assert page.body =~ "There are" + assert page.body =~ "non-deleted images" + end +end diff --git a/test/philomena/source_changes_test.exs b/test/philomena/source_changes_test.exs new file mode 100644 index 000000000..2db0c8df3 --- /dev/null +++ b/test/philomena/source_changes_test.exs @@ -0,0 +1,582 @@ +defmodule Philomena.SourceChangesTest do + @moduledoc "Context-level tests for the actor-scoped SourceChanges read boundary." + + use Philomena.DataCase, async: true + + alias Philomena.SourceChanges + alias Philomena.SourceChanges.SourceChange + alias Philomena.SourceChanges.QueryForm + alias Philomena.SourceChanges.SourceChangePage + alias Philomena.Repo + alias Scrivener.Page + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.ImagesFixtures + import Philomena.SourceChangesFixtures + import Philomena.UsersFixtures + + @pagination [page: 1, page_size: 25] + + describe "list_image_source_changes/3" do + test "an anonymous actor lists a public image's source changes newest-first" do + image = image_fixture() + older = source_change_fixture(image) + newer = source_change_fixture(image) + + assert {:ok, %SourceChangePage{target: loaded_image, source_changes: %Page{} = page}, _} = + SourceChanges.list_image_source_changes( + actor(), + to_string(image.id), + %{}, + @pagination + ) + + assert loaded_image.id == image.id + assert Enum.map(page.entries, & &1.id) == [newer.id, older.id] + end + + test "a regular user lists a public image's source changes" do + user = confirmed_user_fixture() + image = image_fixture() + change = source_change_fixture(image) + + assert {:ok, %SourceChangePage{target: loaded_image, source_changes: page}, _} = + SourceChanges.list_image_source_changes( + actor(user), + to_string(image.id), + %{}, + @pagination + ) + + assert loaded_image.id == image.id + assert Enum.map(page.entries, & &1.id) == [change.id] + end + + test "the result preloads each change's user" do + user = confirmed_user_fixture() + image = image_fixture() + source_change_fixture(image, user_id: user.id) + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_image_source_changes( + actor(), + to_string(image.id), + %{}, + @pagination + ) + + [entry] = page.entries + assert entry.user.id == user.id + end + + test "page_size caps the entries and reports the total" do + image = image_fixture() + for _ <- 1..3, do: source_change_fixture(image) + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_image_source_changes(actor(), to_string(image.id), %{}, + page: 1, + page_size: 2 + ) + + assert page.page_size == 2 + assert length(page.entries) == 2 + assert page.total_entries == 3 + end + + test "an image with no source changes yields an empty page" do + image = image_fixture() + + assert {:ok, %SourceChangePage{target: loaded_image, source_changes: page}, _} = + SourceChanges.list_image_source_changes( + actor(), + to_string(image.id), + %{}, + @pagination + ) + + assert loaded_image.id == image.id + assert page.entries == [] + assert page.total_entries == 0 + end + + test "returns the normalized query changeset" do + image = image_fixture() + + assert {:ok, %SourceChangePage{}, %Ecto.Changeset{data: %QueryForm{added: true}}} = + SourceChanges.list_image_source_changes( + actor(), + image.id, + %{"added" => "1"}, + @pagination + ) + end + + test "returns a changeset for an invalid filter" do + image = image_fixture() + + assert {:error, %Ecto.Changeset{valid?: false}} = + SourceChanges.list_image_source_changes( + actor(), + image.id, + %{"added" => "invalid"}, + @pagination + ) + end + + test "accepts an integer id" do + image = image_fixture() + change = source_change_fixture(image) + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_image_source_changes(actor(), image.id, %{}, @pagination) + + assert Enum.map(page.entries, & &1.id) == [change.id] + end + + test "an unknown well-formed id is not found for every actor" do + assert SourceChanges.list_image_source_changes(actor(), "2147483647", %{}, @pagination) == + {:error, :not_found} + + assert SourceChanges.list_image_source_changes( + actor(confirmed_user_fixture()), + "2147483647", + %{}, + @pagination + ) == {:error, :not_found} + + assert SourceChanges.list_image_source_changes( + actor(moderator_user_fixture()), + "2147483647", + %{}, + @pagination + ) == {:error, :not_found} + + assert SourceChanges.list_image_source_changes( + actor(admin_user_fixture()), + "2147483647", + %{}, + @pagination + ) == {:error, :not_found} + end + + test "a hidden image is forbidden to a regular user and visible to a moderator" do + image = image_fixture(hidden_from_users: true) + change = source_change_fixture(image) + + assert SourceChanges.list_image_source_changes( + actor(confirmed_user_fixture()), + image.id, + %{}, + @pagination + ) == {:error, :unauthorized} + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_image_source_changes( + actor(moderator_user_fixture()), + image.id, + %{}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [change.id] + end + + test "a non-castable id is not found" do + assert SourceChanges.list_image_source_changes(actor(), "not-a-number", %{}, @pagination) == + {:error, :not_found} + end + + test "an out-of-range id is not found" do + # IntegerId.parse rejects a value the integer column could not hold before + # the row is ever queried, ahead of any authorization. + assert SourceChanges.list_image_source_changes( + actor(), + "99999999999999999999", + %{}, + @pagination + ) == + {:error, :not_found} + end + end + + describe "put_erase_source_change/2" do + test "deletes an added source change and removes its source from the image" do + source = "https://spam.example/artwork" + image = image_fixture(sources: [source]) + source_change = source_change_fixture(image, source_url: source, added: true) + admin = admin_user_fixture() + + assert {:ok, %SourceChange{} = deleted_source_change} = + SourceChanges.erase_source_change(actor(admin), source_change.id) + + assert deleted_source_change.id == source_change.id + assert Repo.reload!(image) |> Repo.preload(:sources) |> Map.fetch!(:sources) == [] + refute Repo.get(SourceChange, source_change.id) + end + + test "deletes a removed source change and restores its source to the image" do + source = "https://spam.example/artwork" + image = image_fixture() + source_change = source_change_fixture(image, source_url: source, added: false) + admin = admin_user_fixture() + + assert {:ok, %SourceChange{} = deleted_source_change} = + SourceChanges.erase_source_change(actor(admin), source_change.id) + + assert deleted_source_change.id == source_change.id + + [restored_source] = Repo.reload!(image) |> Repo.preload(:sources) |> Map.fetch!(:sources) + assert restored_source.source == source + + refute Repo.get(SourceChange, source_change.id) + end + end + + describe "list_user_source_changes/4" do + test "a moderator lists a user's source changes newest-first" do + user = confirmed_user_fixture() + image = image_fixture() + older = source_change_fixture(image, user_id: user.id) + newer = source_change_fixture(image, user_id: user.id) + + assert {:ok, + %SourceChangePage{ + target: loaded_user, + source_changes: %Page{} = page, + image_count: image_count + }, _} = + SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{}, + @pagination + ) + + assert loaded_user.id == user.id + assert Enum.map(page.entries, & &1.id) == [newer.id, older.id] + assert image_count == 1 + end + + test "excludes changes to the user's own anonymous uploads" do + user = confirmed_user_fixture() + anon_image = image_fixture(%{user_id: user.id, anonymous: true}) + public_image = image_fixture() + source_change_fixture(anon_image, user_id: user.id) + kept = source_change_fixture(public_image, user_id: user.id) + + assert {:ok, %SourceChangePage{source_changes: page, image_count: image_count}, _} = + SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [kept.id] + assert image_count == 1 + end + + test "the added filter narrows to additions" do + user = confirmed_user_fixture() + image = image_fixture() + removed_image = image_fixture() + added = source_change_fixture(image, user_id: user.id, added: true) + source_change_fixture(removed_image, user_id: user.id, added: false) + + assert {:ok, %SourceChangePage{source_changes: page, image_count: image_count}, _} = + SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{"added" => "1"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [added.id] + assert page.total_entries == 1 + assert image_count == 1 + end + + test "the added filter narrows to removals" do + user = confirmed_user_fixture() + image = image_fixture() + source_change_fixture(image, user_id: user.id, added: true) + removed = source_change_fixture(image, user_id: user.id, added: false) + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{"added" => "0"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [removed.id] + end + + test "image_count reports the number of distinct images touched" do + user = confirmed_user_fixture() + image = image_fixture() + other = image_fixture() + source_change_fixture(image, user_id: user.id) + source_change_fixture(image, user_id: user.id) + source_change_fixture(other, user_id: user.id) + + assert {:ok, %SourceChangePage{image_count: image_count}, _} = + SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{}, + @pagination + ) + + assert image_count == 2 + end + + test "page_size caps the entries and reports the total" do + user = confirmed_user_fixture() + image = image_fixture() + for _ <- 1..3, do: source_change_fixture(image, user_id: user.id) + + assert {:ok, %SourceChangePage{source_changes: page, image_count: image_count}, _} = + SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{}, + page: 1, + page_size: 2 + ) + + assert page.page_size == 2 + assert length(page.entries) == 2 + assert page.total_entries == 3 + assert image_count == 1 + end + + test "a real profile is always allowed" do + user = confirmed_user_fixture() + + assert {:ok, _page, _changeset} = + SourceChanges.list_user_source_changes(actor(), user.slug, %{}, @pagination) + + assert {:ok, _page, _changeset} = + SourceChanges.list_user_source_changes( + actor(confirmed_user_fixture()), + user.slug, + %{}, + @pagination + ) + end + + test "an unknown slug is not found for every actor" do + assert SourceChanges.list_user_source_changes(actor(), "no-such-user", %{}, @pagination) == + {:error, :not_found} + + assert SourceChanges.list_user_source_changes( + actor(admin_user_fixture()), + "no-such-user", + %{}, + @pagination + ) == {:error, :not_found} + end + + test "a deactivated profile is not found before detailed-profile authorization" do + user = deactivated_user_fixture() + + assert SourceChanges.list_user_source_changes( + actor(moderator_user_fixture()), + user.slug, + %{}, + @pagination + ) == {:error, :not_found} + end + end + + describe "count_for_image/1" do + test "returns the number of source changes for the image" do + image = image_fixture() + source_change_fixture(image) + source_change_fixture(image) + + assert SourceChanges.count_for_image(image) == 2 + end + + test "counts only the given image's changes" do + image = image_fixture() + other = image_fixture() + source_change_fixture(image) + source_change_fixture(other) + + assert SourceChanges.count_for_image(image) == 1 + end + + test "returns zero when the image has no source changes" do + image = image_fixture() + + assert SourceChanges.count_for_image(image) == 0 + end + end + + describe "list_ip_source_changes/4" do + test "a moderator lists the changes attributed to an address, newest first" do + image = image_fixture() + older = source_change_fixture(image, ip: "203.0.113.5") + newer = source_change_fixture(image, ip: "203.0.113.5") + + assert {:ok, + %SourceChangePage{ + target: %Postgrex.INET{} = ip, + range: %Postgrex.INET{} = range, + source_changes: page + }, _} = + SourceChanges.list_ip_source_changes( + actor(moderator_user_fixture()), + "203.0.113.5", + %{}, + @pagination + ) + + assert ip == range + assert Enum.map(page.entries, & &1.id) == [newer.id, older.id] + end + + test "the mask param widens the query to a subnet" do + image = image_fixture() + change = source_change_fixture(image, ip: "203.0.113.5") + + assert {:ok, %SourceChangePage{range: range, source_changes: page}, _} = + SourceChanges.list_ip_source_changes( + actor(admin_user_fixture()), + "203.0.113.5", + %{"mask" => "24"}, + @pagination + ) + + assert range.netmask == 24 + assert change.id in Enum.map(page.entries, & &1.id) + end + + test "the added filter narrows to additions" do + image = image_fixture() + added = source_change_fixture(image, ip: "203.0.113.6", added: true) + source_change_fixture(image, ip: "203.0.113.6", added: false) + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_ip_source_changes( + actor(moderator_user_fixture()), + "203.0.113.6", + %{"added" => "1"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [added.id] + end + + test "a staffer submitting an unparsable address is not-found" do + assert SourceChanges.list_ip_source_changes( + actor(moderator_user_fixture()), + "not-an-ip", + %{}, + @pagination + ) == {:error, :not_found} + end + + test "a malformed address is not found before authorization" do + assert SourceChanges.list_ip_source_changes( + actor(confirmed_user_fixture()), + "garbage", + %{}, + @pagination + ) == {:error, :not_found} + end + + test "an anonymous viewer is unauthorized" do + assert SourceChanges.list_ip_source_changes(actor(), "203.0.113.5", %{}, @pagination) == + {:error, :unauthorized} + end + end + + describe "list_fingerprint_source_changes/4" do + test "a moderator lists the changes attributed to a fingerprint, newest first" do + image = image_fixture() + older = source_change_fixture(image, fingerprint: "c123") + newer = source_change_fixture(image, fingerprint: "c123") + + assert {:ok, %SourceChangePage{target: "c123", source_changes: page}, _} = + SourceChanges.list_fingerprint_source_changes( + actor(moderator_user_fixture()), + "c123", + %{}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [newer.id, older.id] + end + + test "a valid fingerprint with no history returns an empty page" do + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_fingerprint_source_changes( + actor(admin_user_fixture()), + "c999", + %{}, + @pagination + ) + + assert page.entries == [] + end + + test "fingerprints are normalized before matching" do + image = image_fixture() + change = source_change_fixture(image, fingerprint: "d63c4581f8cf58d") + + assert {:ok, %SourceChangePage{target: "d63c4581f8cf58d", source_changes: page}, _} = + SourceChanges.list_fingerprint_source_changes( + actor(moderator_user_fixture()), + " D63C4581F8CF58D ", + %{}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [change.id] + end + + test "a malformed fingerprint is not found before authorization" do + assert SourceChanges.list_fingerprint_source_changes( + actor(), + "no-such-fingerprint", + %{}, + @pagination + ) == {:error, :not_found} + end + + test "the added filter narrows to removals" do + image = image_fixture() + source_change_fixture(image, fingerprint: "c456", added: true) + removed = source_change_fixture(image, fingerprint: "c456", added: false) + + assert {:ok, %SourceChangePage{source_changes: page}, _} = + SourceChanges.list_fingerprint_source_changes( + actor(moderator_user_fixture()), + "c456", + %{"added" => "0"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [removed.id] + end + + test "a regular user is unauthorized" do + assert SourceChanges.list_fingerprint_source_changes( + actor(confirmed_user_fixture()), + "c123", + %{}, + @pagination + ) == {:error, :unauthorized} + end + + test "an anonymous viewer is unauthorized" do + assert SourceChanges.list_fingerprint_source_changes(actor(), "c123", %{}, @pagination) == + {:error, :unauthorized} + end + end +end diff --git a/test/philomena/static_pages_test.exs b/test/philomena/static_pages_test.exs new file mode 100644 index 000000000..4831554db --- /dev/null +++ b/test/philomena/static_pages_test.exs @@ -0,0 +1,240 @@ +defmodule Philomena.StaticPagesTest do + @moduledoc """ + Context-level tests for the controller-facing `Philomena.StaticPages` + functions. + + These pin the staff-only index gate, missing-first public show/history + loaders, write-access parity, action authorization, atomic revisions, and + changeset failures. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.StaticPagesFixtures + import Philomena.UsersFixtures + + alias Philomena.Repo + alias Philomena.StaticPages + alias Philomena.StaticPages.StaticPage + + describe "upsert_statistics_page/1" do + test "creates and replaces the generated statistics page" do + assert {1, nil} = StaticPages.upsert_statistics_page("first snapshot") + assert Repo.get_by!(StaticPage, slug: "stats").body == "first snapshot" + + assert {1, nil} = StaticPages.upsert_statistics_page("second snapshot") + + page = Repo.get_by!(StaticPage, slug: "stats") + assert page.title == "Statistics" + assert page.body == "second snapshot" + assert Repo.aggregate(StaticPage, :count, :id) == 1 + end + end + + describe "list_pages/1" do + test "an admin gets the list of static pages" do + page = static_page_fixture(admin_user_fixture()) + + assert {:ok, pages} = StaticPages.list_pages(actor(admin_user_fixture())) + assert page.id in Enum.map(pages, & &1.id) + end + + test "a moderator with the StaticPage admin grant may list them" do + moderator = role_moderator_fixture("StaticPage") + + assert {:ok, _pages} = StaticPages.list_pages(actor(moderator)) + end + + test "a regular user is unauthorized" do + assert StaticPages.list_pages(actor(confirmed_user_fixture())) == + {:error, :unauthorized} + end + + test "an anonymous viewer is unauthorized" do + assert StaticPages.list_pages(actor()) == {:error, :unauthorized} + end + end + + describe "show_page/2" do + test "an anonymous viewer loads a page by slug" do + page = static_page_fixture(admin_user_fixture()) + + assert {:ok, loaded} = StaticPages.show_page(actor(), page.slug) + assert loaded.id == page.id + end + + test "an unknown slug is not-found for every actor" do + assert StaticPages.show_page(actor(confirmed_user_fixture()), "no-such-page") == + {:error, :not_found} + + assert StaticPages.show_page(actor(admin_user_fixture()), "no-such-page") == + {:error, :not_found} + end + end + + describe "list_page_history/2" do + test "an anonymous viewer loads newest-first revision history" do + admin = admin_user_fixture() + page = static_page_fixture(admin, %{body: "First"}) + + assert {:ok, _updated} = + StaticPages.update_page(actor(admin), page.slug, %{ + title: page.title, + slug: page.slug, + body: "Second" + }) + + assert {:ok, {%StaticPage{id: page_id}, [latest, initial]}} = + StaticPages.list_page_history(actor(), page.slug) + + assert page_id == page.id + assert latest.body == "Second" + assert initial.body == "First" + end + + test "an unknown slug is not-found" do + assert StaticPages.list_page_history(actor(), "no-such-page") == {:error, :not_found} + end + end + + describe "new_page/1" do + test "an admin gets a blank changeset" do + assert {:ok, %Ecto.Changeset{data: %StaticPage{}}} = + StaticPages.new_page(actor(admin_user_fixture())) + end + + test "a regular user is unauthorized" do + assert StaticPages.new_page(actor(confirmed_user_fixture())) == {:error, :unauthorized} + end + + test "write-access failures precede authorization" do + admin = admin_user_fixture() + + assert StaticPages.new_page(actor(admin, ban: %{})) == {:error, :ban} + + assert StaticPages.new_page(actor(admin, fingerprint: nil)) == + {:error, :unauthorized} + end + end + + describe "create_page/2" do + test "an admin creates a page and its initial version" do + slug = unique_static_page_slug() + + assert {:ok, %StaticPage{} = page} = + StaticPages.create_page(actor(admin_user_fixture()), %{ + "title" => "Created Page", + "slug" => slug, + "body" => "Body text" + }) + + assert page.slug == slug + end + + test "invalid attrs return the page changeset" do + assert {:error, %Ecto.Changeset{} = changeset} = + StaticPages.create_page(actor(admin_user_fixture()), %{"title" => ""}) + + refute changeset.valid? + end + + test "a regular user is unauthorized" do + assert StaticPages.create_page(actor(confirmed_user_fixture()), %{ + "title" => "x", + "slug" => "y", + "body" => "z" + }) == {:error, :unauthorized} + end + + test "write-access failures precede authorization" do + admin = admin_user_fixture() + + assert StaticPages.create_page(actor(admin, ban: %{}), %{}) == {:error, :ban} + end + end + + describe "edit_page/2" do + test "an admin loads a page and a changeset" do + page = static_page_fixture(admin_user_fixture()) + + assert {:ok, {%StaticPage{} = loaded, %Ecto.Changeset{}}} = + StaticPages.edit_page(actor(admin_user_fixture()), page.slug) + + assert loaded.id == page.id + end + + test "a regular user is unauthorized" do + page = static_page_fixture(admin_user_fixture()) + + assert StaticPages.edit_page(actor(confirmed_user_fixture()), page.slug) == + {:error, :unauthorized} + end + + test "write-access failures match update" do + admin = admin_user_fixture() + page = static_page_fixture(admin) + + assert StaticPages.edit_page(actor(admin, ban: %{}), page.slug) == + {:error, :ban} + end + end + + describe "update_page/3" do + test "an admin updates a page and stores a new version" do + admin = admin_user_fixture() + page = static_page_fixture(admin) + + # The new Version row requires the full page fields, not just the changed + # one, so the update carries slug and body alongside the new title. + assert {:ok, %StaticPage{} = updated} = + StaticPages.update_page(actor(admin), page.slug, %{ + "title" => "Updated Title", + "slug" => page.slug, + "body" => "Updated body" + }) + + assert updated.title == "Updated Title" + assert Repo.get!(StaticPage, page.id).title == "Updated Title" + end + + test "an invalid update returns the page changeset" do + admin = admin_user_fixture() + page = static_page_fixture(admin) + + assert {:error, %Ecto.Changeset{} = changeset} = + StaticPages.update_page(actor(admin), page.slug, %{"title" => ""}) + + refute changeset.valid? + assert Repo.get!(StaticPage, page.id).title == page.title + end + + test "an unknown slug is not-found for every actor with write access" do + assert StaticPages.update_page(actor(confirmed_user_fixture()), "no-such-page", %{ + "title" => "x" + }) == + {:error, :not_found} + + assert StaticPages.update_page(actor(admin_user_fixture()), "no-such-page", %{ + "title" => "x" + }) == + {:error, :not_found} + end + + test "write-access failures precede loading" do + admin = admin_user_fixture() + + assert StaticPages.update_page(actor(admin, ban: %{}), "no-such-page", %{}) == + {:error, :ban} + end + + test "a regular user is unauthorized" do + page = static_page_fixture(admin_user_fixture()) + + assert StaticPages.update_page(actor(confirmed_user_fixture()), page.slug, %{ + "title" => "Hijacked" + }) == + {:error, :unauthorized} + end + end +end diff --git a/test/philomena/tag_changes/limits_test.exs b/test/philomena/tag_changes/limits_test.exs index 5da1ba37b..b1703306c 100644 --- a/test/philomena/tag_changes/limits_test.exs +++ b/test/philomena/tag_changes/limits_test.exs @@ -1,243 +1,87 @@ defmodule Philomena.TagChanges.LimitsTest do - # async: false because these counters live in Valkey, NOT Postgres: the Ecto - # SQL sandbox does not roll them back and they carry a 10-minute TTL. Every - # test therefore uses a fresh user id / IP and clears its own keys in an - # on_exit callback so nothing leaks into a later test or a later `mix test` - # run use Philomena.DataCase, async: false - import Philomena.AttributionFixtures - alias Philomena.TagChanges.Limits alias Philomena.Users.User - # The limit constants from Philomena.TagChanges.Limits. `check_limit/4` - # compares `amt + additional > limit`, so the boundary is inclusive: a change - # that lands the total exactly ON the limit is permitted, and only one that - # would carry it past the limit is refused. The effective tag allowance is the - # full 50, matching the advertised maximum; the tests below pin that boundary. @tag_limit 50 @rating_limit 1 - # Lightweight, DB-free actors. Limits only reads `user.id` (for the scope - # key) plus `user.role`, `user.bypass_rate_limits`, and `user.verified` (for - # the exemptions), and touches Valkey via Redix - it never hits Postgres - so - # a bare struct with a unique id is enough and keeps the counters isolated - # per test. The struct defaults (`role: "user"`, `bypass_rate_limits: false`) - # make the plain actors subject to the limits. - defp unverified_user, do: %User{id: System.unique_integer([:positive]), verified: false} - defp verified_user, do: %User{id: System.unique_integer([:positive]), verified: true} - - defp staff_user(role), - do: %User{id: System.unique_integer([:positive]), verified: false, role: role} - - defp bypass_user, - do: %User{id: System.unique_integer([:positive]), verified: false, bypass_rate_limits: true} + defp user(verified \\ false), + do: %User{id: System.unique_integer([:positive]), verified: verified} defp unique_ip do n = System.unique_integer([:positive]) %Postgrex.INET{address: {203, 0, rem(div(n, 254), 254) + 1, rem(n, 254) + 1}, netmask: 32} end - # Register cleanup of every counter key an actor could have touched. defp track(user, ip) do - on_exit(fn -> reset_tag_change_limits(user: user, ip: ip) end) - end - - defp raw_tag_count(user) do - Redix.command!(:redix, ["GET", "rltcn:u:#{user.id}"]) + on_exit(fn -> + scope = if user, do: "u:#{user.id}", else: "i:#{ip}" + Redix.command!(:redix, ["DEL", "rltcn:#{scope}", "rltcr:#{scope}"]) + end) end - describe "tag-change limit scoping (regression: shared IP, independent buckets)" do - test "two different unverified users on the same IP get independent buckets" do - ip = unique_ip() - user_a = unverified_user() - user_b = unverified_user() - track(user_a, ip) - track(user_b, ip) - - # Fill user A's tag bucket to the limit; the bucket now sits exactly at 50. - Limits.update_tag_count_after_update(user_a, ip, @tag_limit) - - # A's next change is refused: `50 + 1 > 50`. (The bucket is at the limit, - # so it is the pending change that trips it - the form production uses.) - assert Limits.limited_for_tag_count?(user_a, ip, 1) - - # ...but B, sharing the very same IP, is untouched. This is the assertion - # that would have caught the pre-fix behavior, where the counter was keyed - # on IP alone and A would have locked B out. - refute Limits.limited_for_tag_count?(user_b, ip) - end - - test "the same user is limited regardless of which IP they act from" do - ip1 = unique_ip() - ip2 = unique_ip() - user = unverified_user() - track(user, ip1) - track(user, ip2) + test "tag and rating reservations use their inclusive limits" do + user = user() + ip = unique_ip() + track(user, ip) - Limits.update_tag_count_after_update(user, ip1, @tag_limit) - - # Keyed by user id, so with the bucket at the limit the next change from a - # completely different IP is still refused (`50 + 1 > 50`). - assert Limits.limited_for_tag_count?(user, ip2, 1) - end + assert Limits.record_action(user, ip, @tag_limit, 0) == :ok + assert Limits.record_action(user, ip, 1, 0) == {:error, :rate_limited} + assert Limits.record_action(user, ip, 0, @rating_limit) == :ok + assert Limits.record_action(user, ip, 0, 1) == {:error, :rate_limited} end - describe "anonymous actors are keyed by IP" do - test "two anonymous requests from the same IP share a bucket" do - ip = unique_ip() - track(nil, ip) - - refute Limits.limited_for_tag_count?(nil, ip) - - # Fill the shared IP bucket to the limit; the next change is then refused. - Limits.update_tag_count_after_update(nil, ip, @tag_limit) - - assert Limits.limited_for_tag_count?(nil, ip, 1) - end + test "rollback releases both reservations" do + user = user() + ip = unique_ip() + track(user, ip) - test "anonymous requests from different IPs do not share a bucket" do - ip1 = unique_ip() - ip2 = unique_ip() - track(nil, ip1) - track(nil, ip2) - - # Fill ip1's bucket to the limit; its next change is refused, ip2 untouched. - Limits.update_tag_count_after_update(nil, ip1, @tag_limit) - - assert Limits.limited_for_tag_count?(nil, ip1, 1) - refute Limits.limited_for_tag_count?(nil, ip2) - end + assert Limits.record_action(user, ip, 2, 1) == :ok + assert Limits.rollback_action(user, ip, 2, 1) == :ok + assert Limits.record_action(user, ip, @tag_limit, @rating_limit) == :ok end - describe "tag-change limit boundary" do - test "the change landing exactly on the limit is allowed; the one past it is refused" do - ip = unique_ip() - user = unverified_user() - track(user, ip) - - # 49 successful tag changes so far. - Limits.update_tag_count_after_update(user, ip, @tag_limit - 1) - - # NOTE: the boundary is inclusive. `check_limit/4` compares - # `amt + additional > limit`, so a change reaching exactly `limit` (50) is - # allowed and only one that would exceed it is refused. At amt=49 a pending - # change of 1 lands the total on 50 (`49 + 1 > 50` is false → allowed), but - # a pending change of 2 would overshoot to 51 (`49 + 2 > 50` → refused). - refute Limits.limited_for_tag_count?(user, ip) - refute Limits.limited_for_tag_count?(user, ip, 0) - refute Limits.limited_for_tag_count?(user, ip, 1) - assert Limits.limited_for_tag_count?(user, ip, 2) - end - - test "a counter sitting exactly on the limit refuses any further change but is not itself over" do - ip = unique_ip() - user = unverified_user() - track(user, ip) - - # Drive the counter to exactly the limit (50). - Limits.update_tag_count_after_update(user, ip, @tag_limit) - - # NOTE: a bare over-check at exactly the limit is NOT limited - `50 > 50` is - # false - so `additional = 0` reports "not over". But any pending change of - # 1 would push past the limit (`50 + 1 > 50`), so the next change is refused. - refute Limits.limited_for_tag_count?(user, ip, 0) - assert Limits.limited_for_tag_count?(user, ip, 1) - end - end + test "concurrent tag reservations above the limit are rejected" do + user = user() + ip = unique_ip() + track(user, ip) - describe "verified users are exempt" do - test "a verified user is never limited and no counter is recorded for them" do - ip = unique_ip() - user = verified_user() - track(user, ip) - - # Even a massive increment is a no-op for a verified user. - Limits.update_tag_count_after_update(user, ip, @tag_limit * 10) - - refute Limits.limited_for_tag_count?(user, ip) - - # increment_counter/4 short-circuits on `considered_for_limit?/1`, so - # nothing was ever written to Valkey. - assert is_nil(raw_tag_count(user)) - end - end - - describe "staff and rate-limit-bypassing users are exempt" do - test "staff roles are never limited, even unverified, and no counter is recorded" do - for role <- ~w(admin moderator assistant) do - ip = unique_ip() - user = staff_user(role) - track(user, ip) - - # Blowing far past both limits is a no-op for staff... - Limits.update_tag_count_after_update(user, ip, @tag_limit * 10) - Limits.update_rating_count_after_update(user, ip, @rating_limit * 10) - - refute Limits.limited_for_tag_count?(user, ip, 1) - refute Limits.limited_for_rating_count?(user, ip) - - # ...and increment_counter/4 short-circuits on considered_for_limit?/1, - # so the counters never advance. - assert is_nil(raw_tag_count(user)) + results = + for _ <- 1..2 do + Task.async(fn -> Limits.record_action(user, ip, 30, 0) end) end - end - - test "a bypass_rate_limits user is never limited and no counter is recorded" do - ip = unique_ip() - user = bypass_user() - track(user, ip) - - Limits.update_tag_count_after_update(user, ip, @tag_limit * 10) - Limits.update_rating_count_after_update(user, ip, @rating_limit * 10) + |> Enum.map(&Task.await(&1, 5_000)) - refute Limits.limited_for_tag_count?(user, ip, 1) - refute Limits.limited_for_rating_count?(user, ip) - assert is_nil(raw_tag_count(user)) - end + assert Enum.count(results, &(&1 == :ok)) == 1 + assert Enum.count(results, &(&1 == {:error, :rate_limited})) == 1 + assert Redix.command!(:redix, ["GET", "rltcn:u:#{user.id}"]) == "30" end - describe "rating-change limit scoping" do - test "one rating change exhausts an unverified user's bucket without affecting others" do - ip = unique_ip() - user_a = unverified_user() - user_b = unverified_user() - track(user_a, ip) - track(user_b, ip) - - refute Limits.limited_for_rating_count?(user_a, ip) - - # @rating_changes_per_ten_minutes is 1, so a single change trips it. - Limits.update_rating_count_after_update(user_a, ip, @rating_limit) - - assert Limits.limited_for_rating_count?(user_a, ip) + test "anonymous requests share their IP bucket" do + ip = unique_ip() + track(nil, ip) - # A different user on the same IP keeps their own (empty) bucket. - refute Limits.limited_for_rating_count?(user_b, ip) - end - - test "anonymous rating changes are keyed by IP" do - ip1 = unique_ip() - ip2 = unique_ip() - track(nil, ip1) - track(nil, ip2) - - Limits.update_rating_count_after_update(nil, ip1, @rating_limit) - - assert Limits.limited_for_rating_count?(nil, ip1) - refute Limits.limited_for_rating_count?(nil, ip2) - end + assert Limits.record_action(nil, ip, @tag_limit, 0) == :ok + assert Limits.record_action(nil, ip, 1, 0) == {:error, :rate_limited} + end - test "a verified user's rating changes are never limited or recorded" do + for attrs <- [ + [verified: true], + [role: "admin"], + [role: "moderator"], + [role: "assistant"], + [bypass_rate_limits: true] + ] do + test "#{inspect(attrs)} users are exempt" do + user = struct!(%User{id: System.unique_integer([:positive])}, unquote(attrs)) ip = unique_ip() - user = verified_user() track(user, ip) - Limits.update_rating_count_after_update(user, ip, @rating_limit * 10) - - refute Limits.limited_for_rating_count?(user, ip) - assert is_nil(Redix.command!(:redix, ["GET", "rltcr:u:#{user.id}"])) + assert Limits.record_action(user, ip, @tag_limit * 10, @rating_limit * 10) == :ok + assert Limits.rollback_action(user, ip, 1, 1) == :ok + assert Redix.command!(:redix, ["GET", "rltcn:u:#{user.id}"]) == nil end end end diff --git a/test/philomena/tag_changes_concurrency_test.exs b/test/philomena/tag_changes_concurrency_test.exs new file mode 100644 index 000000000..987f8fa8b --- /dev/null +++ b/test/philomena/tag_changes_concurrency_test.exs @@ -0,0 +1,162 @@ +defmodule Philomena.TagChangesConcurrencyTest do + use Philomena.ConcurrentDataCase + + import Ecto.Query + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + alias Philomena.Images + alias Philomena.Repo + alias Philomena.TagChanges + alias Philomena.TagChanges.TagChange + alias Philomena.Tags + + @base_tags "safe, base one, base two" + + defp image_tag_ids(image) do + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.id) + end + + defp tag_change_attributes(user) do + attribution = actor(user) + + %{ + user_id: user.id, + ip: attribution.ip, + fingerprint: attribution.fingerprint + } + end + + defp create_tag_change!(image, tag, user, added) do + image_tag_names = + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.name) + + old_tag_input = Enum.join(image_tag_names, ", ") + + new_tag_input = + if added do + Enum.join([tag.name | image_tag_names], ", ") + else + image_tag_names + |> List.delete(tag.name) + |> Enum.join(", ") + end + + arrangement_actor = actor(%{user | bypass_rate_limits: true}) + + assert {:ok, result} = + Images.update_image_tags( + arrangement_actor, + image.id, + %{"old_tag_input" => old_tag_input, "tag_input" => new_tag_input} + ) + + assert result.image.id == image.id + + Repo.one!( + from tag_change in TagChange, + where: tag_change.image_id == ^image.id, + order_by: [desc: :id], + limit: 1 + ) + end + + test "reversion removes an alias whether migration wins or loses the image lock" do + user = confirmed_user_fixture() + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + image = image_fixture(tags: @base_tags) + tag_change = create_tag_change!(image, source, user, true) + + source = + source + |> Ecto.Changeset.change(aliased_tag_id: target.id, images_count: 1) + |> Repo.update!() + + attributes = tag_change_attributes(user) + + results = + concurrently([ + fn -> Tags.perform_alias(source.id, target.id) end, + fn -> TagChanges.revert_for_worker([tag_change.id], attributes) end + ]) + + assert Enum.any?(results, &(&1 == :ok)) + assert Enum.any?(results, &match?({:ok, [_]}, &1)) + + ids = image_tag_ids(image) + refute source.id in ids + refute target.id in ids + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == 0 + end + + test "reversion does not duplicate a source tagging while it is migrating" do + user = confirmed_user_fixture() + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + image = image_fixture(tags: "#{@base_tags}, #{source.name}") + removed_change = create_tag_change!(image, source, user, false) + _later_add = create_tag_change!(image, source, user, true) + + source = + source + |> Ecto.Changeset.change(aliased_tag_id: target.id, images_count: 1) + |> Repo.update!() + + attributes = tag_change_attributes(user) + initial_change_count = Repo.aggregate(TagChange, :count) + + results = + concurrently([ + fn -> Tags.perform_alias(source.id, target.id) end, + fn -> TagChanges.revert_for_worker([removed_change.id], attributes) end + ]) + + assert Enum.any?(results, &(&1 == :ok)) + assert Enum.any?(results, &match?({:ok, [_]}, &1)) + + ids = image_tag_ids(image) + refute source.id in ids + assert target.id in ids + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == 1 + assert Repo.aggregate(TagChange, :count) == initial_change_count + end + + test "reversion self-cancels edits that straddle an alias migration" do + user = confirmed_user_fixture() + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + image = image_fixture(tags: @base_tags) + added_change = create_tag_change!(image, source, user, true) + + source = + source + |> Ecto.Changeset.change(aliased_tag_id: target.id, images_count: 1) + |> Repo.update!() + + assert :ok = Tags.perform_alias(source.id, target.id) + removed_change = create_tag_change!(image, target, user, false) + initial_change_count = Repo.aggregate(TagChange, :count) + + assert {:ok, [_first, _second]} = + TagChanges.revert_for_worker( + [added_change.id, removed_change.id], + tag_change_attributes(user) + ) + + ids = image_tag_ids(image) + refute source.id in ids + refute target.id in ids + assert Repo.aggregate(TagChange, :count) == initial_change_count + end +end diff --git a/test/philomena/tag_changes_test.exs b/test/philomena/tag_changes_test.exs new file mode 100644 index 000000000..7777911a4 --- /dev/null +++ b/test/philomena/tag_changes_test.exs @@ -0,0 +1,599 @@ +defmodule Philomena.TagChangesTest do + @moduledoc """ + Context-level tests for the actor-first `Philomena.TagChanges` API: + `delete_tag_change/2`, `revert_tag_changes/2`, and `full_revert/2`. + + These pin the authorization matrix (anonymous/user/moderator/admin), the + two global error shapes, and the moderation log entries - type strings, + bodies, and subject paths byte-for-byte - that each function writes on + success. The corresponding controller characterization tests pin the HTTP + behavior on top of these results. + """ + + use Philomena.DataCase, async: false + + # delete_tag_change/2 removes the record's search document, so this module + # follows the OpenSearch test rules (async: false, index cycled in setup). + @moduletag :search + + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + import Ecto.Query + + alias Philomena.Images + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.ModerationLogs.Paths + alias Philomena.Repo + alias Philomena.TagChanges + alias Philomena.TagChanges.QueryForm + alias Philomena.TagChanges.TagChange + alias Philomena.TagChanges.TagChangePage + alias Philomena.Tags.Tag + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + @pagination %{page_number: 1, page_size: 25} + + setup do + Search.clear_index!(TagChange) + # Valkey rate-limit counters are not rolled back by the SQL sandbox; reset + # the tag-change limit so accumulated counts don't trip check_limits. + reset_tag_change_limits() + :ok + end + + # Arranges an image whose tags went from "safe" to three tags, returning the + # image plus the single TagChange row that recorded the two adds. + defp tag_change!(user) do + image = image_fixture() + + # These tests arrange history rather than exercise the write rate limits. + arrangement_actor = + case user do + nil -> + actor() + + user -> + actor(%{user | bypass_rate_limits: true}) + end + + {:ok, result} = + Images.update_image_tags( + arrangement_actor, + image.id, + %{ + "old_tag_input" => "safe", + "tag_input" => "safe, added test tag, other added tag" + } + ) + + assert result.image.id == image.id + {image, Repo.one!(from tc in TagChange, where: tc.image_id == ^image.id)} + end + + defp image_tag_names(image) do + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.name) + end + + defp only_moderation_log! do + Repo.one!(ModerationLog) + end + + defp reindex_tag_changes! do + SearchHelpers.reindex_all!(TagChange) + end + + describe "actor-scoped history reads" do + test "the global listing returns a typed page and normalized query form" do + {_image, tag_change} = tag_change!(confirmed_user_fixture()) + reindex_tag_changes!() + + assert {:ok, + %TagChangePage{ + target: nil, + tag_changes: page + }, %Ecto.Changeset{data: %QueryForm{sf: "tag_count", sd: "asc"}}} = + TagChanges.list_tag_changes( + actor(), + %{"sf" => "tag_count", "sd" => "asc"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + end + + test "invalid query and sort input return rejected changesets" do + assert {:error, %Ecto.Changeset{valid?: false} = changeset} = + TagChanges.list_tag_changes(actor(), %{"sf" => "unknown"}, @pagination) + + assert "is invalid" in errors_on(changeset).sf + + assert {:error, %Ecto.Changeset{valid?: false} = changeset} = + TagChanges.list_tag_changes(actor(), %{"tcq" => "("}, @pagination) + + assert errors_on(changeset).tcq != [] + end + + test "resource APIs resolve image, tag, and user targets independently" do + user = confirmed_user_fixture() + {image, tag_change} = tag_change!(user) + tag = Repo.get_by!(Tag, name: "added test tag") + reindex_tag_changes!() + + assert {:ok, %TagChangePage{target: loaded_image, tag_changes: page}, _} = + TagChanges.list_image_tag_changes(actor(), image.id, %{}, @pagination) + + assert loaded_image.id == image.id + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + + assert {:ok, %TagChangePage{target: loaded_tag, tag_changes: page}, _} = + TagChanges.list_tag_tag_changes(actor(), tag.slug, %{}, @pagination) + + assert loaded_tag.id == tag.id + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + + assert {:ok, %TagChangePage{target: loaded_user, tag_changes: page}, _} = + TagChanges.list_user_tag_changes(actor(), user.slug, %{}, @pagination) + + assert loaded_user.id == user.id + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + end + + test "the tag resource resolves aliases to their canonical target" do + {_image, tag_change} = tag_change!(confirmed_user_fixture()) + canonical = Repo.get_by!(Tag, name: "added test tag") + + alias_tag = + tag_fixture(name: "former added test tag") + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + reindex_tag_changes!() + + assert {:ok, + %TagChangePage{ + target: loaded_tag, + tag_changes: page + }, _changeset} = + TagChanges.list_tag_tag_changes(actor(), alias_tag.slug, %{}, @pagination) + + assert loaded_tag.id == canonical.id + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + end + + test "resource filters compose with tcq rather than replacing it" do + {image, tag_change} = tag_change!(confirmed_user_fixture()) + other = image_fixture() + reindex_tag_changes!() + + assert {:ok, %TagChangePage{tag_changes: page}, _} = + TagChanges.list_image_tag_changes( + actor(), + image.id, + %{"tcq" => "image_id:#{other.id}"}, + @pagination + ) + + assert page.entries == [] + assert page.total_entries == 0 + + assert {:ok, %TagChangePage{tag_changes: page}, _} = + TagChanges.list_image_tag_changes( + actor(), + image.id, + %{"tcq" => "id:#{tag_change.id}"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + end + + test "missing resource targets are not found before search" do + assert TagChanges.list_image_tag_changes(actor(), "not-an-id", %{}, @pagination) == + {:error, :not_found} + + assert TagChanges.list_image_tag_changes(actor(), "2147483647", %{}, @pagination) == + {:error, :not_found} + + assert TagChanges.list_tag_tag_changes(actor(), "no-such-tag", %{}, @pagination) == + {:error, :not_found} + + assert TagChanges.list_user_tag_changes(actor(), "no-such-user", %{}, @pagination) == + {:error, :not_found} + end + + test "hidden image changes ignore image visibility in global listings but not image listings" do + {image, tag_change} = tag_change!(confirmed_user_fixture()) + + image + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + reindex_tag_changes!() + + assert {:ok, %TagChangePage{tag_changes: global_page}, _} = + TagChanges.list_tag_changes(actor(), %{}, @pagination) + + assert Enum.map(global_page.entries, & &1.id) == [tag_change.id] + + assert TagChanges.list_image_tag_changes(actor(), image.id, %{}, @pagination) == + {:error, :unauthorized} + + moderator = actor(moderator_user_fixture()) + + assert {:ok, %TagChangePage{tag_changes: global_page}, _} = + TagChanges.list_tag_changes(moderator, %{}, @pagination) + + assert Enum.map(global_page.entries, & &1.id) == [tag_change.id] + + assert {:ok, %TagChangePage{tag_changes: image_page}, _} = + TagChanges.list_image_tag_changes(moderator, image.id, %{}, @pagination) + + assert Enum.map(image_page.entries, & &1.id) == [tag_change.id] + end + + test "IP and fingerprint locators validate before their sensitive gate" do + moderator = actor(moderator_user_fixture()) + {_image, tag_change} = tag_change!(confirmed_user_fixture()) + reindex_tag_changes!() + + assert TagChanges.list_ip_tag_changes(actor(), "bad-ip", %{}, @pagination) == + {:error, :not_found} + + assert TagChanges.list_ip_tag_changes(actor(), "203.0.113.1", %{}, @pagination) == + {:error, :unauthorized} + + assert {:ok, %TagChangePage{tag_changes: ip_page}, _} = + TagChanges.list_ip_tag_changes(moderator, "203.0.113.1", %{}, @pagination) + + assert Enum.map(ip_page.entries, & &1.id) == [tag_change.id] + + assert TagChanges.list_fingerprint_tag_changes(actor(), "invalid", %{}, @pagination) == + {:error, :not_found} + + assert TagChanges.list_fingerprint_tag_changes( + actor(), + "d015c342859dde3", + %{}, + @pagination + ) == {:error, :unauthorized} + + assert {:ok, + %TagChangePage{ + target: "d015c342859dde3", + tag_changes: fingerprint_page + }, _} = + TagChanges.list_fingerprint_tag_changes( + moderator, + " D015C342859DDE3 ", + %{}, + @pagination + ) + + assert Enum.map(fingerprint_page.entries, & &1.id) == [tag_change.id] + end + + test "sensitive tcq fields are authorized without direct role matching" do + {_image, tag_change} = tag_change!(confirmed_user_fixture()) + reindex_tag_changes!() + + assert {:ok, %TagChangePage{tag_changes: ordinary_page}, + %Ecto.Changeset{ + data: %QueryForm{compiled_query: %{term: %{"tag" => "ip:203.0.113.1"}}} + }} = + TagChanges.list_tag_changes( + actor(confirmed_user_fixture()), + %{"tcq" => "ip:203.0.113.1"}, + @pagination + ) + + assert ordinary_page.entries == [] + + assert {:ok, %TagChangePage{tag_changes: page}, + %Ecto.Changeset{ + data: %QueryForm{compiled_query: %{term: %{"ip" => "203.0.113.1"}}} + }} = + TagChanges.list_tag_changes( + actor(moderator_user_fixture()), + %{"tcq" => "ip:203.0.113.1"}, + @pagination + ) + + assert Enum.map(page.entries, & &1.id) == [tag_change.id] + end + + test "pagination totals and deterministic ordering match the visible result set" do + user = confirmed_user_fixture() + {_image, older} = tag_change!(user) + {_image, middle} = tag_change!(user) + {_image, newer} = tag_change!(user) + reindex_tag_changes!() + + assert {:ok, %TagChangePage{tag_changes: page}, _} = + TagChanges.list_tag_changes(actor(), %{}, %{page_number: 1, page_size: 2}) + + assert Enum.map(page.entries, & &1.id) == [newer.id, middle.id] + assert page.total_entries == 3 + assert page.total_pages == 2 + refute older.id in Enum.map(page.entries, & &1.id) + end + end + + describe "delete_tag_change/2" do + test "denies an anonymous actor" do + {_image, tc} = tag_change!(confirmed_user_fixture()) + + assert TagChanges.delete_tag_change(actor(), "#{tc.id}") == {:error, :unauthorized} + assert Repo.get(TagChange, tc.id) + end + + test "denies a regular user" do + {_image, tc} = tag_change!(confirmed_user_fixture()) + + assert TagChanges.delete_tag_change(actor(confirmed_user_fixture()), "#{tc.id}") == + {:error, :unauthorized} + + assert Repo.get(TagChange, tc.id) + end + + test "a moderator deletes the change and a moderation log is written" do + author = confirmed_user_fixture() + moderator = moderator_user_fixture() + {image, tc} = tag_change!(author) + reindex_tag_changes!() + + assert {:ok, %TagChange{}} = TagChanges.delete_tag_change(actor(moderator), "#{tc.id}") + refute Repo.get(TagChange, tc.id) + + Search.refresh_index!(TagChange) + + assert {:ok, %TagChangePage{tag_changes: page}, _changeset} = + TagChanges.list_tag_changes(actor(moderator), %{}, @pagination) + + assert page.entries == [] + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "TagChange:delete" + assert log.subject_path == "/images/#{image.id}" + + assert log.body == + "Deleted tag change by #{author.name} containing 2 tags on image #{image.id} from history" + end + + test "an admin may also delete" do + {_image, tc} = tag_change!(confirmed_user_fixture()) + + assert {:ok, %TagChange{}} = + TagChanges.delete_tag_change(actor(admin_user_fixture()), tc.id) + end + + test "a moderator deletes an anonymous change without crashing" do + moderator = moderator_user_fixture() + {image, tag_change} = tag_change!(nil) + + assert {:ok, %TagChange{}} = + TagChanges.delete_tag_change(actor(moderator), tag_change.id) + + refute Repo.get(TagChange, tag_change.id) + + log = only_moderation_log!() + assert log.subject_path == Paths.image_path(image) + assert log.body =~ "Deleted tag change by an anonymous user" + end + + test "a well-formed id naming no row is not found" do + assert TagChanges.delete_tag_change(actor(moderator_user_fixture()), "123456789") == + {:error, :not_found} + end + + test "an id that cannot name a row is not found" do + moderator = moderator_user_fixture() + + assert TagChanges.delete_tag_change(actor(moderator), "not-an-integer") == + {:error, :not_found} + + assert TagChanges.delete_tag_change(actor(moderator), "99999999999999999999") == + {:error, :not_found} + end + end + + describe "create_tag_change_revert/2" do + test "denies an anonymous actor" do + assert TagChanges.create_tag_change_revert(actor(), ["1"]) == {:error, :unauthorized} + end + + test "denies a regular user before looking at the ids" do + # Authorization comes first, as it did when it was a plug: a bad ids + # shape from an unprivileged user is still unauthorized. + user_actor = actor(confirmed_user_fixture()) + + assert TagChanges.create_tag_change_revert(user_actor, ["1"]) == {:error, :unauthorized} + assert TagChanges.create_tag_change_revert(user_actor, "42") == {:error, :unauthorized} + end + + test "a moderator reverts the listed changes and a moderation log is written" do + moderator = moderator_user_fixture() + {image, tc} = tag_change!(confirmed_user_fixture()) + + assert "added test tag" in image_tag_names(image) + + assert {:ok, [%TagChange{}]} = + TagChanges.create_tag_change_revert(actor(moderator), %{"ids" => ["#{tc.id}"]}) + + # Reverting the change removes the two tags it had added. + names = image_tag_names(image) + refute "added test tag" in names + refute "other added tag" in names + assert "safe" in names + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "TagChange.Revert:create" + # Slug encoding (e.g. `@` → `%40`) is pinned in the Paths tests. + assert log.subject_path == Paths.profile_path(moderator) + assert log.body == "Reverted 1 tag changes" + end + + test "an empty list is a successful reversion of zero changes" do + assert {:ok, []} = + TagChanges.create_tag_change_revert(actor(moderator_user_fixture()), %{ids: []}) + + assert only_moderation_log!().body == "Reverted 0 tag changes" + end + + test "reverting an already-reverted change is safe" do + moderator = actor(moderator_user_fixture()) + {image, tag_change} = tag_change!(confirmed_user_fixture()) + + assert {:ok, [%TagChange{}]} = + TagChanges.create_tag_change_revert(moderator, %{ids: [tag_change.id]}) + + assert {:ok, [%TagChange{}]} = + TagChanges.create_tag_change_revert(moderator, %{ids: [tag_change.id]}) + + names = image_tag_names(image) + refute "added test tag" in names + refute "other added tag" in names + assert "safe" in names + end + + test "a non-list ids value from a moderator is invalid" do + assert {:error, %Ecto.Changeset{} = changeset} = + TagChanges.create_tag_change_revert(actor(moderator_user_fixture()), %{ + "ids" => "42" + }) + + assert changeset.errors[:ids] + + assert Repo.aggregate(ModerationLog, :count) == 0 + end + + test "a list containing a malformed id is invalid before reversion" do + assert {:error, %Ecto.Changeset{} = changeset} = + TagChanges.create_tag_change_revert(actor(moderator_user_fixture()), %{ + "ids" => ["not-an-id"] + }) + + assert changeset.errors[:ids] + + assert Repo.aggregate(ModerationLog, :count) == 0 + end + end + + describe "full_revert_*_tag_changes/2" do + test "denies an anonymous actor" do + assert TagChanges.create_user_tag_change_revert(actor(), "user") == {:error, :unauthorized} + + assert TagChanges.create_ip_tag_change_revert(actor(), "203.0.113.1") == + {:error, :unauthorized} + + assert TagChanges.create_fingerprint_tag_change_revert(actor(), "c1774") == + {:error, :unauthorized} + end + + test "denies a regular user before looking at the target" do + user_actor = actor(confirmed_user_fixture()) + + assert TagChanges.create_user_tag_change_revert(user_actor, "user") == + {:error, :unauthorized} + + assert TagChanges.create_ip_tag_change_revert(user_actor, "203.0.113.1") == + {:error, :unauthorized} + + assert TagChanges.create_fingerprint_tag_change_revert(user_actor, "c1774") == + {:error, :unauthorized} + end + + test "a moderator enqueues a reversion for a user and the log names them" do + moderator = moderator_user_fixture() + target = confirmed_user_fixture() + + assert {:ok, result} = + TagChanges.create_user_tag_change_revert(actor(moderator), target.slug) + + assert result.id == target.id + + log = only_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "TagChange.FullRevert:create" + assert log.subject_path == Paths.profile_path(target) + assert log.body == "Reverted all tag changes for user #{target.name}" + end + + test "a missing user profile is not found" do + assert TagChanges.create_user_tag_change_revert( + actor(moderator_user_fixture()), + "missing" + ) == {:error, :not_found} + end + + test "a moderator enqueues a reversion for an ip" do + assert {:ok, "203.0.113.9"} = + TagChanges.create_ip_tag_change_revert( + actor(moderator_user_fixture()), + "203.0.113.9" + ) + + log = only_moderation_log!() + assert log.type == "TagChange.FullRevert:create" + assert log.subject_path == "/ip_profiles/203.0.113.9" + assert log.body == "Reverted all tag changes for ip 203.0.113.9" + end + + test "a moderator enqueues a reversion for a fingerprint" do + assert {:ok, "c1774"} = + TagChanges.create_fingerprint_tag_change_revert( + actor(moderator_user_fixture()), + "c1774" + ) + + log = only_moderation_log!() + assert log.subject_path == "/fingerprint_profiles/c1774" + assert log.body == "Reverted all tag changes for fingerprint c1774" + end + + test "invalid targets are not found" do + assert TagChanges.create_user_tag_change_revert( + actor(moderator_user_fixture()), + "not-a-user" + ) == + {:error, :not_found} + + assert TagChanges.create_ip_tag_change_revert(actor(moderator_user_fixture()), "not-an-ip") == + {:error, :not_found} + + assert TagChanges.create_fingerprint_tag_change_revert( + actor(moderator_user_fixture()), + "invalid" + ) == {:error, :not_found} + + assert Repo.aggregate(ModerationLog, :count) == 0 + end + end + + describe "cleanup_empty_for_tag_deletion/0" do + test "deletes only empty changes and returns their ids" do + {_image, retained} = tag_change!(confirmed_user_fixture()) + image = image_fixture() + attribution = actor() + + empty = + Repo.insert!(%TagChange{ + image_id: image.id, + ip: attribution.ip, + fingerprint: attribution.fingerprint + }) + + assert TagChanges.cleanup_empty_for_tag_deletion() == {1, [empty.id]} + refute Repo.get(TagChange, empty.id) + assert Repo.get(TagChange, retained.id) + end + end +end diff --git a/test/philomena/tags_concurrency_test.exs b/test/philomena/tags_concurrency_test.exs new file mode 100644 index 000000000..3276495e8 --- /dev/null +++ b/test/philomena/tags_concurrency_test.exs @@ -0,0 +1,309 @@ +defmodule Philomena.TagsConcurrencyTest do + use Philomena.ConcurrentDataCase + use Patch + + import Philomena.TagsFixtures + import Philomena.DnpEntriesFixtures + import Philomena.FiltersFixtures + + alias Philomena.Images + alias Philomena.DnpEntries + alias Philomena.Filters + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.Tags + alias Philomena.Tags.Tag + alias PhilomenaQuery.Search + + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.UsersFixtures + + test "overlapping vectorized counter updates complete in primary-key lock order" do + tags = Enum.map(1..4, fn index -> tag_fixture(name: "counter tag #{index}") end) + ascending_ids = Enum.map(tags, & &1.id) + descending_ids = Enum.reverse(ascending_ids) + + results = + concurrently( + for tag_ids <- List.duplicate(ascending_ids, 4) ++ List.duplicate(descending_ids, 4) do + fn -> + Repo.transaction(fn -> + Tags.update_image_counts(Repo, 1, tag_ids) + end) + end + end + ) + + assert Enum.all?(results, &(&1 == {:ok, length(tags)})) + + counts = + Tag + |> where([tag], tag.id in ^ascending_ids) + |> order_by(:id) + |> select([tag], tag.images_count) + |> Repo.all() + + assert counts == List.duplicate(length(results), length(tags)) + end + + test "concurrent canonicalization creates one tag and returns it to every transaction" do + name = unique_tag_name() + + results = + concurrently( + for _ <- 1..8 do + fn -> + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([ + {:tags, [name], allow_insert_new?: true} + ]) + |> Multi.transact() + end + end + ) + + assert Enum.all?(results, &match?({:ok, %{canonical_tags: %{tags: [%Tag{name: ^name}]}}}, &1)) + assert Repo.aggregate(from(tag in Tag, where: tag.name == ^name), :count) == 1 + end + + test "concurrent alias requests for one source commit only one target" do + source = tag_fixture(name: unique_tag_name()) + targets = for _ <- 1..2, do: tag_fixture(name: unique_tag_name()) + actors = for _ <- 1..2, do: actor(admin_user_fixture()) + + results = + concurrently( + Enum.zip(actors, targets) + |> Enum.map(fn {actor, target} -> + fn -> Tags.update_tag_alias(actor, source.slug, %{"target_tag" => target.name}) end + end) + ) + + assert Enum.count(results, &match?({:ok, %Tag{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, %Ecto.Changeset{}}, &1)) == 1 + assert Repo.reload!(source).aliased_tag_id in Enum.map(targets, & &1.id) + assert Repo.aggregate(ModerationLog, :count) == 1 + end + + test "concurrent alias workers migrate each image tagging once" do + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + images = for _ <- 1..4, do: image_fixture(tags: "safe, #{source.name}") + + source = + source + |> Ecto.Changeset.change(aliased_tag_id: target.id, images_count: length(images)) + |> Repo.update!() + + results = + concurrently( + for _ <- 1..4 do + fn -> Tags.perform_alias(source.id, target.id) end + end + ) + + assert results == List.duplicate(:ok, 4) + + for image <- images do + tag_ids = + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.id) + + assert target.id in tag_ids + refute source.id in tag_ids + end + + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == length(images) + end + + test "a filter update racing an alias stores the canonical tag" do + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + user = confirmed_user_fixture() + filter = filter_fixture(user) + + results = + concurrently([ + fn -> + Tags.update_tag_alias(actor(admin_user_fixture()), source.slug, %{ + "target_tag" => target.name + }) + end, + fn -> + Filters.update_filter(actor(user), filter.id, %{ + "hidden_tag_list" => source.name, + "spoilered_tag_list" => "" + }) + end + ]) + + assert Enum.all?(results, &match?({:ok, _}, &1)) + assert Repo.reload!(filter).hidden_tag_ids == [target.id] + end + + test "a DNP update racing an alias stores the canonical tag" do + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + moderator = moderator_user_fixture() + entry = dnp_entry_fixture(moderator, source) + + results = + concurrently([ + fn -> + Tags.update_tag_alias(actor(admin_user_fixture()), source.slug, %{ + "target_tag" => target.name + }) + end, + fn -> + DnpEntries.update_dnp_entry( + actor(moderator), + entry.id, + %{ + "tag_id" => to_string(source.id), + "dnp_type" => "No Edits", + "reason" => "Updated reason" + } + ) + end + ]) + + assert Enum.all?(results, &match?({:ok, _}, &1)) + assert Repo.reload!(entry).tag_id == target.id + end + + test "an alias worker and an image tag edit serialize on the image row" do + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + added = unique_tag_name() + image = image_fixture(tags: "safe, #{source.name}") + + source = + source + |> Ecto.Changeset.change(aliased_tag_id: target.id, images_count: 1) + |> Repo.update!() + + results = + concurrently([ + fn -> Tags.perform_alias(source.id, target.id) end, + fn -> + Images.update_image_tags( + actor(admin_user_fixture()), + image.id, + %{ + "old_tag_input" => "safe, #{source.name}", + "tag_input" => "safe, #{source.name}, #{added}" + } + ) + end + ]) + + assert Enum.count(results, &(&1 == :ok)) == 1 + assert Enum.count(results, &match?({:ok, %{}}, &1)) == 1 + + tag_ids = + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.id) + + assert target.id in tag_ids + refute source.id in tag_ids + assert Enum.any?(Repo.preload(image, :tags, force: true).tags, &(&1.name == added)) + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == 1 + end + + test "an alias worker and a batch tag edit serialize on the image row" do + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + image = image_fixture(tags: "safe, #{source.name}") + + source = + source + |> Ecto.Changeset.change(aliased_tag_id: target.id, images_count: 1) + |> Repo.update!() + + results = + concurrently([ + fn -> Tags.perform_alias(source.id, target.id) end, + fn -> + Images.update_batch_tags(actor(admin_user_fixture()), %{ + tag_list: source.name, + image_ids: [image.id] + }) + end + ]) + + assert Enum.count(results, &(&1 == :ok)) == 1 + assert Enum.count(results, &match?({:ok, %{succeeded: 1, failed: 0}}, &1)) == 1 + + tag_ids = + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.id) + + assert target.id in tag_ids + refute source.id in tag_ids + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == 1 + end + + test "concurrent reindex and tagging preserve the image counter" do + patch(Search, :reindex, :ok) + + Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn -> + base_tag_names = ["safe", "filler", "initial"] + + base_tag_ids = + from(tag in Tag, where: tag.name in ^base_tag_names, select: tag.id) + |> Repo.all() + + tag = tag_fixture(name: unique_tag_name()) + image = image_fixture(tags: "safe, filler, #{tag.name}") + other_image = image_fixture(tags: "safe, filler, initial") + admin = admin_user_fixture() + + tag + |> Ecto.Changeset.change(images_count: 99) + |> Repo.update!() + + try do + results = + concurrently([ + fn -> Tags.perform_reindex_images(tag.id) end, + fn -> + Images.update_image_tags( + actor(admin), + other_image.id, + %{ + "old_tag_input" => "safe, filler, initial", + "tag_input" => "safe, filler, initial, #{tag.name}" + } + ) + end + ]) + + assert Enum.any?(results, &(&1 == :ok)) + assert Enum.any?(results, &match?({:ok, %{}}, &1)) + assert Repo.reload!(tag).images_count == 2 + after + Repo.delete!(image) + Repo.delete!(other_image) + Repo.delete!(tag) + + Repo.delete_all( + from tag in Tag, + where: tag.name in ^base_tag_names and tag.id not in ^base_tag_ids + ) + + Repo.delete!(admin) + end + end) + end +end diff --git a/test/philomena/tags_test.exs b/test/philomena/tags_test.exs index 4b4f7c80b..1779f2d05 100644 --- a/test/philomena/tags_test.exs +++ b/test/philomena/tags_test.exs @@ -1,22 +1,984 @@ defmodule Philomena.TagsTest do - use Philomena.DataCase, async: true + @moduledoc """ + Context-level tests for the controller-facing `Philomena.Tags` functions and + their typed page, detail, and search-form results. + + These pin the per-role authorization matrices on the edit/alias/delete/image + paths, load-before-authorize not-found behavior, the byte-exact moderation + logs the write paths emit, and the two search-backed loaders. + """ + + use Philomena.DataCase, async: false + + @moduletag :search + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1, actor: 2] + import Philomena.FiltersFixtures + import Philomena.ImagesFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures alias Philomena.Tags + alias Philomena.Tags.QueryForm + alias Philomena.Tags.Implication alias Philomena.Tags.Tag + alias Philomena.Tags.TagDetail + alias Philomena.Tags.TagPage + alias Philomena.Images.Image + alias Philomena.Images.Search.Scope + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.ModerationLogs.Paths + alias Philomena.Repo + alias Philomena.Multi + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + + @pagination %{page_number: 1, page_size: 25} @limit Tag.name_length_limit() - describe "create_tag/1 name length limit" do + setup do + Search.clear_index!(Tag) + Search.clear_index!(Image) + :ok + end + + # The compiled filter body for a viewer with no active filter: it excludes + # nothing. + defp default_filter do + %{ + bool: %{ + should: [ + %{terms: %{tag_ids: []}}, + %{bool: %{should: [%{match_none: %{}}, %{match_none: %{}}]}} + ] + } + } + end + + defp scope(_user) do + Scope.new(default_filter(), @pagination) + end + + defp only_moderation_log!, do: Repo.one!(ModerationLog) + + defp moderation_log_count, do: Repo.aggregate(ModerationLog, :count) + + describe "list_tags_by_ids/1" do + test "loads matching tags and omits unknown IDs" do + first = tag_fixture(name: "bulk first") + second = tag_fixture(name: "bulk second") + + loaded = Tags.list_tags_by_ids([first.id, 2_147_483_647, second.id]) + + assert Enum.sort(Enum.map(loaded, & &1.id)) == Enum.sort([first.id, second.id]) + end + end + + describe "autocomplete_tags/2" do + test "prefix-matches tags and uses current PostgreSQL counts for ranking" do + lower = + tag_fixture(name: "autocomplete lower") + |> Ecto.Changeset.change(images_count: 4) + |> Repo.update!() + + higher = + tag_fixture(name: "autocomplete higher") + |> Ecto.Changeset.change(images_count: 12) + |> Repo.update!() + + zero = tag_fixture(name: "autocomplete zero") + SearchHelpers.reindex_all!(Tag) + + assert [first, second] = Tags.autocomplete_tags("autocomplete", 2) + assert {first.canonical, first.images} == {higher.name, 12} + assert {second.canonical, second.images} == {lower.name, 4} + refute Enum.any?([first, second], &(&1.canonical == zero.name)) + end + + test "identifies aliases and reports their canonical tag data" do + canonical = + tag_fixture(name: "suggestion target") + |> Ecto.Changeset.change(images_count: 8) + |> Repo.update!() + + alias_tag = + tag_fixture(name: "suggestion alias") + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + SearchHelpers.reindex_all!(Tag) + + assert [suggestion] = Tags.autocomplete_tags("suggestion alias", 1) + assert suggestion.alias == alias_tag.name + assert suggestion.canonical == canonical.name + assert suggestion.images == 8 + end + end + + describe "quick_tag_table/0" do + test "computes quick-tag data and caches it until refreshed" do + on_exit(fn -> :persistent_term.erase({Philomena.Tags.QuickTagTable, :table}) end) + + safe = tag_fixture(name: "safe") + SearchHelpers.reindex_all!(Tag) + + table = Tags.refresh_quick_tag_table() + assert table.tags["safe"].id == safe.id + assert Ecto.assoc_loaded?(table.tags["safe"].implied_tags) + + safe + |> Ecto.Changeset.change(short_description: "changed after caching") + |> Repo.update!() + + assert Tags.quick_tag_table().tags["safe"].short_description == "" + + assert Tags.refresh_quick_tag_table().tags["safe"].short_description == + "changed after caching" + end + end + + describe "query_tags/3" do + test "finds an indexed tag by a wildcard query, carrying the default preloads" do + tag = tag_fixture() + SearchHelpers.reindex_all!(Tag) + + assert {:ok, tags, %Ecto.Changeset{data: %QueryForm{query: "*"}}} = + Tags.query_tags(actor(), %{"query" => "*"}, @pagination) + + assert %Tag{} = found = Enum.find(tags, &(&1.id == tag.id)) + assert Ecto.assoc_loaded?(found.aliases) + assert Ecto.assoc_loaded?(found.dnp_entries) + end + + test "a missing query compiles to match-none, returning an empty page" do + tag_fixture() + SearchHelpers.reindex_all!(Tag) + + assert {:ok, tags, %Ecto.Changeset{data: %QueryForm{query: nil}}} = + Tags.query_tags(actor(), %{"query" => nil}, @pagination) + + assert Enum.empty?(tags) + end + + test "the caller owns pagination and results always carry representation preloads" do + tag = tag_fixture() + SearchHelpers.reindex_all!(Tag) + + pagination = %{@pagination | page_size: 250} + + assert {:ok, tags, %Ecto.Changeset{}} = + Tags.query_tags(actor(), %{"query" => "*"}, pagination) + + assert tags.page_size == 250 + assert %Tag{} = found = Enum.find(tags, &(&1.id == tag.id)) + assert Ecto.assoc_loaded?(found.aliases) + end + + test "a malformed query returns the rejected query form" do + assert {:error, %Ecto.Changeset{} = changeset} = + Tags.query_tags(actor(), %{"query" => "("}, @pagination) + + assert %{query: [message]} = errors_on(changeset) + assert is_binary(message) + end + end + + describe "show_tag_page/2" do + test "assembles the page for a real tag, carrying its tagged image" do + created_at = DateTime.utc_now() |> DateTime.add(-3600) |> DateTime.truncate(:second) + image = image_fixture(tags: "safe", created_at: created_at) + tag = Repo.get_by!(Tag, name: "safe") + SearchHelpers.reindex_all!(Image) + + assert {:ok, %TagPage{} = page} = Tags.show_tag_page(actor(), scope(nil), tag.slug) + + assert page.tag.id == tag.id + assert image.id in Enum.map(page.images, & &1.id) + assert is_list(page.interactions) + # A tag whose name compiles back to itself is used verbatim. + assert page.search_query == "safe" + end + + test "an aliased tag reports the tag it is aliased into" do + target = tag_fixture(name: "load page target") + + aliased = + tag_fixture() + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() + + assert {:aliased_to, %Tag{} = returned} = + Tags.show_tag_page(actor(), scope(nil), aliased.slug) + + assert returned.id == aliased.id + assert returned.aliased_tag.id == target.id + end + + test "an unknown slug is not-found for every viewer" do + assert Tags.show_tag_page(actor(), scope(nil), "nonexistent-tag") == + {:error, :not_found} + + user = confirmed_user_fixture() + + assert Tags.show_tag_page(actor(user), scope(user), "nonexistent-tag") == + {:error, :not_found} + + moderator = moderator_user_fixture() + + assert Tags.show_tag_page(actor(moderator), scope(moderator), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "show_tag/2 and load_canonical_tag/2" do + test "the representation loader preserves an alias while the canonical loader resolves it" do + target = tag_fixture(name: "canonical target") + + aliased = + tag_fixture(name: "canonical alias") + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() + + assert {:ok, %Tag{id: alias_id, aliased_tag: %Tag{id: target_id}}} = + Tags.show_tag(actor(), aliased.slug) + + assert alias_id == aliased.id + assert target_id == target.id + + assert {:ok, %Tag{id: canonical_id}} = Tags.load_canonical_tag(actor(), aliased.slug) + assert canonical_id == target.id + end + + test "missing and malformed locators are not found" do + assert Tags.show_tag(actor(), "missing") == {:error, :not_found} + assert Tags.show_tag(actor(), nil) == {:error, :not_found} + assert Tags.load_canonical_tag(actor(), nil) == {:error, :not_found} + end + end + + describe "edit_tag/3" do + test "a moderator loads the tag paired with its edit changeset" do + tag = tag_fixture() + + assert {:ok, {%Tag{} = loaded, %Ecto.Changeset{} = changeset}} = + Tags.edit_tag(actor(moderator_user_fixture()), tag.slug) + + assert loaded.id == tag.id + assert changeset.data.id == tag.id + end + + test "anonymous and regular users are unauthorized" do + tag = tag_fixture() + + assert Tags.edit_tag(actor(), tag.slug) == {:error, :unauthorized} + + assert Tags.edit_tag(actor(confirmed_user_fixture()), tag.slug) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.edit_tag(actor(admin_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.edit_tag(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "update_tag/3" do + test "a moderator updates the tag and writes a moderation log" do + tag = tag_fixture() + + assert {:ok, %Tag{} = updated} = + Tags.update_tag(actor(moderator_user_fixture()), tag.slug, %{ + "category" => "rating" + }) + + assert updated.id == tag.id + assert Repo.reload!(tag).category == "rating" + + log = only_moderation_log!() + assert log.type == "Tag:update" + assert log.subject_path == Paths.tag_path(tag) + assert log.body == "Updated details on tag '#{tag.name}'" + end + + test "a category outside the allowed list is a rejected changeset and writes no log" do + tag = tag_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Tags.update_tag(actor(moderator_user_fixture()), tag.slug, %{"category" => "bogus"}) + + refute changeset.valid? + assert Repo.reload!(tag).category == tag.category + assert moderation_log_count() == 0 + end + + test "updating to another allowed category succeeds and writes a log" do + tag = tag_fixture() + + assert {:ok, %Tag{}} = + Tags.update_tag(actor(moderator_user_fixture()), tag.slug, %{ + "category" => "species" + }) + + assert Repo.reload!(tag).category == "species" + assert only_moderation_log!().type == "Tag:update" + end + + test "rejects aliased tags in the implied-tag list" do + tag = tag_fixture(name: "implied list source") + implied_tag = tag_fixture(name: "implied list alias") + canonical_tag = tag_fixture(name: "implied list canonical") + + implied_tag + |> Ecto.Changeset.change(aliased_tag_id: canonical_tag.id) + |> Repo.update!() + + assert {:error, changeset} = + Tags.update_tag(actor(moderator_user_fixture()), tag.slug, %{ + "implied_tag_list" => implied_tag.name + }) + + assert %{implied_tag_list: ["contains aliased tags"]} = errors_on(changeset) + refute Repo.exists?(from implication in Implication, where: implication.tag_id == ^tag.id) + assert moderation_log_count() == 0 + end + + test "anonymous and regular users are unauthorized and change nothing" do + tag = tag_fixture() + + assert Tags.update_tag(actor(), tag.slug, %{"category" => "rating"}) == + {:error, :unauthorized} + + assert Tags.update_tag(actor(confirmed_user_fixture()), tag.slug, %{"category" => "rating"}) == + {:error, :unauthorized} + + assert Repo.reload!(tag).category == tag.category + assert moderation_log_count() == 0 + end + + test "an unknown slug is not-found before authorization" do + assert Tags.update_tag(actor(admin_user_fixture()), "nonexistent-tag", %{ + "category" => "rating" + }) == + {:error, :not_found} + + assert Tags.update_tag(actor(moderator_user_fixture()), "nonexistent-tag", %{ + "category" => "rating" + }) == {:error, :not_found} + end + end + + describe "delete_tag/2" do + test "an admin queues the deletion and writes a moderation log" do + tag = tag_fixture() + + assert {:ok, %Tag{} = deleted} = Tags.delete_tag(actor(admin_user_fixture()), tag.slug) + assert deleted.id == tag.id + # Deletion is performed asynchronously by the worker; the row is still + # present synchronously. + assert Repo.get(Tag, tag.id) + + log = only_moderation_log!() + assert log.type == "Tag:delete" + assert log.subject_path == Paths.tag_path(tag) + assert log.body == "Deleted tag '#{tag.name}'" + end + + test "a plain moderator lacks :delete and is unauthorized" do + tag = tag_fixture() + + assert Tags.delete_tag(actor(moderator_user_fixture()), tag.slug) == {:error, :unauthorized} + assert Repo.get(Tag, tag.id) + assert moderation_log_count() == 0 + end + + test "anonymous and regular users are unauthorized" do + tag = tag_fixture() + + assert Tags.delete_tag(actor(), tag.slug) == {:error, :unauthorized} + assert Tags.delete_tag(actor(confirmed_user_fixture()), tag.slug) == {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.delete_tag(actor(admin_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.delete_tag(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "edit_tag_alias/2" do + test "an admin loads the tag paired with its edit changeset" do + tag = tag_fixture() + + assert {:ok, {%Tag{} = loaded, %Ecto.Changeset{}}} = + Tags.edit_tag_alias(actor(admin_user_fixture()), tag.slug) + + assert loaded.id == tag.id + end + + test "a plain moderator lacks :alias and is unauthorized" do + tag = tag_fixture() + + assert Tags.edit_tag_alias(actor(moderator_user_fixture()), tag.slug) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.edit_tag_alias(actor(admin_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.edit_tag_alias(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "update_tag_alias/3" do + test "an admin aliases the tag into the target and writes a moderation log" do + target = tag_fixture(name: "alias context target") + tag = tag_fixture() + + assert {:ok, %Tag{} = aliased} = + Tags.update_tag_alias(actor(admin_user_fixture()), tag.slug, %{ + "target_tag" => target.name + }) + + assert aliased.id == tag.id + assert Repo.reload!(tag).aliased_tag_id == target.id + + log = only_moderation_log!() + assert log.type == "Tag.Alias:update" + assert log.subject_path == Paths.tag_path(tag) + assert log.body == "Aliased tag '#{tag.name}' into '#{target.name}'" + end + + test "aliasing into an unknown target is not found and writes no log" do + tag = tag_fixture() + + assert {:error, :not_found} = + Tags.update_tag_alias(actor(admin_user_fixture()), tag.slug, %{ + "target_tag" => "no such tag" + }) + + assert Repo.reload!(tag).aliased_tag_id == nil + assert moderation_log_count() == 0 + end + + test "accepts atom-keyed form attributes" do + target = tag_fixture(name: "atom alias target") + tag = tag_fixture(name: "atom alias source") + + assert {:ok, %Tag{aliased_tag_id: target_id}} = + Tags.update_tag_alias(actor(admin_user_fixture()), tag.slug, %{ + target_tag: target.name + }) + + assert target_id == target.id + end + + test "rejects self-aliases and tags that already have incoming aliases" do + admin = actor(admin_user_fixture()) + tag = tag_fixture(name: "alias conflict source") + + assert {:error, self_changeset} = + Tags.update_tag_alias(admin, tag.slug, %{"target_tag" => tag.name}) + + assert %{aliased_tag: [_message]} = errors_on(self_changeset) + + _incoming = + tag_fixture(name: "incoming alias") + |> Ecto.Changeset.change(aliased_tag_id: tag.id) + |> Repo.update!() + + target = tag_fixture(name: "alias conflict target") + + assert {:error, incoming_changeset} = + Tags.update_tag_alias(admin, tag.slug, %{"target_tag" => target.name}) + + assert %{tag: [_message]} = errors_on(incoming_changeset) + end + + test "rejects aliasing a tag that is implied by another tag" do + admin = actor(admin_user_fixture()) + parent = tag_fixture(name: "implied parent") + source = tag_fixture(name: "implied source") + target = tag_fixture(name: "implied target") + + parent + |> Repo.preload(:implied_tags) + |> Tag.changeset(%{"implied_tag_list" => source.name}, [source]) + |> Repo.update!() + + assert {:error, changeset} = + Tags.update_tag_alias(admin, source.slug, %{"target_tag" => target.name}) + + assert %{tag: ["is implied by other tags and cannot be aliased"]} = errors_on(changeset) + assert Repo.reload!(source).aliased_tag_id == nil + end + + test "a plain moderator lacks :alias and is unauthorized" do + target = tag_fixture(name: "alias mod target") + tag = tag_fixture() + + assert Tags.update_tag_alias(actor(moderator_user_fixture()), tag.slug, %{ + "target_tag" => target.name + }) == + {:error, :unauthorized} + + assert Repo.reload!(tag).aliased_tag_id == nil + end + + test "anonymous and regular users are unauthorized" do + target = tag_fixture(name: "alias anon target") + tag = tag_fixture() + + assert Tags.update_tag_alias(actor(), tag.slug, %{"target_tag" => target.name}) == + {:error, :unauthorized} + + assert Tags.update_tag_alias(actor(confirmed_user_fixture()), tag.slug, %{ + "target_tag" => target.name + }) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.update_tag_alias(actor(admin_user_fixture()), "nonexistent-tag", %{ + "target_tag" => "x" + }) == + {:error, :not_found} + + assert Tags.update_tag_alias(actor(moderator_user_fixture()), "nonexistent-tag", %{ + "target_tag" => "x" + }) == + {:error, :not_found} + end + end + + describe "delete_tag_alias/2" do + test "an admin queues a dealias and writes a moderation log" do + target = tag_fixture(name: "dealias context target") + + tag = + tag_fixture() + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() + + assert {:ok, %Tag{} = returned} = + Tags.delete_tag_alias(actor(admin_user_fixture()), tag.slug) + + assert returned.id == tag.id + assert Repo.reload!(tag).aliased_tag_id == nil + + log = only_moderation_log!() + assert log.type == "Tag.Alias:delete" + assert log.subject_path == Paths.tag_path(tag) + assert log.body == "Dealiased tag '#{tag.name}'" + end + + test "a plain moderator lacks :alias and is unauthorized" do + tag = tag_fixture() + + assert Tags.delete_tag_alias(actor(moderator_user_fixture()), tag.slug) == + {:error, :unauthorized} + + assert moderation_log_count() == 0 + end + + test "a tag that is not aliased is rejected before audit or queueing" do + tag = tag_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Tags.delete_tag_alias(actor(admin_user_fixture()), tag.slug) + + assert %{aliased_tag: ["is not aliased"]} = errors_on(changeset) + assert moderation_log_count() == 0 + end + + test "an unknown slug is not-found before authorization" do + assert Tags.delete_tag_alias(actor(admin_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.delete_tag_alias(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "list_tag_details/2" do + test "a moderator gets the spoilering/hiding filters and watching users" do + tag = tag_fixture() + + spoiler_owner = confirmed_user_fixture() + hide_owner = confirmed_user_fixture() + spoiler_filter = filter_fixture(spoiler_owner, %{spoilered_tag_list: tag.name}) + hide_filter = filter_fixture(hide_owner, %{hidden_tag_list: tag.name}) + + watcher = + confirmed_user_fixture() + |> Ecto.Changeset.change(watched_tag_ids: [tag.id]) + |> Repo.update!() + + assert {:ok, %TagDetail{} = detail} = + Tags.list_tag_details(actor(moderator_user_fixture()), tag.slug) + + assert detail.tag.id == tag.id + assert Enum.map(detail.filters_spoilering, & &1.id) == [spoiler_filter.id] + assert Enum.map(detail.filters_hiding, & &1.id) == [hide_filter.id] + assert Enum.map(detail.users_watching, & &1.id) == [watcher.id] + end + + test "a fresh tag has empty usage lists" do + tag = tag_fixture() + + assert {:ok, detail} = Tags.list_tag_details(actor(moderator_user_fixture()), tag.slug) + assert detail.filters_spoilering == [] + assert detail.filters_hiding == [] + assert detail.users_watching == [] + end + + test "an unknown slug is not-found before authorization" do + assert Tags.list_tag_details(actor(), "nonexistent-tag") == {:error, :not_found} + + assert Tags.list_tag_details(actor(confirmed_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.list_tag_details(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "edit_tag_image/2" do + test "loads only the spoiler-image form dependencies" do + tag = tag_fixture() + + assert {:ok, {%Tag{} = loaded, %Ecto.Changeset{}}} = + Tags.edit_tag_image(actor(moderator_user_fixture()), tag.slug) + + assert loaded.id == tag.id + assert Ecto.assoc_loaded?(loaded.implied_tags) + refute Ecto.assoc_loaded?(loaded.aliases) + end + end + + describe "update_tag_image/3" do + test "a moderator uploads the spoiler image and writes a moderation log" do + tag = tag_fixture() + + assert {:ok, %Tag{} = updated} = + Tags.update_tag_image( + actor(moderator_user_fixture()), + tag.slug, + media_png_upload() + ) + + assert updated.id == tag.id + reloaded = Repo.reload!(tag) + assert reloaded.image + assert reloaded.image_mime_type == "image/png" + + log = only_moderation_log!() + assert log.type == "Tag.Image:update" + assert log.subject_path == Paths.tag_path(tag) + assert log.body == "Updated image on tag '#{tag.name}'" + end + + test "an upload with no file is a rejected changeset and writes no log" do + tag = tag_fixture() + + assert {:error, %Ecto.Changeset{}} = + Tags.update_tag_image(actor(moderator_user_fixture()), tag.slug, nil) + + assert moderation_log_count() == 0 + end + + test "anonymous and regular users are unauthorized" do + tag = tag_fixture() + + assert Tags.update_tag_image(actor(), tag.slug, media_png_upload()) == + {:error, :unauthorized} + + assert Tags.update_tag_image( + actor(confirmed_user_fixture()), + tag.slug, + media_png_upload() + ) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.update_tag_image( + actor(admin_user_fixture()), + "nonexistent-tag", + media_png_upload() + ) == {:error, :not_found} + + assert Tags.update_tag_image( + actor(moderator_user_fixture()), + "nonexistent-tag", + media_png_upload() + ) == {:error, :not_found} + end + end + + describe "delete_tag_image/2" do + test "a moderator removes the spoiler image and writes a moderation log" do + tag = + tag_fixture() + |> Ecto.Changeset.change(image: "2024/1/1/abc.png") + |> Repo.update!() + + assert {:ok, %Tag{} = updated} = + Tags.delete_tag_image(actor(moderator_user_fixture()), tag.slug) + + assert updated.id == tag.id + assert Repo.reload!(tag).image == nil + + log = only_moderation_log!() + assert log.type == "Tag.Image:delete" + assert log.subject_path == Paths.tag_path(tag) + assert log.body == "Removed image on tag '#{tag.name}'" + end + + test "anonymous and regular users are unauthorized" do + tag = tag_fixture() + + assert Tags.delete_tag_image(actor(), tag.slug) == {:error, :unauthorized} + + assert Tags.delete_tag_image(actor(confirmed_user_fixture()), tag.slug) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.delete_tag_image(actor(admin_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.delete_tag_image(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "create_tag_reindex/2" do + test "an admin queues the reindex and gets the tag back" do + tag = tag_fixture() + + assert {:ok, %Tag{} = returned} = + Tags.create_tag_reindex(actor(admin_user_fixture()), tag.slug) + + assert returned.id == tag.id + # No moderation log is written for a reindex. + assert moderation_log_count() == 0 + end + + test "a plain moderator lacks :alias and is unauthorized" do + tag = tag_fixture() + + assert Tags.create_tag_reindex(actor(moderator_user_fixture()), tag.slug) == + {:error, :unauthorized} + end + + test "anonymous and regular users are unauthorized" do + tag = tag_fixture() + + assert Tags.create_tag_reindex(actor(), tag.slug) == {:error, :unauthorized} + + assert Tags.create_tag_reindex(actor(confirmed_user_fixture()), tag.slug) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before authorization" do + assert Tags.create_tag_reindex(actor(admin_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + + assert Tags.create_tag_reindex(actor(moderator_user_fixture()), "nonexistent-tag") == + {:error, :not_found} + end + end + + describe "create_tag_watch/2 and delete_tag_watch/2" do + test "a signed-in user watches then unwatches a tag" do + user = confirmed_user_fixture() + tag = tag_fixture() + + assert {:ok, %Philomena.Users.User{} = watching} = + Tags.create_tag_watch(actor(user), tag.slug) + + assert watching.watched_tag_ids == [tag.id] + assert Repo.reload!(user).watched_tag_ids == [tag.id] + + assert {:ok, %Philomena.Users.User{}} = Tags.delete_tag_watch(actor(watching), tag.slug) + assert Repo.reload!(user).watched_tag_ids == [] + end + + test "watching and unwatching an alias uses its canonical tag" do + user = confirmed_user_fixture() + canonical = tag_fixture(name: "watched canonical") + + alias_tag = + tag_fixture(name: "watched alias") + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + assert {:ok, watching} = Tags.create_tag_watch(actor(user), alias_tag.slug) + assert watching.watched_tag_ids == [canonical.id] + + assert {:ok, _} = Tags.delete_tag_watch(actor(watching), alias_tag.slug) + assert Repo.reload!(user).watched_tag_ids == [] + end + + test "unwatching a tag that is not watched is an idempotent success" do + user = confirmed_user_fixture() + tag = tag_fixture() + + assert {:ok, %Philomena.Users.User{}} = Tags.delete_tag_watch(actor(user), tag.slug) + assert Repo.reload!(user).watched_tag_ids == [] + end + + test "an unknown slug is not-found for both watch and unwatch" do + user = confirmed_user_fixture() + + assert Tags.create_tag_watch(actor(user), "nonexistent-tag") == {:error, :not_found} + assert Tags.delete_tag_watch(actor(user), "nonexistent-tag") == {:error, :not_found} + end + end + + describe "write access parity" do + test "form loaders and their matching mutations reject a banned staff actor" do + admin = admin_user_fixture() + banned_actor = actor(admin, ban: %{}) + tag = tag_fixture(name: "banned tag source") + target = tag_fixture(name: "banned tag target") + + assert Tags.edit_tag(banned_actor, tag.slug) == {:error, :ban} + assert Tags.edit_tag_image(banned_actor, tag.slug) == {:error, :ban} + assert Tags.edit_tag_alias(banned_actor, tag.slug) == {:error, :ban} + + assert Tags.update_tag(banned_actor, tag.slug, %{}) == {:error, :ban} + assert Tags.update_tag_image(banned_actor, tag.slug, nil) == {:error, :ban} + assert Tags.delete_tag_image(banned_actor, tag.slug) == {:error, :ban} + + assert Tags.update_tag_alias(banned_actor, tag.slug, %{"target_tag" => target.name}) == + {:error, :ban} + + assert Tags.delete_tag_alias(banned_actor, tag.slug) == {:error, :ban} + assert Tags.create_tag_reindex(banned_actor, tag.slug) == {:error, :ban} + assert Tags.delete_tag(banned_actor, tag.slug) == {:error, :ban} + assert moderation_log_count() == 0 + end + end + + describe "put_copy_tags/3" do + test "copies only missing integer tag ids and increments their counters exactly once" do + source = image_fixture(tags: "copy shared, copy source only") + target = image_fixture(tags: "copy shared") + + shared = + Repo.get_by!(Tag, name: "copy shared") + |> Ecto.Changeset.change(images_count: 2) + |> Repo.update!() + + source_only = + Repo.get_by!(Tag, name: "copy source only") + |> Ecto.Changeset.change(images_count: 1) + |> Repo.update!() + + assert {:ok, %{copied_tag_ids: [copied_id]}} = + Multi.new() + |> Tags.put_copy_tags(source, target) + |> Multi.transact() + + assert copied_id == source_only.id + assert Repo.reload!(shared).images_count == 2 + assert Repo.reload!(source_only).images_count == 2 + + target_tag_ids = + target + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.id) + |> Enum.sort() + + assert target_tag_ids == Enum.sort([shared.id, source_only.id]) + end + end + + describe "cleanup!/0" do + test "deletes eligible tags once, returns their ids, and preserves meaningful tags" do + empty = tag_fixture(name: "cleanup empty") + + kept = + tag_fixture(name: "cleanup described") + |> Ecto.Changeset.change(description: "Still useful") + |> Repo.update!() + + assert [empty_id] = Tags.cleanup!() + assert empty_id == empty.id + refute Repo.get(Tag, empty.id) + assert Repo.get(Tag, kept.id) + end + end + + describe "replace_aliases_in_implied_tags!/0" do + test "replaces aliased implied tags and avoids duplicate canonical relationships" do + parent = tag_fixture(name: "repair parent") + alias_tag = tag_fixture(name: "repair alias") + canonical = tag_fixture(name: "repair canonical") + other = tag_fixture(name: "repair other") + + alias_tag + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + Repo.insert_all(Implication, [ + %{tag_id: parent.id, implied_tag_id: alias_tag.id}, + %{tag_id: parent.id, implied_tag_id: canonical.id}, + %{tag_id: parent.id, implied_tag_id: other.id} + ]) + + assert :ok = Tags.replace_aliases_in_implied_tags!() + + implication_ids = + parent + |> Repo.preload(:implied_tags, force: true) + |> Map.fetch!(:implied_tags) + |> Enum.map(& &1.id) + |> Enum.sort() + + assert implication_ids == Enum.sort([canonical.id, other.id]) + end + + test "does nothing when no implied tag is aliased" do + parent = tag_fixture(name: "repair untouched parent") + implied = tag_fixture(name: "repair untouched implied") + + Repo.insert_all(Implication, [%{tag_id: parent.id, implied_tag_id: implied.id}]) + + assert :ok = Tags.replace_aliases_in_implied_tags!() + + assert [loaded] = + parent + |> Repo.preload(:implied_tags, force: true) + |> Map.fetch!(:implied_tags) + + assert loaded.id == implied.id + end + end + + describe "Tag.creation_changeset/2 name length limit" do test "accepts a name of exactly the limit" do name = String.duplicate("a", @limit) - assert {:ok, %Tag{name: ^name}} = Tags.create_tag(%{name: name}) + assert {:ok, %Tag{name: ^name}} = + %Tag{} |> Tag.creation_changeset(%{name: name}) |> Repo.insert() end test "rejects a name over the limit" do name = String.duplicate("a", @limit + 1) - assert {:error, changeset} = Tags.create_tag(%{name: name}) + assert {:error, changeset} = + %Tag{} |> Tag.creation_changeset(%{name: name}) |> Repo.insert() assert %{name: ["should be at most #{@limit} byte(s)"]} == errors_on(changeset) end @@ -25,7 +987,9 @@ defmodule Philomena.TagsTest do # 130 characters of "é" (2 bytes each in UTF-8) = 260 bytes name = String.duplicate("é", 130) - assert {:error, changeset} = Tags.create_tag(%{name: name}) + assert {:error, changeset} = + %Tag{} |> Tag.creation_changeset(%{name: name}) |> Repo.insert() + assert %{name: [_message]} = errors_on(changeset) end end @@ -44,14 +1008,56 @@ defmodule Philomena.TagsTest do end end - describe "get_or_create_tags/1" do - test "does not create tags with oversized names" do + describe "put_canonicalize_tag_name_sets/2" do + test "does not create tags by default" do + name = unique_tag_name() + + assert {:ok, %{canonical_tags: %{tags: []}}} = + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([{:tags, [name], []}]) + |> Multi.transact() + + assert Repo.get_by(Tag, name: name) == nil + end + + test "creates tags when requested and filters oversized names" do oversized = String.duplicate("a", @limit + 1) + tag_names = Tag.parse_tag_list("safe, #{oversized}") + + assert {:ok, %{canonical_tags: %{tags: [%Tag{name: "safe"}]}}} = + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([ + {:tags, tag_names, allow_insert_new?: true} + ]) + |> Multi.transact() + + assert Repo.get_by(Tag, name: oversized) == nil + end + end + + describe "put_lock_tag_alias_families/2" do + test "returns the requested tags and their complete alias families" do + canonical = tag_fixture(name: unique_tag_name()) + alias_tag = tag_fixture(name: unique_tag_name()) + + alias_tag = + alias_tag + |> Ecto.Changeset.change(aliased_tag_id: canonical.id) + |> Repo.update!() + + assert {:ok, %{tags: tags, tag_alias_families: families}} = + Multi.new() + |> Tags.put_lock_tag_alias_families([alias_tag.id, canonical.id]) + |> Multi.transact() + + assert MapSet.new(Enum.map(tags, & &1.id)) == MapSet.new([alias_tag.id, canonical.id]) - tags = Tags.get_or_create_tags("safe, #{oversized}") + for tag_id <- [alias_tag.id, canonical.id] do + assert families[tag_id].canonical_id == canonical.id - assert [%Tag{name: "safe"}] = tags - assert Tags.get_tag_by_name(oversized) == nil + assert MapSet.new(families[tag_id].tag_ids) == + MapSet.new([alias_tag.id, canonical.id]) + end end end end diff --git a/test/philomena/topics_test.exs b/test/philomena/topics_test.exs new file mode 100644 index 000000000..6a0f55ba5 --- /dev/null +++ b/test/philomena/topics_test.exs @@ -0,0 +1,1486 @@ +defmodule Philomena.TopicsTest do + @moduledoc """ + Context-level tests for the actor-first topic APIs on `Philomena.Topics`: + `subscribe/3`, `unsubscribe/3`, and `mark_topic_read/3`. + + These pin the authorization matrix (anonymous / user / moderator / admin), + the failure divergence between the two actions (unknown forum, unknown topic, + hidden topic), and the idempotent success paths. The corresponding controller + characterization tests pin the HTTP behavior on top of these results. + + The actor here is a `Philomena.Attribution.Actor`, matching what the controller + hands in as `conn.assigns.actor`. + """ + + use Philomena.DataCase, async: true + + import Ecto.Query + + import Philomena.AttributionFixtures + import Philomena.ForumsFixtures + import Philomena.PostsFixtures + import Philomena.RulesFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Notifications + alias Philomena.Notifications.ForumPostNotification + alias Philomena.Posts.Post + alias Philomena.Reports.Report + alias Philomena.Repo + alias Philomena.Topics + alias Philomena.Topics.Subscription + alias Philomena.Topics.Topic + alias Philomena.Topics.TopicPage + alias Philomena.Users.User + + # A truthy ban value in the shape production passes (the result of + # Philomena.Bans.find/3); only its presence matters to verify_write_access + # and the global write prerequisite. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + # The request pagination map show_topic_page reads. + @first_page %{page_number: 1, page_size: 25} + + defp subscribed?(topic, user) do + Repo.exists?( + from s in Subscription, + where: s.topic_id == ^topic.id and s.user_id == ^user.id + ) + end + + defp post_notification?(topic, user) do + Repo.exists?( + from n in ForumPostNotification, + where: n.topic_id == ^topic.id and n.user_id == ^user.id + ) + end + + defp subscription_count(topic, user) do + Repo.aggregate( + from(s in Subscription, where: s.topic_id == ^topic.id and s.user_id == ^user.id), + :count + ) + end + + # A visible topic in a normal (publicly readable) forum, the common case both + # actions load through. + defp visible_topic do + forum = forum_fixture() + topic = topic_fixture(forum) + {forum, topic} + end + + # A hidden topic in a normal forum, the shape delete_topic_hide/3 operates on. + defp hidden_topic do + forum = forum_fixture() + topic = topic_fixture(forum) + moderator = moderator_user_fixture() + + {:ok, {_forum, hidden}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "Spam" + }) + + {forum, hidden} + end + + # A locked topic in a normal forum, the shape delete_topic_lock/3 operates on. + # Locking (unlike hiding) leaves the topic visible, so the loader still admits + # a regular user. + defp locked_topic do + forum = forum_fixture() + topic = topic_fixture(forum) + + moderator = moderator_user_fixture() + + {:ok, {_forum, locked}} = + Topics.create_topic_lock(actor(moderator), forum.short_name, topic.slug, %{ + "lock_reason" => "Off topic" + }) + + {forum, locked} + end + + # A sticky topic in a normal forum, the shape delete_topic_stick/3 operates on. + # Sticking (like locking) leaves the topic visible, so the loader still admits + # a regular user. + defp sticky_topic do + forum = forum_fixture() + topic = topic_fixture(forum) + moderator = moderator_user_fixture() + + {:ok, {_forum, sticky}} = + Topics.create_topic_stick(actor(moderator), forum.short_name, topic.slug) + + {forum, sticky} + end + + defp latest_moderation_log! do + Repo.one!(from log in ModerationLog, order_by: [desc: log.id], limit: 1) + end + + defp moderation_log_count, do: Repo.aggregate(ModerationLog, :count) + + describe "create_topic_subscription/3" do + test "a regular user subscribes to a visible topic and the row is created" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.create_topic_subscription(actor(user), forum.short_name, topic.slug) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + assert subscribed?(topic, user) + end + + test "subscribing twice is idempotent and leaves a single row" do + # create_subscription inserts with on_conflict: :nothing, so a repeat is a + # successful no-op rather than a changeset error. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, _} = + Topics.create_topic_subscription(actor(user), forum.short_name, topic.slug) + + assert {:ok, _} = + Topics.create_topic_subscription(actor(user), forum.short_name, topic.slug) + + assert subscription_count(topic, user) == 1 + end + + test "a moderator subscribes to a visible topic" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {_forum, _topic}} = + Topics.create_topic_subscription(actor(moderator), forum.short_name, topic.slug) + + assert subscribed?(topic, moderator) + end + + test "an admin subscribes to a visible topic" do + admin = admin_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {_forum, _topic}} = + Topics.create_topic_subscription(actor(admin), forum.short_name, topic.slug) + + assert subscribed?(topic, admin) + end + + test "an unknown forum slug is unauthorized for a regular user" do + # An unknown short name loads nil, and authorizing nil for :show is + # unauthorized for every non-admin actor. + assert Topics.create_topic_subscription( + actor(confirmed_user_fixture()), + "nonexistent", + "whatever" + ) == + {:error, :not_found} + end + + test "an unknown forum slug is unauthorized for anonymous" do + assert Topics.create_topic_subscription(actor(), "nonexistent", "whatever") == + {:error, :not_found} + end + + test "an existing forum with an unknown topic slug is not found" do + forum = forum_fixture() + + assert Topics.create_topic_subscription( + actor(confirmed_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + end + + test "a restricted forum is unauthorized for a regular user" do + user = confirmed_user_fixture() + forum = forum_fixture(access_level: "staff") + topic = topic_fixture(forum) + + assert Topics.create_topic_subscription(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + + refute subscribed?(topic, user) + end + + test "a restricted forum is subscribable by a moderator" do + moderator = moderator_user_fixture() + forum = forum_fixture(access_level: "staff") + topic = topic_fixture(forum) + + assert {:ok, {_forum, _topic}} = + Topics.create_topic_subscription(actor(moderator), forum.short_name, topic.slug) + + assert subscribed?(topic, moderator) + end + + test "a hidden topic is unauthorized for a regular user and no row is created" do + # subscribe passes show_hidden: false, so a hidden topic falls to the + # topic :show authorization, which a regular user fails. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + moderator = moderator_user_fixture() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "test hiding" + }) + + assert Topics.create_topic_subscription(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + + refute subscribed?(topic, user) + end + + test "a hidden topic is subscribable by a moderator" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "test hiding" + }) + + assert {:ok, {_forum, _topic}} = + Topics.create_topic_subscription(actor(moderator), forum.short_name, topic.slug) + + assert subscribed?(topic, moderator) + end + + test "anonymous cannot create_topic_subscription to a visible topic" do + {forum, topic} = visible_topic() + + assert Topics.create_topic_subscription(actor(), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + + test "an admin with an unknown forum gets not-found" do + assert Topics.create_topic_subscription( + actor(admin_user_fixture()), + "nonexistent", + "whatever" + ) == + {:error, :not_found} + end + end + + describe "delete_topic_subscription/3" do + test "a regular user unsubscribes from a visible topic and the row is removed" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + {:ok, _} = Topics.create_subscription(topic, user) + assert subscribed?(topic, user) + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.delete_topic_subscription(actor(user), forum.short_name, topic.slug) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + refute subscribed?(topic, user) + end + + test "unsubscribing with no existing subscription still succeeds" do + # delete_subscription runs an unconditional delete_all and hard-matches + # {:ok, _}, so the absence of a row is not an error. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + refute subscribed?(topic, user) + + assert {:ok, {_forum, _topic}} = + Topics.delete_topic_subscription(actor(user), forum.short_name, topic.slug) + + refute subscribed?(topic, user) + end + + test "a moderator unsubscribes from a visible topic" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + {:ok, _} = Topics.create_subscription(topic, moderator) + + assert {:ok, {_forum, _topic}} = + Topics.delete_topic_subscription(actor(moderator), forum.short_name, topic.slug) + + refute subscribed?(topic, moderator) + end + + test "a hidden topic can still be unsubscribed from by a regular user" do + # unsubscribe passes show_hidden: true, so a topic hidden after the user + # subscribed stays reachable for removal. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + {:ok, _} = Topics.create_subscription(topic, user) + moderator = moderator_user_fixture() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "test hiding" + }) + + assert {:ok, {_forum, _topic}} = + Topics.delete_topic_subscription(actor(user), forum.short_name, topic.slug) + + refute subscribed?(topic, user) + end + + test "an unknown forum slug is unauthorized for a regular user" do + assert Topics.delete_topic_subscription( + actor(confirmed_user_fixture()), + "nonexistent", + "whatever" + ) == + {:error, :not_found} + end + + test "an existing forum with an unknown topic slug is not found" do + forum = forum_fixture() + + assert Topics.delete_topic_subscription( + actor(confirmed_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + end + + test "a restricted forum is unauthorized for a regular user" do + user = confirmed_user_fixture() + forum = forum_fixture(access_level: "staff") + topic = topic_fixture(forum) + + assert Topics.delete_topic_subscription(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + end + + describe "create_topic_read/3" do + test "an unknown forum slug is not found for a regular user" do + # Divergence from create_topic_subscription/3: the read path loads the forum with a plain + # required load and no authorization, so a missing forum is :not_found + # rather than the :unauthorized that subscribe returns for a regular user. + assert Topics.create_topic_read(actor(confirmed_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + end + + test "an unknown forum slug is not found for anonymous" do + assert Topics.create_topic_read(actor(), "nonexistent", "whatever") == {:error, :not_found} + end + + test "an existing forum with an unknown topic slug is not found" do + forum = forum_fixture() + + assert Topics.create_topic_read( + actor(confirmed_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + end + + test "a hidden topic is marked read by a regular user with no visibility gate" do + # The read path loads the topic with show_hidden: true and runs no :show + # authorization, so a regular user reaches a hidden topic that create_topic_subscription/3 + # would refuse with :unauthorized. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + moderator = moderator_user_fixture() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "test hiding" + }) + + assert {:ok, loaded_topic} = + Topics.create_topic_read(actor(user), forum.short_name, topic.slug) + + assert loaded_topic.id == topic.id + end + + test "a staff-only forum cannot be marked read by a regular user" do + user = confirmed_user_fixture() + forum = forum_fixture(access_level: "staff") + topic = topic_fixture(forum) + + assert Topics.create_topic_read(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + end + + test "success clears the topic notification for the user" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + # Arrange a real unread notification the way the read controller test does: + # subscribe the user to the topic, then have another user post so a + # ForumPostNotification lands for the subscriber. + {:ok, _} = Topics.create_subscription(topic, user) + author = confirmed_user_fixture() + post = hd(topic.posts) + {:ok, 1} = Notifications.broadcast_forum_post(author, topic, post) + assert post_notification?(topic, user) + + assert {:ok, _topic} = Topics.create_topic_read(actor(user), forum.short_name, topic.slug) + refute post_notification?(topic, user) + end + + test "marking read is safe when the user has no notifications" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + refute post_notification?(topic, user) + + assert {:ok, loaded_topic} = + Topics.create_topic_read(actor(user), forum.short_name, topic.slug) + + assert loaded_topic.id == topic.id + end + + test "an anonymous actor marks read harmlessly and returns the topic" do + # clear_topic_notification/2 forwards nil to the notification clear + # service, which returns {:ok, 0}, so an anonymous actor reaching + # a visible topic is a successful no-op rather than a crash (contrast + # create_topic_subscription/3, where the anonymous actor's nil user raises BadMapError in + # create_subscription). + {forum, topic} = visible_topic() + + assert {:ok, loaded_topic} = Topics.create_topic_read(actor(), forum.short_name, topic.slug) + assert loaded_topic.id == topic.id + end + end + + describe "create_topic_hide/4" do + test "a regular user cannot hide a visible topic and the topic stays visible" do + # The visibility loader clears a regular user on a normal, visible topic; + # the block on the topic :hide permission is what denies the action. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert Topics.create_topic_hide(actor(user), forum.short_name, topic.slug, %{ + "deletion_reason" => "Spam" + }) == + {:error, :unauthorized} + + refute Repo.reload!(topic).hidden_from_users + end + + test "an anonymous actor cannot hide a visible topic" do + # nil clears forum :show and topic visibility on normal content, but fails + # the topic :hide permission, so this is a clean unauthorized rather than a + # crash on the nil actor. + {forum, topic} = visible_topic() + + assert Topics.create_topic_hide(actor(), forum.short_name, topic.slug, %{ + "deletion_reason" => "Spam" + }) == + {:error, :unauthorized} + + refute Repo.reload!(topic).hidden_from_users + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.create_topic_hide( + actor(confirmed_user_fixture()), + "nonexistent", + "whatever", + "Spam" + ) == + {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.create_topic_hide( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic", + %{"deletion_reason" => "Spam"} + ) == + {:error, :not_found} + end + + test "a moderator hides the topic, setting the flag, reason, and deleter" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "Rule violation" + }) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + + hidden = Repo.reload!(topic) + assert hidden.hidden_from_users + assert hidden.deletion_reason == "Rule violation" + assert hidden.deleted_by_id == moderator.id + end + + test "a successful hide writes a byte-exact moderation log" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, _} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{ + "deletion_reason" => "Rule violation" + }) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Hide:create" + assert log.subject_path == "/forums/#{forum.short_name}/topics/#{topic.slug}" + assert log.body == "Deleted topic '#{topic.title}' (Rule violation) in #{forum.name}" + end + + test "a missing reason errors and writes no moderation log" do + # hide_changeset requires deletion_reason; create_topic_hide/4 surfaces the + # normalized changeset failure as {:error, forum, topic} (both the loaded + # forum and the pre-update topic) so the controller can still redirect. + moderator = moderator_user_fixture() + + {forum, topic} = visible_topic() + + assert {:error, blank_forum, blank_topic} = + Topics.create_topic_hide(actor(moderator), forum.short_name, topic.slug, %{}) + + assert blank_forum.id == forum.id + assert blank_topic.id == topic.id + + {nil_forum, nil_topic} = visible_topic() + + assert {:error, error_forum, error_topic} = + Topics.create_topic_hide( + actor(moderator), + nil_forum.short_name, + nil_topic.slug, + %{} + ) + + assert error_forum.id == nil_forum.id + assert error_topic.id == nil_topic.id + + refute Repo.reload!(topic).hidden_from_users + refute Repo.reload!(nil_topic).hidden_from_users + assert moderation_log_count() == 0 + end + end + + describe "delete_topic_hide/3" do + test "a regular user cannot reach a hidden topic through the visibility loader" do + # delete_topic_hide/3 loads with show_hidden: false, so a hidden topic falls to + # the topic :show check, which a regular user fails before :hide is even + # considered. + user = confirmed_user_fixture() + {forum, topic} = hidden_topic() + + assert Topics.delete_topic_hide(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + + assert Repo.reload!(topic).hidden_from_users + end + + test "an anonymous actor cannot reach a hidden topic" do + {forum, topic} = hidden_topic() + + assert Topics.delete_topic_hide(actor(), forum.short_name, topic.slug) == + {:error, :unauthorized} + + assert Repo.reload!(topic).hidden_from_users + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.delete_topic_hide(actor(confirmed_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.delete_topic_hide( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + end + + test "a moderator restores a hidden topic, clearing the flag, reason, and deleter" do + # Even though the loader passes show_hidden: false, a moderator may :show a + # hidden topic, so the visibility loader admits it and :hide then permits + # the restore; the moderator reaches and unhides the topic. + moderator = moderator_user_fixture() + {forum, topic} = hidden_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.delete_topic_hide(actor(moderator), forum.short_name, topic.slug) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + + restored = Repo.reload!(topic) + refute restored.hidden_from_users + assert restored.deletion_reason == "" + assert restored.deleted_by_id == nil + end + + test "a successful restore writes a byte-exact moderation log" do + moderator = moderator_user_fixture() + {forum, topic} = hidden_topic() + + assert {:ok, _} = Topics.delete_topic_hide(actor(moderator), forum.short_name, topic.slug) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Hide:delete" + assert log.subject_path == "/forums/#{forum.short_name}/topics/#{topic.slug}" + assert log.body == "Restored topic '#{topic.title}' in #{forum.name}" + end + end + + describe "create_topic_lock/4" do + test "a regular user cannot lock a visible topic and the topic stays unlocked" do + # The visibility loader clears a regular user on a normal, visible topic; + # the block on the topic :hide permission is what denies the lock. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert Topics.create_topic_lock(actor(user), forum.short_name, topic.slug, %{ + "lock_reason" => "Off topic" + }) == + {:error, :unauthorized} + + assert Repo.reload!(topic).locked_at == nil + end + + test "an anonymous actor cannot lock a visible topic" do + # nil clears forum :show and topic visibility on normal content, but fails + # the topic :hide permission, so this is a clean unauthorized rather than a + # crash on the nil actor. + {forum, topic} = visible_topic() + + assert Topics.create_topic_lock(actor(), forum.short_name, topic.slug, %{ + "lock_reason" => "Off topic" + }) == + {:error, :unauthorized} + + assert Repo.reload!(topic).locked_at == nil + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.create_topic_lock( + actor(confirmed_user_fixture()), + "nonexistent", + "whatever", + %{"lock_reason" => "Off topic"} + ) == {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.create_topic_lock( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic", + %{"lock_reason" => "Off topic"} + ) == {:error, :not_found} + end + + test "a moderator locks the topic, setting the timestamp, reason, and locker" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.create_topic_lock(actor(moderator), forum.short_name, topic.slug, %{ + "lock_reason" => "Off topic" + }) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + + locked = Repo.reload!(topic) + assert locked.locked_at != nil + assert locked.lock_reason == "Off topic" + assert locked.locked_by_id == moderator.id + end + + test "a successful lock writes a byte-exact moderation log" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, _} = + Topics.create_topic_lock(actor(moderator), forum.short_name, topic.slug, %{ + "lock_reason" => "Off topic" + }) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Lock:create" + assert log.subject_path == "/forums/#{forum.short_name}/topics/#{topic.slug}" + assert log.body == "Locked topic '#{topic.title}' (Off topic) in #{forum.name}" + end + + test "a blank lock reason yields the 3-tuple error and writes no moderation log" do + # lock_changeset requires lock_reason; create_topic_lock/4 surfaces the rejected + # changeset as {:error, forum, topic} (both the loaded forum and the + # pre-update topic) so the controller can still redirect. + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:error, error_forum, error_topic} = + Topics.create_topic_lock(actor(moderator), forum.short_name, topic.slug, %{ + "lock_reason" => "" + }) + + assert error_forum.id == forum.id + assert error_topic.id == topic.id + + assert Repo.reload!(topic).locked_at == nil + assert moderation_log_count() == 0 + end + end + + describe "delete_topic_lock/3" do + test "a regular user cannot unlock a topic and it stays locked" do + # Locking leaves the topic visible, so the loader admits a regular user, + # who is then denied by the topic :hide permission. + user = confirmed_user_fixture() + {forum, topic} = locked_topic() + + assert Topics.delete_topic_lock(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + + assert Repo.reload!(topic).locked_at != nil + end + + test "an anonymous actor cannot unlock a topic" do + {forum, topic} = locked_topic() + + assert Topics.delete_topic_lock(actor(), forum.short_name, topic.slug) == + {:error, :unauthorized} + + assert Repo.reload!(topic).locked_at != nil + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.delete_topic_lock(actor(confirmed_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.delete_topic_lock( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + end + + test "a moderator unlocks the topic, clearing the timestamp, reason, and locker" do + moderator = moderator_user_fixture() + {forum, topic} = locked_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.delete_topic_lock(actor(moderator), forum.short_name, topic.slug) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + + unlocked = Repo.reload!(topic) + assert unlocked.locked_at == nil + assert unlocked.lock_reason == "" + assert unlocked.locked_by_id == nil + end + + test "a successful unlock writes a byte-exact moderation log" do + moderator = moderator_user_fixture() + {forum, topic} = locked_topic() + + assert {:ok, _} = Topics.delete_topic_lock(actor(moderator), forum.short_name, topic.slug) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Lock:delete" + assert log.subject_path == "/forums/#{forum.short_name}/topics/#{topic.slug}" + assert log.body == "Unlocked topic '#{topic.title}' in #{forum.name}" + end + end + + describe "create_topic_stick/3" do + test "a regular user cannot stick a visible topic and the topic stays unstuck" do + # The visibility loader clears a regular user on a normal, visible topic; + # the block on the topic :hide permission is what denies the stick. + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert Topics.create_topic_stick(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + + refute Repo.reload!(topic).sticky + assert moderation_log_count() == 0 + end + + test "an anonymous actor cannot stick a visible topic" do + # nil clears forum :show and topic visibility on normal content, but fails + # the topic :hide permission, so this is a clean unauthorized rather than a + # crash on the nil actor. + {forum, topic} = visible_topic() + + assert Topics.create_topic_stick(actor(), forum.short_name, topic.slug) == + {:error, :unauthorized} + + refute Repo.reload!(topic).sticky + assert moderation_log_count() == 0 + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.create_topic_stick(actor(confirmed_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.create_topic_stick( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a moderator sticks the topic, setting the sticky flag" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.create_topic_stick(actor(moderator), forum.short_name, topic.slug) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + + assert Repo.reload!(topic).sticky + end + + test "a successful stick writes a byte-exact moderation log" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, _} = Topics.create_topic_stick(actor(moderator), forum.short_name, topic.slug) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Stick:create" + assert log.subject_path == "/forums/#{forum.short_name}/topics/#{topic.slug}" + assert log.body == "Stickied topic '#{topic.title}' in #{forum.name}" + end + end + + describe "delete_topic_stick/3" do + test "a regular user cannot unstick a topic and it stays sticky" do + # Sticking leaves the topic visible, so the loader admits a regular user, + # who is then denied by the topic :hide permission. + user = confirmed_user_fixture() + {forum, topic} = sticky_topic() + + assert Topics.delete_topic_stick(actor(user), forum.short_name, topic.slug) == + {:error, :unauthorized} + + assert Repo.reload!(topic).sticky + assert moderation_log_count() == 1 + end + + test "an anonymous actor cannot unstick a topic" do + {forum, topic} = sticky_topic() + + assert Topics.delete_topic_stick(actor(), forum.short_name, topic.slug) == + {:error, :unauthorized} + + assert Repo.reload!(topic).sticky + assert moderation_log_count() == 1 + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.delete_topic_stick(actor(confirmed_user_fixture()), "nonexistent", "whatever") == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.delete_topic_stick( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic" + ) == + {:error, :not_found} + + assert moderation_log_count() == 0 + end + + test "a moderator unsticks the topic, clearing the sticky flag" do + moderator = moderator_user_fixture() + {forum, topic} = sticky_topic() + + assert {:ok, {loaded_forum, loaded_topic}} = + Topics.delete_topic_stick(actor(moderator), forum.short_name, topic.slug) + + assert loaded_forum.id == forum.id + assert loaded_topic.id == topic.id + + refute Repo.reload!(topic).sticky + end + + test "unsticking a non-sticky topic still succeeds" do + # unstick_changeset sets the column unconditionally, so a topic that was + # never sticky is a successful no-op rather than an error. + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + refute Repo.reload!(topic).sticky + + assert {:ok, {_forum, _topic}} = + Topics.delete_topic_stick(actor(moderator), forum.short_name, topic.slug) + + refute Repo.reload!(topic).sticky + end + + test "a successful unstick writes a byte-exact moderation log" do + moderator = moderator_user_fixture() + {forum, topic} = sticky_topic() + + assert {:ok, _} = Topics.delete_topic_stick(actor(moderator), forum.short_name, topic.slug) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Stick:delete" + assert log.subject_path == "/forums/#{forum.short_name}/topics/#{topic.slug}" + assert log.body == "Unstickied topic '#{topic.title}' in #{forum.name}" + end + end + + describe "create_topic_move/4" do + test "a regular user is not found with a malformed target" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert Topics.create_topic_move(actor(user), forum.short_name, topic.slug, %{ + "target_forum" => "garbage" + }) == {:error, :not_found} + + assert Repo.reload!(topic).forum_id == forum.id + assert moderation_log_count() == 0 + end + + test "an anonymous actor is unauthorized" do + # nil clears forum :show and topic visibility on normal content, but fails + # the topic :hide permission, so this is a clean unauthorized. + {forum, topic} = visible_topic() + target = forum_fixture() + + assert Topics.create_topic_move(actor(), forum.short_name, topic.slug, %{ + "target_forum" => target.short_name + }) == {:error, :unauthorized} + + assert Repo.reload!(topic).forum_id == forum.id + assert moderation_log_count() == 0 + end + + test "an unknown source forum is not-found for a regular user" do + target = forum_fixture() + + assert Topics.create_topic_move( + actor(confirmed_user_fixture()), + "nonexistent", + "whatever", + %{ + "target_forum" => target.short_name + } + ) == {:error, :not_found} + end + + test "an existing source forum with an unknown topic is not found" do + forum = forum_fixture() + target = forum_fixture() + + assert Topics.create_topic_move( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic", + %{"target_forum" => target.short_name} + ) == {:error, :not_found} + end + + test "a moderator moves the topic, changing forum_id and updating both forum counts" do + moderator = moderator_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum) + target = forum_fixture() + + # create_topic left the source forum at topic_count 1 and the empty target + # at topic_count 0; the move engine's Multi shifts one topic across. + assert Repo.reload!(forum).topic_count == 1 + assert Repo.reload!(target).topic_count == 0 + + assert {:ok, {new_forum, moved_topic}} = + Topics.create_topic_move(actor(moderator), forum.short_name, topic.slug, %{ + "target_forum" => target.short_name + }) + + assert new_forum.id == target.id + assert moved_topic.forum_id == target.id + assert Repo.reload!(topic).forum_id == target.id + + assert Repo.reload!(forum).topic_count == 0 + assert Repo.reload!(target).topic_count == 1 + end + + test "a successful move writes a byte-exact moderation log against the NEW forum" do + moderator = moderator_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum) + target = forum_fixture() + + assert {:ok, _} = + Topics.create_topic_move(actor(moderator), forum.short_name, topic.slug, %{ + "target_forum" => target.short_name + }) + + log = latest_moderation_log!() + assert log.user_id == moderator.id + assert log.type == "Topic.Move:create" + assert log.subject_path == "/forums/#{target.short_name}/topics/#{topic.slug}" + assert log.body == "Topic '#{topic.title}' moved to #{target.name}" + end + + test "a moderator with empty params gets not-found" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:error, :not_found} = + Topics.create_topic_move(actor(moderator), forum.short_name, topic.slug, %{}) + + assert Repo.reload!(topic).forum_id == forum.id + assert moderation_log_count() == 0 + end + + test "a moderator with a nonexistent target forum gets no move and no log" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:error, :not_found} = + Topics.create_topic_move(actor(moderator), forum.short_name, topic.slug, %{ + "target_forum" => "nonexistent-forum" + }) + + assert Repo.reload!(topic).forum_id == forum.id + assert moderation_log_count() == 0 + end + end + + describe "show_topic_page/5" do + test "an anonymous visitor reaches a visible topic with raw posts and both changesets" do + {forum, topic} = visible_topic() + + assert {:ok, %TopicPage{} = page} = + Topics.show_topic_page(actor(), forum.short_name, topic.slug, nil, @first_page) + + assert page.forum.id == forum.id + assert page.topic.id == topic.id + + # The page entries are raw Post structs, not rendered markup. + assert [%Post{}] = page.posts.entries + assert page.posts.page_size == 25 + + assert page.watching == false + # A topic with no poll reports its poll as inactive. + assert page.poll_active == false + + assert %Ecto.Changeset{} = page.post_changeset + assert %Ecto.Changeset{} = page.topic_changeset + end + + test "applies pending and destroyed post visibility before presentation" do + viewer = confirmed_user_fixture() + {forum, topic} = visible_topic() + + approved = post_fixture(topic, confirmed_user_fixture(), %{"body" => "approved"}) + + own_pending = + post_fixture(topic, viewer, %{"body" => "own pending"}) + |> Ecto.Changeset.change(approved: false) + |> Repo.update!() + + ip_pending = + post_fixture(topic, confirmed_user_fixture(), %{"body" => "same ip pending"}) + |> Ecto.Changeset.change(approved: false, ip: actor(viewer).ip) + |> Repo.update!() + + other_pending = + post_fixture(topic, confirmed_user_fixture(), %{"body" => "other pending"}) + |> Ecto.Changeset.change(approved: false, ip: random_ip()) + |> Repo.update!() + + destroyed = + post_fixture(topic, confirmed_user_fixture(), %{"body" => "destroyed"}) + |> Ecto.Changeset.change(destroyed_content: true) + |> Repo.update!() + + assert {:ok, page} = + Topics.show_topic_page( + actor(viewer), + forum.short_name, + topic.slug, + nil, + @first_page + ) + + ids = Enum.map(page.posts.entries, & &1.id) + assert approved.id in ids + assert own_pending.id in ids + assert ip_pending.id in ids + refute other_pending.id in ids + refute destroyed.id in ids + end + + test "a moderator receives pending and destroyed posts from the page loader" do + {forum, topic} = visible_topic() + + pending = + post_fixture(topic, confirmed_user_fixture()) + |> Ecto.Changeset.change(approved: false) + |> Repo.update!() + + destroyed = + post_fixture(topic, confirmed_user_fixture()) + |> Ecto.Changeset.change(destroyed_content: true) + |> Repo.update!() + + assert {:ok, page} = + Topics.show_topic_page( + actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + nil, + @first_page + ) + + ids = Enum.map(page.posts.entries, & &1.id) + assert pending.id in ids + assert destroyed.id in ids + end + + test "a hidden topic is unauthorized for a regular user" do + user = confirmed_user_fixture() + {forum, topic} = hidden_topic() + + assert Topics.show_topic_page(actor(user), forum.short_name, topic.slug, nil, @first_page) == + {:error, :unauthorized} + end + + test "an unknown forum is unauthorized for a regular user" do + assert Topics.show_topic_page( + actor(confirmed_user_fixture()), + "nonexistent", + "whatever", + nil, + @first_page + ) == {:error, :not_found} + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.show_topic_page( + actor(confirmed_user_fixture()), + forum.short_name, + "nonexistent-topic", + nil, + @first_page + ) == {:error, :not_found} + end + + test "a post_id naming a post on the second page derives that page over the pagination" do + # The first post sits at topic_position 0; 25 replies fill positions 1..25, + # so the last reply falls on page 2 (div(25, 25) + 1) even though the + # pagination map asks for page 1. + {forum, topic} = visible_topic() + + replies = for _ <- 1..25, do: post_fixture(topic) + last = List.last(replies) + assert last.topic_position == 25 + + assert {:ok, page} = + Topics.show_topic_page( + actor(), + forum.short_name, + topic.slug, + to_string(last.id), + %{ + page_number: 1, + page_size: 25 + } + ) + + assert page.posts.page_number == 2 + assert Enum.map(page.posts.entries, & &1.id) == [last.id] + end + + test "a subscribed user has watching set true" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + {:ok, _} = Topics.create_subscription(topic, user) + + assert {:ok, page} = + Topics.show_topic_page(actor(user), forum.short_name, topic.slug, nil, @first_page) + + assert page.watching + end + + test "loading the page clears the user's topic notification" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + {:ok, _} = Topics.create_subscription(topic, user) + author = confirmed_user_fixture() + post = hd(topic.posts) + {:ok, 1} = Notifications.broadcast_forum_post(author, topic, post) + assert post_notification?(topic, user) + + assert {:ok, _page} = + Topics.show_topic_page(actor(user), forum.short_name, topic.slug, nil, @first_page) + + refute post_notification?(topic, user) + end + end + + describe "new_topic/2" do + test "a banned actor is rejected before any loading" do + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Topics.new_topic(actor, "nonexistent") == {:error, :ban} + end + + test "an actor without a fingerprint is rejected before loading" do + assert Topics.new_topic(actor(nil, fingerprint: nil), "nonexistent") == + {:error, :unauthorized} + end + + test "a regular actor gets the forum and a changeset seeded with a poll and one post" do + forum = forum_fixture() + + assert {:ok, {loaded_forum, changeset}} = + Topics.new_topic(actor(confirmed_user_fixture()), forum.short_name) + + assert loaded_forum.id == forum.id + assert %Ecto.Changeset{} = changeset + assert length(changeset.data.poll.options) == 2 + assert length(changeset.data.posts) == 1 + end + + test "an unknown forum is unauthorized for a regular actor" do + assert Topics.new_topic(actor(confirmed_user_fixture()), "nonexistent") == + {:error, :not_found} + end + end + + describe "create_topic/3" do + @valid_topic_params %{ + "title" => "A brand new topic", + "anonymous" => "false", + "posts" => %{"0" => %{"body" => "First post body"}} + } + + test "a banned actor is rejected before any loading" do + # verify_write_access runs first, so a banned actor is {:error, :ban} even + # against a forum slug that does not exist. + actor = actor(confirmed_user_fixture(), ban: @ban) + + assert Topics.create_topic(actor, "nonexistent", @valid_topic_params) == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before any loading" do + actor = actor(confirmed_user_fixture(), fingerprint: nil) + + assert Topics.create_topic(actor, "nonexistent", @valid_topic_params) == + {:error, :unauthorized} + end + + test "a valid signed-in actor creates the topic with its first post" do + forum = forum_fixture() + user = confirmed_user_fixture() + + assert {:ok, %{topic: topic, forum: loaded_forum, post: post}} = + Topics.create_topic(actor(user), forum.short_name, @valid_topic_params) + + assert loaded_forum.id == forum.id + assert topic.title == "A brand new topic" + assert topic.user_id == user.id + assert post.topic_id == topic.id + + assert Repo.get(Topic, topic.id) + assert Repo.reload!(post).body == "First post body" + end + + test "an approved initial post increments the author's posts_count" do + forum = forum_fixture() + author = confirmed_user_fixture() + before = Repo.get!(User, author.id).posts_count + + assert {:ok, %{topic: _topic, post: post}} = + Topics.create_topic(actor(author), forum.short_name, @valid_topic_params) + + assert post.approved + assert Repo.get!(User, author.id).posts_count == before + 1 + end + + test "a withheld initial post does not decrement the author's posts_count" do + forum = forum_fixture() + author = confirmed_user_fixture() + before = Repo.get!(User, author.id).posts_count + + rule_fixture() + |> Ecto.Changeset.change(name: "Approval") + |> Repo.update!() + + params = + put_in(@valid_topic_params, ["posts", "0", "body"], "First post https://spam.example/") + + assert {:ok, %{topic: _topic, post: post}} = + Topics.create_topic(actor(author), forum.short_name, params) + + refute post.approved + assert Repo.get!(User, author.id).posts_count == before + assert Repo.aggregate(from(r in Report, where: r.post_id == ^post.id), :count) == 1 + end + + test "blank params yield the changeset error carrying the forum" do + forum = forum_fixture() + user = confirmed_user_fixture() + + params = %{ + "title" => "", + "anonymous" => "false", + "posts" => %{"0" => %{"body" => ""}} + } + + assert {:error, error_forum, %Ecto.Changeset{}} = + Topics.create_topic(actor(user), forum.short_name, params) + + assert error_forum.id == forum.id + end + + test "an unknown forum is unauthorized for a valid actor" do + assert Topics.create_topic( + actor(confirmed_user_fixture()), + "nonexistent", + @valid_topic_params + ) == {:error, :not_found} + end + + test "an over-limit actor is rate limited and no topic is created" do + # The :topic_create counter is primed past the limit, so the rate check + # (after write-access, before the forum load and insert) refuses the write. + forum = forum_fixture() + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :topic_create) + + assert Topics.create_topic(actor, forum.short_name, @valid_topic_params) == + {:error, :rate_limited} + + assert Repo.aggregate(from(t in Topic, where: t.forum_id == ^forum.id), :count) == 0 + end + + test "a successful create records the counter" do + forum = forum_fixture() + actor = actor(confirmed_user_fixture()) + track_rate_limit(actor, :topic_create) + + assert {:ok, %{topic: %Topic{}}} = + Topics.create_topic(actor, forum.short_name, @valid_topic_params) + + assert rate_limit_count(actor, :topic_create) == "1" + end + + test "the rate check precedes the forum load: over-limit against an unknown forum is still rate limited" do + # load_authorized_forum runs after the rate check, so an over-limit actor + # gets :rate_limited rather than the :unauthorized a missing forum yields. + actor = actor(confirmed_user_fixture()) + exceed_rate_limit(actor, :topic_create) + + assert Topics.create_topic(actor, "nonexistent", @valid_topic_params) == + {:error, :rate_limited} + end + end + + describe "update_topic/4" do + test "a regular user cannot edit a topic title and it stays unchanged" do + user = confirmed_user_fixture() + {forum, topic} = visible_topic() + + assert Topics.update_topic(actor(user), forum.short_name, topic.slug, %{ + "title" => "New Title" + }) == {:error, :unauthorized} + + assert Repo.reload!(topic).title == topic.title + end + + test "a moderator updates the title" do + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + + assert {:ok, {loaded_forum, updated_topic}} = + Topics.update_topic(actor(moderator), forum.short_name, topic.slug, %{ + "title" => "Renamed topic" + }) + + assert loaded_forum.id == forum.id + assert updated_topic.title == "Renamed topic" + assert Repo.reload!(topic).title == "Renamed topic" + end + + test "a blank title yields the 3-tuple error and leaves the title unchanged" do + # title_changeset requires the title, so a blank one surfaces as + # {:error, forum, topic} (both the loaded forum and the pre-update topic). + moderator = moderator_user_fixture() + {forum, topic} = visible_topic() + original_title = topic.title + + assert {:error, error_forum, error_topic} = + Topics.update_topic(actor(moderator), forum.short_name, topic.slug, %{ + "title" => "" + }) + + assert error_forum.id == forum.id + assert error_topic.id == topic.id + assert Repo.reload!(topic).title == original_title + end + + test "an existing forum with an unknown topic is not found" do + forum = forum_fixture() + + assert Topics.update_topic( + actor(moderator_user_fixture()), + forum.short_name, + "nonexistent-topic", + %{"title" => "New Title"} + ) == {:error, :not_found} + end + end +end diff --git a/test/philomena/user_fingerprints_test.exs b/test/philomena/user_fingerprints_test.exs new file mode 100644 index 000000000..73ad7984f --- /dev/null +++ b/test/philomena/user_fingerprints_test.exs @@ -0,0 +1,129 @@ +defmodule Philomena.UserFingerprintsTest do + @moduledoc """ + Context-level tests for fingerprint profiles and the actor-scoped user-history + services consumed by Profiles. + + These pin canonicalization, validation-before-authorization, the staff-only + sensitive-identity gate, and the distinction between an invalid fingerprint + and a valid fingerprint with no matching history, plus pagination and + latest-row lookup. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.BansFixtures + import Philomena.UserFingerprintsFixtures + import Philomena.UsersFixtures + + alias Philomena.UserFingerprints + alias Philomena.UserFingerprints.FingerprintProfile + + describe "valid_format?/1" do + test "accepts supported legacy and current formats" do + assert UserFingerprints.valid_format?("c637334158") + assert UserFingerprints.valid_format?("d015c342859dde3") + end + + test "rejects malformed or noncanonical values" do + refute UserFingerprints.valid_format?("anything") + refute UserFingerprints.valid_format?("D015C342859DDE3") + refute UserFingerprints.valid_format?(nil) + end + end + + describe "show_fingerprint_profile/2" do + test "a moderator gets the users seen with the fingerprint and the matching bans" do + user = confirmed_user_fixture() + user_fingerprint_fixture(user, "d015c342859dde3") + fingerprint_ban_fixture(%{"fingerprint" => "d015c342859dde3"}) + + assert {:ok, + %FingerprintProfile{ + fingerprint: "d015c342859dde3", + user_fingerprints: user_fingerprints, + fingerprint_bans: fingerprint_bans + }} = + UserFingerprints.show_fingerprint_profile( + actor(moderator_user_fixture()), + " D015C342859DDE3 " + ) + + assert user.id in Enum.map(user_fingerprints, & &1.user.id) + refute fingerprint_bans == [] + end + + test "an admin may load a fingerprint profile" do + assert {:ok, %FingerprintProfile{}} = + UserFingerprints.show_fingerprint_profile( + actor(admin_user_fixture()), + "c637334158" + ) + end + + test "a valid unmatched fingerprint returns an empty typed profile" do + assert {:ok, %FingerprintProfile{user_fingerprints: [], fingerprint_bans: []}} = + UserFingerprints.show_fingerprint_profile( + actor(moderator_user_fixture()), + "d11111111111111" + ) + end + + test "an invalid fingerprint is not found for a moderator" do + assert UserFingerprints.show_fingerprint_profile( + actor(moderator_user_fixture()), + "not-a-fingerprint" + ) == {:error, :not_found} + end + + test "a regular user is unauthorized for a valid fingerprint" do + assert UserFingerprints.show_fingerprint_profile( + actor(confirmed_user_fixture()), + "d11111111111111" + ) == + {:error, :unauthorized} + end + + test "invalid input is not found before the permission gate" do + assert UserFingerprints.show_fingerprint_profile( + actor(confirmed_user_fixture()), + "garbage" + ) == {:error, :not_found} + end + + test "an anonymous viewer is unauthorized for a valid fingerprint" do + assert UserFingerprints.show_fingerprint_profile(actor(), "d11111111111111") == + {:error, :unauthorized} + end + end + + describe "profile history services" do + test "loads a bounded page and latest row for an authorized actor" do + subject = confirmed_user_fixture() + other = confirmed_user_fixture() + latest = user_fingerprint_fixture(subject, "shared-history") + user_fingerprint_fixture(other, "shared-history") + moderator = actor(moderator_user_fixture()) + + assert {:ok, {page, other_users}} = + UserFingerprints.load_user_history(moderator, subject, + page: 1, + page_size: 1 + ) + + assert Enum.map(page.entries, & &1.id) == [latest.id] + assert other.id in Enum.map(other_users[latest.fingerprint], & &1.user_id) + assert UserFingerprints.latest_for_user(moderator, subject) == {:ok, latest} + end + + test "rejects an actor without the identity-metadata permission" do + user = confirmed_user_fixture() + actor = actor(confirmed_user_fixture()) + + assert UserFingerprints.load_user_history(actor, user, page: 1, page_size: 25) == + {:error, :unauthorized} + + assert UserFingerprints.latest_for_user(actor, user) == {:error, :unauthorized} + end + end +end diff --git a/test/philomena/user_ips_test.exs b/test/philomena/user_ips_test.exs new file mode 100644 index 000000000..bdd2b5f6f --- /dev/null +++ b/test/philomena/user_ips_test.exs @@ -0,0 +1,101 @@ +defmodule Philomena.UserIpsTest do + @moduledoc """ + Context-level tests for IP profiles and the actor-scoped user-history services + consumed by Profiles. + + These pin parse-before-authorization error precedence, IPv4/IPv6 + canonicalization, the staff-only sensitive-identity gate, typed profile shape, + pagination, and latest-row lookup. + """ + + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures, only: [actor: 0, actor: 1] + import Philomena.BansFixtures + import Philomena.UserIpsFixtures + import Philomena.UsersFixtures + + alias Philomena.UserIps + alias Philomena.UserIps.IpProfile + + describe "show_ip_profile/2" do + test "a moderator gets the users seen on the address and the covering subnet bans" do + user = confirmed_user_fixture() + user_ip_fixture(user, "203.0.113.50") + subnet_ban_fixture(%{"specification" => "203.0.113.0/24"}) + + assert {:ok, %IpProfile{ip: ip, user_ips: user_ips, subnet_bans: subnet_bans}} = + UserIps.show_ip_profile(actor(moderator_user_fixture()), "203.0.113.50") + + assert %Postgrex.INET{} = ip + assert user.id in Enum.map(user_ips, & &1.user.id) + refute subnet_bans == [] + end + + test "an admin may load an IP profile" do + assert {:ok, %IpProfile{}} = + UserIps.show_ip_profile(actor(admin_user_fixture()), "203.0.113.1") + end + + test "a staffer submitting an unparsable address is not-found" do + assert UserIps.show_ip_profile(actor(moderator_user_fixture()), "not-an-ip") == + {:error, :not_found} + end + + test "a valid unmatched address returns an empty typed profile" do + assert {:ok, %IpProfile{user_ips: [], subnet_bans: []}} = + UserIps.show_ip_profile(actor(moderator_user_fixture()), "198.51.100.42") + end + + test "an equivalent IPv6 spelling is canonicalized" do + assert {:ok, %IpProfile{ip: %Postgrex.INET{address: address}}} = + UserIps.show_ip_profile( + actor(moderator_user_fixture()), + "2001:0DB8:0:0:0:0:0:1" + ) + + assert address == {0x2001, 0xDB8, 0, 0, 0, 0, 0, 1} + end + + test "a regular user is unauthorized, even for a valid address" do + assert UserIps.show_ip_profile(actor(confirmed_user_fixture()), "203.0.113.1") == + {:error, :unauthorized} + end + + test "an unprivileged viewer passing garbage is not-found" do + assert UserIps.show_ip_profile(actor(confirmed_user_fixture()), "garbage") == + {:error, :not_found} + end + + test "an anonymous viewer is unauthorized" do + assert UserIps.show_ip_profile(actor(), "203.0.113.1") == {:error, :unauthorized} + end + end + + describe "profile history services" do + test "loads a bounded page and latest row for an authorized actor" do + subject = confirmed_user_fixture() + other = confirmed_user_fixture() + latest = user_ip_fixture(subject, "203.0.113.60") + user_ip_fixture(other, "203.0.113.60") + moderator = actor(moderator_user_fixture()) + + assert {:ok, {page, other_users}} = + UserIps.load_user_history(moderator, subject, page: 1, page_size: 1) + + assert Enum.map(page.entries, & &1.id) == [latest.id] + assert other.id in Enum.map(other_users[latest.ip], & &1.user_id) + assert UserIps.latest_for_user(moderator, subject) == {:ok, latest} + end + + test "rejects an actor without the identity-metadata permission" do + user = confirmed_user_fixture() + actor = actor(confirmed_user_fixture()) + + assert UserIps.load_user_history(actor, user, page: 1, page_size: 25) == + {:error, :unauthorized} + + assert UserIps.latest_for_user(actor, user) == {:error, :unauthorized} + end + end +end diff --git a/test/philomena/user_name_changes_test.exs b/test/philomena/user_name_changes_test.exs new file mode 100644 index 000000000..b985c8665 --- /dev/null +++ b/test/philomena/user_name_changes_test.exs @@ -0,0 +1,101 @@ +defmodule Philomena.UserNameChangesTest do + use Philomena.DataCase, async: true + + import Philomena.AttributionFixtures + import Philomena.UsersFixtures + + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.UserNameChanges + alias Philomena.UserNameChanges.UserNameChange + alias Philomena.Users + + @pagination %{page: 1, page_size: 1} + + defp record_name!(user) do + {:ok, %{name_change: change}} = + Multi.new() + |> UserNameChanges.record_rename(:name_change, user) + |> Multi.transact() + + change + end + + describe "record_rename/3" do + test "records the exact prior name as an owning transaction step" do + user = confirmed_user_fixture(%{name: "CaseSensitiveName"}) + + change = record_name!(user) + + assert change.user_id == user.id + assert change.name == "CaseSensitiveName" + end + + test "rolls back when a later owning transaction step fails" do + user = confirmed_user_fixture() + + assert {:error, :later_step, :forced, %{}} = + Multi.new() + |> UserNameChanges.record_rename(:name_change, user) + |> Multi.run(:later_step, fn _repo, _changes -> {:error, :forced} end) + |> Multi.transact() + + refute Repo.get_by(UserNameChange, user_id: user.id) + end + + test "Users records case-only renames and duplicate-name failures atomically" do + user = confirmed_user_fixture(%{name: "MixedCaseRename"}) + user = Users.fetch_user_for_worker!(user.id) + + assert {:ok, renamed} = Users.update_name(actor(user), %{"name" => "mixedcaserename"}) + assert renamed.name == "mixedcaserename" + assert Repo.get_by(UserNameChange, user_id: user.id, name: "MixedCaseRename") + + occupied = confirmed_user_fixture(%{name: "AlreadyTakenName"}) + other = confirmed_user_fixture(%{name: "RenameMustRollback"}) + other = Users.fetch_user_for_worker!(other.id) + + assert {:error, %Ecto.Changeset{}} = + Users.update_name(actor(other), %{"name" => occupied.name}) + + refute Repo.get_by(UserNameChange, user_id: other.id) + assert Users.fetch_user_for_worker!(other.id).name == "RenameMustRollback" + end + end + + describe "load_history/3" do + test "a moderator gets newest-first paginated history" do + user = confirmed_user_fixture() + older = record_name!(%{user | name: "older-name"}) + newer = record_name!(%{user | name: "newer-name"}) + + assert {:ok, first_page} = + UserNameChanges.load_history(actor(moderator_user_fixture()), user, @pagination) + + assert Enum.map(first_page.entries, & &1.id) == [newer.id] + assert first_page.total_entries == 2 + + assert {:ok, second_page} = + UserNameChanges.load_history( + actor(admin_user_fixture()), + user, + %{page: 2, page_size: 1} + ) + + assert Enum.map(second_page.entries, & &1.id) == [older.id] + end + + test "anonymous and ordinary viewers are unauthorized" do + user = confirmed_user_fixture() + + assert UserNameChanges.load_history(actor(), user, @pagination) == + {:error, :unauthorized} + + assert UserNameChanges.load_history( + actor(confirmed_user_fixture()), + user, + @pagination + ) == {:error, :unauthorized} + end + end +end diff --git a/test/philomena/user_statistics_concurrency_test.exs b/test/philomena/user_statistics_concurrency_test.exs new file mode 100644 index 000000000..d50d26d17 --- /dev/null +++ b/test/philomena/user_statistics_concurrency_test.exs @@ -0,0 +1,25 @@ +defmodule Philomena.UserStatisticsConcurrencyTest do + use Philomena.ConcurrentDataCase + + import Philomena.UsersFixtures + + alias Philomena.Repo + alias Philomena.UserStatistics + alias Philomena.UserStatistics.UserStatistic + alias Philomena.Users.User + + test "atomic increments do not lose updates from concurrent callers" do + user = confirmed_user_fixture() + + results = + concurrently( + for _ <- 1..8 do + fn -> UserStatistics.increment(user.id, :image_votes_count) end + end + ) + + assert results == List.duplicate({:ok, nil}, 8) + assert Repo.get!(User, user.id).image_votes_count == 8 + assert Repo.get_by!(UserStatistic, user_id: user.id).image_votes_count == 8 + end +end diff --git a/test/philomena/user_statistics_test.exs b/test/philomena/user_statistics_test.exs new file mode 100644 index 000000000..d6a9f1b5c --- /dev/null +++ b/test/philomena/user_statistics_test.exs @@ -0,0 +1,108 @@ +defmodule Philomena.UserStatisticsTest do + use Philomena.DataCase, async: true + + import Philomena.UsersFixtures + + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.Users.User + alias Philomena.UserStatistics + alias Philomena.UserStatistics.UserStatistic + + test "increments a loaded user's lifetime and current UTC-day counters" do + user = confirmed_user_fixture() + + assert UserStatistics.increment(user, :images_count) == {:ok, nil} + + assert Repo.get!(User, user.id).images_count == 1 + + assert %UserStatistic{images_count: 1, day: day} = + Repo.get_by!(UserStatistic, user_id: user.id) + + assert day == Date.utc_today() + end + + test "accepts an ID and negative amounts" do + user = confirmed_user_fixture() + + assert UserStatistics.increment(user.id, :comments_count, 4) == {:ok, nil} + assert UserStatistics.increment(user.id, :comments_count, -2) == {:ok, nil} + + assert Repo.get!(User, user.id).comments_count == 2 + assert Repo.get_by!(UserStatistic, user_id: user.id).comments_count == 2 + end + + test "nil users are a no-op for anonymous activity" do + assert UserStatistics.increment(nil, :comments_count) == {:ok, nil} + assert Repo.aggregate(UserStatistic, :count) == 0 + end + + test "a missing user ID is not-found and creates no daily row" do + assert UserStatistics.increment(2_000_000_000, :posts_count) == {:error, :not_found} + assert Repo.aggregate(UserStatistic, :count) == 0 + end + + test "unknown keys and non-integer amounts do not match the service API" do + user = confirmed_user_fixture() + + assert_raise FunctionClauseError, fn -> + # credo:disable-for-next-line Credo.Check.Refactor.Apply + apply(UserStatistics, :increment, [user, :email, 1]) + end + + assert_raise FunctionClauseError, fn -> + # credo:disable-for-next-line Credo.Check.Refactor.Apply + apply(UserStatistics, :increment, [user, :images_count, 1.5]) + end + end + + test "an owning transaction rollback restores both counters" do + user = confirmed_user_fixture() + + assert Repo.transact(fn -> + assert UserStatistics.increment(user, :topics_count) == {:ok, nil} + {:error, :forced_rollback} + end) == {:error, :forced_rollback} + + assert Repo.get!(User, user.id).topics_count == 0 + refute Repo.get_by(UserStatistic, user_id: user.id) + end + + test "bulk increments update each distinct user in an owning Multi" do + first = confirmed_user_fixture() + second = confirmed_user_fixture() + + assert {:ok, _changes} = + Multi.new() + |> UserStatistics.put_bulk_increment([first, second, first.id], :image_votes_count) + |> Multi.transact() + + for user <- [first, second] do + assert Repo.get!(User, user.id).image_votes_count == 1 + assert Repo.get_by!(UserStatistic, user_id: user.id).image_votes_count == 1 + end + end + + test "bulk increments roll back with their owning Multi" do + user = confirmed_user_fixture() + + assert {:error, :rollback, :forced_rollback, _changes} = + Multi.new() + |> UserStatistics.put_bulk_increment([user], :posts_count) + |> Multi.run(:rollback, fn _repo, _changes -> {:error, :forced_rollback} end) + |> Multi.transact() + + assert Repo.get!(User, user.id).posts_count == 0 + refute Repo.get_by(UserStatistic, user_id: user.id) + end + + test "daily rows cascade on user deletion and deleted IDs are not-found" do + user = confirmed_user_fixture() + assert UserStatistics.increment(user, :posts_count) == {:ok, nil} + + Repo.delete!(user) + + refute Repo.get_by(UserStatistic, user_id: user.id) + assert UserStatistics.increment(user.id, :posts_count) == {:error, :not_found} + end +end diff --git a/test/philomena/users_concurrency_test.exs b/test/philomena/users_concurrency_test.exs new file mode 100644 index 000000000..7c257262b --- /dev/null +++ b/test/philomena/users_concurrency_test.exs @@ -0,0 +1,165 @@ +defmodule Philomena.UsersConcurrencyTest do + use Philomena.ConcurrentDataCase + + import Ecto.Query + import Philomena.UsersFixtures + import Philomena.AttributionFixtures + import Philomena.FiltersFixtures + import Philomena.TagsFixtures + + alias Philomena.Repo + alias Philomena.Tags + alias Philomena.Users + alias Philomena.Users.User + + test "concurrent registrations with the same email persist only one user" do + email = unique_user_email() + + results = + concurrently([ + fn -> + Users.create_registration(actor(), %{ + name: "concurrent-registration-one", + email: email, + password: valid_user_password() + }) + end, + fn -> + Users.create_registration(actor(), %{ + name: "concurrent-registration-two", + email: email, + password: valid_user_password() + }) + end + ]) + + assert Enum.count(results, &match?({:ok, %User{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, %Ecto.Changeset{}}, &1)) == 1 + assert Repo.aggregate(from(user in User, where: user.email == ^email), :count) == 1 + end + + test "concurrent watched-tag updates preserve every tag" do + user = confirmed_user_fixture() + actor = actor(user) + tags = Enum.map(1..8, fn _ -> tag_fixture() end) + + results = concurrently(for tag <- tags, do: fn -> Users.watch_tag(actor, tag) end) + + assert Enum.all?(results, &match?({:ok, %User{}}, &1)) + + assert Repo.get!(User, user.id).watched_tag_ids |> Enum.sort() == + Enum.map(tags, & &1.id) |> Enum.sort() + end + + test "concurrent failed attempts only allow ten attempts" do + user = confirmed_user_fixture() + + results = + concurrently( + for _ <- 1..20 do + fn -> Users.fetch_user_by_email_and_password(user.email, "invalid", & &1) end + end + ) + + assert results == List.duplicate({:error, :not_found}, 20) + + user = Repo.get!(User, user.id) + assert user.failed_attempts == 10 + assert user.locked_at + end + + test "settings updates and watched-tag updates do not lose the serialized result" do + user = confirmed_user_fixture() + actor = actor(user) + tag = tag_fixture() + + results = + concurrently([ + fn -> Users.update_settings(actor, %{"watched_tag_list" => ""}) end, + fn -> Users.watch_tag(actor, tag) end + ]) + + assert Enum.all?(results, &match?({:ok, %User{}}, &1)) + assert Repo.get!(User, user.id).watched_tag_ids in [[], [tag.id]] + end + + test "watched-tag settings racing an alias store the canonical tag" do + user = confirmed_user_fixture() + source = tag_fixture(name: unique_tag_name()) + target = tag_fixture(name: unique_tag_name()) + + results = + concurrently([ + fn -> + Tags.update_tag_alias(actor(admin_user_fixture()), source.slug, %{ + "target_tag" => target.name + }) + end, + fn -> Users.update_settings(actor(user), %{"watched_tag_list" => source.name}) end + ]) + + assert Enum.all?(results, &match?({:ok, _}, &1)) + assert Repo.get!(User, user.id).watched_tag_ids == [target.id] + end + + test "setting the current filter races safely with clearing recent filters" do + user = confirmed_user_fixture() + actor = actor(user) + filter = filter_fixture(user) + + results = + concurrently([ + fn -> Users.set_current_filter(user, filter) end, + fn -> Users.delete_recent_filters(actor) end + ]) + + assert Enum.all?(results, &match?({:ok, %User{}}, &1)) + user = Repo.get!(User, user.id) + assert user.current_filter_id in [nil, filter.id] + assert user.recent_filter_ids in [[], [filter.id], [filter.id, nil], [nil]] + end + + test "concurrent reactivation attempts validate the current state" do + target = deactivated_user_fixture() + slug = target.slug + admin = actor(admin_user_fixture()) + + results = + concurrently([ + fn -> Users.create_user_activation(admin, slug) end, + fn -> Users.create_user_activation(admin, slug) end + ]) + + assert Enum.count(results, &match?({:ok, %User{}}, &1)) == 1 + end + + test "concurrent deactivation attempts allow only the active transition" do + target = confirmed_user_fixture() + slug = target.slug + admin = actor(admin_user_fixture()) + + results = + concurrently([ + fn -> Users.delete_user_activation(admin, slug) end, + fn -> Users.delete_user_activation(admin, slug) end + ]) + + assert Enum.count(results, &match?({:ok, %User{}}, &1)) == 1 + assert Repo.get!(User, target.id).deleted_at + end + + test "concurrent name changes allow only one rename within the rename window" do + user = confirmed_user_fixture() + actor = actor(user) + + results = + concurrently([ + fn -> Users.update_name(actor, %{"name" => "first_concurrent_name"}) end, + fn -> Users.update_name(actor, %{"name" => "second_concurrent_name"}) end + ]) + + assert Enum.count(results, &match?({:ok, %User{}}, &1)) == 1 + assert Enum.count(results, &match?({:error, :unauthorized}, &1)) == 1 + assert Repo.get!(User, user.id).name in ["first_concurrent_name", "second_concurrent_name"] + end +end diff --git a/test/philomena/users_test.exs b/test/philomena/users_test.exs index 433e75a69..fbaf1ac50 100644 --- a/test/philomena/users_test.exs +++ b/test/philomena/users_test.exs @@ -1,10 +1,104 @@ defmodule Philomena.UsersTest do - use Philomena.DataCase, async: true + use Philomena.DataCase, async: false + + @moduletag :search alias Philomena.Users import Philomena.UsersFixtures - alias Philomena.Users.{Settings, User, UserToken} + import Philomena.AttributionFixtures + import Philomena.UserIpsFixtures + import Philomena.UserFingerprintsFixtures + import Philomena.FiltersFixtures + import Philomena.RulesFixtures + alias Philomena.Roles.Role + alias Philomena.ModerationLogs.{ModerationLog, Paths} + alias Philomena.Reports.Report + alias PhilomenaQuery.Search + alias PhilomenaQuery.SearchHelpers + alias Philomena.Users.{AdminUserForm, AliasMatches, Settings, User, UserToken} alias Philomena.Repo + alias PhilomenaMedia.Upload + + @png_fixture Path.absname("test/support/fixtures/files/upload-test.png") + + @pagination %{page_number: 1, page_size: 25} + + # A truthy ban value in the shape production passes; only its presence matters + # to the write-access and not-banned checks the profile loaders run first. + @ban %{ + reason: "Rule #0", + valid_until: ~U[3000-01-01 00:00:00Z], + generated_ban_id: "U123456", + type: "User" + } + + # A Plug.Upload whose tempfile is registered to the test process, the way + # Plug.Parsers would provide it. + defp png_upload do + {:ok, path} = Plug.Upload.random_file("avatar-test") + File.cp!(@png_fixture, path) + %Plug.Upload{path: path, content_type: "image/png", filename: "upload-test.png"} + end + + defp media_png_upload do + png_upload() + |> Upload.from_plug() + end + + defp valid_totp_code(user), do: :pot.totp(User.totp_secret(user)) + + # Controller-shaped params for enabling or disabling TOTP: current password + # plus a live second-factor code computed from the user's stored secret. + defp totp_params(token) do + %{"user" => %{"current_password" => valid_user_password(), "twofactor_token" => token}} + end + + # A confirmed user with a role and, optionally, a secondary role and the + # hide-default-role flag, in the shape the staff page groups on. + defp staff_user(role, opts \\ []) do + confirmed_user_fixture() + |> Ecto.Changeset.change( + role: role, + secondary_role: Keyword.get(opts, :secondary_role), + hide_default_role: Keyword.get(opts, :hide_default_role, false) + ) + |> Repo.update!() + end + + # A confirmed user reloaded from the database, so its last_renamed_at carries + # the 1970 column default the way a request-loaded actor does. A freshly + # inserted struct instead has last_renamed_at nil, which the :change_username + # ability's DateTime.diff cannot handle - the request pipeline never sees that + # struct. + defp renameable_user do + Users.fetch_user_for_worker!(confirmed_user_fixture().id) + end + + # A user whose rename window is closed: last_renamed_at is now, so the + # :change_username ability (which requires the last rename to be over 90 days + # ago) refuses. + defp recently_renamed_user do + confirmed_user_fixture() + |> Ecto.Changeset.change(last_renamed_at: DateTime.utc_now(:second)) + |> Repo.update!() + end + + # A confirmed user with a slug-clean name, used as the target of the staff + # user-management functions so subject paths and log bodies read predictably. + defp managed_target(name \\ "managed_target_#{System.unique_integer([:positive])}") do + confirmed_user_fixture(%{name: name}) + end + + # A moderator carrying the "User" admin role_map grant. The user-management + # ability keys on the "moderator" sub-grant instead, so this actor is + # authorized for :index but rejected for :edit/:update - the almost-privileged + # role. + defp user_admin_moderator, do: role_moderator_fixture("User") + + # The most recently written moderation log row. + defp last_moderation_log do + ModerationLog |> order_by(desc: :id) |> limit(1) |> Repo.one() + end describe "get_user_by_email/1" do test "does not return the user if the email does not exist" do @@ -17,16 +111,19 @@ defmodule Philomena.UsersTest do end end - describe "get_user_by_email_and_password/3" do + describe "fetch_user_by_email_and_password/3" do test "does not return the user if the email does not exist" do - refute Users.get_user_by_email_and_password("unknown@example.com", "hello world!", & &1) + assert Users.fetch_user_by_email_and_password("unknown@example.com", "hello world!", & &1) == + {:error, :not_found} end test "does not return the user if the password is not valid" do user = user_fixture() - refute Users.get_user_by_email_and_password(user.email, "invalid", & &1) - user = Users.get_user!(user.id) + assert Users.fetch_user_by_email_and_password(user.email, "invalid", & &1) == + {:error, :not_found} + + user = Users.fetch_user_for_worker!(user.id) assert user.failed_attempts == 1 end @@ -34,10 +131,11 @@ defmodule Philomena.UsersTest do user = user_fixture() Enum.map(1..10, fn _ -> - refute Users.get_user_by_email_and_password(user.email, "invalid", & &1) + assert Users.fetch_user_by_email_and_password(user.email, "invalid", & &1) == + {:error, :not_found} end) - user = Users.get_user!(user.id) + user = Users.fetch_user_for_worker!(user.id) token = extract_user_token(fn url -> @@ -57,36 +155,73 @@ defmodule Philomena.UsersTest do user = user_fixture() Enum.map(1..10, fn _ -> - refute Users.get_user_by_email_and_password(user.email, "invalid", & &1) + assert Users.fetch_user_by_email_and_password(user.email, "invalid", & &1) == + {:error, :not_found} end) - refute Users.get_user_by_email_and_password(user.email, valid_user_password(), & &1) + assert Users.fetch_user_by_email_and_password(user.email, valid_user_password(), & &1) == + {:error, :not_found} end test "returns the user if the email and password are valid" do - %{id: id} = user = user_fixture() + %{id: id} = user = confirmed_user_fixture() - assert %User{id: ^id} = - Users.get_user_by_email_and_password(user.email, valid_user_password(), & &1) + assert {:ok, %User{id: ^id}} = + Users.fetch_user_by_email_and_password(user.email, valid_user_password(), & &1) end end - describe "get_user!/1" do - test "raises if id is invalid" do - assert_raise Ecto.NoResultsError, fn -> - Users.get_user!(-1) + describe "load_profile/2" do + test "loads an active profile for an actor" do + user = confirmed_user_fixture() + + assert {:ok, loaded} = Users.load_profile(actor(), user.slug) + assert loaded.id == user.id + end + + test "returns not found for missing and deactivated profiles for every actor" do + user = + confirmed_user_fixture() + |> Ecto.Changeset.change(deleted_at: DateTime.utc_now(:second)) + |> Repo.update!() + + for viewer <- [actor(), actor(confirmed_user_fixture()), actor(admin_user_fixture())] do + assert Users.load_profile(viewer, "missing-profile") == {:error, :not_found} + assert Users.load_profile(viewer, user.slug) == {:error, :not_found} end end + end - test "returns the user with the given id" do - %{id: id} = user = user_fixture() - assert %User{id: ^id} = Users.get_user!(user.id) + describe "show_profile/2" do + test "loads an active profile and its public associations" do + user = confirmed_user_fixture() + + assert {:ok, loaded} = Users.show_profile(actor(), user.id) + assert loaded.id == user.id + assert is_list(loaded.public_links) + assert is_list(loaded.awards) + end + + test "normalizes malformed, missing, and deactivated IDs to not-found" do + user = + confirmed_user_fixture() + |> Ecto.Changeset.change(deleted_at: DateTime.utc_now(:second)) + |> Repo.update!() + + for viewer <- [actor(), actor(confirmed_user_fixture()), actor(admin_user_fixture())], + id <- ["not-an-id", -1, user.id] do + assert Users.show_profile(viewer, id) == {:error, :not_found} + end end end - describe "register_user/1" do + describe "create_registration/2" do + test "rejects banned actors" do + assert Users.create_registration(actor(nil, ban: @ban), %{}) == {:error, :ban} + end + test "requires email and password to be set" do - {:error, changeset} = Users.register_user(%{}) + {:error, changeset} = Users.create_registration(actor(), %{}) assert %{ password: ["can't be blank"], @@ -95,7 +230,8 @@ defmodule Philomena.UsersTest do end test "validates email and password when given" do - {:error, changeset} = Users.register_user(%{email: "not valid", password: "not valid"}) + {:error, changeset} = + Users.create_registration(actor(), %{email: "not valid", password: "not valid"}) assert %{ email: ["must be valid (e.g., user@example.com)"], @@ -105,7 +241,10 @@ defmodule Philomena.UsersTest do test "validates maximum values for email and password for security" do too_long = String.duplicate("db", 100) - {:error, changeset} = Users.register_user(%{email: too_long, password: too_long}) + + {:error, changeset} = + Users.create_registration(actor(), %{email: too_long, password: too_long}) + assert "should be at most 160 character(s)" in errors_on(changeset).email assert "should be at most 80 character(s)" in errors_on(changeset).password end @@ -113,12 +252,18 @@ defmodule Philomena.UsersTest do test "validates email uniqueness" do %{email: email} = user_fixture() - {:error, changeset} = Users.register_user(%{name: email, email: email}) + {:error, changeset} = + Users.create_registration(actor(), %{ + name: email, + password: valid_user_password(), + email: email + }) + assert "has already been taken" in errors_on(changeset).email # Now try with the upper cased email too, to check that email case is ignored. {:error, changeset} = - Users.register_user(%{ + Users.create_registration(actor(), %{ name: String.upcase(email), email: String.upcase(email), password: valid_user_password() @@ -131,7 +276,11 @@ defmodule Philomena.UsersTest do email = unique_user_email() {:ok, user} = - Users.register_user(%{name: email, email: email, password: valid_user_password()}) + Users.create_registration(actor(), %{ + name: email, + email: email, + password: valid_user_password() + }) assert user.email == email assert is_binary(user.hashed_password) @@ -143,7 +292,11 @@ defmodule Philomena.UsersTest do email = unique_user_email() {:ok, user} = - Users.register_user(%{name: email, email: email, password: valid_user_password()}) + Users.create_registration(actor(), %{ + name: email, + email: email, + password: valid_user_password() + }) settings = Repo.get!(Settings, user.id) assert settings.user_id == user.id @@ -156,11 +309,15 @@ defmodule Philomena.UsersTest do end end - describe "change_user_registration/2" do + describe "new_registration/3" do test "returns a changeset" do - assert %Ecto.Changeset{} = changeset = Users.change_user_registration(%User{}) + assert {:ok, %Ecto.Changeset{} = changeset} = Users.new_registration(actor(), %User{}) assert changeset.required == [:password, :email, :name] end + + test "rejects banned actors" do + assert Users.new_registration(actor(nil, ban: @ban), %User{}) == {:error, :ban} + end end describe "change_user_email/2" do @@ -170,19 +327,19 @@ defmodule Philomena.UsersTest do end end - describe "apply_user_email/3" do + describe "create_email/3" do setup do %{user: user_fixture()} end test "requires email to change", %{user: user} do - {:error, changeset} = Users.apply_user_email(user, valid_user_password(), %{}) + {:error, changeset} = Users.create_email(user, valid_user_password(), %{}) assert %{email: ["did not change"]} = errors_on(changeset) end test "validates email", %{user: user} do {:error, changeset} = - Users.apply_user_email(user, valid_user_password(), %{email: "not valid"}) + Users.create_email(user, valid_user_password(), %{email: "not valid"}) assert %{email: ["must be valid (e.g., user@example.com)"]} = errors_on(changeset) end @@ -191,7 +348,7 @@ defmodule Philomena.UsersTest do too_long = String.duplicate("db", 100) {:error, changeset} = - Users.apply_user_email(user, valid_user_password(), %{email: too_long}) + Users.create_email(user, valid_user_password(), %{email: too_long}) assert "should be at most 160 character(s)" in errors_on(changeset).email end @@ -199,22 +356,22 @@ defmodule Philomena.UsersTest do test "validates email uniqueness", %{user: user} do %{email: email} = user_fixture() - {:error, changeset} = Users.apply_user_email(user, valid_user_password(), %{email: email}) + {:error, changeset} = Users.create_email(user, valid_user_password(), %{email: email}) assert "has already been taken" in errors_on(changeset).email end test "validates current password", %{user: user} do - {:error, changeset} = Users.apply_user_email(user, "invalid", %{email: unique_user_email()}) + {:error, changeset} = Users.create_email(user, "invalid", %{email: unique_user_email()}) assert %{current_password: ["is not valid"]} = errors_on(changeset) end test "applies the email without persisting it", %{user: user} do email = unique_user_email() - {:ok, user} = Users.apply_user_email(user, valid_user_password(), %{email: email}) + {:ok, user} = Users.create_email(user, valid_user_password(), %{email: email}) assert user.email == email - assert Users.get_user!(user.id).email != email + assert Users.fetch_user_for_worker!(user.id).email != email end end @@ -237,7 +394,7 @@ defmodule Philomena.UsersTest do end end - describe "update_user_email/2" do + describe "show_email/2" do setup do user = user_fixture() email = unique_user_email() @@ -251,7 +408,7 @@ defmodule Philomena.UsersTest do end test "updates the email with a valid token", %{user: user, token: token, email: email} do - assert Users.update_user_email(user, token) == :ok + assert Users.show_email(user, token) == :ok changed_user = Repo.get!(User, user.id) assert changed_user.email != user.email assert changed_user.email == email @@ -261,40 +418,40 @@ defmodule Philomena.UsersTest do end test "does not update email with invalid token", %{user: user} do - assert Users.update_user_email(user, "oops") == :error + assert Users.show_email(user, "oops") == :error assert Repo.get!(User, user.id).email == user.email assert Repo.get_by(UserToken, user_id: user.id) end test "does not update email if user email changed", %{user: user, token: token} do - assert Users.update_user_email(%{user | email: "current@example.com"}, token) == :error + assert Users.show_email(%{user | email: "current@example.com"}, token) == :error assert Repo.get!(User, user.id).email == user.email assert Repo.get_by(UserToken, user_id: user.id) end test "does not update email if token expired", %{user: user, token: token} do {1, nil} = Repo.update_all(UserToken, set: [created_at: ~N[2020-01-01 00:00:00]]) - assert Users.update_user_email(user, token) == :error + assert Users.show_email(user, token) == :error assert Repo.get!(User, user.id).email == user.email assert Repo.get_by(UserToken, user_id: user.id) end end - describe "change_user_password/2" do + describe "edit_password/2" do test "returns a user changeset" do - assert %Ecto.Changeset{} = changeset = Users.change_user_password(%User{}) + assert %Ecto.Changeset{} = changeset = Users.edit_password(%User{}) assert changeset.required == [:password] end end - describe "update_user_password/3" do + describe "update_password/3" do setup do - %{user: user_fixture()} + %{user: confirmed_user_fixture()} end test "validates password", %{user: user} do {:error, changeset} = - Users.update_user_password(user, valid_user_password(), %{ + Users.update_password(user, valid_user_password(), %{ password: "not valid", password_confirmation: "another" }) @@ -309,33 +466,35 @@ defmodule Philomena.UsersTest do too_long = String.duplicate("db", 200) {:error, changeset} = - Users.update_user_password(user, valid_user_password(), %{password: too_long}) + Users.update_password(user, valid_user_password(), %{password: too_long}) assert "should be at most 80 character(s)" in errors_on(changeset).password end test "validates current password", %{user: user} do {:error, changeset} = - Users.update_user_password(user, "invalid", %{password: valid_user_password()}) + Users.update_password(user, "invalid", %{password: valid_user_password()}) assert %{current_password: ["is not valid"]} = errors_on(changeset) end test "updates the password", %{user: user} do {:ok, user} = - Users.update_user_password(user, valid_user_password(), %{ + Users.update_password(user, valid_user_password(), %{ password: "new valid password" }) assert is_nil(user.password) - assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1) + + assert {:ok, _} = + Users.fetch_user_by_email_and_password(user.email, "new valid password", & &1) end test "deletes all tokens for the given user", %{user: user} do _ = Users.generate_user_session_token(user) {:ok, _} = - Users.update_user_password(user, valid_user_password(), %{ + Users.update_password(user, valid_user_password(), %{ password: "new valid password" }) @@ -479,7 +638,7 @@ defmodule Philomena.UsersTest do end end - describe "confirm_user/2" do + describe "update_confirmation/2" do setup do user = user_fixture() @@ -492,7 +651,7 @@ defmodule Philomena.UsersTest do end test "confirms the email with a valid token", %{user: user, token: token} do - assert {:ok, confirmed_user} = Users.confirm_user(token) + assert {:ok, confirmed_user} = Users.update_confirmation(token) assert confirmed_user.confirmed_at assert confirmed_user.confirmed_at != user.confirmed_at assert Repo.get!(User, user.id).confirmed_at @@ -500,14 +659,14 @@ defmodule Philomena.UsersTest do end test "does not confirm with invalid token", %{user: user} do - assert Users.confirm_user("oops") == :error + assert Users.update_confirmation("oops") == :error refute Repo.get!(User, user.id).confirmed_at assert Repo.get_by(UserToken, user_id: user.id) end test "does not confirm email if token expired", %{user: user, token: token} do {1, nil} = Repo.update_all(UserToken, set: [created_at: ~N[2020-01-01 00:00:00]]) - assert Users.confirm_user(token) == :error + assert Users.update_confirmation(token) == :error refute Repo.get!(User, user.id).confirmed_at assert Repo.get_by(UserToken, user_id: user.id) end @@ -532,7 +691,7 @@ defmodule Philomena.UsersTest do end end - describe "unlock_user_by_token/1" do + describe "show_unlock/1" do setup do user = locked_user_fixture() @@ -545,47 +704,26 @@ defmodule Philomena.UsersTest do end test "unlocks the user with a valid token", %{user: user, token: token} do - assert {:ok, unlocked_user} = Users.unlock_user_by_token(token) + assert {:ok, unlocked_user} = Users.show_unlock(token) refute unlocked_user.locked_at refute Repo.get!(User, user.id).locked_at refute Repo.get_by(UserToken, user_id: user.id) end test "does not confirm with invalid token", %{user: user} do - assert Users.unlock_user_by_token("oops") == :error + assert Users.show_unlock("oops") == :error assert Repo.get!(User, user.id).locked_at assert Repo.get_by(UserToken, user_id: user.id) end test "does not unlocked if token expired", %{user: user, token: token} do {1, nil} = Repo.update_all(UserToken, set: [created_at: ~N[2020-01-01 00:00:00]]) - assert Users.unlock_user_by_token(token) == :error + assert Users.show_unlock(token) == :error assert Repo.get!(User, user.id).locked_at assert Repo.get_by(UserToken, user_id: user.id) end end - describe "unlock_user/1" do - setup do - user = user_fixture() - locked_user = locked_user_fixture() - - %{user: user, locked_user: locked_user} - end - - test "unlocks the user when locked", %{locked_user: locked_user} do - assert {:ok, unlocked_user} = Users.unlock_user(locked_user) - refute unlocked_user.locked_at - refute Repo.get!(User, unlocked_user.id).locked_at - end - - test "does nothing when not locked", %{user: user} do - assert {:ok, unlocked_user} = Users.unlock_user(user) - refute unlocked_user.locked_at - refute Repo.get!(User, unlocked_user.id).locked_at - end - end - describe "deliver_user_reset_password_instructions/2" do setup do %{user: user_fixture()} @@ -634,14 +772,14 @@ defmodule Philomena.UsersTest do end end - describe "reset_user_password/3" do + describe "update_password/2" do setup do - %{user: user_fixture()} + %{user: confirmed_user_fixture()} end test "validates password", %{user: user} do {:error, changeset} = - Users.reset_user_password(user, %{ + Users.update_password(user, %{ password: "not valid", password_confirmation: "another" }) @@ -654,19 +792,21 @@ defmodule Philomena.UsersTest do test "validates maximum values for password for security", %{user: user} do too_long = String.duplicate("db", 100) - {:error, changeset} = Users.reset_user_password(user, %{password: too_long}) + {:error, changeset} = Users.update_password(user, %{password: too_long}) assert "should be at most 80 character(s)" in errors_on(changeset).password end test "updates the password", %{user: user} do - {:ok, updated_user} = Users.reset_user_password(user, %{password: "new valid password"}) + {:ok, updated_user} = Users.update_password(user, %{password: "new valid password"}) assert is_nil(updated_user.password) - assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1) + + assert {:ok, _} = + Users.fetch_user_by_email_and_password(user.email, "new valid password", & &1) end test "deletes all tokens for the given user", %{user: user} do _ = Users.generate_user_session_token(user) - {:ok, _} = Users.reset_user_password(user, %{password: "new valid password"}) + {:ok, _} = Users.update_password(user, %{password: "new valid password"}) refute Repo.get_by(UserToken, user_id: user.id) end end @@ -676,4 +816,1316 @@ defmodule Philomena.UsersTest do refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" end end + + describe "edit_profile_description/2" do + test "the profile owner may edit their own description" do + user = confirmed_user_fixture() + + assert {:ok, %Ecto.Changeset{data: loaded}} = + Users.edit_profile_description(actor(user), user.slug) + + assert loaded.id == user.id + end + + test "a moderator may edit another user's description" do + user = confirmed_user_fixture() + + assert {:ok, %Ecto.Changeset{data: loaded}} = + Users.edit_profile_description(actor(moderator_user_fixture()), user.slug) + + assert loaded.id == user.id + end + + test "an unrelated user may not edit another user's description" do + user = confirmed_user_fixture() + + assert Users.edit_profile_description(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "a banned actor is rejected before any authorization" do + user = confirmed_user_fixture() + + assert Users.edit_profile_description(actor(user, ban: @ban), user.slug) == + {:error, :ban} + end + + test "an actor without a fingerprint is rejected before authorization" do + user = confirmed_user_fixture() + + assert Users.edit_profile_description( + actor(user, fingerprint: nil), + user.slug + ) == {:error, :unauthorized} + end + + test "an unknown slug is not-found before instance authorization" do + assert Users.edit_profile_description( + actor(moderator_user_fixture()), + "no-such-user" + ) == + {:error, :not_found} + + assert Users.edit_profile_description(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "update_profile_description/3" do + test "the owner updates their description" do + user = confirmed_user_fixture() + + assert {:ok, updated} = + Users.update_profile_description(actor(user), user.slug, %{ + "description" => "New bio text" + }) + + assert updated.id == user.id + assert Users.fetch_user_for_worker!(user.id).description == "New bio text" + end + + test "files one report when the profile becomes unapproved" do + user = confirmed_user_fixture() + rule_fixture(name: "Review") + + assert {:ok, _updated} = + Users.update_profile_description(actor(user), user.slug, %{ + "description" => "https://outside.example/profile" + }) + + assert Repo.aggregate(Report, :count) == 1 + + assert {:ok, _updated} = + Users.update_profile_description(actor(user), user.slug, %{ + "description" => "https://another-outside.example/profile" + }) + + assert Repo.aggregate(Report, :count) == 1 + end + + test "a banned actor is rejected" do + user = confirmed_user_fixture() + + assert Users.update_profile_description(actor(user, ban: @ban), user.slug, %{ + "description" => "New bio text" + }) == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + user = confirmed_user_fixture() + + assert Users.update_profile_description(actor(user, fingerprint: nil), user.slug, %{ + "description" => "New bio text" + }) == {:error, :unauthorized} + end + + test "an unrelated user may not update another user's description" do + user = confirmed_user_fixture() + + assert Users.update_profile_description(actor(confirmed_user_fixture()), user.slug, %{ + "description" => "New bio text" + }) == {:error, :unauthorized} + end + end + + describe "edit_profile_scratchpad/2" do + test "a moderator may edit the scratchpad" do + user = confirmed_user_fixture() + + assert {:ok, %Ecto.Changeset{data: loaded}} = + Users.edit_profile_scratchpad(actor(moderator_user_fixture()), user.slug) + + assert loaded.id == user.id + end + + test "an assistant may edit the scratchpad" do + user = confirmed_user_fixture() + + assert {:ok, %Ecto.Changeset{data: loaded}} = + Users.edit_profile_scratchpad(actor(assistant_user_fixture()), user.slug) + + assert loaded.id == user.id + end + + test "a regular user may not edit the scratchpad" do + user = confirmed_user_fixture() + + assert Users.edit_profile_scratchpad(actor(confirmed_user_fixture()), user.slug) == + {:error, :unauthorized} + end + + test "a banned actor is rejected before the mod-note check" do + user = confirmed_user_fixture() + + assert Users.edit_profile_scratchpad( + actor(moderator_user_fixture(), ban: @ban), + user.slug + ) == {:error, :ban} + end + + test "an actor without a fingerprint is rejected before the mod-note check" do + user = confirmed_user_fixture() + + assert Users.edit_profile_scratchpad( + actor(moderator_user_fixture(), fingerprint: nil), + user.slug + ) == {:error, :unauthorized} + end + + test "a permitted actor naming an unknown slug is not-found" do + assert Users.edit_profile_scratchpad( + actor(moderator_user_fixture()), + "no-such-user" + ) == + {:error, :not_found} + end + end + + describe "update_profile_scratchpad/3" do + test "a moderator updates the scratchpad" do + user = confirmed_user_fixture() + + assert {:ok, updated} = + Users.update_profile_scratchpad(actor(moderator_user_fixture()), user.slug, %{ + "scratchpad" => "Mod notes here" + }) + + assert updated.id == user.id + assert Users.fetch_user_for_worker!(user.id).scratchpad == "Mod notes here" + end + + test "a regular user may not update the scratchpad" do + user = confirmed_user_fixture() + + assert Users.update_profile_scratchpad(actor(confirmed_user_fixture()), user.slug, %{ + "scratchpad" => "Mod notes here" + }) == {:error, :unauthorized} + end + + test "a banned actor is rejected" do + user = confirmed_user_fixture() + + assert Users.update_profile_scratchpad( + actor(moderator_user_fixture(), ban: @ban), + user.slug, + %{ + "scratchpad" => "Mod notes here" + } + ) == {:error, :ban} + end + end + + describe "list_profile_aliases/2" do + test "a moderator sees a user sharing only an IP under ip_matches" do + subject = confirmed_user_fixture() + alias_user = confirmed_user_fixture() + user_ip_fixture(subject, "203.0.113.70") + user_ip_fixture(alias_user, "203.0.113.70") + + assert {:ok, %AliasMatches{} = matches} = + Users.list_profile_aliases(actor(moderator_user_fixture()), subject.slug) + + assert alias_user.id in Enum.map(matches.ip_matches, & &1.id) + refute alias_user.id in Enum.map(matches.fp_matches, & &1.id) + refute alias_user.id in Enum.map(matches.both_matches, & &1.id) + end + + test "a moderator sees a user sharing only a fingerprint under fp_matches" do + subject = confirmed_user_fixture() + alias_user = confirmed_user_fixture() + user_fingerprint_fixture(subject, "aliasfp70") + user_fingerprint_fixture(alias_user, "aliasfp70") + + assert {:ok, %AliasMatches{} = matches} = + Users.list_profile_aliases(actor(moderator_user_fixture()), subject.slug) + + assert alias_user.id in Enum.map(matches.fp_matches, & &1.id) + refute alias_user.id in Enum.map(matches.ip_matches, & &1.id) + end + + test "a moderator sees a user sharing both an IP and a fingerprint under both_matches" do + subject = confirmed_user_fixture() + alias_user = confirmed_user_fixture() + user_ip_fixture(subject, "203.0.113.71") + user_ip_fixture(alias_user, "203.0.113.71") + user_fingerprint_fixture(subject, "aliasfp71") + user_fingerprint_fixture(alias_user, "aliasfp71") + + assert {:ok, %AliasMatches{} = matches} = + Users.list_profile_aliases(actor(moderator_user_fixture()), subject.slug) + + assert alias_user.id in Enum.map(matches.both_matches, & &1.id) + refute alias_user.id in Enum.map(matches.ip_matches, & &1.id) + refute alias_user.id in Enum.map(matches.fp_matches, & &1.id) + end + + test "a regular user may not load alias matches" do + assert Users.list_profile_aliases( + actor(confirmed_user_fixture()), + confirmed_user_fixture().slug + ) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found before instance authorization" do + assert Users.list_profile_aliases(actor(moderator_user_fixture()), "no-such-user") == + {:error, :not_found} + + assert Users.list_profile_aliases(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "staff_categories/0" do + test "groups an admin under Administrators" do + admin = staff_user("admin") + + categories = Users.staff_categories() + assert admin.id in Enum.map(categories[:administrators], & &1.id) + end + + test "groups a plain moderator under Moderators" do + mod = staff_user("moderator") + + categories = Users.staff_categories() + assert mod.id in Enum.map(categories[:moderators], & &1.id) + refute mod.id in Enum.map(categories[:administrators], & &1.id) + end + + test "groups a plain assistant under Assistants" do + assistant = staff_user("assistant") + + categories = Users.staff_categories() + assert assistant.id in Enum.map(categories[:assistants], & &1.id) + end + + test "groups a Site Developer under Technical Team by secondary role" do + dev = staff_user("moderator", secondary_role: "Site Developer") + + categories = Users.staff_categories() + assert dev.id in Enum.map(categories[:developers], & &1.id) + # A distinguishing secondary role takes the user out of Moderators. + refute dev.id in Enum.map(categories[:moderators], & &1.id) + end + + test "groups a Public Relations staffer under Public Relations" do + pr = staff_user("moderator", secondary_role: "Public Relations") + + categories = Users.staff_categories() + assert pr.id in Enum.map(categories[:public_relations], & &1.id) + end + + test "shows a staff member who hides their default role with no distinguishing secondary role" do + hidden = staff_user("moderator", hide_default_role: true) + + all_listed = + Users.staff_categories() + |> Enum.flat_map(fn {_category, users} -> Enum.map(users, & &1.id) end) + + assert hidden.id in all_listed + end + + test "does not list ordinary users" do + user = confirmed_user_fixture() + + all_listed = + Users.staff_categories() + |> Enum.flat_map(fn {_category, users} -> Enum.map(users, & &1.id) end) + + refute user.id in all_listed + end + end + + describe "edit_name/1" do + test "returns a changeset for a user whose rename window is open" do + user = renameable_user() + + assert {:ok, %Ecto.Changeset{data: loaded}} = + Users.edit_name(actor(user)) + + assert loaded.id == user.id + end + + test "a banned actor is rejected before authorization" do + user = confirmed_user_fixture() + + assert Users.edit_name(actor(user, ban: @ban)) == {:error, :ban} + end + + test "an actor without a fingerprint is rejected before authorization" do + user = confirmed_user_fixture() + + assert Users.edit_name(actor(user, fingerprint: nil)) == + {:error, :unauthorized} + end + + test "a user who renamed within the window is unauthorized" do + user = recently_renamed_user() + + assert Users.edit_name(actor(user)) == {:error, :unauthorized} + end + end + + describe "update_name/2" do + test "renames the acting user and records the change in history" do + user = renameable_user() + old_name = user.name + + assert {:ok, updated} = Users.update_name(actor(user), %{"name" => "renamed_user_ok"}) + assert updated.name == "renamed_user_ok" + assert Users.fetch_user_for_worker!(user.id).name == "renamed_user_ok" + + assert Repo.get_by(Philomena.UserNameChanges.UserNameChange, + user_id: user.id, + name: old_name + ) + end + + test "a banned actor is rejected" do + user = confirmed_user_fixture() + + assert Users.update_name(actor(user, ban: @ban), %{"name" => "renamed_user_ban"}) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + user = confirmed_user_fixture() + + assert Users.update_name(actor(user, fingerprint: nil), %{"name" => "renamed_user_fp"}) == + {:error, :unauthorized} + end + + test "a user whose rename window is closed is unauthorized" do + user = recently_renamed_user() + + assert Users.update_name(actor(user), %{"name" => "renamed_user_window"}) == + {:error, :unauthorized} + end + + test "a blank name is a rejected changeset" do + user = renameable_user() + + assert {:error, %Ecto.Changeset{} = changeset} = + Users.update_name(actor(user), %{"name" => ""}) + + assert %{name: ["can't be blank"]} = errors_on(changeset) + end + end + + describe "edit_avatar/1" do + test "returns the avatar form changeset for a normal actor" do + user = confirmed_user_fixture() + + assert {:ok, %Ecto.Changeset{data: loaded}} = + Users.edit_avatar(actor(user)) + + assert loaded.id == user.id + end + + test "a banned actor is rejected" do + user = confirmed_user_fixture() + + assert Users.edit_avatar(actor(user, ban: @ban)) == {:error, :ban} + end + + test "an actor without a fingerprint is rejected" do + user = confirmed_user_fixture() + + assert Users.edit_avatar(actor(user, fingerprint: nil)) == + {:error, :unauthorized} + end + end + + describe "update_avatar/2 (actor)" do + test "uploads the acting user's avatar" do + user = confirmed_user_fixture() + + assert {:ok, updated} = + Users.update_avatar(actor(user), media_png_upload()) + + assert updated.avatar =~ ~r/\.png$/ + assert Users.fetch_user_for_worker!(user.id).avatar =~ ~r/\.png$/ + end + + test "a banned actor is rejected before analysis" do + user = confirmed_user_fixture() + + assert Users.update_avatar(actor(user, ban: @ban), media_png_upload()) == + {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized before analysis" do + user = confirmed_user_fixture() + + assert Users.update_avatar(actor(user, fingerprint: nil), media_png_upload()) == + {:error, :unauthorized} + end + + test "the ban wins over a missing fingerprint" do + user = confirmed_user_fixture() + + assert Users.update_avatar( + actor(user, ban: @ban, fingerprint: nil), + media_png_upload() + ) == {:error, :ban} + end + + test "a missing avatar file is a rejected changeset" do + user = confirmed_user_fixture() + + assert {:error, %Ecto.Changeset{}} = + Users.update_avatar(actor(user), nil) + end + end + + describe "delete_avatar/1 (actor)" do + test "removes the acting user's avatar" do + user = user_with_avatar_fixture() + + assert {:ok, updated} = Users.delete_avatar(actor(user)) + refute updated.avatar + refute Users.fetch_user_for_worker!(user.id).avatar + end + + test "a banned actor is rejected" do + user = user_with_avatar_fixture() + + assert Users.delete_avatar(actor(user, ban: @ban)) == {:error, :ban} + end + + test "an actor with no fingerprint is unauthorized" do + user = user_with_avatar_fixture() + + assert Users.delete_avatar(actor(user, fingerprint: nil)) == {:error, :unauthorized} + end + end + + describe "edit_totp/1" do + test "stores a fresh TOTP secret without enabling 2FA" do + user = confirmed_user_fixture() + refute user.encrypted_otp_secret + + assert {:ok, updated} = Users.edit_totp(user) + assert updated.encrypted_otp_secret + refute updated.otp_required_for_login + end + end + + describe "update_totp/2" do + test "enables 2FA with a valid code, returning ten fresh backup codes" do + {:ok, user} = Users.edit_totp(confirmed_user_fixture()) + + assert {:ok, updated, backup_codes} = + Users.update_totp(user, totp_params(valid_totp_code(user))) + + assert updated.otp_required_for_login + assert length(backup_codes) == 10 + assert Enum.all?(backup_codes, &is_binary/1) + # The persisted codes are the hashed form of the returned plaintext. + assert length(Users.fetch_user_for_worker!(user.id).otp_backup_codes) == 10 + end + + test "disables 2FA and still returns ten fresh backup codes, clearing the stored ones" do + user = totp_user_fixture() + + assert {:ok, updated, backup_codes} = + Users.update_totp(user, totp_params(valid_totp_code(user))) + + refute updated.otp_required_for_login + # Codes are regenerated even on disable, but the account keeps none. + assert length(backup_codes) == 10 + assert Users.fetch_user_for_worker!(user.id).otp_backup_codes == [] + refute Users.fetch_user_for_worker!(user.id).encrypted_otp_secret + end + + test "a wrong password is a rejected changeset" do + {:ok, user} = Users.edit_totp(confirmed_user_fixture()) + + assert {:error, %Ecto.Changeset{} = changeset} = + Users.update_totp(user, %{ + "user" => %{ + "current_password" => "wrong password", + "twofactor_token" => valid_totp_code(user) + } + }) + + assert %{current_password: ["is invalid"]} = errors_on(changeset) + refute Users.fetch_user_for_worker!(user.id).otp_required_for_login + end + + test "an invalid second-factor code is a rejected changeset when enabling" do + {:ok, user} = Users.edit_totp(confirmed_user_fixture()) + + assert {:error, %Ecto.Changeset{} = changeset} = + Users.update_totp(user, totp_params("not a code")) + + assert %{twofactor_token: ["Invalid token"]} = errors_on(changeset) + refute Users.fetch_user_for_worker!(user.id).otp_required_for_login + end + end + + describe "create_session_totp/2" do + test "accepts a valid live TOTP code" do + user = totp_user_fixture() + + assert {:ok, %User{} = consumed} = + Users.create_session_totp(user, %{ + "user" => %{"twofactor_token" => valid_totp_code(user)} + }) + + assert consumed.consumed_timestep + end + + test "accepts a backup code once, then rejects the same code" do + {:ok, user} = Users.edit_totp(confirmed_user_fixture()) + + {:ok, user, [backup_code | _rest]} = + Users.update_totp(user, totp_params(valid_totp_code(user))) + + assert {:ok, %User{} = consumed} = + Users.create_session_totp(user, %{ + "user" => %{"twofactor_token" => backup_code} + }) + + # The consumed code is removed from the remaining set. + assert length(consumed.otp_backup_codes) == 9 + + assert {:error, %Ecto.Changeset{} = changeset} = + Users.create_session_totp(consumed, %{ + "user" => %{"twofactor_token" => backup_code} + }) + + assert %{twofactor_token: ["Invalid token"]} = errors_on(changeset) + end + + test "rejects an invalid token" do + user = totp_user_fixture() + + assert {:error, %Ecto.Changeset{} = changeset} = + Users.create_session_totp(user, %{ + "user" => %{"twofactor_token" => "not a code"} + }) + + assert %{twofactor_token: ["Invalid token"]} = errors_on(changeset) + end + end + + describe "query_users/3" do + setup do + Search.clear_index!(User) + :ok + end + + test "an anonymous viewer is rejected before any search" do + assert Users.query_users(actor(), %{}, @pagination) == {:error, :unauthorized} + end + + test "a regular user is rejected" do + assert Users.query_users(actor(confirmed_user_fixture()), %{}, @pagination) == + {:error, :unauthorized} + end + + test "a moderator sees a user in the default view" do + target = confirmed_user_fixture() + SearchHelpers.reindex_all!(User) + + assert {:ok, page, _changeset} = + Users.query_users(actor(moderator_user_fixture()), %{}, @pagination) + + assert target.id in Enum.map(page.entries, & &1.id) + end + + test "an admin sees a user in the default view" do + target = confirmed_user_fixture() + SearchHelpers.reindex_all!(User) + + assert {:ok, page, _changeset} = + Users.query_users(actor(admin_user_fixture()), %{}, @pagination) + + assert target.id in Enum.map(page.entries, & &1.id) + end + + test "a blank query searches everything" do + target = confirmed_user_fixture() + SearchHelpers.reindex_all!(User) + + assert {:ok, page, _changeset} = + Users.query_users(actor(admin_user_fixture()), %{"query" => ""}, @pagination) + + assert target.id in Enum.map(page.entries, & &1.id) + end + + test "the query param filters by name" do + target = confirmed_user_fixture(%{name: "search_target_needle"}) + _other = confirmed_user_fixture(%{name: "search_other_haystack"}) + SearchHelpers.reindex_all!(User) + + assert {:ok, page, _changeset} = + Users.query_users( + actor(admin_user_fixture()), + %{"query" => "name:search_target_needle"}, + @pagination + ) + + assert target.id in Enum.map(page.entries, & &1.id) + end + + test "an unparsable query returns the parser's message string" do + assert {:error, changeset} = + Users.query_users(actor(admin_user_fixture()), %{"query" => "("}, @pagination) + + assert errors_on(changeset)[:query] + end + end + + describe "edit_user/2" do + test "an admin loads the user with roles preloaded" do + target = managed_target() + role = Repo.insert!(%Role{name: "admin", resource_type: "Forum"}) + + assert {:ok, %AdminUserForm{changeset: changeset, roles: roles}} = + Users.edit_user(actor(admin_user_fixture()), target.slug) + + user = changeset.data + + assert user.id == target.id + assert is_list(user.roles) + assert changeset.data.id == target.id + assert role.id in Enum.map(roles, & &1.id) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.edit_user(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "a User-admin role_map moderator is rejected" do + target = managed_target() + + assert Users.edit_user(actor(user_admin_moderator()), target.slug) == + {:error, :unauthorized} + end + + test "an anonymous actor is rejected" do + target = managed_target() + + assert Users.edit_user(actor(), target.slug) == {:error, :unauthorized} + end + + test "an unknown slug is not-found before instance authorization" do + assert Users.edit_user(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + + assert Users.edit_user(actor(moderator_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "update_user/3" do + test "an admin updates the user and writes the update log" do + target = managed_target() + + assert {:ok, updated} = + Users.update_user(actor(admin_user_fixture()), target.slug, %{ + "name" => target.name, + "email" => target.email, + "role" => "assistant" + }) + + assert updated.role == "assistant" + assert Users.fetch_user_for_worker!(target.id).role == "assistant" + + log = last_moderation_log() + assert log.type == "Admin.User:update" + assert log.body == "Updated user details for #{target.name}" + assert log.subject_path == Paths.profile_path(updated) + end + + test "an invalid role is a rejected changeset whose data has roles preloaded" do + target = managed_target() + + assert {:error, %AdminUserForm{changeset: changeset}} = + Users.update_user(actor(admin_user_fixture()), target.slug, %{ + "name" => target.name, + "email" => target.email, + "role" => "not-a-role" + }) + + assert is_list(changeset.data.roles) + assert Users.fetch_user_for_worker!(target.id).role == "user" + end + + test "assigns existing role IDs and rejects malformed or missing role IDs" do + target = managed_target() + role = Repo.insert!(%Role{name: "admin", resource_type: "Forum"}) + + valid_params = %{ + "name" => target.name, + "email" => target.email, + "role" => "user", + "roles" => [to_string(role.id)] + } + + assert {:ok, updated} = + Users.update_user(actor(admin_user_fixture()), target.slug, valid_params) + + assert Enum.map(updated.roles, & &1.id) == [role.id] + + for invalid_id <- ["not-an-id", "2147483647"] do + assert {:error, %AdminUserForm{changeset: changeset}} = + Users.update_user( + actor(admin_user_fixture()), + target.slug, + %{valid_params | "roles" => [invalid_id]} + ) + + assert %{roles: ["contains an invalid role"]} = errors_on(changeset) + + assert Enum.map( + Users.fetch_user_for_worker!(target.id) + |> Repo.preload(:roles) + |> Map.get(:roles), + & &1.id + ) == + [role.id] + end + end + + test "a plain moderator may not update a user" do + target = managed_target() + + assert Users.update_user(actor(moderator_user_fixture()), target.slug, %{ + "name" => target.name, + "email" => target.email, + "role" => "assistant" + }) == {:error, :unauthorized} + end + + test "a User-admin role_map moderator may not update a user" do + target = managed_target() + + assert Users.update_user(actor(user_admin_moderator()), target.slug, %{ + "name" => target.name, + "email" => target.email, + "role" => "assistant" + }) == {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.update_user(actor(admin_user_fixture()), "no-such-user", %{}) == + {:error, :not_found} + end + end + + # The staff child-write functions share write-access checks and safe target + # loading, then authorize their action against the real user. This matrix pins + # the common boundary once; each action below pins its effect, log, and missing + # target behavior. + describe "staff user-management authorization gate" do + test "a missing target is not-found before instance authorization" do + assert Users.create_user_unlock(actor(), "no-such-user") == {:error, :not_found} + end + + test "a regular user is rejected" do + target = managed_target() + + assert Users.create_user_unlock(actor(confirmed_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.create_user_unlock(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "a User-admin role_map moderator is rejected" do + target = managed_target() + + assert Users.create_user_unlock(actor(user_admin_moderator()), target.slug) == + {:error, :unauthorized} + end + + test "an admin is permitted" do + target = managed_target() + + assert {:ok, _user} = Users.create_user_unlock(actor(admin_user_fixture()), target.slug) + end + end + + describe "create_user_activation/2" do + test "an admin reactivates a deactivated user and logs it" do + target = deactivated_user_fixture() + + assert {:ok, user} = Users.create_user_activation(actor(admin_user_fixture()), target.slug) + refute user.deleted_at + refute Users.fetch_user_for_worker!(target.id).deleted_at + + log = last_moderation_log() + assert log.type == "Admin.User.Activation:create" + assert log.body == "Reactivated #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = deactivated_user_fixture() + + assert Users.create_user_activation(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.create_user_activation(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "delete_user_activation/2" do + test "an admin deactivates a user, recording the actor, and logs it" do + target = managed_target() + admin = admin_user_fixture() + + assert {:ok, user} = Users.delete_user_activation(actor(admin), target.slug) + assert user.deleted_at + assert user.deleted_by_user_id == admin.id + assert Users.fetch_user_for_worker!(target.id).deleted_at + + log = last_moderation_log() + assert log.type == "Admin.User.Activation:delete" + assert log.body == "Deactivated #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.delete_user_activation(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_activation(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "delete_user_api_key/2" do + test "an admin resets the API token and logs it" do + target = managed_target() + old_token = target.authentication_token + + assert {:ok, user} = Users.delete_user_api_key(actor(admin_user_fixture()), target.slug) + assert user.authentication_token != old_token + + assert Users.fetch_user_for_worker!(target.id).authentication_token == + user.authentication_token + + log = last_moderation_log() + assert log.type == "Admin.User.ApiKey:delete" + assert log.body == "Reset API key for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.delete_user_api_key(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_api_key(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "delete_user_avatar/2" do + test "an admin removes the avatar and logs it" do + target = + user_with_avatar_fixture(%{name: "avatar_target_#{System.unique_integer([:positive])}"}) + + assert {:ok, user} = Users.delete_user_avatar(actor(admin_user_fixture()), target.slug) + refute user.avatar + refute Users.fetch_user_for_worker!(target.id).avatar + + log = last_moderation_log() + assert log.type == "Admin.User.Avatar:delete" + assert log.body == "Removed avatar for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = user_with_avatar_fixture() + + assert Users.delete_user_avatar(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_avatar(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "delete_user_downvotes/2" do + test "an admin starts the downvote wipe and logs it" do + target = managed_target() + + assert {:ok, user} = Users.delete_user_downvotes(actor(admin_user_fixture()), target.slug) + assert user.id == target.id + + log = last_moderation_log() + assert log.type == "Admin.User.Downvote:delete" + assert log.body == "Wiped downvotes for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.delete_user_downvotes(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_downvotes(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "delete_user_votes/2" do + test "an admin starts the vote and fave wipe and logs it" do + target = managed_target() + + assert {:ok, user} = Users.delete_user_votes(actor(admin_user_fixture()), target.slug) + assert user.id == target.id + + log = last_moderation_log() + assert log.type == "Admin.User.Vote:delete" + assert log.body == "Wiped votes and faves for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.delete_user_votes(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_votes(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "create_user_wipe/2" do + test "an admin queues the PII wipe and logs it" do + target = managed_target() + + assert {:ok, user} = Users.create_user_wipe(actor(admin_user_fixture()), target.slug) + assert user.id == target.id + + log = last_moderation_log() + assert log.type == "Admin.User.Wipe:create" + assert log.body == "Wiped PII for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.create_user_wipe(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.create_user_wipe(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "create_user_unlock/2" do + test "an admin unlocks a locked user and logs it" do + target = locked_user_fixture(%{name: "unlock_target_#{System.unique_integer([:positive])}"}) + + assert {:ok, user} = Users.create_user_unlock(actor(admin_user_fixture()), target.slug) + refute user.locked_at + refute Users.fetch_user_for_worker!(target.id).locked_at + + log = last_moderation_log() + assert log.type == "Admin.User.Unlock:create" + assert log.body == "Unlocked #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = locked_user_fixture() + + assert Users.create_user_unlock(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.create_user_unlock(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "create_user_verification/2" do + test "an admin grants verification and logs it" do + target = managed_target() + + assert {:ok, user} = + Users.create_user_verification(actor(admin_user_fixture()), target.slug) + + assert user.verified + assert Users.fetch_user_for_worker!(target.id).verified + + log = last_moderation_log() + assert log.type == "Admin.User.Verification:create" + assert log.body == "Granted verification to #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.create_user_verification(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.create_user_verification(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "delete_user_verification/2" do + test "an admin revokes verification and logs it" do + target = + verified_user_fixture(%{name: "unverify_target_#{System.unique_integer([:positive])}"}) + + assert {:ok, user} = + Users.delete_user_verification(actor(admin_user_fixture()), target.slug) + + refute user.verified + refute Users.fetch_user_for_worker!(target.id).verified + + log = last_moderation_log() + assert log.type == "Admin.User.Verification:delete" + assert log.body == "Revoked verification from #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = verified_user_fixture() + + assert Users.delete_user_verification(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_verification(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "new_user_force_filter/2" do + test "an admin loads the target user" do + target = managed_target() + + assert {:ok, %Ecto.Changeset{data: user}} = + Users.new_user_force_filter(actor(admin_user_fixture()), target.slug) + + assert user.id == target.id + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.new_user_force_filter(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.new_user_force_filter(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "create_user_force_filter/3" do + test "an admin forces a filter and logs it" do + target = managed_target() + filter = filter_fixture(confirmed_user_fixture()) + + assert {:ok, user} = + Users.create_user_force_filter(actor(admin_user_fixture()), target.slug, %{ + "forced_filter_id" => filter.id + }) + + assert user.forced_filter_id == filter.id + assert Users.fetch_user_for_worker!(target.id).forced_filter_id == filter.id + + log = last_moderation_log() + assert log.type == "Admin.User.ForceFilter:create" + assert log.body == "Forced filter #{filter.id} for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a nonexistent forced_filter_id returns the typed form error" do + target = managed_target() + + assert {:error, %Ecto.Changeset{data: user} = changeset} = + Users.create_user_force_filter(actor(admin_user_fixture()), target.slug, %{ + "forced_filter_id" => 2_000_000_000 + }) + + assert user.id == target.id + assert %{forced_filter_id: ["does not exist"]} = errors_on(changeset) + refute Users.fetch_user_for_worker!(target.id).forced_filter_id + end + + test "a plain moderator is rejected before the filter is applied" do + target = managed_target() + filter = filter_fixture(confirmed_user_fixture()) + + assert Users.create_user_force_filter(actor(moderator_user_fixture()), target.slug, %{ + "forced_filter_id" => filter.id + }) == {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.create_user_force_filter(actor(admin_user_fixture()), "no-such-user", %{}) == + {:error, :not_found} + end + end + + describe "delete_user_force_filter/2" do + test "an admin clears a forced filter and logs it" do + target = managed_target() + filter = filter_fixture(confirmed_user_fixture()) + + target + |> User.force_filter_changeset(%{"forced_filter_id" => filter.id}) + |> Repo.update!() + + assert {:ok, user} = + Users.delete_user_force_filter(actor(admin_user_fixture()), target.slug) + + refute user.forced_filter_id + refute Users.fetch_user_for_worker!(target.id).forced_filter_id + + log = last_moderation_log() + assert log.type == "Admin.User.ForceFilter:delete" + assert log.body == "Removed forced filter for #{target.name}" + assert log.subject_path == Paths.profile_path(user) + end + + test "a plain moderator is rejected" do + target = managed_target() + + assert Users.delete_user_force_filter(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an admin naming an unknown slug gets not-found" do + assert Users.delete_user_force_filter(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + end + + describe "new_user_erase/2" do + test "an admin loads an ordinary unverified user with roles preloaded" do + target = managed_target() + + assert {:ok, user} = Users.new_user_erase(actor(admin_user_fixture()), target.slug) + assert user.id == target.id + assert is_list(user.roles) + end + + test "a missing target is not-found before instance authorization" do + assert Users.new_user_erase(actor(moderator_user_fixture()), "no-such-user") == + {:error, :not_found} + end + + test "an unknown slug is not-found" do + assert Users.new_user_erase(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + + test "a privileged target is rejected" do + target = assistant_user_fixture() + + assert {:error, {:privileged, user}} = + Users.new_user_erase(actor(admin_user_fixture()), target.slug) + + assert user.id == target.id + end + + test "a verified target is rejected" do + target = verified_user_fixture() + + assert {:error, {:verified, user}} = + Users.new_user_erase(actor(admin_user_fixture()), target.slug) + + assert user.id == target.id + end + end + + describe "create_user_erase/2" do + test "an admin erases the user, renaming and deactivating the account, and logs it" do + target = managed_target() + original_name = target.name + + assert {:ok, erased} = Users.create_user_erase(actor(admin_user_fixture()), target.slug) + assert erased.name =~ ~r/^deactivated_/ + assert erased.name != original_name + assert erased.deleted_at + + reloaded = Users.fetch_user_for_worker!(target.id) + assert reloaded.name == erased.name + assert reloaded.deleted_at + + # The log body names the original account; the subject path points at the + # renamed account. + log = last_moderation_log() + assert log.type == "Admin.User.Erase:create" + assert log.body == "Erased #{original_name}" + assert log.subject_path == Paths.profile_path(erased) + end + + test "an unauthorized actor is rejected" do + target = managed_target() + + assert Users.create_user_erase(actor(moderator_user_fixture()), target.slug) == + {:error, :unauthorized} + end + + test "an unknown slug is not-found" do + assert Users.create_user_erase(actor(admin_user_fixture()), "no-such-user") == + {:error, :not_found} + end + + test "a privileged target is rejected without erasing" do + target = assistant_user_fixture() + + assert {:error, {:privileged, _user}} = + Users.create_user_erase(actor(admin_user_fixture()), target.slug) + + assert Users.fetch_user_for_worker!(target.id).role == "assistant" + end + + test "a verified target is rejected without erasing" do + target = verified_user_fixture() + + assert {:error, {:verified, _user}} = + Users.create_user_erase(actor(admin_user_fixture()), target.slug) + + refute Users.fetch_user_for_worker!(target.id).deleted_at + end + end end diff --git a/test/philomena/versions/legacy_backfill_test.exs b/test/philomena/versions/legacy_backfill_test.exs index f1f6da786..e168fa68b 100644 --- a/test/philomena/versions/legacy_backfill_test.exs +++ b/test/philomena/versions/legacy_backfill_test.exs @@ -13,6 +13,7 @@ defmodule Philomena.Versions.LegacyBackfillTest do use Philomena.DataCase, async: true + import Philomena.AttributionFixtures, only: [actor: 1] import Philomena.CommentsFixtures import Philomena.ForumsFixtures import Philomena.ImagesFixtures @@ -27,6 +28,11 @@ defmodule Philomena.Versions.LegacyBackfillTest do alias Philomena.Versions alias Philomena.Versions.LegacyBackfill + defp update_post(post, actor, attrs) do + post = Repo.preload(post, topic: :forum) + Posts.update_post(actor, post.topic.forum.short_name, post.topic.slug, post.id, attrs) + end + # Insert a paper_trail-shaped row into versions_legacy. `object` is a JSON # string (or nil for a 'create' event), `whodunnit` is a string id (or nil), # and `created_at` is a NaiveDateTime. @@ -118,7 +124,7 @@ defmodule Philomena.Versions.LegacyBackfillTest do # Four rows: the synthesized initial (author, oldest object body, nil # reason), then each legacy row shifted forward to take the next row's - # object body — the newest taking the live post's current state. Each + # object body - the newest taking the live post's current state. Each # shifted row keeps its own legacy whodunnit. assert post_rows(post) == [ {"body v0", nil, author.id}, @@ -336,12 +342,16 @@ defmodule Philomena.Versions.LegacyBackfillTest do test "raises when a target table already contains rows" do forum = forum_fixture() author = confirmed_user_fixture() - editor = confirmed_user_fixture() topic = topic_fixture(forum, author, %{"posts" => %{"0" => %{"body" => "original"}}}) [post] = topic.posts # A real edit populates post_versions through the normal path. - {:ok, _} = Posts.update_post(post, editor, %{"body" => "edited", "edit_reason" => "x"}) + {:ok, _} = + update_post(post, actor(author), %{ + "body" => "edited", + "edit_reason" => "x" + }) + assert Repo.aggregate(PostVersion, :count) > 0 assert_raise RuntimeError, ~r/post_versions already contains/, fn -> @@ -350,7 +360,7 @@ defmodule Philomena.Versions.LegacyBackfillTest do end end - describe "load_post_versions/1 after backfill" do + describe "for_post/1 after backfill" do test "reproduces the legacy edit history as display entries" do {post, _author} = seed_post() u1 = confirmed_user_fixture() @@ -389,7 +399,7 @@ defmodule Philomena.Versions.LegacyBackfillTest do assert :ok = LegacyBackfill.run!() entries = - Versions.load_post_versions(post) + Versions.for_post(post) |> Enum.map(&{&1.body, &1.previous_body, &1.edit_reason}) # Newest-first, each entry pairs an after-edit body with the next-older diff --git a/test/philomena/versions_concurrency_test.exs b/test/philomena/versions_concurrency_test.exs new file mode 100644 index 000000000..7aa05c8a8 --- /dev/null +++ b/test/philomena/versions_concurrency_test.exs @@ -0,0 +1,78 @@ +defmodule Philomena.VersionsConcurrencyTest do + use Philomena.ConcurrentDataCase + + alias Ecto.Adapters.SQL.Sandbox + alias Philomena.Posts + alias Philomena.Posts.Post + alias Philomena.Posts.PostVersion + alias Philomena.Repo + alias Philomena.Versions + + import Ecto.Query + import Philomena.AttributionFixtures, only: [actor: 1] + import Philomena.ForumsFixtures + import Philomena.TopicsFixtures + import Philomena.UsersFixtures + + defp update_post(post, actor, attrs) do + post = Repo.preload(post, topic: :forum) + Posts.update_post(actor, post.topic.forum.short_name, post.topic.slug, post.id, attrs) + end + + defp post_versions(post) do + PostVersion + |> where(post_id: ^post.id) + |> order_by(asc: :id) + |> Repo.all() + end + + defp post_fixture_with_body(body) do + forum = forum_fixture() + author = confirmed_user_fixture() + topic = topic_fixture(forum, author, %{"posts" => %{"0" => %{"body" => body}}}) + [post] = topic.posts + {post, author} + end + + test "concurrent first edits create one initial row and ordered snapshots" do + {post, _author} = post_fixture_with_body("v0") + editor = moderator_user_fixture() + parent = self() + + tasks = + for body <- ["v1", "v2"] do + task = + Task.async(fn -> + original = Repo.get!(Post, post.id) + send(parent, {:ready, self()}) + + receive do + :edit -> update_post(original, actor(editor), %{"body" => body}) + end + end) + + Sandbox.allow(Repo, parent, task.pid) + task + end + + task_pids = + for _ <- tasks do + assert_receive {:ready, task_pid} + task_pid + end + + Enum.each(task_pids, &send(&1, :edit)) + assert Enum.all?(tasks, &match?({:ok, _}, Task.await(&1, 5_000))) + + assert [initial, first_edit, second_edit] = post_versions(post) + assert initial.body == "v0" + assert Enum.sort([first_edit.body, second_edit.body]) == ["v1", "v2"] + assert first_edit.id < second_edit.id + + assert [newest, older] = Versions.for_post(post) + assert newest.body == second_edit.body + assert newest.previous_body == first_edit.body + assert older.body == first_edit.body + assert older.previous_body == "v0" + end +end diff --git a/test/philomena/versions_test.exs b/test/philomena/versions_test.exs index da7d5fca7..cd587072e 100644 --- a/test/philomena/versions_test.exs +++ b/test/philomena/versions_test.exs @@ -1,24 +1,25 @@ defmodule Philomena.VersionsTest do - @moduledoc """ - Tests for `Philomena.Versions.record_edit/4` (exercised through the public - `Posts.update_post/3` and `Comments.update_comment/3` context functions, as - it only runs inside their update Multi) and `load_post_versions/1` / - `load_comment_versions/1`. - """ - use Philomena.DataCase, async: true + alias Philomena.Multi + alias Philomena.Comments + alias Philomena.Comments.CommentVersion + alias Philomena.Posts + alias Philomena.Posts.Post + alias Philomena.Posts.PostVersion + alias Philomena.Versions + + import Philomena.AttributionFixtures, only: [actor: 1] import Philomena.CommentsFixtures import Philomena.ForumsFixtures import Philomena.ImagesFixtures import Philomena.TopicsFixtures import Philomena.UsersFixtures - alias Philomena.Comments - alias Philomena.Comments.CommentVersion - alias Philomena.Posts - alias Philomena.Posts.PostVersion - alias Philomena.Versions + defp update_post(post, actor, attrs) do + post = Repo.preload(post, topic: :forum) + Posts.update_post(actor, post.topic.forum.short_name, post.topic.slug, post.id, attrs) + end defp post_versions(post) do PostVersion @@ -34,130 +35,202 @@ defmodule Philomena.VersionsTest do |> Repo.all() end - describe "record_edit/4 for posts" do - test "first edit creates the initial row and the edit row" do - forum = forum_fixture() - author = confirmed_user_fixture() - editor = confirmed_user_fixture() - - topic = - topic_fixture(forum, author, %{"posts" => %{"0" => %{"body" => "Original body"}}}) + defp post_fixture_with_body(body) do + forum = forum_fixture() + author = confirmed_user_fixture() + topic = topic_fixture(forum, author, %{"posts" => %{"0" => %{"body" => body}}}) + [post] = topic.posts + {post, author} + end - [post] = topic.posts + describe "record_edit/5 for posts" do + test "first edit creates an attributed initial row and edited snapshot" do + {post, author} = post_fixture_with_body("Original body") + editor = moderator_user_fixture() - {:ok, _} = - Posts.update_post(post, editor, %{"body" => "Edited body", "edit_reason" => "typo fix"}) + {:ok, _result} = + update_post(post, actor(editor), %{ + "body" => "Edited body", + "edit_reason" => "typo fix" + }) assert [initial, edit] = post_versions(post) - - # Initial row: captures the pre-first-edit state, stamped with the item's - # author and creation time, with no edit reason. assert initial.post_id == post.id assert initial.user_id == author.id assert initial.body == "Original body" assert initial.edit_reason == nil assert DateTime.compare(initial.created_at, post.created_at) == :eq - # Edit row: the after-edit snapshot, stamped with the editor. assert edit.post_id == post.id assert edit.user_id == editor.id assert edit.body == "Edited body" assert edit.edit_reason == "typo fix" end - test "second edit adds exactly one more row" do - forum = forum_fixture() - author = confirmed_user_fixture() - editor = confirmed_user_fixture() + test "later edits add one snapshot and same-second ids preserve their order" do + {post, _author} = post_fixture_with_body("v0") + editor = moderator_user_fixture() + + {:ok, post} = + update_post(post, actor(editor), %{ + "body" => "v1", + "edit_reason" => "r1" + }) + + {:ok, _result} = + update_post(post, actor(editor), %{ + "body" => "v2", + "edit_reason" => "r2" + }) - topic = - topic_fixture(forum, author, %{"posts" => %{"0" => %{"body" => "Original body"}}}) + assert [initial, first_edit, second_edit] = post_versions(post) + assert initial.body == "v0" + assert first_edit.body == "v1" + assert second_edit.body == "v2" + assert initial.id < first_edit.id + assert first_edit.id < second_edit.id + end + + test "a stale caller snapshots the locked row rather than stale unchanged fields" do + {stale_post, _author} = post_fixture_with_body("v0") + editor = moderator_user_fixture() + + assert {:ok, _result} = + update_post(stale_post, actor(editor), %{ + "body" => "v1", + "edit_reason" => "first reason" + }) - [post] = topic.posts + assert {:ok, _result} = + update_post(stale_post, actor(editor), %{"body" => "v2"}) - {:ok, _} = - Posts.update_post(post, editor, %{"body" => "Edit one", "edit_reason" => "first"}) + assert [_initial, _first_edit, second_edit] = post_versions(stale_post) + assert second_edit.body == "v2" + assert second_edit.edit_reason == "first reason" + assert Repo.get!(Post, stale_post.id).edit_reason == second_edit.edit_reason + end + + test "an update with unchanged body and edit reason creates no history" do + {post, author} = post_fixture_with_body("same body") - assert length(post_versions(post)) == 2 + assert {:ok, %Post{}} = + update_post(post, actor(author), %{ + "body" => post.body, + "edit_reason" => post.edit_reason + }) - # Reusing the same in-memory post is fine: record_edit re-checks the DB - # for an existing initial row, so the second edit adds only its edit row. - {:ok, _} = - Posts.update_post(post, editor, %{"body" => "Edit two", "edit_reason" => "second"}) + assert post_versions(post) == [] + end - assert [_initial, first_edit, second_edit] = post_versions(post) - assert first_edit.body == "Edit one" - assert second_edit.body == "Edit two" - assert second_edit.edit_reason == "second" + test "the parent update and version rows roll back together" do + {post, _author} = post_fixture_with_body("before") + now = DateTime.utc_now(:second) + + result = + Multi.new() + |> Multi.put(:original_post, post) + |> Multi.update(:post, Post.changeset(post, %{"body" => "after"}, now)) + |> Versions.record_edit( + :version, + :original_post, + :post, + actor(confirmed_user_fixture()) + ) + |> Multi.run(:forced_failure, fn _repo, _changes -> {:error, :forced_rollback} end) + |> Multi.transact() + + assert {:error, :forced_failure, :forced_rollback, _changes} = result + assert Repo.get!(Post, post.id).body == "before" + assert post_versions(post) == [] end end - describe "record_edit/4 for comments" do - test "first edit creates the initial row and the edit row" do + describe "record_edit/5 for comments" do + test "first edit creates the initial row and attributed edited snapshot" do image = image_fixture() author = confirmed_user_fixture() - editor = confirmed_user_fixture() - + editor = admin_user_fixture() comment = comment_fixture(image, author, %{"body" => "Original comment"}) - {:ok, _} = - Comments.update_comment(comment, editor, %{ + {:ok, _result} = + Comments.update_comment(actor(editor), image.id, comment.id, %{ "body" => "Edited comment", "edit_reason" => "clarify" }) assert [initial, edit] = comment_versions(comment) - assert initial.comment_id == comment.id assert initial.user_id == author.id assert initial.body == "Original comment" - assert initial.edit_reason == nil assert DateTime.compare(initial.created_at, comment.created_at) == :eq - - assert edit.comment_id == comment.id assert edit.user_id == editor.id assert edit.body == "Edited comment" assert edit.edit_reason == "clarify" end - end - describe "load_post_versions/1" do - test "returns display entries newest-first with paired previous bodies" do - forum = forum_fixture() - author = confirmed_user_fixture() - editor = confirmed_user_fixture() + test "an update with unchanged content creates no history" do + user = confirmed_user_fixture() + image = image_fixture() + comment = comment_fixture(image, user, %{"body" => "same"}) - topic = - topic_fixture(forum, author, %{"posts" => %{"0" => %{"body" => "v0"}}}) + assert {:ok, _comment} = + Comments.update_comment(actor(user), image.id, comment.id, %{ + "body" => comment.body, + "edit_reason" => comment.edit_reason + }) - [post] = topic.posts + assert comment_versions(comment) == [] + end + end - {:ok, _} = Posts.update_post(post, editor, %{"body" => "v1", "edit_reason" => "r1"}) - {:ok, _} = Posts.update_post(post, editor, %{"body" => "v2", "edit_reason" => "r2"}) + describe "loaded-parent history services" do + test "post history is newest-first and pairs previous bodies" do + {post, _author} = post_fixture_with_body("v0") + editor = moderator_user_fixture() - # Three rows exist (initial v0, edit v1, edit v2), but the oldest row is - # only a diff base, so two display entries are returned newest-first. - assert [entry1, entry2] = Versions.load_post_versions(post) + {:ok, post} = + update_post(post, actor(editor), %{ + "body" => "v1", + "edit_reason" => "r1" + }) - assert entry1.body == "v2" - assert entry1.previous_body == "v1" - assert entry1.edit_reason == "r2" - assert entry1.parent.id == post.id + {:ok, _result} = + update_post(post, actor(editor), %{ + "body" => "v2", + "edit_reason" => "r2" + }) - assert entry2.body == "v1" - assert entry2.previous_body == "v0" - assert entry2.edit_reason == "r1" + assert [newest, older] = Versions.for_post(post) + assert {newest.body, newest.previous_body, newest.edit_reason} == {"v2", "v1", "r2"} + assert newest.parent.id == post.id + assert {older.body, older.previous_body, older.edit_reason} == {"v1", "v0", "r1"} + refute Enum.any?(Versions.for_post(post), &(&1.body == "v0")) + end - # The initial row (body "v0") is never returned as an entry. - refute Enum.any?(Versions.load_post_versions(post), &(&1.body == "v0")) + test "comment history follows the same pairing rules" do + user = confirmed_user_fixture() + image = image_fixture() + comment = comment_fixture(image, user, %{"body" => "c0"}) + + {:ok, {_image, comment}} = + Comments.update_comment(actor(user), image.id, comment.id, %{"body" => "c1"}) + + assert [%CommentVersion{} = version] = Versions.for_comment(comment) + assert version.body == "c1" + assert version.previous_body == "c0" + assert version.parent.id == comment.id end - test "returns an empty list for a never-edited post" do - forum = forum_fixture() - topic = topic_fixture(forum) - [post] = topic.posts + test "never-edited loaded parents have no history and ids are not accepted" do + {post, _author} = post_fixture_with_body("unchanged") + image = image_fixture() + comment = comment_fixture(image) + + assert Versions.for_post(post) == [] + assert Versions.for_comment(comment) == [] - assert Versions.load_post_versions(post) == [] + assert_raise FunctionClauseError, fn -> Versions.for_post(post.id) end + assert_raise FunctionClauseError, fn -> Versions.for_comment(comment.id) end end end end diff --git a/test/philomena/workers/tag_change_revert_worker_test.exs b/test/philomena/workers/tag_change_revert_worker_test.exs index 4414ec543..dfb2ea940 100644 --- a/test/philomena/workers/tag_change_revert_worker_test.exs +++ b/test/philomena/workers/tag_change_revert_worker_test.exs @@ -10,6 +10,7 @@ defmodule Philomena.TagChangeRevertWorkerTest do alias Philomena.Images alias Philomena.TagChangeRevertWorker + alias Philomena.TagChanges.TagChange # Images validate a 3-tag minimum, so every input keeps these on top of # whatever tag the test adds or removes. @@ -23,16 +24,17 @@ defmodule Philomena.TagChangeRevertWorkerTest do end defp change_tags!(image, user, old_input, new_input) do - # Force-reload :tags so successive edits diff against the current state, - # as a controller-loaded image would; update_tags's own preload no-ops on - # an already-loaded association. - image = Repo.preload(image, [:tags], force: true) - - {:ok, _} = - Images.update_tags(image, attribution(user), %{ - "old_tag_input" => old_input, - "tag_input" => new_input - }) + # These tests arrange history rather than exercise the write rate limits. + arrangement_actor = actor(%{user | bypass_rate_limits: true}) + + assert {:ok, result} = + Images.update_image_tags( + arrangement_actor, + image.id, + %{"old_tag_input" => old_input, "tag_input" => new_input} + ) + + assert result.image.id == image.id end defp full_revert!(user, batch_size) do @@ -54,17 +56,26 @@ defmodule Philomena.TagChangeRevertWorkerTest do |> Enum.map(& &1.name) end + defp tag_change_count(image) do + Repo.aggregate(from(tag_change in TagChange, where: tag_change.image_id == ^image.id), :count) + end + test "a full revert removes tags the user added", %{user: user} do image_a = image_fixture(tags: @base_tags) image_b = image_fixture(tags: @base_tags) change_tags!(image_a, user, @base_tags, "#{@base_tags}, vandal tag") change_tags!(image_b, user, @base_tags, "#{@base_tags}, vandal tag") + assert tag_change_count(image_a) == 1 + assert tag_change_count(image_b) == 1 + # batch_size 1 forces the two images into separate batches. full_revert!(user, 1) refute "vandal tag" in image_tag_names(image_a) refute "vandal tag" in image_tag_names(image_b) + assert tag_change_count(image_a) == 2 + assert tag_change_count(image_b) == 2 end test "a full revert restores tags the user removed", %{user: user} do @@ -129,6 +140,8 @@ defmodule Philomena.TagChangeRevertWorkerTest do refute "vandal one" in names_b refute "vandal two" in names_b assert "safe" in names_b + assert tag_change_count(image_a) == 2 + assert tag_change_count(image_b) == 4 end test "a self-canceled remove/add pair does not strip the tag", %{user: user} do diff --git a/test/philomena/workers/tag_workers_test.exs b/test/philomena/workers/tag_workers_test.exs new file mode 100644 index 000000000..a2763f687 --- /dev/null +++ b/test/philomena/workers/tag_workers_test.exs @@ -0,0 +1,143 @@ +defmodule Philomena.TagWorkersTest do + use Philomena.DataCase, async: false + use Patch + + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.ArtistLinksFixtures + import Philomena.TagsFixtures + import Philomena.UsersFixtures + + alias Philomena.Repo + alias Philomena.ArtistLinks.ArtistLink + alias Philomena.TagAliasWorker + alias Philomena.TagDeleteWorker + alias Philomena.TagChanges.TagChange + alias Philomena.TagChanges.TagChangeTag + alias Philomena.Tags.Tag + alias Philomena.Tags + alias PhilomenaQuery.Search + + defp tag_ids(image) do + image + |> Repo.preload(:tags, force: true) + |> Map.fetch!(:tags) + |> Enum.map(& &1.id) + end + + test "the alias worker moves image taggings to the target tag" do + source = tag_fixture(name: "worker alias source") + target = tag_fixture(name: "worker alias target") + image = image_fixture(tags: "safe, #{source.name}") + + source + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() + + assert :ok = TagAliasWorker.perform(source.id, target.id) + + ids = tag_ids(image) + assert target.id in ids + refute source.id in ids + assert Repo.reload!(source).aliased_tag_id == target.id + end + + test "the alias worker moves visible image counts from source to target" do + source = tag_fixture(name: "worker visible count source") + target = tag_fixture(name: "worker visible count target") + _image = image_fixture(tags: source.name) + + source = source |> Ecto.Changeset.change(images_count: 1) |> Repo.update!() + target = target |> Ecto.Changeset.change(images_count: 0) |> Repo.update!() + + source + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() + + assert :ok = TagAliasWorker.perform(source.id, target.id) + + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == 1 + end + + test "the alias worker does not count hidden images when moving taggings" do + source = tag_fixture(name: "worker hidden count source") + target = tag_fixture(name: "worker hidden count target") + _image = image_fixture(tags: source.name, hidden_from_users: true) + + source = source |> Ecto.Changeset.change(images_count: 0) |> Repo.update!() + target = target |> Ecto.Changeset.change(images_count: 0) |> Repo.update!() + + source + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() + + assert :ok = TagAliasWorker.perform(source.id, target.id) + + assert Repo.reload!(source).images_count == 0 + assert Repo.reload!(target).images_count == 0 + end + + test "aliasing deletes conflicting artist links before the worker runs" do + admin = admin_user_fixture() + user = confirmed_user_fixture() + source = tag_fixture(name: "artist:worker alias source") + target = tag_fixture(name: "artist:worker alias target") + uri = "https://example.com/artist" + + source_link = artist_link_fixture(user, source, %{"uri" => uri}) + target_link = artist_link_fixture(user, target, %{"uri" => uri}) + + assert {:ok, _tag} = + Tags.update_tag_alias( + actor(admin), + source.slug, + %{"target_tag" => target.name} + ) + + refute Repo.get(ArtistLink, source_link.id) + assert Repo.get!(ArtistLink, target_link.id).tag_id == target.id + assert Repo.reload!(source).aliased_tag_id == target.id + end + + test "the delete worker removes the tag and its image taggings" do + patch(Search, :delete_document, :ok) + patch(Search, :reindex, :ok) + + tag = tag_fixture(name: "worker delete tag") + image = image_fixture(tags: "safe, #{tag.name}") + + assert :ok = TagDeleteWorker.perform(tag.id) + + assert Repo.get(Tag, tag.id) == nil + refute tag.id in tag_ids(image) + end + + test "the delete worker removes tag change tags before deleting the tag" do + patch(Search, :delete_document, :ok) + + tag = tag_fixture(name: "worker delete history tag") + image = image_fixture() + attribution = actor() + + tag_change = + Repo.insert!(%TagChange{ + image_id: image.id, + ip: attribution.ip, + fingerprint: attribution.fingerprint + }) + + Repo.insert!(%TagChangeTag{ + tag_change_id: tag_change.id, + tag_id: tag.id, + added: true + }) + + assert Repo.get_by(TagChangeTag, tag_change_id: tag_change.id, tag_id: tag.id) + + assert :ok = TagDeleteWorker.perform(tag.id) + + refute Repo.get_by(TagChangeTag, tag_change_id: tag_change.id, tag_id: tag.id) + refute Repo.get(TagChange, tag_change.id) + end +end diff --git a/test/philomena/workers/user_workers_test.exs b/test/philomena/workers/user_workers_test.exs new file mode 100644 index 000000000..59ecfb5f1 --- /dev/null +++ b/test/philomena/workers/user_workers_test.exs @@ -0,0 +1,134 @@ +defmodule Philomena.UserWorkersTest do + use Philomena.DataCase, async: false + use Patch + + import Philomena.AttributionFixtures + import Philomena.ImagesFixtures + import Philomena.SourceChangesFixtures + import Philomena.UserFingerprintsFixtures + import Philomena.UserIpsFixtures + import Philomena.UsersFixtures + + alias Philomena.Bans.User, as: UserBan + alias Philomena.ImageFaves.ImageFave + alias Philomena.Images + alias Philomena.ImageVotes.ImageVote + alias Philomena.Repo + alias Philomena.SourceChanges.SourceChange + alias Philomena.UserEraseWorker + alias Philomena.UserFingerprints.UserFingerprint + alias Philomena.UserIps.UserIp + alias Philomena.UserUnvoteWorker + alias Philomena.UserWipeWorker + alias Philomena.Users.User + alias Philomena.Users.UserDownvoteWipe + + test "the unvote worker removes all requested interactions and repairs counters" do + patch(UserDownvoteWipe, :reindex, :ok) + + user = confirmed_user_fixture() + downvoted = image_fixture() + faved = image_fixture() + + assert {:ok, _image} = Images.create_image_vote(actor(user), downvoted.id, %{up: false}) + assert {:ok, _image} = Images.create_image_fave(actor(user), faved.id) + + assert :ok = UserUnvoteWorker.perform(user.id, true) + + refute Repo.get_by(ImageVote, user_id: user.id, image_id: downvoted.id) + refute Repo.get_by(ImageVote, user_id: user.id, image_id: faved.id) + refute Repo.get_by(ImageFave, user_id: user.id, image_id: faved.id) + + assert %{score: 0, downvotes_count: 0} = Repo.reload!(downvoted) + assert %{score: 0, upvotes_count: 0, faves_count: 0} = Repo.reload!(faved) + assert %{image_votes_count: 0, image_faves_count: 0} = Repo.reload!(user) + end + + test "the wipe worker erases stored attribution and contact information" do + user = confirmed_user_fixture() + user_ip_fixture(user, "198.51.100.8") + user_fingerprint_fixture(user, "worker-fingerprint") + + image = + image_fixture( + user_id: user.id, + ip: inet("198.51.100.8"), + fingerprint: "worker-fingerprint" + ) + + assert %User{id: user_id} = UserWipeWorker.perform(user.id) + assert user_id == user.id + + wiped_user = Repo.reload!(user) + assert wiped_user.email =~ ~r/^deactivated[0-9a-f]{32}@example\.com$/ + refute Repo.exists?(from ip in UserIp, where: ip.user_id == ^user.id) + refute Repo.exists?(from fp in UserFingerprint, where: fp.user_id == ^user.id) + + wiped_image = Repo.reload!(image) + assert to_string(wiped_image.ip) == "127.0.1.1" + assert wiped_image.fingerprint == "ffff" + end + + test "the erase worker clears the profile and bans the account" do + moderator = moderator_user_fixture() + role = Repo.insert!(%Philomena.Roles.Role{name: "moderator", resource_type: "User"}) + Repo.insert_all("users_roles", [%{user_id: moderator.id, role_id: role.id}]) + + user = + user_fixture() + |> User.description_changeset(%{ + description: "public profile text", + personal_title: "A title" + }) + |> Repo.update!() + + assert :ok = UserEraseWorker.perform(user.id, moderator.id) + + erased = Repo.reload!(user) + assert erased.description in [nil, ""] + assert erased.personal_title in [nil, ""] + + assert Repo.exists?( + from ban in UserBan, + where: ban.user_id == ^user.id and ban.banning_user_id == ^moderator.id, + where: ban.reason == "Site abuse" and ban.enabled == true + ) + end + + test "the erase worker removes source history without recording reversions" do + moderator = moderator_user_fixture() + role = Repo.insert!(%Philomena.Roles.Role{name: "moderator", resource_type: "User"}) + Repo.insert_all("users_roles", [%{user_id: moderator.id, role_id: role.id}]) + + user = user_fixture() + added_source = "https://spam.example/added" + removed_source = "https://spam.example/removed" + added_image = image_fixture(sources: [added_source]) + removed_image = image_fixture() + + source_change_fixture(added_image, + user_id: user.id, + source_url: added_source, + added: true + ) + + source_change_fixture(removed_image, + user_id: user.id, + source_url: removed_source, + added: false + ) + + assert :ok = UserEraseWorker.perform(user.id, moderator.id) + + assert Repo.reload!(added_image) |> Repo.preload(:sources) |> Map.fetch!(:sources) == [] + + [restored_source] = + Repo.reload!(removed_image) |> Repo.preload(:sources) |> Map.fetch!(:sources) + + assert restored_source.source == removed_source + + refute Repo.exists?( + from source_change in SourceChange, where: source_change.user_id == ^user.id + ) + end +end diff --git a/test/philomena/workers/worker_test.exs b/test/philomena/workers/worker_test.exs new file mode 100644 index 000000000..8816c5a65 --- /dev/null +++ b/test/philomena/workers/worker_test.exs @@ -0,0 +1,109 @@ +defmodule Philomena.WorkerTest do + use Philomena.DataCase, async: false + use Patch + + @moduletag :search + + import Philomena.ImagesFixtures + + alias Philomena.ImagePurgeWorker + alias Philomena.Images + alias Philomena.Images.Image + alias Philomena.Images.Thumbnailer + alias Philomena.IndexWorker + alias Philomena.ThumbnailWorker + alias Philomena.UserRenameWorker + alias PhilomenaQuery.Search + + @index_contexts [ + {"Comments", Philomena.Comments}, + {"Filters", Philomena.Filters}, + {"Galleries", Philomena.Galleries}, + {"Images", Philomena.Images}, + {"Posts", Philomena.Posts}, + {"Reports", Philomena.Reports}, + {"TagChanges", Philomena.TagChanges}, + {"Tags", Philomena.Tags}, + {"Users", Philomena.Users} + ] + + @rename_contexts [ + Philomena.Images, + Philomena.Comments, + Philomena.Posts, + Philomena.Galleries, + Philomena.Reports, + Philomena.Filters, + Philomena.TagChanges, + Philomena.Users + ] + + setup do + Search.clear_index!(Image) + :ok + end + + defp assert_exact_call(module, function, arguments) do + call = {function, arguments} + assert Enum.count(history(module), &(&1 == call)) == 1 + end + + test "the index worker indexes matching records" do + image = image_fixture() + + assert :ok = IndexWorker.perform("Images", "id", [image.id]) + :ok = Search.refresh_index!(Image) + + hits = Search.search(Image, %{query: %{match_all: %{}}})["hits"]["hits"] + assert Enum.any?(hits, &(&1["_id"] == to_string(image.id))) + end + + test "the index worker routes every job name to its context" do + Enum.each(@index_contexts, fn {_name, context} -> + patch(context, :perform_reindex, :ok) + end) + + Enum.each(@index_contexts, fn {name, _context} -> + assert :ok = IndexWorker.perform(name, "id", [123]) + end) + + Enum.each(@index_contexts, fn {_name, context} -> + assert_exact_call(context, :perform_reindex, [:id, [123]]) + end) + end + + test "the thumbnail worker generates media, broadcasts completion, and reindexes" do + image = %Image{id: 321} + patch(Thumbnailer, :generate_thumbnails, :ok) + patch(Images, :load_image_for_reindex!, image) + patch(Images, :reindex_image, image) + + assert image == ThumbnailWorker.perform(image.id) + + assert_exact_call(Thumbnailer, :generate_thumbnails, [image.id]) + assert_exact_call(Images, :load_image_for_reindex!, [image.id]) + assert_exact_call(Images, :reindex_image, [image]) + end + + test "the purge worker passes the complete file list to the purge operation" do + files = ["/img/1/full.png", "/img/1/thumb.png"] + patch(System, :cmd, {"", 0}) + + assert :ok = ImagePurgeWorker.perform(files) + + assert_exact_call(System, :cmd, [ + "purge-cache", + [JSON.encode!(%{files: files})] + ]) + end + + test "the rename worker updates every index containing a user name" do + Enum.each(@rename_contexts, &patch(&1, :user_name_reindex, :ok)) + + assert :ok = UserRenameWorker.perform("Old Name", "New Name") + + Enum.each(@rename_contexts, fn context -> + assert_exact_call(context, :user_name_reindex, ["Old Name", "New Name"]) + end) + end +end diff --git a/test/philomena_query/search_helpers_test.exs b/test/philomena_query/search_helpers_test.exs index 2d423a42d..507534d21 100644 --- a/test/philomena_query/search_helpers_test.exs +++ b/test/philomena_query/search_helpers_test.exs @@ -34,6 +34,23 @@ defmodule PhilomenaQuery.SearchHelpersTest do assert hit["_id"] == to_string(image.id) end + test "multi-search results retain query names" do + image = image_fixture() + SearchHelpers.reindex_all!(Image) + + definition = Search.search_definition(Image, %{query: %{match_all: %{}}}) + + assert %{documents: raw_result} = Search.msearch(documents: definition) + assert [%{"_id" => id}] = raw_result["hits"]["hits"] + assert id == to_string(image.id) + + assert %{records: page} = + Search.msearch_records(records: {definition, Image}) + + assert [%Image{id: id}] = page.entries + assert id == image.id + end + test "Search.clear_index!/1 leaves the index empty" do results = Search.search(Image, %{query: %{match_all: %{}}}) diff --git a/test/philomena_web/controllers/activity_controller_test.exs b/test/philomena_web/controllers/activity_controller_test.exs index 1a3d9671a..91bcc229e 100644 --- a/test/philomena_web/controllers/activity_controller_test.exs +++ b/test/philomena_web/controllers/activity_controller_test.exs @@ -3,7 +3,10 @@ defmodule PhilomenaWeb.ActivityControllerTest do @moduletag :search + import Philomena.AttributionFixtures + import Philomena.ChannelsFixtures import Philomena.CommentsFixtures + import Philomena.FiltersFixtures import Philomena.ForumsFixtures import Philomena.ImagesFixtures import Philomena.TopicsFixtures @@ -12,8 +15,11 @@ defmodule PhilomenaWeb.ActivityControllerTest do alias PhilomenaQuery.Search alias PhilomenaQuery.SearchHelpers alias Philomena.Comments.Comment - alias Philomena.Images + alias Philomena.Filters + alias Philomena.ImageFeatures.ImageFeature alias Philomena.Images.Image + alias Philomena.Repo + alias Philomena.Users setup do Search.clear_index!(Image) @@ -48,7 +54,10 @@ defmodule PhilomenaWeb.ActivityControllerTest do test "shows the featured image", %{conn: conn} do user = confirmed_user_fixture() image = image_fixture(created_at: hours_ago(1)) - {:ok, _feature} = Images.feature_image(user, image) + + %ImageFeature{user_id: user.id, image_id: image.id} + |> ImageFeature.changeset(%{}) + |> Repo.insert!() SearchHelpers.reindex_all!(Image) @@ -70,6 +79,36 @@ defmodule PhilomenaWeb.ActivityControllerTest do assert response =~ "Homepage - Derpibooru" assert response =~ ~p"/images/#{image.id}" end + + test "applies the logged-in user's active filter", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + image = image_fixture(created_at: hours_ago(1)) + [tag | _rest] = image.tags + filter = filter_fixture(user) + {:ok, filter} = Filters.create_filter_hide(actor(user), filter, tag.slug) + {:ok, _user} = Users.set_current_filter(user, filter) + SearchHelpers.reindex_all!(Image) + + response = html_response(get(conn, ~p"/"), 200) + + refute response =~ ~p"/images/#{image.id}" + end + + test "the NSFW channel cookie controls the stream strip", %{conn: conn} do + channel = + listed_channel_fixture(%{}, %{nsfw: true, title: "Private stream test channel"}) + + hidden_response = html_response(get(conn, ~p"/"), 200) + refute hidden_response =~ channel.title + + visible_response = + conn + |> put_req_cookie("chan_nsfw", "true") + |> get(~p"/") + |> html_response(200) + + assert visible_response =~ channel.title + end end describe "GET /activity" do diff --git a/test/philomena_web/controllers/admin/artist_link/contact_controller_test.exs b/test/philomena_web/controllers/admin/artist_link/contact_controller_test.exs index 3e9fb82cf..61e7414d1 100644 --- a/test/philomena_web/controllers/admin/artist_link/contact_controller_test.exs +++ b/test/philomena_web/controllers/admin/artist_link/contact_controller_test.exs @@ -46,17 +46,12 @@ defmodule PhilomenaWeb.Admin.ArtistLink.ContactControllerTest do describe "POST /admin/artist_links/:artist_link_id/contact (create) failure paths" do setup [:register_and_log_in_moderator] - # NOTE: an unknown link id takes Canary's not-found path on :create. test "redirects for an unknown link id", %{conn: conn} do conn = post(conn, ~p"/admin/artist_links/#{0}/contact") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer link id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found - # message rather than the "You can't access that page." an unknown integer - # id gets. test "redirects with the not-found flash for a non-integer link id", %{conn: conn} do conn = post(conn, ~p"/admin/artist_links/not-an-integer/contact") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/artist_link/reject_controller_test.exs b/test/philomena_web/controllers/admin/artist_link/reject_controller_test.exs index 79dcc4dce..6b7896d81 100644 --- a/test/philomena_web/controllers/admin/artist_link/reject_controller_test.exs +++ b/test/philomena_web/controllers/admin/artist_link/reject_controller_test.exs @@ -46,17 +46,12 @@ defmodule PhilomenaWeb.Admin.ArtistLink.RejectControllerTest do describe "POST /admin/artist_links/:artist_link_id/reject (create) failure paths" do setup [:register_and_log_in_moderator] - # NOTE: an unknown link id takes Canary's not-found path on :create. test "redirects for an unknown link id", %{conn: conn} do conn = post(conn, ~p"/admin/artist_links/#{0}/reject") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer link id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found - # message rather than the "You can't access that page." an unknown integer - # id gets. test "redirects with the not-found flash for a non-integer link id", %{conn: conn} do conn = post(conn, ~p"/admin/artist_links/not-an-integer/reject") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/artist_link/verification_controller_test.exs b/test/philomena_web/controllers/admin/artist_link/verification_controller_test.exs index 35b3cadd6..70d1ece1e 100644 --- a/test/philomena_web/controllers/admin/artist_link/verification_controller_test.exs +++ b/test/philomena_web/controllers/admin/artist_link/verification_controller_test.exs @@ -46,18 +46,12 @@ defmodule PhilomenaWeb.Admin.ArtistLink.VerificationControllerTest do describe "POST /admin/artist_links/:artist_link_id/verification (create) failure paths" do setup [:register_and_log_in_moderator] - # NOTE: an unknown link id takes Canary's not-found path on :create - # (authorization fails against the nil resource) - redirect to /. test "redirects for an unknown link id", %{conn: conn} do conn = post(conn, ~p"/admin/artist_links/#{0}/verification") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer link id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found - # message rather than the "You can't access that page." an unknown integer - # id gets. test "redirects with the not-found flash for a non-integer link id", %{conn: conn} do conn = post(conn, ~p"/admin/artist_links/not-an-integer/verification") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/artist_link_controller_test.exs b/test/philomena_web/controllers/admin/artist_link_controller_test.exs index 685fa8678..2d5180668 100644 --- a/test/philomena_web/controllers/admin/artist_link_controller_test.exs +++ b/test/philomena_web/controllers/admin/artist_link_controller_test.exs @@ -53,9 +53,7 @@ defmodule PhilomenaWeb.Admin.ArtistLinkControllerTest do assert response =~ user.name end - # NOTE: the default index only lists links in the unverified/link_verified/ - # contacted states - a verified link is hidden unless ?all is passed. - test "hides a verified link by default but shows it with ?all", %{conn: conn} do + test "hides a verified link by default but shows it when selected", %{conn: conn} do user = confirmed_user_fixture() tag = tag_fixture(name: "artist:index-verified") link = verified_artist_link_fixture(user, tag) @@ -63,11 +61,11 @@ defmodule PhilomenaWeb.Admin.ArtistLinkControllerTest do conn = get(conn, ~p"/admin/artist_links") refute html_response(conn, 200) =~ link.uri - conn = get(conn, ~p"/admin/artist_links?#{[all: "true"]}") + conn = get(conn, ~p"/admin/artist_links?#{[lq: [states: ~w(verified)]]}") assert html_response(conn, 200) =~ link.uri end - test "filters by user name or uri with lq", %{conn: conn} do + test "filters by user name or uri with text", %{conn: conn} do user = confirmed_user_fixture() tag = tag_fixture(name: "artist:index-lq") link = artist_link_fixture(user, tag) @@ -76,7 +74,7 @@ defmodule PhilomenaWeb.Admin.ArtistLinkControllerTest do other_tag = tag_fixture(name: "artist:index-lq-other") other_link = artist_link_fixture(other, other_tag) - conn = get(conn, ~p"/admin/artist_links?#{[lq: user.name]}") + conn = get(conn, ~p"/admin/artist_links?#{[lq: [text: user.name]]}") response = html_response(conn, 200) assert response =~ link.uri refute response =~ other_link.uri diff --git a/test/philomena_web/controllers/admin/badge/user_controller_test.exs b/test/philomena_web/controllers/admin/badge/user_controller_test.exs index c052adca9..717d9cd71 100644 --- a/test/philomena_web/controllers/admin/badge/user_controller_test.exs +++ b/test/philomena_web/controllers/admin/badge/user_controller_test.exs @@ -66,9 +66,9 @@ defmodule PhilomenaWeb.Admin.Badge.UserControllerTest do describe "GET /admin/badges/:badge_id/users unknown id" do setup [:register_and_log_in_admin] - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on the :index action too - an unknown badge_id redirects rather than - # dereferencing a nil badge. + # NOTE: the context authorizes the loaded badge on :index; an unknown + # badge_id loads nil, the admin is authorized on it, so it returns not_found + # and redirects rather than dereferencing a nil badge. test "redirects with a not-found flash for an unknown badge_id", %{conn: conn} do conn = get(conn, ~p"/admin/badges/#{2_000_000_000}/users") diff --git a/test/philomena_web/controllers/admin/batch/tag_controller_test.exs b/test/philomena_web/controllers/admin/batch/tag_controller_test.exs index a6eb725de..180bfb49c 100644 --- a/test/philomena_web/controllers/admin/batch/tag_controller_test.exs +++ b/test/philomena_web/controllers/admin/batch/tag_controller_test.exs @@ -11,14 +11,14 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do describe "PATCH /admin/batch/tags authorization" do test "redirects anonymous users to login", %{conn: conn} do - conn = patch(conn, ~p"/admin/batch/tags", tags: "safe", image_ids: []) + conn = patch(conn, ~p"/admin/batch/tags", tag_list: "safe", image_ids: [1]) assert redirected_to(conn) == ~p"/sessions/new" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "must log in" end test "rejects a regular user", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - conn = patch(conn, ~p"/admin/batch/tags", tags: "safe", image_ids: []) + conn = patch(conn, ~p"/admin/batch/tags", tag_list: "safe", image_ids: [1]) assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end @@ -27,7 +27,7 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do # rejected; only admins (or a Tag-admin/batch_update role_map grant) pass. test "rejects a plain moderator", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) - conn = patch(conn, ~p"/admin/batch/tags", tags: "safe", image_ids: []) + conn = patch(conn, ~p"/admin/batch/tags", tag_list: "safe", image_ids: [1]) assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end @@ -36,18 +36,18 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do describe "PATCH /admin/batch/tags (update)" do setup [:register_and_log_in_admin] - test "adds a tag to the given images and returns the ids as succeeded", + test "adds a tag to the given images and returns the succeeded count", %{conn: conn} do image = image_fixture() _tag = tag_fixture(name: "batch-added-tag") conn = patch(conn, ~p"/admin/batch/tags", - tags: "batch-added-tag", + tag_list: "batch-added-tag", image_ids: [to_string(image.id)] ) - assert json_response(conn, 200) == %{"succeeded" => [image.id], "failed" => []} + assert json_response(conn, 200) == %{"succeeded" => 1, "failed" => 0} tag_names = image.id @@ -63,11 +63,11 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do conn = patch(conn, ~p"/admin/batch/tags", - tags: "-batch-removed-tag", + tag_list: "-batch-removed-tag", image_ids: [to_string(image.id)] ) - assert json_response(conn, 200) == %{"succeeded" => [image.id], "failed" => []} + assert json_response(conn, 200) == %{"succeeded" => 1, "failed" => 0} tag_names = image.id @@ -77,22 +77,21 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do refute tag.name in tag_names end - # `succeeded` contains only the ids the batch actually matched; a - # well-formed id that names no image is reported as failed. + # Only existing image IDs contribute to the succeeded count. test "reports unknown image ids as failed", %{conn: conn} do _tag = tag_fixture(name: "batch-unknown-id-tag") conn = patch(conn, ~p"/admin/batch/tags", - tags: "-batch-unknown-id-tag", + tag_list: "-batch-unknown-id-tag", image_ids: ["2000000000"] ) - assert json_response(conn, 200) == %{"succeeded" => [], "failed" => [2_000_000_000]} + assert json_response(conn, 200) == %{"succeeded" => 0, "failed" => 1} end - # Tag additions are windowed to the matched images just like removals, so - # the batch commits over the matched subset and the unknown id fails. + # The batch commits over the matched subset and counts the unknown ID as + # failed. test "adds an existing tag to the matched images and fails the unknown id", %{conn: conn} do image = image_fixture() @@ -100,14 +99,11 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do conn = patch(conn, ~p"/admin/batch/tags", - tags: "batch-fk-tag", + tag_list: "batch-fk-tag", image_ids: [to_string(image.id), "2000000000"] ) - assert json_response(conn, 200) == %{ - "succeeded" => [image.id], - "failed" => [2_000_000_000] - } + assert json_response(conn, 200) == %{"succeeded" => 1, "failed" => 1} tag_names = image.id @@ -120,20 +116,20 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do refute Repo.exists?(where(Tagging, image_id: 2_000_000_000)) end - test "reports a hidden image's id as failed without tagging it", %{conn: conn} do + test "adds a tag to a hidden image", %{conn: conn} do image = image_fixture(hidden_from_users: true) tag = tag_fixture(name: "batch-hidden-tag") conn = patch(conn, ~p"/admin/batch/tags", - tags: "batch-hidden-tag", + tag_list: "batch-hidden-tag", image_ids: [to_string(image.id)] ) - assert json_response(conn, 200) == %{"succeeded" => [], "failed" => [image.id]} + assert json_response(conn, 200) == %{"succeeded" => 1, "failed" => 0} - # The hidden image never receives the tagging. - refute Repo.exists?(where(Tagging, image_id: ^image.id, tag_id: ^tag.id)) + # The hidden image receives the tagging. + assert Repo.exists?(where(Tagging, image_id: ^image.id, tag_id: ^tag.id)) end # NOTE: a tag list that resolves to zero actual tag changes (here a tag @@ -145,11 +141,11 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do conn = patch(conn, ~p"/admin/batch/tags", - tags: "this-tag-does-not-exist", + tag_list: "this-tag-does-not-exist", image_ids: [to_string(image.id)] ) - assert json_response(conn, 200) == %{"succeeded" => [], "failed" => [image.id]} + assert json_response(conn, 200) == %{"succeeded" => 0, "failed" => 1} end test "works via PUT as well", %{conn: conn} do @@ -158,38 +154,30 @@ defmodule PhilomenaWeb.Admin.Batch.TagControllerTest do conn = put(conn, ~p"/admin/batch/tags", - tags: "batch-put-tag", + tag_list: "batch-put-tag", image_ids: [to_string(image.id)] ) - assert json_response(conn, 200) == %{"succeeded" => [image.id], "failed" => []} + assert json_response(conn, 200) == %{"succeeded" => 1, "failed" => 0} end - # NOTE: a non-integer image id can't name an image, so it is now reported in - # `failed` while the parsable ids still process, rather than raising. - test "returns a non-integer image id in failed and processes the rest", %{conn: conn} do + test "returns 400 for a non-integer image id", %{conn: conn} do image = image_fixture() _tag = tag_fixture(name: "batch-mixed-tag") conn = patch(conn, ~p"/admin/batch/tags", - tags: "batch-mixed-tag", + tag_list: "batch-mixed-tag", image_ids: [to_string(image.id), "not-an-integer"] ) - assert json_response(conn, 200) == %{ - "succeeded" => [image.id], - "failed" => ["not-an-integer"] - } + assert response(conn, 400) end - # NOTE: a request missing tags (or carrying a non-list image_ids / non-binary - # tags) no longer matches the primary update/2 clause and now answers 400 - # with empty lists rather than raising. test "answers 400 when required params are missing", %{conn: conn} do conn = patch(conn, ~p"/admin/batch/tags", image_ids: []) - assert json_response(conn, 400) == %{"succeeded" => [], "failed" => []} + assert response(conn, 400) end end diff --git a/test/philomena_web/controllers/admin/dnp_entry/transition_controller_test.exs b/test/philomena_web/controllers/admin/dnp_entry/transition_controller_test.exs index 61ce9c49f..24f8bed40 100644 --- a/test/philomena_web/controllers/admin/dnp_entry/transition_controller_test.exs +++ b/test/philomena_web/controllers/admin/dnp_entry/transition_controller_test.exs @@ -54,9 +54,6 @@ defmodule PhilomenaWeb.Admin.DnpEntry.TransitionControllerTest do describe "POST /admin/dnp_entries/:dnp_entry_id/transition (create) failure paths" do setup [:register_and_log_in_moderator] - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :create too - an unknown entry id redirects rather than crashing - # in transition_dnp_entry/3. test "redirects with the not-found flash for an unknown entry id", %{conn: conn} do conn = post(conn, ~p"/admin/dnp_entries/#{0}/transition", state: "claimed") @@ -64,8 +61,6 @@ defmodule PhilomenaWeb.Admin.DnpEntry.TransitionControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer entry id short-circuits to NotFoundPlug via the central - # IntegerId guard. test "redirects with the not-found flash for a non-integer entry id", %{conn: conn} do conn = post(conn, ~p"/admin/dnp_entries/not-an-integer/transition", state: "claimed") @@ -73,16 +68,16 @@ defmodule PhilomenaWeb.Admin.DnpEntry.TransitionControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a missing state param does not match the create/2 clause and - # raises Phoenix.ActionClauseError (a 500). - test "raises when the state param is missing", %{conn: conn} do + test "redirects with a validation flash when the state param is missing", %{conn: conn} do user = confirmed_user_fixture() tag = tag_fixture(name: "artist:transition-nostate") entry = dnp_entry_fixture(user, tag) - assert_raise Phoenix.ActionClauseError, fn -> - post(conn, ~p"/admin/dnp_entries/#{entry}/transition") - end + conn = post(conn, ~p"/admin/dnp_entries/#{entry}/transition") + + assert redirected_to(conn) == ~p"/dnp/#{entry}" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Failed to update" + assert Repo.get(DnpEntry, entry.id).aasm_state == "requested" end end end diff --git a/test/philomena_web/controllers/admin/dnp_entry_controller_test.exs b/test/philomena_web/controllers/admin/dnp_entry_controller_test.exs index 1a5424371..5b5330518 100644 --- a/test/philomena_web/controllers/admin/dnp_entry_controller_test.exs +++ b/test/philomena_web/controllers/admin/dnp_entry_controller_test.exs @@ -63,7 +63,7 @@ defmodule PhilomenaWeb.Admin.DnpEntryControllerTest do conn = get(conn, ~p"/admin/dnp_entries") refute html_response(conn, 200) =~ ~p"/dnp/#{entry}" - conn = get(conn, ~p"/admin/dnp_entries?#{[states: ["listed"]]}") + conn = get(conn, ~p"/admin/dnp_entries?#{[eq: [states: ["listed"]]]}") assert html_response(conn, 200) =~ ~p"/dnp/#{entry}" end @@ -76,7 +76,7 @@ defmodule PhilomenaWeb.Admin.DnpEntryControllerTest do other_tag = tag_fixture(name: "artist:dnp-eq-other") other_entry = dnp_entry_fixture(other_user, other_tag) - conn = get(conn, ~p"/admin/dnp_entries?#{[eq: "dnp-eq-match"]}") + conn = get(conn, ~p"/admin/dnp_entries?#{[eq: [text: "dnp-eq-match"]]}") response = html_response(conn, 200) assert response =~ ~p"/dnp/#{entry}" refute response =~ ~p"/dnp/#{other_entry}" diff --git a/test/philomena_web/controllers/admin/donation/user_controller_test.exs b/test/philomena_web/controllers/admin/donation/user_controller_test.exs index c36a7a347..884533ff5 100644 --- a/test/philomena_web/controllers/admin/donation/user_controller_test.exs +++ b/test/philomena_web/controllers/admin/donation/user_controller_test.exs @@ -52,8 +52,9 @@ defmodule PhilomenaWeb.Admin.Donation.UserControllerTest do assert response =~ donation.email end - # NOTE: :load_resource runs the not-found handler on :show, so an unknown - # slug redirects to / with the not-found flash rather than crashing. + # NOTE: the context authorizes the loaded record on :show; an unknown slug + # loads nil, the actor is authorized on it, so it returns not_found and + # redirects to / with the not-found flash rather than crashing. test "redirects for an unknown user slug", %{conn: conn} do conn = get(conn, ~p"/admin/donations/user/no-such-user") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/forum_controller_test.exs b/test/philomena_web/controllers/admin/forum_controller_test.exs index 9b651dfd5..e9772db4b 100644 --- a/test/philomena_web/controllers/admin/forum_controller_test.exs +++ b/test/philomena_web/controllers/admin/forum_controller_test.exs @@ -160,8 +160,9 @@ defmodule PhilomenaWeb.Admin.ForumControllerTest do # NOTE: Forums are loaded by `short_name` (id_field), a string column, so # an unknown/non-integer short name never casts - it just misses (no # Ecto.Query.CastError, unlike the integer-id badge/advert routes). - # Canary's plain load_resource runs its not_found handler for :edit here, - # so a missing short name redirects with the not-found flash. + # The context authorizes the loaded forum (nil for a missing short name), + # the admin is authorized on the nil load, so :edit redirects with the + # not-found flash. test "redirects with a not-found flash on an unknown short_name for :edit", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) conn = get(conn, ~p"/admin/forums/does-not-exist/edit") @@ -200,9 +201,9 @@ defmodule PhilomenaWeb.Admin.ForumControllerTest do assert Repo.get(Forum, forum.id).name == "Test Forum" end - # NOTE: For the :update action Canary's plain load_resource DOES run its - # not_found handler on a missing resource, so an unknown short name - # redirects with the not-found flash (unlike :edit above, which crashes). + # NOTE: the context authorizes the loaded forum on :update; a missing short + # name loads nil, the admin is authorized on it, so an unknown short name + # redirects with the not-found flash (like :edit above). test "redirects with a not-found flash on an unknown short_name for :update", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) conn = patch(conn, ~p"/admin/forums/does-not-exist", %{"forum" => %{"name" => "x"}}) diff --git a/test/philomena_web/controllers/admin/mod_note_controller_test.exs b/test/philomena_web/controllers/admin/mod_note_controller_test.exs index f45916a7a..998406cf1 100644 --- a/test/philomena_web/controllers/admin/mod_note_controller_test.exs +++ b/test/philomena_web/controllers/admin/mod_note_controller_test.exs @@ -104,14 +104,13 @@ defmodule PhilomenaWeb.Admin.ModNoteControllerTest do assert html_response(conn, 200) =~ "New mod note for" end - # NOTE: new/2 now accepts a bare request and renders a blank form (200) - # rather than raising ActionClauseError. - test "renders a blank form without target params", %{conn: conn} do + test "redirects to / with a not-found flash with no id", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = get(conn, ~p"/admin/mod_notes/new") - assert html_response(conn, 200) =~ "New mod note for" + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end diff --git a/test/philomena_web/controllers/admin/report/claim_controller_test.exs b/test/philomena_web/controllers/admin/report/claim_controller_test.exs index 59afeb5af..4efa52c72 100644 --- a/test/philomena_web/controllers/admin/report/claim_controller_test.exs +++ b/test/philomena_web/controllers/admin/report/claim_controller_test.exs @@ -46,19 +46,12 @@ defmodule PhilomenaWeb.Admin.Report.ClaimControllerTest do describe "POST /admin/reports/:report_id/claim (create) failure paths" do setup [:register_and_log_in_moderator] - # NOTE: an unknown report id takes Canary's not-found path on :create - # (the authorization check fails against the nil resource), so it is the - # authorization flash + redirect to /, not a 404. test "redirects for an unknown report id", %{conn: conn} do conn = post(conn, ~p"/admin/reports/#{0}/claim") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer report id short-circuits to NotFoundPlug via the - # central IntegerId guard before Canary authorizes, so the flash is the - # not-found message rather than the "You can't access that page." an unknown - # integer id gets. test "redirects with the not-found flash for a non-integer report id", %{conn: conn} do conn = post(conn, ~p"/admin/reports/not-an-integer/claim") assert redirected_to(conn) == "/" @@ -86,7 +79,14 @@ defmodule PhilomenaWeb.Admin.Report.ClaimControllerTest do describe "DELETE /admin/reports/:report_id/claim (delete)" do setup [:register_and_log_in_moderator, :report_fixture!] - test "releases the report and redirects to the report", %{conn: conn, report: report} do + test "releases the report and redirects to the report", %{ + conn: conn, + report: report, + user: mod + } do + {:ok, _report} = + Philomena.Reports.create_report_claim(Philomena.AttributionFixtures.actor(mod), report.id) + conn = delete(conn, ~p"/admin/reports/#{report}/claim") assert redirected_to(conn) == ~p"/admin/reports/#{report}" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "released" diff --git a/test/philomena_web/controllers/admin/report/close_controller_test.exs b/test/philomena_web/controllers/admin/report/close_controller_test.exs index 3d3e09972..3ff32ba0f 100644 --- a/test/philomena_web/controllers/admin/report/close_controller_test.exs +++ b/test/philomena_web/controllers/admin/report/close_controller_test.exs @@ -47,19 +47,12 @@ defmodule PhilomenaWeb.Admin.Report.CloseControllerTest do describe "POST /admin/reports/:report_id/close (create) failure paths" do setup [:register_and_log_in_moderator] - # NOTE: an unknown report id takes Canary's not-found path on :create - # (authorization fails against the nil resource) - the authorization - # flash + redirect to /, not a 404. test "redirects for an unknown report id", %{conn: conn} do conn = post(conn, ~p"/admin/reports/#{0}/close") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer report id short-circuits to NotFoundPlug via the - # central IntegerId guard before Canary authorizes, so the flash is the - # not-found message rather than the "You can't access that page." an unknown - # integer id gets. test "redirects with the not-found flash for a non-integer report id", %{conn: conn} do conn = post(conn, ~p"/admin/reports/not-an-integer/close") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/report_controller_test.exs b/test/philomena_web/controllers/admin/report_controller_test.exs index 5cade90c8..3c0cdbf46 100644 --- a/test/philomena_web/controllers/admin/report_controller_test.exs +++ b/test/philomena_web/controllers/admin/report_controller_test.exs @@ -88,7 +88,7 @@ defmodule PhilomenaWeb.Admin.ReportControllerTest do report = open_report_fixture() SearchHelpers.reindex_all!(Report) - conn = get(conn, ~p"/admin/reports?#{[rq: "open:true"]}") + conn = get(conn, ~p"/admin/reports?#{[rq: [query: "open:true"]]}") response = html_response(conn, 200) assert response =~ report.reason end @@ -147,7 +147,8 @@ defmodule PhilomenaWeb.Admin.ReportControllerTest do describe "GET /admin/reports/:id (show) failure paths" do setup [:register_and_log_in_admin] - # NOTE: :show runs Canary's not-found handler for an unknown id. + # NOTE: an unknown id loads nil; an admin is authorized on the nil load, so + # :show returns not_found. test "redirects for an unknown report id", %{conn: conn} do conn = get(conn, ~p"/admin/reports/#{0}") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/subnet_ban_controller_test.exs b/test/philomena_web/controllers/admin/subnet_ban_controller_test.exs index fd745846d..373289d31 100644 --- a/test/philomena_web/controllers/admin/subnet_ban_controller_test.exs +++ b/test/philomena_web/controllers/admin/subnet_ban_controller_test.exs @@ -64,15 +64,11 @@ defmodule PhilomenaWeb.Admin.SubnetBanControllerTest do assert response =~ ban.generated_ban_id end - # NOTE: an unparsable address now redirects to the index with a flash rather - # than raising MatchError. - test "redirects with a flash on an invalid ip in the ip branch", %{conn: conn} do + test "renders an inline error on an invalid ip in the ip branch", %{conn: conn} do conn = get(conn, ~p"/admin/subnet_bans?#{[ip: "not-an-ip"]}") - assert redirected_to(conn) == ~p"/admin/subnet_bans" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "`not-an-ip' is not a valid IP address or CIDR range." + response = html_response(conn, 200) + assert response =~ "Ip is invalid" end end @@ -92,21 +88,10 @@ defmodule PhilomenaWeb.Admin.SubnetBanControllerTest do test "prefills the form when a specification is supplied", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) conn = get(conn, ~p"/admin/subnet_bans/new?#{[specification: "203.0.113.0/24"]}") - assert html_response(conn, 200) =~ "New Subnet Ban" - end - - # NOTE: an invalid specification now renders a blank form (200) with a flash - # rather than raising MatchError. - test "renders a blank form with a flash on an invalid specification", %{conn: conn} do - %{conn: conn} = register_and_log_in_admin(%{conn: conn}) - - conn = get(conn, ~p"/admin/subnet_bans/new?#{[specification: "not-an-ip"]}") response = html_response(conn, 200) assert response =~ "New Subnet Ban" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "`not-an-ip' is not a valid IP address or CIDR range." + assert response =~ "203.0.113.0/24" end end @@ -174,6 +159,22 @@ defmodule PhilomenaWeb.Admin.SubnetBanControllerTest do assert html_response(conn, 200) =~ "New Subnet Ban" refute Repo.exists?(SubnetBan) end + + test "renders an inline error on an invalid specification", %{conn: conn} do + %{conn: conn} = register_and_log_in_admin(%{conn: conn}) + + conn = + post(conn, ~p"/admin/subnet_bans", %{ + "subnet" => %{ + "specification" => "not-an-ip", + "valid_until" => "5 years from now" + } + }) + + response = html_response(conn, 200) + assert response =~ "New Subnet Ban" + assert response =~ "Specification is invalid" + end end describe "GET /admin/subnet_bans/:id/edit" do diff --git a/test/philomena_web/controllers/admin/user/activation_controller_test.exs b/test/philomena_web/controllers/admin/user/activation_controller_test.exs index 61f410c83..4426f2485 100644 --- a/test/philomena_web/controllers/admin/user/activation_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/activation_controller_test.exs @@ -47,9 +47,9 @@ defmodule PhilomenaWeb.Admin.User.ActivationControllerTest do assert Repo.get(User, target.id).deleted_at == nil end - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :create too - an unknown slug redirects with the not-found flash - # rather than passing nil into Users.reactivate_user/1. + # NOTE: the context authorizes the loaded record on :create; an unknown slug + # loads nil, the admin is authorized on it, so it redirects with the + # not-found flash rather than passing nil into Users.reactivate_user/1. test "redirects with the not-found flash for an unknown slug", %{conn: conn} do conn = post(conn, ~p"/admin/users/no-such-user/activation") @@ -121,9 +121,9 @@ defmodule PhilomenaWeb.Admin.User.ActivationControllerTest do assert reloaded.deleted_by_user_id == admin.id end - # NOTE: unlike the :create sibling above, Canary's not_found handler DOES - # run on this :delete action, so an unknown slug redirects to "/" with the - # generic not-found flash instead of crashing. + # NOTE: an unknown slug loads nil and the admin is authorized on it, so this + # :delete action returns not_found - a redirect to "/" with the generic + # not-found flash instead of crashing. test "redirects for an unknown slug", %{conn: conn} do conn = delete(conn, ~p"/admin/users/no-such-user/activation") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/user/avatar_controller_test.exs b/test/philomena_web/controllers/admin/user/avatar_controller_test.exs index 6abb51f59..e5c6587c9 100644 --- a/test/philomena_web/controllers/admin/user/avatar_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/avatar_controller_test.exs @@ -1,7 +1,7 @@ defmodule PhilomenaWeb.Admin.User.AvatarControllerTest do use PhilomenaWeb.ConnCase, async: true - # Postgres-only: the S3 delete in Users.remove_avatar/1 goes through the + # Postgres-only: the S3 delete in Users.delete_avatar/1 goes through the # stubbed ex_aws client and the reindex is a dead Exq enqueue; # moderation_log/2 is a synchronous insert. @@ -54,8 +54,9 @@ defmodule PhilomenaWeb.Admin.User.AvatarControllerTest do assert Repo.get(User, target.id).avatar == nil end - # NOTE: :delete runs Canary's not_found handler, so an unknown slug - # redirects to "/" with the not-found flash instead of crashing. + # NOTE: an unknown slug loads nil and the actor is authorized on it, so + # :delete returns not_found - a redirect to "/" with the not-found flash + # instead of crashing. test "redirects for an unknown slug", %{conn: conn} do conn = delete(conn, ~p"/admin/users/no-such-user/avatar") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/admin/user/erase_controller_test.exs b/test/philomena_web/controllers/admin/user/erase_controller_test.exs index 16503b81b..7855068d7 100644 --- a/test/philomena_web/controllers/admin/user/erase_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/erase_controller_test.exs @@ -42,10 +42,9 @@ defmodule PhilomenaWeb.Admin.User.EraseControllerTest do assert html_response(conn, 200) =~ "Erase user" end - # NOTE: the prevent_deleting_nonexistent_users guard catches the nil the - # load_resource plug assigns for an unknown slug (before any not_found - # handler would apply on :new), redirecting to the user index with a custom - # flash instead of crashing. + # NOTE: the prevent_deleting_nonexistent_users guard catches the nil loaded + # for an unknown slug (before context authorization would apply on :new), + # redirecting to the user index with a custom flash instead of crashing. test "redirects an unknown slug to the user index", %{conn: conn} do conn = get(conn, ~p"/admin/users/no-such-user/erase/new") assert redirected_to(conn) == ~p"/admin/users" diff --git a/test/philomena_web/controllers/admin/user/force_filter_controller_test.exs b/test/philomena_web/controllers/admin/user/force_filter_controller_test.exs index ff6cb77f5..83fbd79d8 100644 --- a/test/philomena_web/controllers/admin/user/force_filter_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/force_filter_controller_test.exs @@ -37,8 +37,8 @@ defmodule PhilomenaWeb.Admin.User.ForceFilterControllerTest do assert html_response(conn, 200) =~ "Forcing filter for user" end - # NOTE: the verify_authorized plug guards :new too, so a plain moderator no - # longer even sees the force-filter form. + # NOTE: :new is authorized too, so a plain moderator does not even see the + # force-filter form. test "is denied to a plain moderator", %{conn: conn} do target = confirmed_user_fixture() %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -47,9 +47,9 @@ defmodule PhilomenaWeb.Admin.User.ForceFilterControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :new too - an unknown slug redirects with the not-found flash - # rather than passing nil into Users.change_user/1. + # NOTE: the context authorizes the loaded record on :new; an unknown slug + # loads nil, the admin is authorized on it, so it redirects with the + # not-found flash rather than passing nil into Users.change_user/1. test "redirects with the not-found flash for an unknown slug", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) @@ -106,25 +106,21 @@ defmodule PhilomenaWeb.Admin.User.ForceFilterControllerTest do assert Repo.get(User, target.id).forced_filter_id == filter.id end - # NOTE: force_filter_changeset only casts forced_filter_id with a - # foreign_key_constraint; a nonexistent id fails the FK on update, returning - # {:error, changeset}, and the controller's `{:ok, user} = ...` match raises - # MatchError (no re-render branch) - the write action's failure path. - test "raises MatchError on a nonexistent forced_filter_id", %{conn: conn} do + test "re-renders a structured error for a nonexistent forced_filter_id", %{conn: conn} do target = confirmed_user_fixture() - assert_raise MatchError, - ~r/no match of right hand side value:.*constraint_name: "users_forced_filter_id_fkey"/s, - fn -> - post(conn, ~p"/admin/users/#{target.slug}/force_filter", %{ - "user" => %{"forced_filter_id" => 2_147_483_647} - }) - end + conn = + post(conn, ~p"/admin/users/#{target.slug}/force_filter", %{ + "user" => %{"forced_filter_id" => 2_147_483_647} + }) + + assert html_response(conn, 200) =~ "does not exist" + refute Repo.get(User, target.id).forced_filter_id end - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :create too - an unknown slug redirects with the not-found flash - # rather than passing nil into Users.force_filter/2. + # NOTE: the context authorizes the loaded record on :create; an unknown slug + # loads nil, the admin is authorized on it, so it redirects with the + # not-found flash rather than passing nil into Users.force_filter/2. test "redirects with the not-found flash for an unknown slug", %{conn: conn} do conn = post(conn, ~p"/admin/users/no-such-user/force_filter", %{ @@ -161,7 +157,12 @@ defmodule PhilomenaWeb.Admin.User.ForceFilterControllerTest do test "removes the forced filter and redirects to their profile", %{conn: conn} do target = confirmed_user_fixture() filter = filter_fixture(target) - {:ok, target} = Philomena.Users.force_filter(target, %{"forced_filter_id" => filter.id}) + + target = + target + |> User.force_filter_changeset(%{"forced_filter_id" => filter.id}) + |> Repo.update!() + assert target.forced_filter_id == filter.id conn = delete(conn, ~p"/admin/users/#{target.slug}/force_filter") @@ -207,7 +208,11 @@ defmodule PhilomenaWeb.Admin.User.ForceFilterControllerTest do test "is denied to a plain moderator", %{conn: conn} do target = confirmed_user_fixture() filter = filter_fixture(target) - {:ok, target} = Philomena.Users.force_filter(target, %{"forced_filter_id" => filter.id}) + + target = + target + |> User.force_filter_changeset(%{"forced_filter_id" => filter.id}) + |> Repo.update!() %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/admin/users/#{target.slug}/force_filter") diff --git a/test/philomena_web/controllers/admin/user/unlock_controller_test.exs b/test/philomena_web/controllers/admin/user/unlock_controller_test.exs index dd0cd04a7..2d1e0355a 100644 --- a/test/philomena_web/controllers/admin/user/unlock_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/unlock_controller_test.exs @@ -55,9 +55,9 @@ defmodule PhilomenaWeb.Admin.User.UnlockControllerTest do assert Repo.get(User, target.id).locked_at == nil end - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :create too - an unknown slug redirects with the not-found flash - # rather than passing nil into Users.unlock_user/1. + # NOTE: the context authorizes the loaded record on :create; an unknown slug + # loads nil, the admin is authorized on it, so it redirects with the + # not-found flash rather than passing nil into Users.unlock_user/1. test "redirects with the not-found flash for an unknown slug", %{conn: conn} do conn = post(conn, ~p"/admin/users/no-such-user/unlock") diff --git a/test/philomena_web/controllers/admin/user/verification_controller_test.exs b/test/philomena_web/controllers/admin/user/verification_controller_test.exs index fbb776654..cba5293d9 100644 --- a/test/philomena_web/controllers/admin/user/verification_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/verification_controller_test.exs @@ -44,9 +44,9 @@ defmodule PhilomenaWeb.Admin.User.VerificationControllerTest do assert Repo.get(User, target.id).verified end - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :create too - an unknown slug redirects with the not-found flash - # rather than passing nil into Users.verify_user/1. + # NOTE: the context authorizes the loaded record on :create; an unknown slug + # loads nil, the actor is authorized on it, so it redirects with the + # not-found flash rather than passing nil into Users.verify_user/1. test "redirects with the not-found flash for an unknown slug", %{conn: conn} do conn = post(conn, ~p"/admin/users/no-such-user/verification") diff --git a/test/philomena_web/controllers/admin/user/wipe_controller_test.exs b/test/philomena_web/controllers/admin/user/wipe_controller_test.exs index 300a744b9..0204d099e 100644 --- a/test/philomena_web/controllers/admin/user/wipe_controller_test.exs +++ b/test/philomena_web/controllers/admin/user/wipe_controller_test.exs @@ -38,9 +38,9 @@ defmodule PhilomenaWeb.Admin.User.WipeControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "PII wipe queued" end - # NOTE: load_resource now uses required: true, so Canary's not_found handler - # runs on :create too - an unknown slug redirects with the not-found flash - # rather than dereferencing a nil user. + # NOTE: the context authorizes the loaded record on :create; an unknown slug + # loads nil, the admin is authorized on it, so it redirects with the + # not-found flash rather than dereferencing a nil user. test "redirects with the not-found flash for an unknown slug", %{conn: conn} do conn = post(conn, ~p"/admin/users/no-such-user/wipe") diff --git a/test/philomena_web/controllers/admin/user_ban_controller_test.exs b/test/philomena_web/controllers/admin/user_ban_controller_test.exs index 8ee8f371d..7b83e164c 100644 --- a/test/philomena_web/controllers/admin/user_ban_controller_test.exs +++ b/test/philomena_web/controllers/admin/user_ban_controller_test.exs @@ -73,7 +73,8 @@ defmodule PhilomenaWeb.Admin.UserBanControllerTest do describe "GET /admin/user_bans/new" do test "rejects a regular user", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - conn = get(conn, ~p"/admin/user_bans/new") + target = confirmed_user_fixture() + conn = get(conn, ~p"/admin/user_bans/new?#{[user_id: target.id]}") assert redirected_to(conn) == "/" end diff --git a/test/philomena_web/controllers/admin/user_controller_test.exs b/test/philomena_web/controllers/admin/user_controller_test.exs index a3b5fb26c..fff68a604 100644 --- a/test/philomena_web/controllers/admin/user_controller_test.exs +++ b/test/philomena_web/controllers/admin/user_controller_test.exs @@ -58,11 +58,11 @@ defmodule PhilomenaWeb.Admin.UserControllerTest do assert response =~ target.name end - test "filters by uq", %{conn: conn} do + test "filters by user", %{conn: conn} do target = confirmed_user_fixture() SearchHelpers.reindex_all!(User) - conn = get(conn, ~p"/admin/users?#{[uq: "name:#{target.name}"]}") + conn = get(conn, ~p"/admin/users?#{[uq: [query: "name:#{target.name}"]]}") response = html_response(conn, 200) assert response =~ target.name end @@ -70,9 +70,9 @@ defmodule PhilomenaWeb.Admin.UserControllerTest do # NOTE: an unparsable query takes the error branch - the index re-renders # (200) with a query-parse error message and an empty user list. test "renders the parse-error branch for an invalid query", %{conn: conn} do - conn = get(conn, ~p"/admin/users?#{[uq: "("]}") + conn = get(conn, ~p"/admin/users?#{[uq: [query: "("]]}") response = html_response(conn, 200) - assert response =~ "there was an error parsing your query" + assert response =~ "Imbalanced parentheses." end end diff --git a/test/philomena_web/controllers/advert_controller_test.exs b/test/philomena_web/controllers/advert_controller_test.exs index 6a96363e4..29a1f1c77 100644 --- a/test/philomena_web/controllers/advert_controller_test.exs +++ b/test/philomena_web/controllers/advert_controller_test.exs @@ -33,6 +33,17 @@ defmodule PhilomenaWeb.AdvertControllerTest do "Couldn't find what you were looking for!" end + test "redirects to / for a disabled advert", %{conn: conn} do + advert = advert_fixture(%{live: false}) + + conn = get(conn, ~p"/adverts/#{advert}") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" + end + test "redirects with the not-found flash for a non-integer advert id", %{conn: conn} do conn = get(conn, ~p"/adverts/not-a-number") diff --git a/test/philomena_web/controllers/api/json/comment_controller_test.exs b/test/philomena_web/controllers/api/json/comment_controller_test.exs index bf0eae6c3..bc2c7770f 100644 --- a/test/philomena_web/controllers/api/json/comment_controller_test.exs +++ b/test/philomena_web/controllers/api/json/comment_controller_test.exs @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.Api.Json.CommentControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.AttributionFixtures import Philomena.CommentsFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures @@ -56,60 +57,100 @@ defmodule PhilomenaWeb.Api.Json.CommentControllerTest do assert author =~ ~r/\ABackground Pony #[0-9A-F]{4}\z/ end - test "nulls out the body of a hidden comment but stays 200", %{conn: conn} do + test "returns 403 for a hidden comment", %{conn: conn} do user = confirmed_user_fixture() moderator = moderator_user_fixture() image = image_fixture() comment = comment_fixture(image, user, %{"body" => "Rule-breaking comment"}) - {:ok, _} = Comments.hide_comment(comment, %{"deletion_reason" => "spam"}, moderator) + {:ok, _} = + Comments.create_comment_hide(actor(moderator), image.id, comment.id, %{ + "deletion_reason" => "spam" + }) conn = get(conn, ~p"/api/v1/json/comments/#{comment.id}") - assert %{ - "comment" => %{ - "body" => nil, - "edited_at" => nil, - "edit_reason" => nil, - "author" => author - } - } = json_response(conn, 200) + assert response(conn, 403) == "" + end - assert author == user.name + test "a moderator API key reads a hidden comment with redacted content", %{conn: conn} do + moderator = moderator_user_fixture() + image = image_fixture() + comment = comment_fixture(image, confirmed_user_fixture(), %{"body" => "Hidden body"}) + + {:ok, _} = + Comments.create_comment_hide(actor(moderator), image.id, comment.id, %{ + "deletion_reason" => "spam" + }) + + conn = + get( + conn, + ~p"/api/v1/json/comments/#{comment.id}?key=#{moderator.authentication_token}" + ) + + assert %{"comment" => %{"id" => id, "body" => nil}} = json_response(conn, 200) + assert id == comment.id end - test "returns 404 for a destroyed comment", %{conn: conn} do + test "returns 403 for a destroyed comment", %{conn: conn} do image = image_fixture() comment = comment_fixture(image, nil) - {:ok, _} = Comments.destroy_comment(comment) + {:ok, _} = + Comments.create_comment_hide(actor(admin_user_fixture()), image.id, comment.id, %{ + "deletion_reason" => "spam" + }) + + {:ok, _} = Comments.create_comment_delete(actor(admin_user_fixture()), image.id, comment.id) conn = get(conn, ~p"/api/v1/json/comments/#{comment.id}") - assert json_response(conn, 404) == %{"error" => "Not found"} + assert response(conn, 403) == "" end test "returns 403 for a comment on a hidden image", %{conn: conn} do image = image_fixture(hidden_from_users: true) - comment = comment_fixture(image, nil) + comment = comment_fixture(image, moderator_user_fixture()) conn = get(conn, ~p"/api/v1/json/comments/#{comment.id}") assert response(conn, 403) == "" end + test "a moderator API key reads a hidden image comment with fully redacted metadata", + %{conn: conn} do + moderator = moderator_user_fixture() + image = image_fixture(hidden_from_users: true) + comment = comment_fixture(image, moderator, %{"body" => "Hidden image body"}) + + conn = + get( + conn, + ~p"/api/v1/json/comments/#{comment.id}?key=#{moderator.authentication_token}" + ) + + assert %{ + "comment" => %{ + "id" => id, + "body" => nil, + "author" => nil, + "created_at" => nil + } + } = json_response(conn, 200) + + assert id == comment.id + end + test "returns 404 for an unknown id", %{conn: conn} do conn = get(conn, ~p"/api/v1/json/comments/#{0}") assert json_response(conn, 404) == %{"error" => "Not found"} end - test "raises for a non-integer id", %{conn: conn} do - # NOTE: the id is interpolated into the query without casting, so a - # non-integer id becomes a 500 rather than a 404. - assert_raise Ecto.Query.CastError, fn -> - get(conn, ~p"/api/v1/json/comments/not-a-number") - end + test "returns 404 for a non-integer id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/json/comments/not-a-number") + assert json_response(conn, 404) == %{"error" => "Not found"} end end end diff --git a/test/philomena_web/controllers/api/json/forum/topic/post_controller_test.exs b/test/philomena_web/controllers/api/json/forum/topic/post_controller_test.exs index 0dd8cd0d1..ff46bee01 100644 --- a/test/philomena_web/controllers/api/json/forum/topic/post_controller_test.exs +++ b/test/philomena_web/controllers/api/json/forum/topic/post_controller_test.exs @@ -27,29 +27,66 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostControllerTest do assert reply_id == reply.id end - test "includes hidden posts with a null body", %{conn: conn} do + test "does not exclude hidden posts", %{conn: conn} do user = confirmed_user_fixture() moderator = moderator_user_fixture() forum = forum_fixture() topic = topic_fixture(forum, user) reply = post_fixture(topic, user, %{"body" => "Rule-breaking reply"}) - {:ok, _} = Posts.hide_post(reply, %{"deletion_reason" => "spam"}, moderator) + {:ok, _} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + reply.id, + %{"deletion_reason" => "spam"} + ) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts") - # NOTE: hidden posts are not filtered from the index; they render with - # a null body. - assert %{"posts" => [_first, hidden], "total" => 2} = json_response(conn, 200) - assert %{"body" => nil, "id" => hidden_id} = hidden - assert hidden_id == reply.id + assert %{"posts" => [first, second], "total" => 2} = json_response(conn, 200) + refute first["id"] == reply.id + refute first["body"] == nil + assert second["id"] == reply.id + assert second["body"] == nil + end + + test "includes hidden posts for moderators", %{conn: conn} do + moderator = moderator_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum) + reply = post_fixture(topic, nil, %{"body" => "Rule-breaking reply"}) + + {:ok, _} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + reply.id, + %{"deletion_reason" => "spam"} + ) + + conn = + get( + conn, + ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts?key=#{moderator.authentication_token}" + ) + + assert %{"posts" => posts, "total" => 2} = json_response(conn, 200) + assert Enum.any?(posts, &(&1["id"] == reply.id)) end test "paginates in windows of 25 by topic position by default", %{conn: conn} do user = confirmed_user_fixture() forum = forum_fixture() topic = topic_fixture(forum, user) - for n <- 1..25, do: post_fixture(topic, user, %{"body" => "Reply number #{n}"}) + + for n <- 1..25, + do: + post_fixture(topic, confirmed_user_fixture(), %{ + "body" => "Reply number #{n}" + }) conn2 = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts?page=2") @@ -95,16 +132,16 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostControllerTest do assert json_response(conn, 404) == %{"error" => "Not found"} end - test "returns an empty list for a page past the last post", %{conn: conn} do + test "returns no results for a page past the last post", %{conn: conn} do forum = forum_fixture() topic = topic_fixture(forum) - # NOTE: a page past the end now returns an empty list with the topic's - # post_count as the total, rather than crashing on hd([]). + # Scrivener clamps an out-of-range page to the final valid page. + # The database-backed pagination used for topics does not. conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts?page=2") total = Repo.reload!(topic).post_count - assert json_response(conn, 200) == %{"posts" => [], "total" => total} + assert %{"posts" => [], "total" => ^total} = json_response(conn, 200) end end @@ -141,7 +178,22 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostControllerTest do topic = topic_fixture(forum) post = post_fixture(topic, nil) - {:ok, _} = Posts.destroy_post(post) + {:ok, _} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id, + %{deletion_reason: "Spam"} + ) + + {:ok, _} = + Posts.create_post_delete( + Philomena.AttributionFixtures.actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id + ) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts/#{post.id}") @@ -154,7 +206,13 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostControllerTest do topic = topic_fixture(forum) post = post_fixture(topic, nil) - {:ok, _} = Topics.hide_topic(topic, "spam", moderator) + {:ok, {_forum, _topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts/#{post.id}") @@ -175,7 +233,7 @@ defmodule PhilomenaWeb.Api.Json.Forum.Topic.PostControllerTest do test "returns 404 for a post in a restricted forum", %{conn: conn} do forum = forum_fixture(access_level: "staff") topic = topic_fixture(forum) - post = post_fixture(topic, nil) + post = post_fixture(topic, moderator_user_fixture()) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}/posts/#{post.id}") diff --git a/test/philomena_web/controllers/api/json/forum/topic_controller_test.exs b/test/philomena_web/controllers/api/json/forum/topic_controller_test.exs index 43db58319..484bf2e57 100644 --- a/test/philomena_web/controllers/api/json/forum/topic_controller_test.exs +++ b/test/philomena_web/controllers/api/json/forum/topic_controller_test.exs @@ -53,33 +53,72 @@ defmodule PhilomenaWeb.Api.Json.Forum.TopicControllerTest do } end - test "excludes hidden topics", %{conn: conn} do + test "excludes hidden topics for users", %{conn: conn} do moderator = moderator_user_fixture() forum = forum_fixture() topic = topic_fixture(forum) - {:ok, _} = Topics.hide_topic(topic, "spam", moderator) + {:ok, {_forum, _topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics") assert json_response(conn, 200) == %{"topics" => [], "total" => 0} end - test "returns an empty list for an unknown forum", %{conn: conn} do + test "excludes hidden topics for moderators", %{conn: conn} do + moderator = moderator_user_fixture() + forum = forum_fixture() + topic = topic_fixture(forum) + + {:ok, {_forum, _topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) + + conn = + get( + conn, + ~p"/api/v1/json/forums/#{forum}/topics?key=#{moderator.authentication_token}" + ) + + assert %{"topics" => [], "total" => 0} = json_response(conn, 200) + end + + test "returns 404 for an unknown forum", %{conn: conn} do # NOTE: unlike the show action, an unknown forum is a 200 with an empty # list, not a 404. conn = get(conn, ~p"/api/v1/json/forums/nonexistent/topics") - assert json_response(conn, 200) == %{"topics" => [], "total" => 0} + assert json_response(conn, 404) == %{"error" => "Not found"} end - test "returns an empty list for a restricted forum", %{conn: conn} do + test "returns 404 for a restricted forum", %{conn: conn} do forum = forum_fixture(access_level: "staff") _topic = topic_fixture(forum) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics") - assert json_response(conn, 200) == %{"topics" => [], "total" => 0} + assert json_response(conn, 404) == %{"error" => "Not found"} + end + + test "shows restricted forums to staff", %{conn: conn} do + moderator = moderator_user_fixture() + forum = forum_fixture(access_level: "staff") + _topic = topic_fixture(forum) + + conn = + get(conn, ~p"/api/v1/json/forums/#{forum}/topics?key=#{moderator.authentication_token}") + + assert %{"topics" => [_], "total" => 1} = json_response(conn, 200) end test "paginates with page and per_page", %{conn: conn} do @@ -131,7 +170,13 @@ defmodule PhilomenaWeb.Api.Json.Forum.TopicControllerTest do forum = forum_fixture() topic = topic_fixture(forum) - {:ok, _} = Topics.hide_topic(topic, "spam", moderator) + {:ok, {_forum, _topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) conn = get(conn, ~p"/api/v1/json/forums/#{forum}/topics/#{topic}") diff --git a/test/philomena_web/controllers/api/json/image/featured_controller_test.exs b/test/philomena_web/controllers/api/json/image/featured_controller_test.exs index 22072d9e0..6fbc9df06 100644 --- a/test/philomena_web/controllers/api/json/image/featured_controller_test.exs +++ b/test/philomena_web/controllers/api/json/image/featured_controller_test.exs @@ -6,16 +6,21 @@ defmodule PhilomenaWeb.Api.Json.Image.FeaturedControllerTest do import Philomena.UsersFixtures alias Philomena.ImageFeatures.ImageFeature - alias Philomena.Images alias Philomena.Repo + defp feature!(user, image) do + %ImageFeature{user_id: user.id, image_id: image.id} + |> ImageFeature.changeset(%{}) + |> Repo.insert!() + end + describe "GET /api/v1/json/images/featured" do test "shows the most recently featured image", %{conn: conn} do admin = admin_user_fixture() old_image = image_fixture() new_image = image_fixture() - {:ok, _} = Images.feature_image(admin, old_image) + feature!(admin, old_image) # Backdate the first feature so the ordering is unambiguous. Repo.update_all( @@ -23,7 +28,7 @@ defmodule PhilomenaWeb.Api.Json.Image.FeaturedControllerTest do set: [created_at: DateTime.add(DateTime.utc_now(:second), -3600)] ) - {:ok, _} = Images.feature_image(admin, new_image) + feature!(admin, new_image) conn = get(conn, ~p"/api/v1/json/images/featured") @@ -36,14 +41,14 @@ defmodule PhilomenaWeb.Api.Json.Image.FeaturedControllerTest do visible = image_fixture() hidden = image_fixture(hidden_from_users: true) - {:ok, _} = Images.feature_image(admin, visible) + feature!(admin, visible) Repo.update_all( where(ImageFeature, image_id: ^visible.id), set: [created_at: DateTime.add(DateTime.utc_now(:second), -3600)] ) - {:ok, _} = Images.feature_image(admin, hidden) + feature!(admin, hidden) conn = get(conn, ~p"/api/v1/json/images/featured") diff --git a/test/philomena_web/controllers/api/json/image_controller_test.exs b/test/philomena_web/controllers/api/json/image_controller_test.exs index 75dff14b0..53c899c79 100644 --- a/test/philomena_web/controllers/api/json/image_controller_test.exs +++ b/test/philomena_web/controllers/api/json/image_controller_test.exs @@ -7,6 +7,7 @@ defmodule PhilomenaWeb.Api.Json.ImageControllerTest do import Philomena.ImagesFixtures import Philomena.UsersFixtures + alias Philomena.Multi alias Philomena.ImageFaves alias Philomena.Images.Image alias Philomena.Repo @@ -97,28 +98,18 @@ defmodule PhilomenaWeb.Api.Json.ImageControllerTest do assert body["image"]["uploader_id"] == nil end - test "returns a metadata stub for a hidden image instead of a 404", %{conn: conn} do + test "returns a limited object for a hidden image", %{conn: conn} do image = image_fixture(hidden_from_users: true, deletion_reason: "Rule #0") conn = get(conn, ~p"/api/v1/json/images/#{image.id}") - # NOTE: hidden (deleted) images are still shown, as a reduced stub; - # there is no `spoilered` key in this branch. - assert json_response(conn, 200) == %{ - "interactions" => [], - "image" => %{ - "id" => image.id, - "created_at" => DateTime.to_iso8601(image.created_at), - "updated_at" => DateTime.to_iso8601(image.updated_at), - "first_seen_at" => DateTime.to_iso8601(image.first_seen_at), - "deletion_reason" => "Rule #0", - "duplicate_of" => nil, - "hidden_from_users" => true - } - } + body = json_response(conn, 200) + assert body["image"]["hidden_from_users"] == true + assert body["image"]["duplicate_id"] == nil + refute Map.has_key?(body["image"], "tags") end - test "shows the duplicate target instead of the deletion reason for a merged image", + test "returns a limited object for a merged image", %{conn: conn} do target = image_fixture() @@ -131,22 +122,20 @@ defmodule PhilomenaWeb.Api.Json.ImageControllerTest do conn = get(conn, ~p"/api/v1/json/images/#{image.id}") - assert %{ - "image" => %{ - "duplicate_of" => duplicate_of, - "deletion_reason" => nil, - "hidden_from_users" => true - } - } = json_response(conn, 200) - - assert duplicate_of == target.id + body = json_response(conn, 200) + assert body["image"]["hidden_from_users"] == true + assert body["image"]["duplicate_of"] == target.id + refute Map.has_key?(body["image"], "tags") end test "returns the user's interactions with the image for an API key", %{conn: conn} do user = confirmed_user_fixture() image = image_fixture() - {:ok, _} = Repo.transaction(ImageFaves.create_fave_transaction(image, user)) + {:ok, _} = + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> Multi.transact() conn = get(conn, ~p"/api/v1/json/images/#{image.id}?key=#{user.authentication_token}") @@ -169,12 +158,10 @@ defmodule PhilomenaWeb.Api.Json.ImageControllerTest do assert json_response(conn, 404) == %{"error" => "Not found"} end - test "raises for a non-integer id", %{conn: conn} do - # NOTE: the id is interpolated into the query without casting, so a - # non-integer id becomes a 500 rather than a 404. - assert_raise Ecto.Query.CastError, fn -> - get(conn, ~p"/api/v1/json/images/not-a-number") - end + test "returns 404 for a non-integer id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/json/images/not-a-number") + + assert json_response(conn, 404) == %{"error" => "Not found"} end end diff --git a/test/philomena_web/controllers/api/json/post_controller_test.exs b/test/philomena_web/controllers/api/json/post_controller_test.exs index a0c6d0bd8..3a7142dc0 100644 --- a/test/philomena_web/controllers/api/json/post_controller_test.exs +++ b/test/philomena_web/controllers/api/json/post_controller_test.exs @@ -49,21 +49,25 @@ defmodule PhilomenaWeb.Api.Json.PostControllerTest do assert author =~ ~r/\ABackground Pony #[0-9A-F]{4}\z/ end - test "nulls out the body of a hidden post but stays 200", %{conn: conn} do + test "returns 404 for a hidden post", %{conn: conn} do user = confirmed_user_fixture() moderator = moderator_user_fixture() forum = forum_fixture() topic = topic_fixture(forum) post = post_fixture(topic, user, %{"body" => "Rule-breaking post"}) - {:ok, _} = Posts.hide_post(post, %{"deletion_reason" => "spam"}, moderator) + {:ok, _} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + post.id, + %{"deletion_reason" => "spam"} + ) conn = get(conn, ~p"/api/v1/json/posts/#{post.id}") - assert %{"post" => %{"body" => nil, "edited_at" => nil, "author" => author}} = - json_response(conn, 200) - - assert author == user.name + assert json_response(conn, 404) == %{"error" => "Not found"} end test "returns 404 for a destroyed post", %{conn: conn} do @@ -71,7 +75,22 @@ defmodule PhilomenaWeb.Api.Json.PostControllerTest do topic = topic_fixture(forum) post = post_fixture(topic, nil) - {:ok, _} = Posts.destroy_post(post) + {:ok, _} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id, + %{deletion_reason: "Spam"} + ) + + {:ok, _} = + Posts.create_post_delete( + Philomena.AttributionFixtures.actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id + ) conn = get(conn, ~p"/api/v1/json/posts/#{post.id}") @@ -84,7 +103,13 @@ defmodule PhilomenaWeb.Api.Json.PostControllerTest do topic = topic_fixture(forum) post = post_fixture(topic, nil) - {:ok, _} = Topics.hide_topic(topic, "spam", moderator) + {:ok, {_forum, _topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) conn = get(conn, ~p"/api/v1/json/posts/#{post.id}") @@ -94,7 +119,7 @@ defmodule PhilomenaWeb.Api.Json.PostControllerTest do test "returns 404 for a post in a restricted forum", %{conn: conn} do forum = forum_fixture(access_level: "staff") topic = topic_fixture(forum) - post = post_fixture(topic, nil) + post = post_fixture(topic, moderator_user_fixture()) conn = get(conn, ~p"/api/v1/json/posts/#{post.id}") @@ -107,12 +132,9 @@ defmodule PhilomenaWeb.Api.Json.PostControllerTest do assert json_response(conn, 404) == %{"error" => "Not found"} end - test "raises for a non-integer id", %{conn: conn} do - # NOTE: the id is interpolated into the query without casting, so a - # non-integer id becomes a 500 rather than a 404. - assert_raise Ecto.Query.CastError, fn -> - get(conn, ~p"/api/v1/json/posts/not-a-number") - end + test "returns 404 for a non-integer id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/json/posts/not-a-number") + assert json_response(conn, 404) == %{"error" => "Not found"} end end end diff --git a/test/philomena_web/controllers/api/json/profile_controller_test.exs b/test/philomena_web/controllers/api/json/profile_controller_test.exs index c3e884558..9fbcdbab0 100644 --- a/test/philomena_web/controllers/api/json/profile_controller_test.exs +++ b/test/philomena_web/controllers/api/json/profile_controller_test.exs @@ -85,12 +85,10 @@ defmodule PhilomenaWeb.Api.Json.ProfileControllerTest do assert json_response(conn, 404) == %{"error" => "Not found"} end - test "raises for a non-integer id", %{conn: conn} do - # NOTE: the id is interpolated into the query without casting, so a - # non-integer id becomes a 500 rather than a 404. - assert_raise Ecto.Query.CastError, fn -> - get(conn, ~p"/api/v1/json/profiles/not-a-number") - end + test "returns 404 for a non-integer id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/json/profiles/not-a-number") + + assert json_response(conn, 404) == %{"error" => "Not found"} end end end diff --git a/test/philomena_web/controllers/api/json/search/comment_controller_test.exs b/test/philomena_web/controllers/api/json/search/comment_controller_test.exs index 2acb56345..30d8b712e 100644 --- a/test/philomena_web/controllers/api/json/search/comment_controller_test.exs +++ b/test/philomena_web/controllers/api/json/search/comment_controller_test.exs @@ -3,9 +3,11 @@ defmodule PhilomenaWeb.Api.Json.Search.CommentControllerTest do @moduletag :search + import Philomena.AttributionFixtures import Philomena.CommentsFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures + import Philomena.RulesFixtures alias Philomena.Comments alias Philomena.Comments.Comment @@ -40,9 +42,13 @@ defmodule PhilomenaWeb.Api.Json.Search.CommentControllerTest do hidden_image = image_fixture(hidden_from_users: true) _visible = comment_fixture(image, nil, %{"body" => "chartreuse llama"}) - _on_hidden = comment_fixture(hidden_image, nil, %{"body" => "chartreuse vicuna"}) - hidden = comment_fixture(image, nil, %{"body" => "chartreuse guanaco"}) - {:ok, _} = Comments.hide_comment(hidden, %{"deletion_reason" => "spam"}, moderator) + _on_hidden = comment_fixture(hidden_image, moderator, %{"body" => "chartreuse vicuna"}) + hidden = comment_fixture(image, moderator, %{"body" => "chartreuse guanaco"}) + + {:ok, _} = + Comments.create_comment_hide(actor(moderator), image.id, hidden.id, %{ + "deletion_reason" => "spam" + }) SearchHelpers.reindex_all!(Comment) @@ -58,6 +64,8 @@ defmodule PhilomenaWeb.Api.Json.Search.CommentControllerTest do # A body with an external link is withheld from approval when the # author is new. + _rule = rule_fixture(name: "Approval") + _unapproved = comment_fixture(image, user, %{"body" => "chartreuse https://spam.example/"}) diff --git a/test/philomena_web/controllers/api/json/search/image_controller_test.exs b/test/philomena_web/controllers/api/json/search/image_controller_test.exs index bd7570612..8ec022d98 100644 --- a/test/philomena_web/controllers/api/json/search/image_controller_test.exs +++ b/test/philomena_web/controllers/api/json/search/image_controller_test.exs @@ -6,9 +6,9 @@ defmodule PhilomenaWeb.Api.Json.Search.ImageControllerTest do import Philomena.ImagesFixtures import Philomena.UsersFixtures + alias Philomena.Multi alias Philomena.ImageFaves alias Philomena.Images.Image - alias Philomena.Repo alias PhilomenaQuery.Search alias PhilomenaQuery.SearchHelpers @@ -70,7 +70,12 @@ defmodule PhilomenaWeb.Api.Json.Search.ImageControllerTest do test "returns the user's interactions for an API key", %{conn: conn} do user = confirmed_user_fixture() image = image_fixture() - {:ok, _} = Repo.transaction(ImageFaves.create_fave_transaction(image, user)) + + {:ok, _} = + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> Multi.transact() + SearchHelpers.reindex_all!(Image) conn = diff --git a/test/philomena_web/controllers/api/json/search/post_controller_test.exs b/test/philomena_web/controllers/api/json/search/post_controller_test.exs index 98b23abe0..e11a1655a 100644 --- a/test/philomena_web/controllers/api/json/search/post_controller_test.exs +++ b/test/philomena_web/controllers/api/json/search/post_controller_test.exs @@ -35,7 +35,9 @@ defmodule PhilomenaWeb.Api.Json.Search.PostControllerTest do assert author == user.name end - test "excludes hidden posts and posts in restricted forums", %{conn: conn} do + test "excludes hidden posts and posts in restricted forums for anonymous actors", %{ + conn: conn + } do moderator = moderator_user_fixture() forum = forum_fixture() staff_forum = forum_fixture(access_level: "staff") @@ -45,7 +47,16 @@ defmodule PhilomenaWeb.Api.Json.Search.PostControllerTest do topic_fixture(staff_forum, nil, %{"posts" => %{"0" => %{"body" => "chartreuse vicuna"}}}) hidden = post_fixture(topic, nil, %{"body" => "chartreuse guanaco"}) - {:ok, _} = Posts.hide_post(hidden, %{"deletion_reason" => "spam"}, moderator) + + {:ok, _} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + hidden.id, + %{"deletion_reason" => "spam"} + ) + SearchHelpers.reindex_all!(Post) conn = get(conn, ~p"/api/v1/json/search/posts?q=chartreuse") @@ -54,12 +65,42 @@ defmodule PhilomenaWeb.Api.Json.Search.PostControllerTest do json_response(conn, 200) end + test "allows moderators to search posts in restricted forums", %{conn: conn} do + moderator = moderator_user_fixture() + staff_forum = forum_fixture(access_level: "staff") + + post = + staff_forum + |> topic_fixture() + |> post_fixture(admin_user_fixture(), %{"body" => "chartreuse vicuna"}) + + SearchHelpers.reindex_all!(Post) + + conn = + get( + conn, + ~p"/api/v1/json/search/posts?q=chartreuse&key=#{moderator.authentication_token}" + ) + + assert %{"total" => 1, "posts" => [%{"id" => id, "body" => "chartreuse vicuna"}]} = + json_response(conn, 200) + + assert id == post.id + end + test "excludes a matched post whose topic is hidden", %{conn: conn} do moderator = moderator_user_fixture() forum = forum_fixture() topic = topic_fixture(forum, nil, %{"posts" => %{"0" => %{"body" => "chartreuse okapi"}}}) - {:ok, _} = Topics.hide_topic(topic, "spam", moderator) + {:ok, {_forum, _topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) + SearchHelpers.reindex_all!(Post) conn = get(conn, ~p"/api/v1/json/search/posts?q=chartreuse") @@ -78,7 +119,14 @@ defmodule PhilomenaWeb.Api.Json.Search.PostControllerTest do topic = topic_fixture(forum, nil, %{"posts" => %{"0" => %{"body" => "chartreuse okapi"}}}) # Hiding the topic and reindexing folds its posts to hidden, excluding them. - {:ok, hidden_topic} = Topics.hide_topic(topic, "spam", moderator) + {:ok, {_forum, hidden_topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "spam"} + ) + SearchHelpers.reindex_all!(Post) conn = get(conn, ~p"/api/v1/json/search/posts?q=chartreuse") @@ -87,7 +135,13 @@ defmodule PhilomenaWeb.Api.Json.Search.PostControllerTest do # Unhiding it (which enqueues a topic-wide post reindex in production; here # we drive the reindex explicitly) folds the posts back to visible, so the # post is searchable again with its real body. - {:ok, _} = Topics.unhide_topic(hidden_topic) + {:ok, {_forum, _topic}} = + Topics.delete_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + hidden_topic.slug + ) + SearchHelpers.reindex_all!(Post) conn = get(conn, ~p"/api/v1/json/search/posts?q=chartreuse") diff --git a/test/philomena_web/controllers/api/json/search/reverse_controller_test.exs b/test/philomena_web/controllers/api/json/search/reverse_controller_test.exs index 5b325a590..3ec445f51 100644 --- a/test/philomena_web/controllers/api/json/search/reverse_controller_test.exs +++ b/test/philomena_web/controllers/api/json/search/reverse_controller_test.exs @@ -47,6 +47,18 @@ defmodule PhilomenaWeb.Api.Json.Search.ReverseControllerTest do assert json_response(conn, 200) == %{"images" => [], "interactions" => [], "total" => 0} end + test "excludes hidden matching images", %{conn: conn} do + hidden = image_fixture(hidden_from_users: true) + insert_intensities(hidden, @png_intensity) + + conn = + post(conn, ~p"/api/v1/json/search/reverse", %{ + "image" => %{"image" => png_upload()} + }) + + assert json_response(conn, 200) == %{"images" => [], "interactions" => [], "total" => 0} + end + test "returns empty results instead of an error for an invalid limit", %{conn: conn} do image = image_fixture() insert_intensities(image, @png_intensity) diff --git a/test/philomena_web/controllers/api/json/search/tag_controller_test.exs b/test/philomena_web/controllers/api/json/search/tag_controller_test.exs index 60fc8834f..2fcc589b2 100644 --- a/test/philomena_web/controllers/api/json/search/tag_controller_test.exs +++ b/test/philomena_web/controllers/api/json/search/tag_controller_test.exs @@ -76,7 +76,7 @@ defmodule PhilomenaWeb.Api.Json.Search.TagControllerTest do test "returns 400 with a JSON error for an unparsable query", %{conn: conn} do conn = get(conn, ~p"/api/v1/json/search/tags?q=)") - assert json_response(conn, 400) == %{"error" => "Imbalanced parentheses."} + assert json_response(conn, 400) == %{"error" => "is invalid: Imbalanced parentheses."} end end end diff --git a/test/philomena_web/controllers/api/json/tag_controller_test.exs b/test/philomena_web/controllers/api/json/tag_controller_test.exs index 9118357cd..8a92996b0 100644 --- a/test/philomena_web/controllers/api/json/tag_controller_test.exs +++ b/test/philomena_web/controllers/api/json/tag_controller_test.exs @@ -3,7 +3,7 @@ defmodule PhilomenaWeb.Api.Json.TagControllerTest do import Philomena.TagsFixtures - alias Philomena.Tags + alias Philomena.Repo describe "GET /api/v1/json/tags/:slug" do test "shows a tag by slug", %{conn: conn} do @@ -52,7 +52,9 @@ defmodule PhilomenaWeb.Api.Json.TagControllerTest do tag = tag_fixture(name: "pegasus pony") target = tag_fixture(name: "pegasus") - {:ok, _tag} = Tags.alias_tag(tag, %{"target_tag" => target.name}) + tag + |> Ecto.Changeset.change(aliased_tag_id: target.id) + |> Repo.update!() conn1 = get(conn, ~p"/api/v1/json/tags/#{tag}") diff --git a/test/philomena_web/controllers/api/rss/watched_controller_test.exs b/test/philomena_web/controllers/api/rss/watched_controller_test.exs index b44ca65ca..1fea324bc 100644 --- a/test/philomena_web/controllers/api/rss/watched_controller_test.exs +++ b/test/philomena_web/controllers/api/rss/watched_controller_test.exs @@ -56,12 +56,13 @@ defmodule PhilomenaWeb.Api.Rss.WatchedControllerTest do refute response =~ "" end - test "redirects an anonymous request to the HTML login page", %{conn: conn} do - # NOTE: an unauthenticated request gets the browser-style login + test "redirects an anonymous request with the authorization flash", %{conn: conn} do + # NOTE: an unauthenticated request gets the browser-style # redirect, not a 401. conn = get(conn, ~p"/api/v1/rss/watched") - assert redirected_to(conn) == ~p"/sessions/new" + assert redirected_to(conn) == ~p"/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access" end end end diff --git a/test/philomena_web/controllers/autocomplete/compiled_controller_test.exs b/test/philomena_web/controllers/autocomplete/compiled_controller_test.exs index 0a1d0135f..082c36564 100644 --- a/test/philomena_web/controllers/autocomplete/compiled_controller_test.exs +++ b/test/philomena_web/controllers/autocomplete/compiled_controller_test.exs @@ -29,7 +29,7 @@ defmodule PhilomenaWeb.Autocomplete.CompiledControllerTest do conn = get(conn, ~p"/autocomplete/compiled") - # NOTE: get_autocomplete/0 orders by created_at desc; both rows here get + # NOTE: show_compiled_autocomplete/0 orders by created_at desc; both rows here get # the same second-granularity timestamp, so the winner is whichever the # database returns first - assert only that a valid binary is served. assert response(conn, 200) in [<<1>>, <<2>>] diff --git a/test/philomena_web/controllers/avatar_controller_test.exs b/test/philomena_web/controllers/avatar_controller_test.exs index df3d96d12..448f155b4 100644 --- a/test/philomena_web/controllers/avatar_controller_test.exs +++ b/test/philomena_web/controllers/avatar_controller_test.exs @@ -48,7 +48,7 @@ defmodule PhilomenaWeb.AvatarControllerTest do # Only the avatar path column persists; the width/height/size/mime # fields on the schema are virtual and validation-only. - assert Users.get_user!(user.id).avatar =~ ~r/\.png$/ + assert Users.fetch_user_for_worker!(user.id).avatar =~ ~r/\.png$/ end test "re-renders the form without an avatar file", %{conn: conn, user: user} do @@ -57,7 +57,7 @@ defmodule PhilomenaWeb.AvatarControllerTest do # NOTE: the failure branch re-renders edit.html without the :title # assign, so pin page content rather than the title. assert html_response(conn, 200) =~ "Your avatar" - refute Users.get_user!(user.id).avatar + refute Users.fetch_user_for_worker!(user.id).avatar end test "redirects anonymous users to the login page" do @@ -74,7 +74,7 @@ defmodule PhilomenaWeb.AvatarControllerTest do conn = put(conn, ~p"/avatar", %{"user" => %{"avatar" => png_upload()}}) assert redirected_to(conn) == ~p"/avatar/edit" - assert Users.get_user!(user.id).avatar + assert Users.fetch_user_for_worker!(user.id).avatar end end @@ -96,7 +96,7 @@ defmodule PhilomenaWeb.AvatarControllerTest do assert redirected_to(conn) == ~p"/avatar/edit" assert Flash.get(conn.assigns.flash, :info) =~ "Successfully removed avatar." - refute Users.get_user!(user.id).avatar + refute Users.fetch_user_for_worker!(user.id).avatar end test "succeeds even when no avatar is set", %{conn: conn} do diff --git a/test/philomena_web/controllers/channel/read_controller_test.exs b/test/philomena_web/controllers/channel/read_controller_test.exs index 946b12b59..8495b884d 100644 --- a/test/philomena_web/controllers/channel/read_controller_test.exs +++ b/test/philomena_web/controllers/channel/read_controller_test.exs @@ -21,7 +21,7 @@ defmodule PhilomenaWeb.Channel.ReadControllerTest do path: ~p"/channels/#{channel}/read", arrange!: fn -> {:ok, _} = Channels.create_subscription(channel, user) - {:ok, 1} = Notifications.create_channel_live_notification(channel) + {:ok, 1} = Notifications.broadcast_channel_live(channel) end, notification?: fn -> Repo.exists?( @@ -35,9 +35,8 @@ defmodule PhilomenaWeb.Channel.ReadControllerTest do read_singleton_tests() test "POST for an unknown channel redirects with the not-found flash", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its not-found - # handler on :create - an unknown channel redirects instead of passing nil - # into clear_channel_notification/2. + # The shared loader rejects the missing row before the named :mark_read + # authorization is attempted. %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/channels/999999999/read") diff --git a/test/philomena_web/controllers/channel/subscription_controller_test.exs b/test/philomena_web/controllers/channel/subscription_controller_test.exs index 70e3e079e..2eac68df6 100644 --- a/test/philomena_web/controllers/channel/subscription_controller_test.exs +++ b/test/philomena_web/controllers/channel/subscription_controller_test.exs @@ -29,20 +29,19 @@ defmodule PhilomenaWeb.Channel.SubscriptionControllerTest do subscription_toggle_tests() - test "POST for an unknown channel redirects to / with the authorization flash", + test "POST for an unknown channel redirects to / with the not-found flash", %{conn: conn} do - # Canary sends the nil resource down the unauthorized path %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/channels/999999999/subscription") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer channel id redirects to / with the not-found flash", %{conn: conn} do # the central IntegerId guard short-circuits a non-integer id to - # NotFoundPlug before Canary authorizes + # NotFoundPlug before authorization runs %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/channels/not-a-number/subscription") diff --git a/test/philomena_web/controllers/channel_controller_test.exs b/test/philomena_web/controllers/channel_controller_test.exs index 8dbcf9c6d..d8a4087ec 100644 --- a/test/philomena_web/controllers/channel_controller_test.exs +++ b/test/philomena_web/controllers/channel_controller_test.exs @@ -17,11 +17,11 @@ defmodule PhilomenaWeb.ChannelControllerTest do defp fetched_channel_fixture(attrs) do # A channel only appears in the index once the fetcher has stamped - # last_fetched_at; update_channel_state is the changeset the fetcher + # last_fetched_at; update_fetch_state is the changeset the fetcher # uses (update_channel only casts type and short_name). {:ok, channel} = channel_fixture() - |> Channels.update_channel_state(Map.put(attrs, "last_fetched_at", DateTime.utc_now())) + |> Channels.update_fetch_state(Map.put(attrs, "last_fetched_at", DateTime.utc_now())) channel end @@ -47,7 +47,7 @@ defmodule PhilomenaWeb.ChannelControllerTest do test "does not list channels that have never been fetched", %{conn: conn} do {:ok, _unfetched} = - Channels.update_channel_state(channel_fixture(), %{"title" => "Test Unfetched Stream"}) + Channels.update_fetch_state(channel_fixture(), %{"title" => "Test Unfetched Stream"}) conn = get(conn, ~p"/channels") response = html_response(conn, 200) @@ -102,7 +102,7 @@ defmodule PhilomenaWeb.ChannelControllerTest do conn = get(conn, ~p"/channels/999999") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -267,7 +267,7 @@ defmodule PhilomenaWeb.ChannelControllerTest do # NOTE: update_channel casts only :type and :short_name (via # Channel.changeset); the fetcher-managed fields (title, nsfw, is_live, - # viewers, thumbnail_url, last_fetched_at) go through update_channel_state + # viewers, thumbnail_url, last_fetched_at) go through update_fetch_state # and are silently ignored here. test "ignores fetcher-managed fields in the update", %{conn: conn} do channel = fetched_channel_fixture(%{"title" => "Original Title"}) diff --git a/test/philomena_web/controllers/comment_controller_test.exs b/test/philomena_web/controllers/comment_controller_test.exs index 796b4b6f3..9c17eb929 100644 --- a/test/philomena_web/controllers/comment_controller_test.exs +++ b/test/philomena_web/controllers/comment_controller_test.exs @@ -5,6 +5,7 @@ defmodule PhilomenaWeb.CommentControllerTest do import Philomena.CommentsFixtures import Philomena.ImagesFixtures + import Philomena.UsersFixtures alias PhilomenaQuery.Search alias PhilomenaQuery.SearchHelpers @@ -48,7 +49,10 @@ defmodule PhilomenaWeb.CommentControllerTest do test "does not show comments on hidden images to anonymous users", %{conn: conn} do image = image_fixture(hidden_from_users: true) - _comment = comment_fixture(image, nil, %{"body" => "Test orphaned comment body"}) + + _comment = + comment_fixture(image, moderator_user_fixture(), %{"body" => "Test orphaned comment body"}) + SearchHelpers.reindex_all!(Comment) conn = get(conn, ~p"/comments") diff --git a/test/philomena_web/controllers/commission_controller_test.exs b/test/philomena_web/controllers/commission_controller_test.exs index ce5698ada..802f6579c 100644 --- a/test/philomena_web/controllers/commission_controller_test.exs +++ b/test/philomena_web/controllers/commission_controller_test.exs @@ -91,9 +91,9 @@ defmodule PhilomenaWeb.CommissionControllerTest do end test "renders an empty result set on invalid search parameters", %{conn: conn} do - # NOTE: an invalid search now renders an empty Scrivener page (200) with an - # error changeset rather than crashing the pagination partial on a bare - # list. + # NOTE: an invalid search now renders an empty page (200) with an + # error changeset rather than crashing the pagination partial on a + # bare list. conn = get(conn, ~p"/commissions?#{[commission: [price_min: "not-a-price"]]}") response = html_response(conn, 200) diff --git a/test/philomena_web/controllers/confirmation_controller_test.exs b/test/philomena_web/controllers/confirmation_controller_test.exs index 7334d65f8..5a0df81d5 100644 --- a/test/philomena_web/controllers/confirmation_controller_test.exs +++ b/test/philomena_web/controllers/confirmation_controller_test.exs @@ -57,20 +57,37 @@ defmodule PhilomenaWeb.ConfirmationControllerTest do end describe "GET /confirmations/:id" do - test "confirms the given token once", %{conn: conn, user: user} do + test "renders the confirmation form without confirming the account", %{conn: conn, user: user} do token = extract_user_token(fn url -> Users.deliver_user_confirmation_instructions(user, url) end) conn = get(conn, ~p"/confirmations/#{token}") + response = html_response(conn, 200) + + assert response =~ "

Confirm account

" + assert response =~ ~s(action="/confirmations/#{token}") + refute Users.fetch_user_for_worker!(user.id).confirmed_at + assert Repo.get_by!(Users.UserToken, user_id: user.id).context == "confirm" + end + end + + describe "PUT /confirmations/:id" do + test "confirms the given token once", %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Users.deliver_user_confirmation_instructions(user, url) + end) + + conn = put(conn, ~p"/confirmations/#{token}") assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :info) =~ "Account confirmed successfully" - assert Users.get_user!(user.id).confirmed_at + assert Users.fetch_user_for_worker!(user.id).confirmed_at refute get_session(conn, :user_token) assert Repo.all(Users.UserToken) == [] - conn = get(conn, ~p"/confirmations/#{token}") + conn = put(conn, ~p"/confirmations/#{token}") assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :error) =~ @@ -78,13 +95,13 @@ defmodule PhilomenaWeb.ConfirmationControllerTest do end test "does not confirm email with invalid token", %{conn: conn, user: user} do - conn = get(conn, ~p"/confirmations/oops") + conn = put(conn, ~p"/confirmations/oops") assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :error) =~ "Confirmation link is invalid or it has expired" - refute Users.get_user!(user.id).confirmed_at + refute Users.fetch_user_for_worker!(user.id).confirmed_at end end @@ -96,11 +113,10 @@ defmodule PhilomenaWeb.ConfirmationControllerTest do end # EnsureUserEnabledPlug exempts a merely-unconfirmed session on the - # confirmation-show path only, and the router moved GET /confirmations/:id + # confirmation path only, and the router moved /confirmations/:id # out of redirect_if_user_is_authenticated, so a logged-in unconfirmed user - # can follow their own link: the account is confirmed and they stay logged - # in. - test "GET /confirmations/:id confirms an unconfirmed logged-in user and keeps them logged in", + # can follow their own link and confirm the account while staying logged in. + test "GET /confirmations/:id renders the form for an unconfirmed logged-in user", %{conn: conn, user: user} do token = extract_user_token(fn url -> @@ -109,10 +125,24 @@ defmodule PhilomenaWeb.ConfirmationControllerTest do conn = conn |> log_in_user(user) |> get(~p"/confirmations/#{token}") + assert html_response(conn, 200) =~ "

Confirm account

" + refute Users.fetch_user_for_worker!(user.id).confirmed_at + assert get_session(conn, :user_token) + end + + test "PUT /confirmations/:id confirms an unconfirmed logged-in user and keeps them logged in", + %{conn: conn, user: user} do + token = + extract_user_token(fn url -> + Users.deliver_user_confirmation_instructions(user, url) + end) + + conn = conn |> log_in_user(user) |> put(~p"/confirmations/#{token}") + assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :info) =~ "Account confirmed successfully." assert get_session(conn, :user_token) - assert Users.get_user!(user.id).confirmed_at + assert Users.fetch_user_for_worker!(user.id).confirmed_at end # A deactivated (deleted_at set) account is locked out everywhere, @@ -132,24 +162,17 @@ defmodule PhilomenaWeb.ConfirmationControllerTest do assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :error) =~ "Your account is not currently active." refute get_session(conn, :user_token) - refute Users.get_user!(user.id).confirmed_at + refute Users.fetch_user_for_worker!(user.id).confirmed_at end - # NOTE: GET /confirmations/:id is no longer guarded by - # redirect_if_user_is_authenticated, so a confirmed logged-in user now - # reaches the controller (previously it silently redirected to "/") and - # gets the invalid-link error while remaining logged in. - test "GET /confirmations/:id reaches the controller for a confirmed logged-in user", + test "PUT /confirmations/:id silently redirects confirmed users for invalid tokens", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - conn = get(conn, ~p"/confirmations/oops") + conn = put(conn, ~p"/confirmations/oops") assert redirected_to(conn) == "/" - - assert Flash.get(conn.assigns.flash, :error) =~ - "Confirmation link is invalid or it has expired" - + refute Flash.get(conn.assigns.flash, :error) assert get_session(conn, :user_token) end diff --git a/test/philomena_web/controllers/conversation/hide_controller_test.exs b/test/philomena_web/controllers/conversation/hide_controller_test.exs index dbdf6b463..d26decd76 100644 --- a/test/philomena_web/controllers/conversation/hide_controller_test.exs +++ b/test/philomena_web/controllers/conversation/hide_controller_test.exs @@ -49,7 +49,12 @@ defmodule PhilomenaWeb.Conversation.HideControllerTest do %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) conversation = conversation_fixture(confirmed_user_fixture(), user) - {:ok, _} = Conversations.mark_conversation_hidden(conversation, user) + + {:ok, _} = + Conversations.update_conversation_hide( + Philomena.AttributionFixtures.actor(user), + conversation.slug + ) conn = delete(conn, ~p"/conversations/#{conversation}/hide") @@ -69,14 +74,15 @@ defmodule PhilomenaWeb.Conversation.HideControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end - test "POST for an unknown conversation redirects to / with the authorization flash", + test "POST for an unknown conversation redirects with the not-found flash", %{conn: conn} do - # Canary sends the nil resource down the unauthorized path %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/conversations/unknown-slug/hide") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end diff --git a/test/philomena_web/controllers/conversation/message/approve_controller_test.exs b/test/philomena_web/controllers/conversation/message/approve_controller_test.exs index c51e8b443..9036cbb9a 100644 --- a/test/philomena_web/controllers/conversation/message/approve_controller_test.exs +++ b/test/philomena_web/controllers/conversation/message/approve_controller_test.exs @@ -68,7 +68,7 @@ defmodule PhilomenaWeb.Conversation.Message.ApproveControllerTest do assert Repo.reload!(message).approved end - test "approving an already-approved message is idempotent", %{conn: conn} do + test "approving an already-approved still succeeds", %{conn: conn} do from = confirmed_user_fixture() to = confirmed_user_fixture() conversation = conversation_fixture(from, to) @@ -81,21 +81,23 @@ defmodule PhilomenaWeb.Conversation.Message.ApproveControllerTest do conn = post(conn, ~p"/conversations/#{conversation}/messages/#{message}/approve") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Conversation message approved." + + assert Phoenix.Flash.get(conn.assigns.flash, :info) == + "Conversation message has already been approved." + assert Repo.reload!(message).approved end - # Failure path: an unknown message_id is loaded as nil and authorized - # against the ability rules, where the moderator has no matching rule, - # so it takes the not-authorized redirect rather than a not-found one. - test "for an unknown message_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown message_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) conn = post(conn, ~p"/conversations/#{conversation}/messages/999999999/approve") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer message_id short-circuits to NotFoundPlug via the @@ -111,5 +113,19 @@ defmodule PhilomenaWeb.Conversation.Message.ApproveControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Couldn't find what you were looking for!" end + + test "for a message belonging to another conversation redirects as not found", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + other = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) + message = message_fixture(other, other.from) + + conn = post(conn, ~p"/conversations/#{conversation}/messages/#{message}/approve") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" + end end end diff --git a/test/philomena_web/controllers/conversation/message_controller_test.exs b/test/philomena_web/controllers/conversation/message_controller_test.exs index d27dc86c8..69a97f36a 100644 --- a/test/philomena_web/controllers/conversation/message_controller_test.exs +++ b/test/philomena_web/controllers/conversation/message_controller_test.exs @@ -1,12 +1,20 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do use PhilomenaWeb.ConnCase, async: true + import Ecto.Query import Philomena.ConversationsFixtures import Philomena.UsersFixtures - alias Philomena.Conversations + alias Philomena.Conversations.Message alias Philomena.Repo + defp message_count(conversation) do + Repo.aggregate( + from(message in Message, where: message.conversation_id == ^conversation.id), + :count + ) + end + test "anonymous POST redirects to the login page", %{conn: conn} do conn = post(conn, ~p"/conversations/dummy-slug/messages", %{}) @@ -30,7 +38,7 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do assert redirected_to(conn) == ~p"/conversations/#{conversation}?#{[page: 1]}" assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Message successfully sent." - assert Conversations.count_messages(conversation) == 2 + assert message_count(conversation) == 2 # a new message marks both sides unread again conversation = Repo.reload!(conversation) @@ -38,7 +46,7 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do refute conversation.to_read end - test "POST with an empty body redirects back with an error flash", %{conn: conn} do + test "POST with an empty body re-renders the conversation with errors", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) conversation = conversation_fixture(confirmed_user_fixture(), user) @@ -47,12 +55,10 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do "message" => %{"body" => ""} }) - assert redirected_to(conn) == ~p"/conversations/#{conversation}" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "There was an error posting your message" - - assert Conversations.count_messages(conversation) == 1 + response = html_response(conn, 200) + assert response =~ conversation.title + assert response =~ "can't be blank" + assert message_count(conversation) == 1 end test "POST as a non-participant moderator creates the message", %{conn: conn} do @@ -67,7 +73,7 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do }) assert redirected_to(conn) == ~p"/conversations/#{conversation}?#{[page: 1]}" - assert Conversations.count_messages(conversation) == 2 + assert message_count(conversation) == 2 end test "POST as a non-participant redirects to / with the authorization flash", %{conn: conn} do @@ -81,12 +87,11 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." - assert Conversations.count_messages(conversation) == 1 + assert message_count(conversation) == 1 end - test "POST for an unknown conversation redirects to / with the authorization flash", + test "POST for an unknown conversation redirects with the not-found flash", %{conn: conn} do - # Canary sends the nil resource down the unauthorized path %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = @@ -95,7 +100,9 @@ defmodule PhilomenaWeb.Conversation.MessageControllerTest do }) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end test "POST as a banned user redirects with the ban flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/conversation/read_controller_test.exs b/test/philomena_web/controllers/conversation/read_controller_test.exs index b8fe2b8eb..502b5b1cc 100644 --- a/test/philomena_web/controllers/conversation/read_controller_test.exs +++ b/test/philomena_web/controllers/conversation/read_controller_test.exs @@ -42,7 +42,12 @@ defmodule PhilomenaWeb.Conversation.ReadControllerTest do %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) conversation = conversation_fixture(confirmed_user_fixture(), user) - {:ok, _} = Conversations.mark_conversation_read(conversation, user) + + {:ok, _} = + Conversations.update_conversation_read( + Philomena.AttributionFixtures.actor(user), + conversation.slug + ) conn = delete(conn, ~p"/conversations/#{conversation}/read") @@ -80,7 +85,7 @@ defmodule PhilomenaWeb.Conversation.ReadControllerTest do test "POST as a non-participant moderator succeeds but changes neither flag", %{conn: conn} do # NOTE: moderators pass the :show authorization, but - # mark_conversation_read/3 only sets the flag for the from/to sides, so + # the context only sets flags for the from/to sides, so # the action is a flash + redirect no-op for them. %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conversation = conversation_fixture(confirmed_user_fixture(), confirmed_user_fixture()) @@ -95,14 +100,15 @@ defmodule PhilomenaWeb.Conversation.ReadControllerTest do assert conversation.from_read end - test "POST for an unknown conversation redirects to / with the authorization flash", + test "POST for an unknown conversation redirects with the not-found flash", %{conn: conn} do - # Canary sends the nil resource down the unauthorized path %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/conversations/unknown-slug/read") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end diff --git a/test/philomena_web/controllers/conversation_controller_test.exs b/test/philomena_web/controllers/conversation_controller_test.exs index 5e600009b..5c4411589 100644 --- a/test/philomena_web/controllers/conversation_controller_test.exs +++ b/test/philomena_web/controllers/conversation_controller_test.exs @@ -40,7 +40,12 @@ defmodule PhilomenaWeb.ConversationControllerTest do test "GET /conversations does not list conversations the user has hidden", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) hidden = conversation_fixture(confirmed_user_fixture(), user) - {:ok, _} = Philomena.Conversations.mark_conversation_hidden(hidden, user) + + {:ok, _} = + Philomena.Conversations.update_conversation_hide( + Philomena.AttributionFixtures.actor(user), + hidden.slug + ) response = html_response(get(conn, ~p"/conversations"), 200) @@ -91,15 +96,25 @@ defmodule PhilomenaWeb.ConversationControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end - test "GET /conversations/:id for an unknown slug redirects to / with the authorization flash", + test "GET /conversations/:id for an unknown slug redirects with the not-found flash", %{conn: conn} do - # Canary sends the nil resource down the unauthorized path %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = get(conn, ~p"/conversations/unknown-slug") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" + end + + test "GET /conversations with a malformed partner filter renders an empty index", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + + response = html_response(get(conn, ~p"/conversations?#{[with: "not-a-number"]}"), 200) + + assert response =~ "My Conversations" + assert response =~ "Invalid conversation filter." end test "POST /conversations creates the conversation and first message", %{conn: conn} do @@ -143,6 +158,14 @@ defmodule PhilomenaWeb.ConversationControllerTest do assert Repo.aggregate(from(c in Conversation, where: c.from_id == ^user.id), :count) == 0 end + test "POST /conversations with non-map params raises", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + + assert_raise Ecto.CastError, fn -> + post(conn, ~p"/conversations", %{"conversation" => "invalid"}) + end + end + test "POST /conversations as a banned user redirects with the ban flash", %{conn: conn} do %{conn: conn} = register_and_log_in_banned_user(%{conn: conn}) diff --git a/test/philomena_web/controllers/deactivation_controller_test.exs b/test/philomena_web/controllers/deactivation_controller_test.exs index 566b0c2b7..b2241c65b 100644 --- a/test/philomena_web/controllers/deactivation_controller_test.exs +++ b/test/philomena_web/controllers/deactivation_controller_test.exs @@ -20,7 +20,7 @@ defmodule PhilomenaWeb.DeactivationControllerTest do conn = get(conn, ~p"/registrations/edit") assert redirected_to(conn) == ~p"/sessions/new" - user = Users.get_user!(user.id) + user = Users.fetch_user_for_worker!(user.id) assert user.deleted_by_user_id == user.id end end diff --git a/test/philomena_web/controllers/dnp_entry_controller_test.exs b/test/philomena_web/controllers/dnp_entry_controller_test.exs index bc231a9dc..b4d756979 100644 --- a/test/philomena_web/controllers/dnp_entry_controller_test.exs +++ b/test/philomena_web/controllers/dnp_entry_controller_test.exs @@ -2,24 +2,17 @@ defmodule PhilomenaWeb.DnpEntryControllerTest do use PhilomenaWeb.ConnCase, async: true import Ecto.Query + import Philomena.ArtistLinksFixtures import Philomena.DnpEntriesFixtures import Philomena.TagsFixtures import Philomena.UsersFixtures - alias Philomena.ArtistLinks alias Philomena.DnpEntries.DnpEntry alias Philomena.Repo - # The DNP form only offers tags from the user's verified artist links - # (the :set_tags plug rejects users without any) + # The DNP form only offers regular users tags from verified artist links. defp verify_artist_link!(user, tag) do - {:ok, link} = - ArtistLinks.create_artist_link(user, %{ - "tag_name" => tag.name, - "uri" => "https://example.com/gallery" - }) - - {:ok, _link} = ArtistLinks.verify_artist_link(link, user) + verified_artist_link_fixture(user, tag, %{"uri" => "https://example.com/gallery"}) :ok end @@ -112,7 +105,7 @@ defmodule PhilomenaWeb.DnpEntryControllerTest do conn = get(conn, ~p"/dnp/999999") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -254,19 +247,16 @@ defmodule PhilomenaWeb.DnpEntryControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end - test "redirects a moderator without a tag_id param with the authorization flash", - %{conn: conn} do - # NOTE: the :set_tags plug offers moderators the ?tag_id= tag, but - # falls back to their own linked tags without it - a moderator with no - # verified artist link of their own cannot open the edit form bare + test "renders the current tag for a moderator without a tag_id param", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) tag = tag_fixture(name: "artist:test-mod-bare-artist") entry = dnp_entry_fixture(confirmed_user_fixture(), tag) conn = get(conn, ~p"/dnp/#{entry}/edit") + response = html_response(conn, 200) - assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert response =~ "Editing DNP Listing - Derpibooru" + assert response =~ "test-mod-bare-artist" end test "renders the form for a moderator with a tag_id param", %{conn: conn} do diff --git a/test/philomena_web/controllers/duplicate_report/accept_controller_test.exs b/test/philomena_web/controllers/duplicate_report/accept_controller_test.exs index 4a99a391d..b970ed068 100644 --- a/test/philomena_web/controllers/duplicate_report/accept_controller_test.exs +++ b/test/philomena_web/controllers/duplicate_report/accept_controller_test.exs @@ -54,15 +54,13 @@ defmodule PhilomenaWeb.DuplicateReport.AcceptControllerTest do assert source.duplicate_id == target.id end - test "an unknown report id takes the not-authorized redirect", %{conn: conn} do + test "an unknown report id takes the not-found redirect", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) - # NOTE: load_and_authorize_resource authorizes a nil resource for a - # moderator (no rule matches), so an unknown id redirects rather than 404s. conn = post(conn, ~p"/duplicate_reports/#{123_456_789}/accept") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer report id redirects with the not-found flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/duplicate_report/accept_reverse_controller_test.exs b/test/philomena_web/controllers/duplicate_report/accept_reverse_controller_test.exs index 06bb1d7e1..fb90daf6f 100644 --- a/test/philomena_web/controllers/duplicate_report/accept_reverse_controller_test.exs +++ b/test/philomena_web/controllers/duplicate_report/accept_reverse_controller_test.exs @@ -57,13 +57,13 @@ defmodule PhilomenaWeb.DuplicateReport.AcceptReverseControllerTest do assert target.duplicate_id == source.id end - test "an unknown report id takes the not-authorized redirect", %{conn: conn} do + test "an unknown report id takes the not-found redirect", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/duplicate_reports/#{123_456_789}/accept_reverse") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer report id redirects with the not-found flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/duplicate_report/claim_controller_test.exs b/test/philomena_web/controllers/duplicate_report/claim_controller_test.exs index f25c1b54f..d555a68d8 100644 --- a/test/philomena_web/controllers/duplicate_report/claim_controller_test.exs +++ b/test/philomena_web/controllers/duplicate_report/claim_controller_test.exs @@ -48,13 +48,13 @@ defmodule PhilomenaWeb.DuplicateReport.ClaimControllerTest do assert dr.modifier_id == mod.id end - test "an unknown report id takes the not-authorized redirect", %{conn: conn} do + test "an unknown report id takes the not-found redirect", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/duplicate_reports/#{123_456_789}/claim") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer report id redirects with the not-found flash", %{conn: conn} do @@ -100,13 +100,13 @@ defmodule PhilomenaWeb.DuplicateReport.ClaimControllerTest do assert dr.modifier_id == nil end - test "an unknown report id takes the not-authorized redirect", %{conn: conn} do + test "an unknown report id takes the not-found redirect", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/duplicate_reports/#{123_456_789}/claim") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer report id redirects with the not-found flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/duplicate_report/reject_controller_test.exs b/test/philomena_web/controllers/duplicate_report/reject_controller_test.exs index e061d625b..3216075f7 100644 --- a/test/philomena_web/controllers/duplicate_report/reject_controller_test.exs +++ b/test/philomena_web/controllers/duplicate_report/reject_controller_test.exs @@ -40,13 +40,13 @@ defmodule PhilomenaWeb.DuplicateReport.RejectControllerTest do assert dr.modifier_id == mod.id end - test "an unknown report id takes the not-authorized redirect", %{conn: conn} do + test "an unknown report id takes the not-found redirect", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/duplicate_reports/#{123_456_789}/reject") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer report id redirects with the not-found flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/duplicate_report_controller_test.exs b/test/philomena_web/controllers/duplicate_report_controller_test.exs index 3695c3266..74ede8f41 100644 --- a/test/philomena_web/controllers/duplicate_report_controller_test.exs +++ b/test/philomena_web/controllers/duplicate_report_controller_test.exs @@ -1,9 +1,8 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do use PhilomenaWeb.ConnCase, async: true - # The :index/:show/:create actions live in the public (Tor-authorized) - # scope with no Canary gate, so any visitor can reach them; the - # accept/reject/claim moderation children are tested separately. + # Show and create are public. The index is a staff review surface and is + # authorized by the context even though its route lives in the public scope. import Philomena.ImagesFixtures import Philomena.UsersFixtures @@ -13,7 +12,8 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do alias Philomena.Repo describe "GET /duplicate_reports" do - test "lists open/claimed reports for anonymous users", %{conn: conn} do + test "lists open/claimed reports for moderators", %{conn: conn} do + conn = log_in_user(conn, moderator_user_fixture()) source = image_fixture() target = image_fixture() dr = duplicate_report_fixture(source, target) @@ -27,13 +27,13 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do _ = dr end - test "renders with no reports", %{conn: conn} do + test "permits anonymous users", %{conn: conn} do conn = get(conn, ~p"/duplicate_reports") - - assert html_response(conn, 200) =~ "Duplicate Reports - Derpibooru" + assert html_response(conn, 200) end test "the default view omits rejected/accepted reports", %{conn: conn} do + conn = log_in_user(conn, moderator_user_fixture()) source = image_fixture() target = image_fixture() dr = duplicate_report_fixture(source, target) @@ -54,6 +54,7 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do end test "an unrecognized state param falls back to nothing matching", %{conn: conn} do + conn = log_in_user(conn, moderator_user_fixture()) source = image_fixture() target = image_fixture() duplicate_report_fixture(source, target) @@ -141,6 +142,24 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do assert dr.user_id == user.id end + test "a banned user is rejected before submission", %{conn: conn} do + %{conn: conn} = register_and_log_in_banned_user(%{conn: conn}) + source = image_fixture() + target = image_fixture() + + conn = + post(conn, ~p"/duplicate_reports", %{ + "duplicate_report" => %{ + "image_id" => source.id, + "duplicate_of_image_id" => target.id + } + }) + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You are currently banned" + refute Repo.exists?(DuplicateReport) + end + test "reporting an image as a duplicate of itself fails validation", %{conn: conn} do source = image_fixture() @@ -175,9 +194,7 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a valid source with an unknown duplicate_of_image_id redirects back - # to the source image with the submission-failure flash. - test "an unknown target image id redirects to the source with the failure flash", + test "an unknown target image id redirects with the not-found flash", %{conn: conn} do source = image_fixture() @@ -189,8 +206,8 @@ defmodule PhilomenaWeb.DuplicateReportControllerTest do } }) - assert redirected_to(conn) == ~p"/images/#{source}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Failed to submit duplicate report" + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" refute Repo.exists?(DuplicateReport) end diff --git a/test/philomena_web/controllers/fallback_controller_test.exs b/test/philomena_web/controllers/fallback_controller_test.exs index 20a6ad507..ef80a9e3b 100644 --- a/test/philomena_web/controllers/fallback_controller_test.exs +++ b/test/philomena_web/controllers/fallback_controller_test.exs @@ -21,6 +21,24 @@ defmodule PhilomenaWeb.FallbackControllerTest do |> assign(:ajax?, ajax?) end + # A conn with a session initialised and the `:referrer` assign populated the + # way `PhilomenaWeb.ReferrerPlug` does - from the Referer header, or "/" when + # that header is absent. + defp conn_with_referer(referer) do + conn = + build_conn() + |> Plug.Test.init_test_session(%{}) + |> Phoenix.Controller.fetch_flash([]) + + conn = + case referer do + nil -> conn + value -> put_req_header(conn, "referer", value) + end + + PhilomenaWeb.ReferrerPlug.call(conn, []) + end + describe "call/2 with {:error, :unauthorized}" do test "AJAX request gets a bare 403 with the not-authorized message" do conn = FallbackController.call(conn_with_ajax(true), {:error, :unauthorized}) @@ -57,4 +75,22 @@ defmodule PhilomenaWeb.FallbackControllerTest do assert conn.halted end end + + describe "call/2 with {:error, :ban}" do + test "redirects to / with the banned flash when no referer header is present" do + conn = FallbackController.call(conn_with_referer(nil), {:error, :ban}) + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are currently banned." + assert conn.halted + end + + test "redirects to the referer when one is set" do + conn = FallbackController.call(conn_with_referer("/forums/dis"), {:error, :ban}) + + assert redirected_to(conn) == "/forums/dis" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are currently banned." + assert conn.halted + end + end end diff --git a/test/philomena_web/controllers/filter/clear_recent_controller_test.exs b/test/philomena_web/controllers/filter/clear_recent_controller_test.exs index bdd23754a..6d0d67240 100644 --- a/test/philomena_web/controllers/filter/clear_recent_controller_test.exs +++ b/test/philomena_web/controllers/filter/clear_recent_controller_test.exs @@ -8,21 +8,21 @@ defmodule PhilomenaWeb.Filter.ClearRecentControllerTest do alias Philomena.Users.User describe "DELETE /filters/clear_recent" do - test "anonymous users are redirected with the sign-in flash", %{conn: conn} do + test "anonymous users are redirected with the authorization flash", %{conn: conn} do conn = delete(conn, ~p"/filters/clear_recent") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must be signed in to see this page." + "You can't access that page." end test "resets the recent filter list to the current filter", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) old_filter = filter_fixture(user) filter = filter_fixture(user) - {:ok, user} = Users.update_filter(user, old_filter) - {:ok, user} = Users.update_filter(user, filter) + {:ok, user} = Users.set_current_filter(user, old_filter) + {:ok, user} = Users.set_current_filter(user, filter) assert user.recent_filter_ids == [filter.id, old_filter.id] conn = delete(conn, ~p"/filters/clear_recent") @@ -31,5 +31,16 @@ defmodule PhilomenaWeb.Filter.ClearRecentControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Cleared recent filters." assert Repo.get!(User, user.id).recent_filter_ids == [filter.id] end + + test "banned users can clear their recent filter list", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_banned_user(%{conn: conn}) + filter = filter_fixture(user) + {:ok, _user} = Users.set_current_filter(user, filter) + + conn = delete(conn, ~p"/filters/clear_recent") + + assert redirected_to(conn) == ~p"/filters" + assert Repo.get!(User, user.id).recent_filter_ids == [filter.id] + end end end diff --git a/test/philomena_web/controllers/filter/current_controller_test.exs b/test/philomena_web/controllers/filter/current_controller_test.exs index 376ff0605..2e32ab872 100644 --- a/test/philomena_web/controllers/filter/current_controller_test.exs +++ b/test/philomena_web/controllers/filter/current_controller_test.exs @@ -33,17 +33,14 @@ defmodule PhilomenaWeb.Filter.CurrentControllerTest do assert conn.resp_cookies["filter_id"].value == Integer.to_string(filter.id) end - test "anonymous users are switched to the default filter for a private filter", + test "anonymous users are not authorized to switch to unowned private filters", %{conn: conn} do filter = filter_fixture(confirmed_user_fixture()) - default = Filters.default_filter() conn = patch(conn, ~p"/filters/current?#{[id: filter.id]}") - assert Phoenix.Flash.get(conn.assigns.flash, :info) == - "Switched to filter #{default.name}" - - assert conn.resp_cookies["filter_id"].value == Integer.to_string(default.id) + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You can't access that page." end test "logged-in users switch their account filter", %{conn: conn} do @@ -63,7 +60,7 @@ defmodule PhilomenaWeb.Filter.CurrentControllerTest do refute Map.has_key?(conn.resp_cookies, "filter_id") end - test "logged-in users are switched to the default filter for a private filter", + test "logged-in users are not authorized to switch to an unowned private filter", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) filter = filter_fixture(confirmed_user_fixture()) @@ -71,16 +68,16 @@ defmodule PhilomenaWeb.Filter.CurrentControllerTest do conn = patch(conn, ~p"/filters/current?#{[id: filter.id]}") - assert Phoenix.Flash.get(conn.assigns.flash, :info) == - "Switched to filter #{default.name}" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You can't access that page." assert Repo.get!(User, user.id).current_filter_id == default.id end test "an unknown filter id redirects with the not-found flash", %{conn: conn} do - # NOTE: unlike the :index/:create nil pass-through, load_resource runs - # its not_found_handler for :update actions, so this 404s instead of - # falling back to the default filter. + # NOTE: unlike the :index/:create nil pass-through, the :update action + # resolves an unknown filter id to not_found, so it redirects with the + # not-found flash instead of falling back to the default filter. conn = patch(conn, ~p"/filters/current?#{[id: 999_999_999]}") assert redirected_to(conn) == "/" @@ -108,10 +105,30 @@ defmodule PhilomenaWeb.Filter.CurrentControllerTest do assert conn.resp_cookies["filter_id"].value == Integer.to_string(filter.id) end - test "crashes without an id parameter", %{conn: conn} do - assert_raise ArgumentError, ~r/nil given for :id\. Comparison with nil is forbidden/, fn -> - patch(conn, ~p"/filters/current") - end + test "without an id parameter explicitly switches to the default", %{conn: conn} do + default = Filters.default_filter() + + conn = patch(conn, ~p"/filters/current") + + assert redirected_to(conn) == "/" + assert conn.resp_cookies["filter_id"].value == Integer.to_string(default.id) + + assert Phoenix.Flash.get(conn.assigns.flash, :info) == + "Switched to filter #{default.name}" + end + + test "a banned user can still switch filters", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_banned_user(%{conn: conn}) + filter = filter_fixture(user) + + conn = patch(conn, ~p"/filters/current?#{[id: filter.id]}") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :info) == + "Switched to filter #{filter.name}" + + assert Repo.get!(User, user.id).current_filter_id == filter.id end end end diff --git a/test/philomena_web/controllers/filter/hide_controller_test.exs b/test/philomena_web/controllers/filter/hide_controller_test.exs index 9e135ad5c..c5f921b3f 100644 --- a/test/philomena_web/controllers/filter/hide_controller_test.exs +++ b/test/philomena_web/controllers/filter/hide_controller_test.exs @@ -47,7 +47,7 @@ defmodule PhilomenaWeb.Filter.HideControllerTest do test "POST hides the tag on the user's own current filter", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) filter = filter_fixture(user) - {:ok, _} = Users.update_filter(user, filter) + {:ok, _} = Users.set_current_filter(user, filter) tag = tag_fixture() path = ~p"/filters/hide?#{[tag: tag.slug]}" @@ -60,9 +60,11 @@ defmodule PhilomenaWeb.Filter.HideControllerTest do test "DELETE unhides the tag on the user's own current filter", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) filter = filter_fixture(user) - {:ok, _} = Users.update_filter(user, filter) + {:ok, _} = Users.set_current_filter(user, filter) tag = tag_fixture() - {:ok, _} = Filters.hide_tag(filter, tag) + + {:ok, _} = + Filters.create_filter_hide(Philomena.AttributionFixtures.actor(user), filter, tag.slug) path = ~p"/filters/hide?#{[tag: tag.slug]}" conn = delete(conn, path) @@ -72,11 +74,10 @@ defmodule PhilomenaWeb.Filter.HideControllerTest do end test "POST for an unknown tag redirects with the not-found flash", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its not-found - # handler on :create - an unknown slug redirects instead of passing nil - # into Filters.hide_tag/2. + # The context authorizes the current filter before safely loading the tag, + # so an unknown slug returns the normalized not-found result. %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) - {:ok, _} = Users.update_filter(user, filter_fixture(user)) + {:ok, _} = Users.set_current_filter(user, filter_fixture(user)) conn = post(conn, ~p"/filters/hide?tag=unknown-slug") diff --git a/test/philomena_web/controllers/filter/public_controller_test.exs b/test/philomena_web/controllers/filter/public_controller_test.exs index 364e906d3..f497786a4 100644 --- a/test/philomena_web/controllers/filter/public_controller_test.exs +++ b/test/philomena_web/controllers/filter/public_controller_test.exs @@ -54,13 +54,13 @@ defmodule PhilomenaWeb.Filter.PublicControllerTest do refute Repo.get!(Filter, filter.id).public end - test "redirects with the authorization flash for an unknown filter", %{conn: conn} do + test "redirects with the not-found flash for an unknown filter", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/filters/999999999/public") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end end diff --git a/test/philomena_web/controllers/filter/spoiler_controller_test.exs b/test/philomena_web/controllers/filter/spoiler_controller_test.exs index de8bd2f20..7f51b6555 100644 --- a/test/philomena_web/controllers/filter/spoiler_controller_test.exs +++ b/test/philomena_web/controllers/filter/spoiler_controller_test.exs @@ -44,7 +44,7 @@ defmodule PhilomenaWeb.Filter.SpoilerControllerTest do test "POST spoilers the tag on the user's own current filter", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) filter = filter_fixture(user) - {:ok, _} = Users.update_filter(user, filter) + {:ok, _} = Users.set_current_filter(user, filter) tag = tag_fixture() path = ~p"/filters/spoiler?#{[tag: tag.slug]}" @@ -57,9 +57,11 @@ defmodule PhilomenaWeb.Filter.SpoilerControllerTest do test "DELETE unspoilers the tag on the user's own current filter", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) filter = filter_fixture(user) - {:ok, _} = Users.update_filter(user, filter) + {:ok, _} = Users.set_current_filter(user, filter) tag = tag_fixture() - {:ok, _} = Filters.spoiler_tag(filter, tag) + + {:ok, _} = + Filters.create_filter_spoiler(Philomena.AttributionFixtures.actor(user), filter, tag.slug) path = ~p"/filters/spoiler?#{[tag: tag.slug]}" conn = delete(conn, path) @@ -69,11 +71,10 @@ defmodule PhilomenaWeb.Filter.SpoilerControllerTest do end test "POST for an unknown tag redirects with the not-found flash", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its not-found - # handler on :create - an unknown slug redirects instead of passing nil - # into Filters.spoiler_tag/2. + # The context authorizes the current filter before safely loading the tag, + # so an unknown slug returns the normalized not-found result. %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) - {:ok, _} = Users.update_filter(user, filter_fixture(user)) + {:ok, _} = Users.set_current_filter(user, filter_fixture(user)) conn = post(conn, ~p"/filters/spoiler?tag=unknown-slug") diff --git a/test/philomena_web/controllers/filter/spoiler_type_controller_test.exs b/test/philomena_web/controllers/filter/spoiler_type_controller_test.exs index f950ac01d..01aad657a 100644 --- a/test/philomena_web/controllers/filter/spoiler_type_controller_test.exs +++ b/test/philomena_web/controllers/filter/spoiler_type_controller_test.exs @@ -42,6 +42,18 @@ defmodule PhilomenaWeb.Filter.SpoilerTypeControllerTest do assert Repo.get!(Settings, user.id).spoiler_type == "off" end + test "banned users can change their spoiler type", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_banned_user(%{conn: conn}) + + conn = + patch(conn, ~p"/filters/spoiler_type", %{ + "settings" => %{"spoiler_type" => "click"} + }) + + assert redirected_to(conn) == "/" + assert Repo.get!(Settings, user.id).spoiler_type == "click" + end + test "PATCH with an invalid spoiler type redirects with the failure flash", %{conn: conn} do # NOTE: an invalid spoiler_type now redirects to the referrer with the # failure flash rather than raising MatchError. diff --git a/test/philomena_web/controllers/filter_controller_test.exs b/test/philomena_web/controllers/filter_controller_test.exs index df4ffe6ad..48ef4b679 100644 --- a/test/philomena_web/controllers/filter_controller_test.exs +++ b/test/philomena_web/controllers/filter_controller_test.exs @@ -100,16 +100,13 @@ defmodule PhilomenaWeb.FilterControllerTest do assert html_response(get(conn, ~p"/filters/#{filter}"), 200) =~ filter.name end - test "redirects with the authorization flash for an unknown filter", %{conn: conn} do + test "redirects with the not-found flash for an unknown filter", %{conn: conn} do conn = get(conn, ~p"/filters/999999999") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer id short-circuits to NotFoundPlug via the central - # IntegerId guard, so the flash is the not-found message rather than the - # "You can't access that page." an unknown integer id gets. test "redirects with the not-found flash for a non-integer id", %{conn: conn} do conn = get(conn, ~p"/filters/not-a-number") @@ -121,13 +118,13 @@ defmodule PhilomenaWeb.FilterControllerTest do end describe "GET /filters/new" do - test "redirects anonymous users with the sign-in flash", %{conn: conn} do + test "redirects anonymous users with the authorization flash", %{conn: conn} do conn = get(conn, ~p"/filters/new") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must be signed in to see this page." + "You can't access that page." end test "renders the form for logged-in users", %{conn: conn} do @@ -158,13 +155,13 @@ defmodule PhilomenaWeb.FilterControllerTest do end describe "POST /filters" do - test "redirects anonymous users with the sign-in flash", %{conn: conn} do + test "redirects anonymous users with the authorization flash", %{conn: conn} do conn = post(conn, ~p"/filters", %{"filter" => %{"name" => "Anon filter"}}) assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "You must be signed in to see this page." + "You can't access that page." end test "creates a filter and redirects to it", %{conn: conn} do @@ -286,7 +283,7 @@ defmodule PhilomenaWeb.FilterControllerTest do test "a filter in use as a current filter is not deleted", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) filter = filter_fixture(user) - {:ok, _user} = Users.update_filter(user, filter) + {:ok, _user} = Users.set_current_filter(user, filter) conn = delete(conn, ~p"/filters/#{filter}") diff --git a/test/philomena_web/controllers/fingerprint_profile/source_change_controller_test.exs b/test/philomena_web/controllers/fingerprint_profile/source_change_controller_test.exs index 082963d3f..1ee74e5c2 100644 --- a/test/philomena_web/controllers/fingerprint_profile/source_change_controller_test.exs +++ b/test/philomena_web/controllers/fingerprint_profile/source_change_controller_test.exs @@ -6,7 +6,7 @@ defmodule PhilomenaWeb.FingerprintProfile.SourceChangeControllerTest do describe "GET /fingerprint_profiles/:fingerprint_profile_id/source_changes" do test "redirects anonymous users to the login page", %{conn: conn} do - conn = get(conn, ~p"/fingerprint_profiles/#{"abc123"}/source_changes") + conn = get(conn, ~p"/fingerprint_profiles/#{"c123"}/source_changes") assert redirected_to(conn) == ~p"/sessions/new" @@ -14,10 +14,19 @@ defmodule PhilomenaWeb.FingerprintProfile.SourceChangeControllerTest do "You must log in to access this page." end + test "the route's login requirement runs before malformed fingerprint validation", %{ + conn: conn + } do + conn = get(conn, ~p"/fingerprint_profiles/#{"not-a-fingerprint"}/source_changes") + + assert redirected_to(conn) == ~p"/sessions/new" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You must log in" + end + test "redirects a regular user with the authorization flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - conn = get(conn, ~p"/fingerprint_profiles/#{"abc123"}/source_changes") + conn = get(conn, ~p"/fingerprint_profiles/#{"c123"}/source_changes") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." @@ -28,26 +37,35 @@ defmodule PhilomenaWeb.FingerprintProfile.SourceChangeControllerTest do image = image_fixture() source_change_fixture(image, - fingerprint: "abc123", + fingerprint: "c123", source_url: "https://pinned.example/fp-art" ) response = - html_response(get(conn, ~p"/fingerprint_profiles/#{"abc123"}/source_changes"), 200) + html_response(get(conn, ~p"/fingerprint_profiles/#{"c123"}/source_changes"), 200) assert response =~ "Source changes by" assert response =~ "https://pinned.example/fp-art" end - # NOTE: the fingerprint is used directly as a string (no cast), so any - # value renders a 200 empty listing rather than crashing. - test "renders an empty listing for a fingerprint with no source changes", %{conn: conn} do + test "renders an empty listing for a valid fingerprint with no source changes", %{ + conn: conn + } do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) response = - html_response(get(conn, ~p"/fingerprint_profiles/#{"no-such-fp"}/source_changes"), 200) + html_response(get(conn, ~p"/fingerprint_profiles/#{"c999"}/source_changes"), 200) assert response =~ "Source changes by" end + + test "uses the not-found response for a malformed fingerprint", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + + conn = get(conn, ~p"/fingerprint_profiles/#{"no-such-fp"}/source_changes") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + end end end diff --git a/test/philomena_web/controllers/fingerprint_profile_controller_test.exs b/test/philomena_web/controllers/fingerprint_profile_controller_test.exs index 5dfa689b9..3045a9edd 100644 --- a/test/philomena_web/controllers/fingerprint_profile_controller_test.exs +++ b/test/philomena_web/controllers/fingerprint_profile_controller_test.exs @@ -17,7 +17,7 @@ defmodule PhilomenaWeb.FingerprintProfileControllerTest do test "redirects a regular user with the authorization flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - conn = get(conn, ~p"/fingerprint_profiles/#{"abc123"}") + conn = get(conn, ~p"/fingerprint_profiles/#{"d015c342859dde3"}") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." @@ -26,23 +26,31 @@ defmodule PhilomenaWeb.FingerprintProfileControllerTest do test "renders the profile and lists users seen on the fingerprint", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) user = confirmed_user_fixture() - user_fingerprint_fixture(user, "abc123") + user_fingerprint_fixture(user, "d015c342859dde3") - response = html_response(get(conn, ~p"/fingerprint_profiles/#{"abc123"}"), 200) + response = + html_response(get(conn, ~p"/fingerprint_profiles/#{"d015c342859dde3"}"), 200) - assert response =~ "abc123's fingerprint profile" + assert response =~ "d015c342859dde3's fingerprint profile" assert response =~ user.name end - # NOTE: unlike the IP profile, the fingerprint is used directly as a string - # (no `EctoNetwork.INET.cast`), so any value - including one with no - # activity - renders a 200 rather than crashing. - test "renders an empty profile for a fingerprint with no activity", %{conn: conn} do + test "renders an empty profile for a valid fingerprint with no activity", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) - response = html_response(get(conn, ~p"/fingerprint_profiles/#{"no-such-fp"}"), 200) + response = + html_response(get(conn, ~p"/fingerprint_profiles/#{"d11111111111111"}"), 200) + + assert response =~ "d11111111111111's fingerprint profile" + end - assert response =~ "no-such-fp's fingerprint profile" + test "redirects with a not-found flash for an invalid fingerprint", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + + conn = get(conn, ~p"/fingerprint_profiles/#{"not-a-fingerprint"}") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end end diff --git a/test/philomena_web/controllers/forum/subscription_controller_test.exs b/test/philomena_web/controllers/forum/subscription_controller_test.exs index 9b0b5d2c6..4c2ebd762 100644 --- a/test/philomena_web/controllers/forum/subscription_controller_test.exs +++ b/test/philomena_web/controllers/forum/subscription_controller_test.exs @@ -29,14 +29,16 @@ defmodule PhilomenaWeb.Forum.SubscriptionControllerTest do subscription_toggle_tests() - test "POST for an unknown forum redirects to / with the authorization flash", + test "POST for an unknown forum redirects to / with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/forums/nonexistent/subscription") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end test "POST for a staff forum as a regular user redirects to / with the authorization flash", diff --git a/test/philomena_web/controllers/forum_controller_test.exs b/test/philomena_web/controllers/forum_controller_test.exs index e98f896f9..4b596120a 100644 --- a/test/philomena_web/controllers/forum_controller_test.exs +++ b/test/philomena_web/controllers/forum_controller_test.exs @@ -31,9 +31,9 @@ defmodule PhilomenaWeb.ForumControllerTest do test "renders an empty index when the user can see no forums", %{conn: conn} do _staff = forum_fixture(name: "Staff Lounge", access_level: "staff") - # NOTE: the empty ForumListPlug assign is now handled - Canary no longer - # probes Enum.at(resources, 0).__struct__ on the empty list, so a user - # who can see zero forums gets an empty index instead of a 500. + # NOTE: an empty forum list renders cleanly - nothing probes the first + # element of the loaded list, so a user who can see zero forums gets an + # empty index rather than a 500. conn = get(conn, ~p"/forums") response = html_response(conn, 200) @@ -57,11 +57,13 @@ defmodule PhilomenaWeb.ForumControllerTest do test "redirects to / for an unknown short name", %{conn: conn} do # NOTE: an unknown forum is a 302 redirect with a flash, not a 404 page # (unlike the JSON API, which returns a bare 404) - and the flash is the - # *authorization* message, not the not-found one. + # not-found message. conn = get(conn, ~p"/forums/nonexistent") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end test "redirects to / for a restricted forum", %{conn: conn} do diff --git a/test/philomena_web/controllers/gallery/image_controller_test.exs b/test/philomena_web/controllers/gallery/image_controller_test.exs index b32fd5efc..814a4dd37 100644 --- a/test/philomena_web/controllers/gallery/image_controller_test.exs +++ b/test/philomena_web/controllers/gallery/image_controller_test.exs @@ -9,7 +9,6 @@ defmodule PhilomenaWeb.Gallery.ImageControllerTest do import Philomena.ImagesFixtures import Philomena.UsersFixtures - alias Philomena.Galleries alias Philomena.Galleries.Interaction alias Philomena.Repo @@ -43,26 +42,26 @@ defmodule PhilomenaWeb.Gallery.ImageControllerTest do assert Repo.reload!(gallery).image_count == 1 end - test "responds 400 when the image is already in the gallery", %{conn: conn} do + test "responds conflict when the image is already in the gallery", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) image = image_fixture() - {:ok, _} = Galleries.add_image_to_gallery(gallery, image) + gallery_image_fixture(gallery, image) conn = post(conn, ~p"/galleries/#{gallery}/images", %{"image_id" => to_string(image.id)}) - assert json_response(conn, 400) == %{} + assert json_response(conn, 409) == %{} assert Repo.reload!(gallery).image_count == 1 end - test "redirects with the authorization flash for an unknown image id", %{conn: conn} do + test "redirects with the not-found flash for an unknown image id", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) conn = post(conn, ~p"/galleries/#{gallery}/images", %{"image_id" => "999999999"}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "redirects other users with the authorization flash", %{conn: conn} do @@ -92,7 +91,7 @@ defmodule PhilomenaWeb.Gallery.ImageControllerTest do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) image = image_fixture() - {:ok, _} = Galleries.add_image_to_gallery(gallery, image) + gallery_image_fixture(gallery, image) conn = delete(conn, ~p"/galleries/#{gallery}/images", %{"image_id" => to_string(image.id)}) @@ -107,8 +106,7 @@ defmodule PhilomenaWeb.Gallery.ImageControllerTest do assert Repo.reload!(gallery).image_count == 0 end - test "responds 200 when the image is not in the gallery", %{conn: conn} do - # the delete_all simply removes zero rows and decrements the count by 0 + test "responds bad request when the image is not in the gallery", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) image = image_fixture() @@ -116,7 +114,8 @@ defmodule PhilomenaWeb.Gallery.ImageControllerTest do conn = delete(conn, ~p"/galleries/#{gallery}/images", %{"image_id" => to_string(image.id)}) - assert json_response(conn, 200) == %{} + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" assert Repo.reload!(gallery).image_count == 0 end @@ -125,7 +124,7 @@ defmodule PhilomenaWeb.Gallery.ImageControllerTest do owner = confirmed_user_fixture() gallery = gallery_fixture(owner) image = image_fixture() - {:ok, _} = Galleries.add_image_to_gallery(gallery, image) + gallery_image_fixture(gallery, image) conn = delete(conn, ~p"/galleries/#{gallery}/images", %{"image_id" => to_string(image.id)}) diff --git a/test/philomena_web/controllers/gallery/order_controller_test.exs b/test/philomena_web/controllers/gallery/order_controller_test.exs index d1062ae67..946639ac1 100644 --- a/test/philomena_web/controllers/gallery/order_controller_test.exs +++ b/test/philomena_web/controllers/gallery/order_controller_test.exs @@ -5,8 +5,6 @@ defmodule PhilomenaWeb.Gallery.OrderControllerTest do import Philomena.ImagesFixtures import Philomena.UsersFixtures - alias Philomena.Galleries - test "anonymous requests redirect to the login page", %{conn: conn} do conn = patch(conn, ~p"/galleries/1/order", %{"image_ids" => []}) @@ -17,13 +15,13 @@ defmodule PhilomenaWeb.Gallery.OrderControllerTest do end test "PATCH responds 200 as the gallery's owner", %{conn: conn} do - # the reorder itself is only enqueued (dead Exq job in test), so the 200 - # is the whole observable contract + # The response is empty; the reorder is applied synchronously by the + # context before the controller returns. %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) [image_a, image_b] = [image_fixture(), image_fixture()] - {:ok, _} = Galleries.add_image_to_gallery(gallery, image_a) - {:ok, _} = Galleries.add_image_to_gallery(gallery, image_b) + gallery_image_fixture(gallery, image_a) + gallery_image_fixture(gallery, image_b) conn = patch(conn, ~p"/galleries/#{gallery}/order", %{"image_ids" => [image_b.id, image_a.id]}) @@ -31,30 +29,49 @@ defmodule PhilomenaWeb.Gallery.OrderControllerTest do assert json_response(conn, 200) == %{} end - test "PUT responds 200 as the gallery's owner", %{conn: conn} do + test "PUT responds 400 for empty submission as the gallery's owner", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) conn = put(conn, ~p"/galleries/#{gallery}/order", %{"image_ids" => []}) + assert json_response(conn, 400) == %{ + "error" => "image_ids must be a non-empty subset of the gallery's images" + } + end + + test "accepts image_ids for a paginated subset", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + gallery = gallery_fixture(user) + [image_a, image_b, image_c] = Enum.map(1..3, fn _ -> image_fixture() end) + Enum.each([image_a, image_b, image_c], &gallery_image_fixture(gallery, &1)) + + conn = + patch(conn, ~p"/galleries/#{gallery}/order", %{ + "image_ids" => [image_b.id, image_a.id] + }) + assert json_response(conn, 200) == %{} end - test "crashes when image_ids is missing", %{conn: conn} do - # update/2 only matches when image_ids is a list + test "does not crash when image_ids is missing", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(user) - assert_raise Phoenix.ActionClauseError, fn -> - patch(conn, ~p"/galleries/#{gallery}/order", %{}) - end + conn = patch(conn, ~p"/galleries/#{gallery}/order", %{}) + + assert json_response(conn, 400) == %{ + "error" => "image_ids must be a non-empty subset of the gallery's images" + } end test "redirects other users with the authorization flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) gallery = gallery_fixture(confirmed_user_fixture()) + image = image_fixture() + gallery_image_fixture(gallery, image) - conn = patch(conn, ~p"/galleries/#{gallery}/order", %{"image_ids" => []}) + conn = patch(conn, ~p"/galleries/#{gallery}/order", %{"image_ids" => [image.id]}) assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." diff --git a/test/philomena_web/controllers/gallery/read_controller_test.exs b/test/philomena_web/controllers/gallery/read_controller_test.exs index 73ba79014..74973e134 100644 --- a/test/philomena_web/controllers/gallery/read_controller_test.exs +++ b/test/philomena_web/controllers/gallery/read_controller_test.exs @@ -22,7 +22,7 @@ defmodule PhilomenaWeb.Gallery.ReadControllerTest do path: ~p"/galleries/#{gallery}/read", arrange!: fn -> {:ok, _} = Galleries.create_subscription(gallery, user) - {:ok, 1} = Notifications.create_gallery_image_notification(gallery) + {:ok, 1} = Notifications.broadcast_gallery_image(gallery) end, notification?: fn -> Repo.exists?( @@ -36,9 +36,10 @@ defmodule PhilomenaWeb.Gallery.ReadControllerTest do read_singleton_tests() test "POST for an unknown gallery redirects with the not-found flash", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its not-found - # handler on :create - an unknown gallery redirects instead of passing nil - # into clear_gallery_notification/2. + # NOTE: the context authorizes the loaded record before checking existence; + # id 999999999 loads nil, authorization passes on the nil load, so :create + # returns not_found and redirects instead of passing nil into + # clear_gallery_notification/2. %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/galleries/999999999/read") diff --git a/test/philomena_web/controllers/gallery/report_controller_test.exs b/test/philomena_web/controllers/gallery/report_controller_test.exs index 11b71473f..8271474a6 100644 --- a/test/philomena_web/controllers/gallery/report_controller_test.exs +++ b/test/philomena_web/controllers/gallery/report_controller_test.exs @@ -28,11 +28,11 @@ defmodule PhilomenaWeb.Gallery.ReportControllerTest do assert response =~ "Reporting Gallery - Derpibooru" end - test "redirects to / with the authorization flash for an unknown gallery", %{conn: conn} do + test "redirects to / with the not-found flash for an unknown gallery", %{conn: conn} do conn = get(conn, ~p"/galleries/999999999/reports/new") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end diff --git a/test/philomena_web/controllers/gallery/subscription_controller_test.exs b/test/philomena_web/controllers/gallery/subscription_controller_test.exs index 0ab72a7b9..59fa6242e 100644 --- a/test/philomena_web/controllers/gallery/subscription_controller_test.exs +++ b/test/philomena_web/controllers/gallery/subscription_controller_test.exs @@ -30,13 +30,13 @@ defmodule PhilomenaWeb.Gallery.SubscriptionControllerTest do subscription_toggle_tests() - test "POST for an unknown gallery redirects to / with the authorization flash", + test "POST for an unknown gallery redirects to / with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/galleries/999999999/subscription") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end diff --git a/test/philomena_web/controllers/gallery_controller_test.exs b/test/philomena_web/controllers/gallery_controller_test.exs index 141e4f381..292b930ad 100644 --- a/test/philomena_web/controllers/gallery_controller_test.exs +++ b/test/philomena_web/controllers/gallery_controller_test.exs @@ -10,7 +10,6 @@ defmodule PhilomenaWeb.GalleryControllerTest do alias PhilomenaQuery.Search alias PhilomenaQuery.SearchHelpers - alias Philomena.Galleries alias Philomena.Galleries.Gallery alias Philomena.Images.Image @@ -60,7 +59,7 @@ defmodule PhilomenaWeb.GalleryControllerTest do gallery = gallery_fixture(user, title: "Test Shown Gallery") image = image_fixture() - {:ok, _} = Galleries.add_image_to_gallery(gallery, image) + gallery_image_fixture(gallery, image) SearchHelpers.reindex_all!(Image) conn = get(conn, ~p"/galleries/#{gallery}") @@ -95,7 +94,7 @@ defmodule PhilomenaWeb.GalleryControllerTest do conn = get(conn, ~p"/galleries/999999999") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -212,13 +211,13 @@ defmodule PhilomenaWeb.GalleryControllerTest do assert response =~ "Editing Gallery - Derpibooru" end - test "redirects to / with the authorization flash for an unknown id", %{conn: conn} do + test "redirects to / with the not-found flash for an unknown id", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = get(conn, ~p"/galleries/999999999/edit") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end diff --git a/test/philomena_web/controllers/image/anonymous_controller_test.exs b/test/philomena_web/controllers/image/anonymous_controller_test.exs index 06b8b474e..59b9363ff 100644 --- a/test/philomena_web/controllers/image/anonymous_controller_test.exs +++ b/test/philomena_web/controllers/image/anonymous_controller_test.exs @@ -17,8 +17,8 @@ defmodule PhilomenaWeb.Image.AnonymousControllerTest do refute anonymous?(image) end - # NOTE: this controller's verify_authorized checks `:show, :ip_address`, - # which a regular user lacks, so they get the authorization redirect. + # NOTE: the context authorizes `:show, :identity_metadata`, which a regular user + # lacks, so they get the authorization redirect. test "rejects a regular user", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) image = image_fixture(anonymous: false) @@ -51,9 +51,9 @@ defmodule PhilomenaWeb.Image.AnonymousControllerTest do assert anonymous?(image) end - # NOTE: the load_resource now uses required: true, so Canary's - # not_found_handler runs on :create too - an unknown id redirects rather - # than crashing in update_anonymous. + # Missing image locators resolve to not-found before authorization. + # authorized on it, so :create returns not_found - an unknown id redirects + # rather than crashing in update_image_anonymous. test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -121,8 +121,9 @@ defmodule PhilomenaWeb.Image.AnonymousControllerTest do refute anonymous?(image) end - # NOTE: unlike :create, Canary's not_found_handler runs on the :delete - # load_resource, so an unknown id redirects rather than crashing. + # Missing image locators resolve to not-found before authorization. + # authorized on it, so :delete returns not_found - an unknown id redirects + # rather than crashing. test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/approve_controller_test.exs b/test/philomena_web/controllers/image/approve_controller_test.exs index 926137f2a..c3db016dd 100644 --- a/test/philomena_web/controllers/image/approve_controller_test.exs +++ b/test/philomena_web/controllers/image/approve_controller_test.exs @@ -62,20 +62,22 @@ defmodule PhilomenaWeb.Image.ApproveControllerTest do "Someone else already approved this image." end - # Failure path: an unknown image_id is authorized against a nil resource, + # Missing image locators resolve to not-found before authorization. # for which the moderator has no matching ability rule, taking the # not-authorized redirect. - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/approve") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/comment/approve_controller_test.exs b/test/philomena_web/controllers/image/comment/approve_controller_test.exs index 2f39bbf01..bd6645c26 100644 --- a/test/philomena_web/controllers/image/comment/approve_controller_test.exs +++ b/test/philomena_web/controllers/image/comment/approve_controller_test.exs @@ -4,12 +4,15 @@ defmodule PhilomenaWeb.Image.Comment.ApproveControllerTest do import Philomena.CommentsFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures + import Philomena.RulesFixtures alias Philomena.Repo # A comment whose body contains an external link, posted by an untrusted # (freshly-registered) user, is withheld from approval. defp unapproved_comment(image) do + _rule = rule_fixture(name: "Approval") + comment = comment_fixture(image, confirmed_user_fixture(), %{ "body" => "buy now at https://spam.example/" @@ -74,22 +77,24 @@ defmodule PhilomenaWeb.Image.Comment.ApproveControllerTest do conn = post(conn, ~p"/images/#{image}/comments/#{comment}/approve") - assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Comment has been approved." + assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Comment has already been approved." assert Repo.reload!(comment).approved end - test "for an unknown comment_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown comment_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() conn = post(conn, ~p"/images/#{image}/comments/999999999/approve") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer comment_id short-circuits to NotFoundPlug via the - # central IntegerId guard before Canary authorizes. + # central IntegerId guard before authorization runs. test "for a non-integer comment_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() @@ -101,5 +106,17 @@ defmodule PhilomenaWeb.Image.Comment.ApproveControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Couldn't find what you were looking for!" end + + test "does not approve a comment through a different route image", %{conn: conn} do + image = image_fixture() + other_image = image_fixture() + comment = unapproved_comment(image) + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + + conn = post(conn, ~p"/images/#{other_image}/comments/#{comment}/approve") + + assert redirected_to(conn) == "/" + refute Repo.reload!(comment).approved + end end end diff --git a/test/philomena_web/controllers/image/comment/delete_controller_test.exs b/test/philomena_web/controllers/image/comment/delete_controller_test.exs index a11d56018..06c3d7f2d 100644 --- a/test/philomena_web/controllers/image/comment/delete_controller_test.exs +++ b/test/philomena_web/controllers/image/comment/delete_controller_test.exs @@ -1,15 +1,32 @@ defmodule PhilomenaWeb.Image.Comment.DeleteControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.AttributionFixtures import Philomena.CommentsFixtures import Philomena.ImagesFixtures + import Philomena.UsersFixtures + alias Philomena.Comments alias Philomena.Repo + defp hidden_comment(image, user \\ nil, attrs \\ %{}) do + comment = comment_fixture(image, user, attrs) + + {:ok, comment} = + Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + comment.id, + %{"deletion_reason" => "Spam"} + ) + + comment + end + describe "POST /images/:image_id/comments/:comment_id/delete" do test "redirects anonymous users to login", %{conn: conn} do image = image_fixture() - comment = comment_fixture(image, nil, %{"body" => "keep me"}) + comment = hidden_comment(image, nil, %{"body" => "keep me"}) conn = post(conn, ~p"/images/#{image}/comments/#{comment}/delete") @@ -20,7 +37,7 @@ defmodule PhilomenaWeb.Image.Comment.DeleteControllerTest do test "rejects a regular user", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) image = image_fixture() - comment = comment_fixture(image, nil, %{"body" => "keep me"}) + comment = hidden_comment(image, nil, %{"body" => "keep me"}) conn = post(conn, ~p"/images/#{image}/comments/#{comment}/delete") @@ -32,7 +49,7 @@ defmodule PhilomenaWeb.Image.Comment.DeleteControllerTest do test "as a moderator destroys the comment content", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() - comment = comment_fixture(image, nil, %{"body" => "obliterate me"}) + comment = hidden_comment(image, nil, %{"body" => "obliterate me"}) conn = post(conn, ~p"/images/#{image}/comments/#{comment}/delete") @@ -47,7 +64,7 @@ defmodule PhilomenaWeb.Image.Comment.DeleteControllerTest do test "as an admin destroys the comment content", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) image = image_fixture() - comment = comment_fixture(image) + comment = hidden_comment(image) conn = post(conn, ~p"/images/#{image}/comments/#{comment}/delete") @@ -55,18 +72,35 @@ defmodule PhilomenaWeb.Image.Comment.DeleteControllerTest do assert Repo.reload!(comment).destroyed_content end - test "for an unknown comment_id redirects with the authorization flash", %{conn: conn} do + test "fails if the comment is visible", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + image = image_fixture() + comment = comment_fixture(image, nil, %{"body" => "keep me"}) + + conn = post(conn, ~p"/images/#{image}/comments/#{comment}/delete") + + assert redirected_to(conn) == ~p"/images/#{image}" <> "#comment_#{comment.id}" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to destroy comment!" + + comment = Repo.reload!(comment) + refute comment.destroyed_content + refute comment.body == "" + end + + test "for an unknown comment_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() conn = post(conn, ~p"/images/#{image}/comments/999999999/delete") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer comment_id short-circuits to NotFoundPlug via the - # central IntegerId guard before Canary authorizes. + # central IntegerId guard before authorization runs. test "for a non-integer comment_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() @@ -78,5 +112,17 @@ defmodule PhilomenaWeb.Image.Comment.DeleteControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Couldn't find what you were looking for!" end + + test "does not destroy a comment through a different route image", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + image = image_fixture() + other_image = image_fixture() + comment = hidden_comment(image, nil, %{"body" => "keep me"}) + + conn = post(conn, ~p"/images/#{other_image}/comments/#{comment}/delete") + + assert redirected_to(conn) == "/" + refute Repo.reload!(comment).destroyed_content + end end end diff --git a/test/philomena_web/controllers/image/comment/hide_controller_test.exs b/test/philomena_web/controllers/image/comment/hide_controller_test.exs index 1477aa6e5..bf40581ac 100644 --- a/test/philomena_web/controllers/image/comment/hide_controller_test.exs +++ b/test/philomena_web/controllers/image/comment/hide_controller_test.exs @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.Image.Comment.HideControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.AttributionFixtures import Philomena.CommentsFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures @@ -12,7 +13,12 @@ defmodule PhilomenaWeb.Image.Comment.HideControllerTest do comment = comment_fixture(image) {:ok, comment} = - Comments.hide_comment(comment, %{"deletion_reason" => "Spam"}, moderator_user_fixture()) + Comments.create_comment_hide( + actor(moderator_user_fixture()), + image.id, + comment.id, + %{"deletion_reason" => "Spam"} + ) comment end @@ -81,7 +87,7 @@ defmodule PhilomenaWeb.Image.Comment.HideControllerTest do refute Repo.reload!(comment).hidden_from_users end - test "for an unknown comment_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown comment_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() @@ -91,11 +97,13 @@ defmodule PhilomenaWeb.Image.Comment.HideControllerTest do }) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer comment_id short-circuits to NotFoundPlug via the - # central IntegerId guard before Canary authorizes. + # central IntegerId guard before authorization runs. test "for a non-integer comment_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() @@ -110,6 +118,21 @@ defmodule PhilomenaWeb.Image.Comment.HideControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Couldn't find what you were looking for!" end + + test "does not hide a comment through a different route image", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image) + + conn = + post(conn, ~p"/images/#{other_image}/comments/#{comment}/hide", %{ + "comment" => %{"deletion_reason" => "Spam"} + }) + + assert redirected_to(conn) == "/" + refute Repo.reload!(comment).hidden_from_users + end end describe "DELETE /images/:image_id/comments/:comment_id/hide" do @@ -162,5 +185,17 @@ defmodule PhilomenaWeb.Image.Comment.HideControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Comment successfully restored!" refute Repo.reload!(comment).hidden_from_users end + + test "does not restore a comment through a different route image", %{conn: conn} do + image = image_fixture() + other_image = image_fixture() + comment = hidden_comment(image) + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + + conn = delete(conn, ~p"/images/#{other_image}/comments/#{comment}/hide") + + assert redirected_to(conn) == "/" + assert Repo.reload!(comment).hidden_from_users + end end end diff --git a/test/philomena_web/controllers/image/comment/history_controller_test.exs b/test/philomena_web/controllers/image/comment/history_controller_test.exs index 8df8db30b..122068ddf 100644 --- a/test/philomena_web/controllers/image/comment/history_controller_test.exs +++ b/test/philomena_web/controllers/image/comment/history_controller_test.exs @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.Image.Comment.HistoryControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.AttributionFixtures, only: [actor: 1] import Philomena.CommentsFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures @@ -14,7 +15,7 @@ defmodule PhilomenaWeb.Image.Comment.HistoryControllerTest do comment = comment_fixture(image, author, %{"body" => "Original comment body"}) {:ok, _} = - Comments.update_comment(comment, author, %{ + Comments.update_comment(actor(author), image.id, comment.id, %{ "body" => "Original comment body plus an edit", "edit_reason" => "typo fix" }) @@ -61,7 +62,22 @@ defmodule PhilomenaWeb.Image.Comment.HistoryControllerTest do conn = get(conn, ~p"/images/999999999/comments/1/history") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" + end + + test "redirects to / when the route image does not own the comment", %{conn: conn} do + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image) + + conn = get(conn, ~p"/images/#{other_image}/comments/#{comment}/history") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/image/comment/report_controller_test.exs b/test/philomena_web/controllers/image/comment/report_controller_test.exs index b67caeefb..eb8358e7a 100644 --- a/test/philomena_web/controllers/image/comment/report_controller_test.exs +++ b/test/philomena_web/controllers/image/comment/report_controller_test.exs @@ -34,6 +34,31 @@ defmodule PhilomenaWeb.Image.Comment.ReportControllerTest do "Couldn't find what you were looking for!" end + test "GET and POST reject a comment through a different route image", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image) + rule = rule_fixture() + + get_conn = get(conn, ~p"/images/#{other_image}/comments/#{comment}/reports/new") + assert redirected_to(get_conn) == "/" + + post_conn = + conn + |> recycle() + |> post(~p"/images/#{other_image}/comments/#{comment}/reports", %{ + "report" => %{ + "reason" => "Wrong parent", + "rule_id" => rule.id, + "user_agent" => "Test Browser/1.0" + } + }) + + assert redirected_to(post_conn) == "/" + assert Repo.aggregate(Report, :count) == 0 + end + test "POST as a logged-in user creates the report and redirects to /reports", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) image = image_fixture() diff --git a/test/philomena_web/controllers/image/comment_controller_test.exs b/test/philomena_web/controllers/image/comment_controller_test.exs index 3ea06751f..76d2e3883 100644 --- a/test/philomena_web/controllers/image/comment_controller_test.exs +++ b/test/philomena_web/controllers/image/comment_controller_test.exs @@ -3,6 +3,7 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do import Ecto.Query import Philomena.CommentsFixtures + import Philomena.FiltersFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures @@ -34,7 +35,9 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do conn = get(conn, ~p"/images/999999999/comments") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end @@ -52,7 +55,7 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do test "redirects to / for a comment on a hidden image", %{conn: conn} do image = image_fixture(hidden_from_users: true) - comment = comment_fixture(image) + comment = comment_fixture(image, moderator_user_fixture()) conn = get(conn, ~p"/images/#{image}/comments/#{comment}") @@ -70,6 +73,19 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find what you were looking for!" end + + test "redirects to / when the route image does not own the comment", %{conn: conn} do + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image) + + conn = get(conn, ~p"/images/#{other_image}/comments/#{comment}") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" + end end describe "POST /images/:image_id/comments" do @@ -148,6 +164,28 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You are currently banned" end + + test "a forced-filter match redirects without creating a comment", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + image = image_fixture() + filter = system_filter_fixture(hidden_complex_str: "id:#{image.id}") + + user + |> Ecto.Changeset.change(forced_filter_id: filter.id) + |> Repo.update!() + + conn = + post(conn, ~p"/images/#{image}/comments", %{ + "comment" => %{"body" => "Should not appear"} + }) + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You have been blocked from performing this action on this image." + + assert Repo.aggregate(Comment, :count) == 0 + end end describe "GET /images/:image_id/comments/:id/edit" do @@ -184,6 +222,20 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end + + test "the author cannot edit through a different route image", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image, user) + + conn = get(conn, ~p"/images/#{other_image}/comments/#{comment}/edit") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" + end end describe "PATCH /images/:image_id/comments/:id" do @@ -263,5 +315,24 @@ defmodule PhilomenaWeb.Image.CommentControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find what you were looking for!" end + + test "does not update a comment through a different route image", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + image = image_fixture() + other_image = image_fixture() + comment = comment_fixture(image, user, %{"body" => "Original"}) + + conn = + patch(conn, ~p"/images/#{other_image}/comments/#{comment}", %{ + "comment" => %{"body" => "Changed"} + }) + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" + + assert Repo.reload!(comment).body == "Original" + end end end diff --git a/test/philomena_web/controllers/image/comment_lock_controller_test.exs b/test/philomena_web/controllers/image/comment_lock_controller_test.exs index 861859209..051f40dea 100644 --- a/test/philomena_web/controllers/image/comment_lock_controller_test.exs +++ b/test/philomena_web/controllers/image/comment_lock_controller_test.exs @@ -51,17 +51,19 @@ defmodule PhilomenaWeb.Image.CommentLockControllerTest do refute comments_allowed?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/comment_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -116,17 +118,19 @@ defmodule PhilomenaWeb.Image.CommentLockControllerTest do assert comments_allowed?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/images/999999999/comment_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/delete_controller_test.exs b/test/philomena_web/controllers/image/delete_controller_test.exs index 45ca0747a..ba7c0ee5e 100644 --- a/test/philomena_web/controllers/image/delete_controller_test.exs +++ b/test/philomena_web/controllers/image/delete_controller_test.exs @@ -61,14 +61,16 @@ defmodule PhilomenaWeb.Image.DeleteControllerTest do refute Repo.reload!(image).hidden_from_users end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/delete", %{"image" => %{"deletion_reason" => "Spam"}}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end @@ -101,7 +103,7 @@ defmodule PhilomenaWeb.Image.DeleteControllerTest do end # verify_deleted halts when the image is not currently hidden. - test "on a non-deleted image redirects with the not-deleted flash", %{conn: conn} do + test "on a non-deleted image redirects with the error flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() @@ -113,7 +115,7 @@ defmodule PhilomenaWeb.Image.DeleteControllerTest do assert redirected_to(conn) == ~p"/images/#{image}" assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Cannot change deletion reason on a non-deleted image!" + "Couldn't update deletion reason." end # Failure path: a blank reason on a hidden image fails validation. @@ -176,20 +178,18 @@ defmodule PhilomenaWeb.Image.DeleteControllerTest do refute Repo.reload!(image).hidden_from_users end - # unhide_image/1 has a fall-through clause for non-hidden images, so the - # controller's {:ok, image} match still succeeds. - test "restoring a non-hidden image still succeeds", %{conn: conn} do + test "restoring a non-hidden image fails", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() conn = delete(conn, ~p"/images/#{image}/delete") - assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Image successfully restored." + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Failed to restore image." refute Repo.reload!(image).hidden_from_users end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/description_lock_controller_test.exs b/test/philomena_web/controllers/image/description_lock_controller_test.exs index 6a1673e5d..702de90fc 100644 --- a/test/philomena_web/controllers/image/description_lock_controller_test.exs +++ b/test/philomena_web/controllers/image/description_lock_controller_test.exs @@ -51,17 +51,19 @@ defmodule PhilomenaWeb.Image.DescriptionLockControllerTest do refute description_editable?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/description_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -116,17 +118,19 @@ defmodule PhilomenaWeb.Image.DescriptionLockControllerTest do assert description_editable?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/images/999999999/description_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/destroy_controller_test.exs b/test/philomena_web/controllers/image/destroy_controller_test.exs index 16f3f31f7..c00504e3f 100644 --- a/test/philomena_web/controllers/image/destroy_controller_test.exs +++ b/test/philomena_web/controllers/image/destroy_controller_test.exs @@ -71,8 +71,7 @@ defmodule PhilomenaWeb.Image.DestroyControllerTest do assert Repo.reload!(image).image == nil end - # verify_deleted halts when the image is not currently hidden. - test "on a non-deleted image redirects with the not-deleted flash", %{conn: conn} do + test "on a non-deleted image redirects with an error flash", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) image = image_fixture() @@ -81,24 +80,26 @@ defmodule PhilomenaWeb.Image.DestroyControllerTest do assert redirected_to(conn) == ~p"/images/#{image}" assert Phoenix.Flash.get(conn.assigns.flash, :error) == - "Cannot destroy a non-deleted image!" + "Failed to destroy image." assert Repo.reload!(image).image != nil end - # Failure path: an unknown image_id is authorized against a nil resource, + # Missing image locators resolve to not-found before authorization. # for which the role_map moderator has no matching rule. - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do conn = log_in_role_moderator(conn, "Image") conn = post(conn, ~p"/images/999999999/destroy") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do conn = log_in_role_moderator(conn, "Image") diff --git a/test/philomena_web/controllers/image/fave_controller_test.exs b/test/philomena_web/controllers/image/fave_controller_test.exs index c31caa4c2..1b0f20782 100644 --- a/test/philomena_web/controllers/image/fave_controller_test.exs +++ b/test/philomena_web/controllers/image/fave_controller_test.exs @@ -3,8 +3,10 @@ defmodule PhilomenaWeb.Image.FaveControllerTest do use PhilomenaWeb.SingletonToggleTests import Ecto.Query + import Philomena.FiltersFixtures import Philomena.ImagesFixtures + alias Philomena.Multi alias Philomena.ImageFaves alias Philomena.ImageFaves.ImageFave alias Philomena.ImageVotes @@ -24,7 +26,10 @@ defmodule PhilomenaWeb.Image.FaveControllerTest do end defp fave!(image, user) do - {:ok, _} = Repo.transaction(ImageFaves.create_fave_transaction(image, user)) + {:ok, _} = + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> Multi.transact() end describe "POST /images/:image_id/fave" do @@ -51,7 +56,11 @@ defmodule PhilomenaWeb.Image.FaveControllerTest do test "when the user had downvoted, replaces the downvote with an upvote", %{conn: conn, user: user} do image = image_fixture() - {:ok, _} = Repo.transaction(ImageVotes.create_vote_transaction(image, user, false)) + + {:ok, _} = + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, false) + |> Multi.transact() conn = post(conn, ~p"/images/#{image}/fave") @@ -64,6 +73,25 @@ defmodule PhilomenaWeb.Image.FaveControllerTest do assert %ImageVote{up: true} = vote(image, user) end + + test "a forced-filter match redirects without recording a fave", %{conn: conn, user: user} do + image = image_fixture() + filter = system_filter_fixture(hidden_complex_str: "id:#{image.id}") + + user + |> Ecto.Changeset.change(forced_filter_id: filter.id) + |> Repo.update!() + + conn = post(conn, ~p"/images/#{image}/fave") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You have been blocked from performing this action on this image." + + refute fave(image, user) + refute vote(image, user) + end end describe "DELETE /images/:image_id/fave" do @@ -72,7 +100,11 @@ defmodule PhilomenaWeb.Image.FaveControllerTest do test "removes the fave but keeps the implicit upvote", %{conn: conn, user: user} do image = image_fixture() fave!(image, user) - {:ok, _} = Repo.transaction(ImageVotes.create_vote_transaction(image, user, true)) + + {:ok, _} = + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, true) + |> Multi.transact() conn = delete(conn, ~p"/images/#{image}/fave") diff --git a/test/philomena_web/controllers/image/favorite_controller_test.exs b/test/philomena_web/controllers/image/favorite_controller_test.exs index 25b92404f..e6f592db7 100644 --- a/test/philomena_web/controllers/image/favorite_controller_test.exs +++ b/test/philomena_web/controllers/image/favorite_controller_test.exs @@ -6,14 +6,20 @@ defmodule PhilomenaWeb.Image.FavoriteControllerTest do alias Philomena.ImageFaves alias Philomena.ImageVotes - alias Philomena.Repo + alias Philomena.Multi defp fave!(image, user) do - {:ok, _} = Repo.transaction(ImageFaves.create_fave_transaction(image, user)) + {:ok, _} = + Multi.new() + |> ImageFaves.put_fave_for_loaded_image(image, user) + |> Multi.transact() end defp upvote!(image, user) do - {:ok, _} = Repo.transaction(ImageVotes.create_vote_transaction(image, user, true)) + {:ok, _} = + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, true) + |> Multi.transact() end describe "GET /images/:image_id/favorites" do @@ -67,7 +73,9 @@ defmodule PhilomenaWeb.Image.FavoriteControllerTest do conn = get(conn, ~p"/images/999999999/favorites") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/image/feature_controller_test.exs b/test/philomena_web/controllers/image/feature_controller_test.exs index 7c1a1a717..f7c7d1b72 100644 --- a/test/philomena_web/controllers/image/feature_controller_test.exs +++ b/test/philomena_web/controllers/image/feature_controller_test.exs @@ -53,29 +53,29 @@ defmodule PhilomenaWeb.Image.FeatureControllerTest do assert featured?(image) end - # verify_not_deleted halts before featuring a hidden image. - test "on a deleted image redirects with the deleted-image flash", %{conn: conn} do + test "operates on a deleted image", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture(hidden_from_users: true, deletion_reason: "Spam") conn = post(conn, ~p"/images/#{image}/feature") - assert redirected_to(conn) == ~p"/images/#{image}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Cannot feature a deleted image." - refute featured?(image) + assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Image marked as featured image." + assert featured?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/feature") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/file_controller_test.exs b/test/philomena_web/controllers/image/file_controller_test.exs index beb25ab81..8fee6fbbb 100644 --- a/test/philomena_web/controllers/image/file_controller_test.exs +++ b/test/philomena_web/controllers/image/file_controller_test.exs @@ -1,7 +1,7 @@ defmodule PhilomenaWeb.Image.FileControllerTest do use PhilomenaWeb.ConnCase, async: true - # `Images.update_file/2` drives the media pipeline synchronously + # `Images.update_image_file/3` drives the media pipeline synchronously # (analyze, persist to the stubbed S3, enqueue the dead # ThumbnailWorker/reindex jobs), with no spawned upload process, so this # file stays `async: true`. @@ -41,7 +41,7 @@ defmodule PhilomenaWeb.Image.FileControllerTest do assert redirected_to(conn) == ~p"/images/#{image}" assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Successfully updated file." - # repair_image marks the image for reprocessing. + # create_image_repair marks the image for reprocessing. reloaded = Repo.reload!(image) assert reloaded.processed == false assert reloaded.thumbnails_generated == false @@ -88,7 +88,7 @@ defmodule PhilomenaWeb.Image.FileControllerTest do # A file that matches a *different* image's fingerprint is still rejected as # a duplicate (image_changeset adds the "has already been uploaded" error), - # so update_file returns an error and the target image is left untouched - + # so update_image_file returns an error and the target image is left untouched - # its own fingerprint preserved. test "as a moderator replacing with a file already uploaded as another image fails", %{ conn: conn @@ -109,22 +109,31 @@ defmodule PhilomenaWeb.Image.FileControllerTest do assert reloaded.processed == true end - # verify_not_deleted halts before replacing a hidden image. - test "on a deleted image redirects with the deleted-image flash", %{conn: conn} do + test "as a moderator replaces a hidden image", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) - image = image_fixture(hidden_from_users: true, deletion_reason: "Spam") + + image = + image_fixture( + hidden_from_users: true, + hidden_image_key: "hidden-key", + deletion_reason: "Spam" + ) conn = put(conn, ~p"/images/#{image}/file", %{"image" => %{"image" => png_upload()}}) assert redirected_to(conn) == ~p"/images/#{image}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Cannot replace a deleted image." - # The hash is untouched because the action never runs. - assert Repo.reload!(image).image_orig_sha512_hash != nil + assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Successfully updated file." + + reloaded = Repo.reload!(image) + assert reloaded.hidden_from_users + assert reloaded.processed == false + assert reloaded.thumbnails_generated == false + assert reloaded.image_orig_sha512_hash == png_upload_sha512() end - # update_file re-renders the "Failed to update file!" error branch on a + # update_image_file re-renders the "Failed to update file!" error branch on a # request with no file (image_changeset's validate_required(:image) fails). - # The action goes straight to update_file, so a failed replacement leaves + # The action goes straight to update_image_file, so a failed replacement leaves # the image untouched - crucially the dedup fingerprint is preserved. test "without a file redirects with the failure flash and preserves the hash", %{ conn: conn @@ -144,18 +153,20 @@ defmodule PhilomenaWeb.Image.FileControllerTest do assert reloaded.processed == true end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = put(conn, ~p"/images/999999999/file", %{"image" => %{"image" => png_upload()}}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/hash_controller_test.exs b/test/philomena_web/controllers/image/hash_controller_test.exs index 4ca2058ba..bda9ba726 100644 --- a/test/philomena_web/controllers/image/hash_controller_test.exs +++ b/test/philomena_web/controllers/image/hash_controller_test.exs @@ -47,17 +47,19 @@ defmodule PhilomenaWeb.Image.HashControllerTest do assert Repo.reload!(image).image_orig_sha512_hash == nil end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/images/999999999/hash") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/hide_controller_test.exs b/test/philomena_web/controllers/image/hide_controller_test.exs index eda7cce09..cc05d6c2b 100644 --- a/test/philomena_web/controllers/image/hide_controller_test.exs +++ b/test/philomena_web/controllers/image/hide_controller_test.exs @@ -8,9 +8,9 @@ defmodule PhilomenaWeb.Image.HideControllerTest do import Ecto.Query import Philomena.ImagesFixtures + alias Philomena.Multi alias Philomena.ImageHides alias Philomena.ImageHides.ImageHide - alias Philomena.Images alias Philomena.Repo defp interaction_path(image_id), do: ~p"/images/#{image_id}/hide" @@ -22,7 +22,10 @@ defmodule PhilomenaWeb.Image.HideControllerTest do end defp hide!(image, user) do - {:ok, _} = Repo.transaction(ImageHides.create_hide_transaction(image, user)) + {:ok, _} = + Multi.new() + |> ImageHides.put_hide_for_loaded_image(image, user) + |> Multi.transact() end describe "POST /images/:image_id/hide" do @@ -42,7 +45,7 @@ defmodule PhilomenaWeb.Image.HideControllerTest do } assert hide(image, user) - assert Images.get_image!(image.id).hides_count == 1 + assert Repo.reload!(image).hides_count == 1 end test "when already hidden stays hidden", %{conn: conn, user: user} do @@ -59,7 +62,7 @@ defmodule PhilomenaWeb.Image.HideControllerTest do } assert hide(image, user) - assert Images.get_image!(image.id).hides_count == 1 + assert Repo.reload!(image).hides_count == 1 end end @@ -80,7 +83,7 @@ defmodule PhilomenaWeb.Image.HideControllerTest do } refute hide(image, user) - assert Images.get_image!(image.id).hides_count == 0 + assert Repo.reload!(image).hides_count == 0 end test "with no existing hide still returns 200 interaction data", %{conn: conn} do diff --git a/test/philomena_web/controllers/image/navigate_controller_test.exs b/test/philomena_web/controllers/image/navigate_controller_test.exs index 024d34e0a..ea1989179 100644 --- a/test/philomena_web/controllers/image/navigate_controller_test.exs +++ b/test/philomena_web/controllers/image/navigate_controller_test.exs @@ -76,7 +76,9 @@ defmodule PhilomenaWeb.Image.NavigateControllerTest do conn = get(conn, ~p"/images/999999999/navigate?rel=next") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end diff --git a/test/philomena_web/controllers/image/read_controller_test.exs b/test/philomena_web/controllers/image/read_controller_test.exs index ba07f0f0f..209441574 100644 --- a/test/philomena_web/controllers/image/read_controller_test.exs +++ b/test/philomena_web/controllers/image/read_controller_test.exs @@ -25,7 +25,7 @@ defmodule PhilomenaWeb.Image.ReadControllerTest do {:ok, _} = Images.create_subscription(image, user) author = confirmed_user_fixture() comment = comment_fixture(image, author) - {:ok, 1} = Notifications.create_image_comment_notification(author, image, comment) + {:ok, 1} = Notifications.broadcast_image_comment(author, image, comment) end, notification?: fn -> Repo.exists?( @@ -39,9 +39,9 @@ defmodule PhilomenaWeb.Image.ReadControllerTest do read_singleton_tests() test "POST for an unknown image redirects with the not-found flash", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its not-found - # handler on :create - an unknown image redirects instead of passing nil - # into clear_image_notification/2. + # NOTE: the context authorizes the loaded record on :create; id 999999999 + # Missing image locators resolve to not-found before authorization. + # and redirects instead of passing nil into clear_image_notification/2. %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/images/999999999/read") diff --git a/test/philomena_web/controllers/image/related_controller_test.exs b/test/philomena_web/controllers/image/related_controller_test.exs index a18522b98..0526d8b12 100644 --- a/test/philomena_web/controllers/image/related_controller_test.exs +++ b/test/philomena_web/controllers/image/related_controller_test.exs @@ -40,7 +40,9 @@ defmodule PhilomenaWeb.Image.RelatedControllerTest do conn = get(conn, ~p"/images/999999999/related") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/image/repair_controller_test.exs b/test/philomena_web/controllers/image/repair_controller_test.exs index 054d0126d..96587d499 100644 --- a/test/philomena_web/controllers/image/repair_controller_test.exs +++ b/test/philomena_web/controllers/image/repair_controller_test.exs @@ -27,7 +27,7 @@ defmodule PhilomenaWeb.Image.RepairControllerTest do assert Repo.reload!(image).processed end - # repair_image flags the image for reprocessing (the actual thumbnail + # create_image_repair flags the image for reprocessing (the actual thumbnail # job is enqueued to a dead queue in tests). test "as a moderator enqueues the repair and flags the image", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -53,17 +53,19 @@ defmodule PhilomenaWeb.Image.RepairControllerTest do refute Repo.reload!(image).processed end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/repair") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/report_controller_test.exs b/test/philomena_web/controllers/image/report_controller_test.exs index 83f2c86ee..d6cbb4e1f 100644 --- a/test/philomena_web/controllers/image/report_controller_test.exs +++ b/test/philomena_web/controllers/image/report_controller_test.exs @@ -25,12 +25,14 @@ defmodule PhilomenaWeb.Image.ReportControllerTest do assert response =~ "Reporting Image - Derpibooru" end - test "GET new for an unknown image redirects to / with the authorization flash", + test "GET new for an unknown image redirects to / with the not-found flash", %{conn: conn} do conn = get(conn, ~p"/images/999999999/reports/new") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end test "POST as a logged-in user creates the report and redirects to /reports", %{conn: conn} do diff --git a/test/philomena_web/controllers/image/reporting_controller_test.exs b/test/philomena_web/controllers/image/reporting_controller_test.exs index b5d2d7082..5d3ea3468 100644 --- a/test/philomena_web/controllers/image/reporting_controller_test.exs +++ b/test/philomena_web/controllers/image/reporting_controller_test.exs @@ -28,6 +28,16 @@ defmodule PhilomenaWeb.Image.ReportingControllerTest do refute response =~ "You must" end + test "rejects a banned user with the same prerequisite as submission", %{conn: conn} do + %{conn: conn} = register_and_log_in_banned_user(%{conn: conn}) + image = image_fixture() + + conn = get(conn, ~p"/images/#{image}/reporting") + + assert redirected_to(conn) == "/" + assert Flash.get(conn.assigns.flash, :error) =~ "You are currently banned" + end + test "redirects to / for a hidden image as anonymous", %{conn: conn} do image = image_fixture(hidden_from_users: true) @@ -41,7 +51,7 @@ defmodule PhilomenaWeb.Image.ReportingControllerTest do conn = get(conn, ~p"/images/999999999/reporting") assert redirected_to(conn) == "/" - assert Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end end diff --git a/test/philomena_web/controllers/image/scratchpad_controller_test.exs b/test/philomena_web/controllers/image/scratchpad_controller_test.exs index b871b121a..b8e87b311 100644 --- a/test/philomena_web/controllers/image/scratchpad_controller_test.exs +++ b/test/philomena_web/controllers/image/scratchpad_controller_test.exs @@ -46,17 +46,19 @@ defmodule PhilomenaWeb.Image.ScratchpadControllerTest do assert html_response(conn, 200) =~ "Editing moderation notes for image" end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = get(conn, ~p"/images/999999999/scratchpad/edit") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -137,18 +139,20 @@ defmodule PhilomenaWeb.Image.ScratchpadControllerTest do assert scratchpad(image) == nil end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = put(conn, ~p"/images/999999999/scratchpad", %{"image" => %{"scratchpad" => "notes"}}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/source_change_controller_test.exs b/test/philomena_web/controllers/image/source_change_controller_test.exs index 73845e1f1..7f0d0a341 100644 --- a/test/philomena_web/controllers/image/source_change_controller_test.exs +++ b/test/philomena_web/controllers/image/source_change_controller_test.exs @@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Image.SourceChangeControllerTest do image = image_fixture() {:ok, _result} = - Images.update_sources(image, attribution(nil), %{ + Images.update_image_sources(actor(nil), image.id, %{ "old_sources" => %{}, "sources" => %{"0" => %{"source" => "https://example.com/test-source"}} }) @@ -35,7 +35,16 @@ defmodule PhilomenaWeb.Image.SourceChangeControllerTest do conn = get(conn, ~p"/images/999999999/source_changes") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + end + + test "redirects with an error flash for an invalid filter", %{conn: conn} do + image = image_fixture() + + conn = get(conn, ~p"/images/#{image}/source_changes?added=invalid") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid source change filter." end end end diff --git a/test/philomena_web/controllers/image/source_controller_test.exs b/test/philomena_web/controllers/image/source_controller_test.exs index 4b2d27870..39c902f15 100644 --- a/test/philomena_web/controllers/image/source_controller_test.exs +++ b/test/philomena_web/controllers/image/source_controller_test.exs @@ -46,6 +46,26 @@ defmodule PhilomenaWeb.Image.SourceControllerTest do assert html_response(conn, 200) =~ "https://example.com/put-source" end + test "PATCH with an invalid source URL re-renders the existing image", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + image = image_fixture(sources: ["https://example.com/existing-source"]) + + conn = + patch(conn, ~p"/images/#{image}/sources", %{ + "image" => %{ + "old_sources" => %{}, + "sources" => %{"0" => %{"source" => "not-a-url"}} + } + }) + + response = html_response(conn, 200) + + assert response =~ "https://example.com/existing-source" + assert response =~ "not-a-url" + assert response =~ "has invalid format" + refute Repo.exists?(from sc in SourceChange, where: sc.image_id == ^image.id) + end + test "PATCH anonymously updates the sources", %{conn: conn} do image = image_fixture() diff --git a/test/philomena_web/controllers/image/source_history_controller_test.exs b/test/philomena_web/controllers/image/source_history_controller_test.exs index d93adf0ab..8f1dbbb34 100644 --- a/test/philomena_web/controllers/image/source_history_controller_test.exs +++ b/test/philomena_web/controllers/image/source_history_controller_test.exs @@ -52,17 +52,19 @@ defmodule PhilomenaWeb.Image.SourceHistoryControllerTest do assert Repo.reload!(image).source_url == nil end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/images/999999999/source_history") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/subscription_controller_test.exs b/test/philomena_web/controllers/image/subscription_controller_test.exs index e60277725..989bc5a1b 100644 --- a/test/philomena_web/controllers/image/subscription_controller_test.exs +++ b/test/philomena_web/controllers/image/subscription_controller_test.exs @@ -29,25 +29,25 @@ defmodule PhilomenaWeb.Image.SubscriptionControllerTest do subscription_toggle_tests() - test "POST for an unknown image redirects to / with the authorization flash", + test "POST for an unknown image redirects to / with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/images/999999999/subscription") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end - test "banned users can still subscribe", %{conn: conn} do - # NOTE: unlike vote/fave/hide, the subscription controller has no - # FilterBannedUsersPlug, so a ban does not block watching an image + test "banned users can subscribe", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_banned_user(%{conn: conn}) target = subscription_target(user) conn = post(conn, target.path) - assert PhilomenaWeb.SingletonToggleTests.subscription_partial_watching?(conn) + assert response(conn, 200) =~ "js-subscription-target" assert target.subscribed?.() end end diff --git a/test/philomena_web/controllers/image/tag_controller_test.exs b/test/philomena_web/controllers/image/tag_controller_test.exs index 377fa31d1..761d5311d 100644 --- a/test/philomena_web/controllers/image/tag_controller_test.exs +++ b/test/philomena_web/controllers/image/tag_controller_test.exs @@ -6,6 +6,7 @@ defmodule PhilomenaWeb.Image.TagControllerTest do import Ecto.Query import Philomena.AttributionFixtures import Philomena.ImagesFixtures + import Philomena.TagsFixtures alias PhilomenaQuery.Search alias Philomena.TagChanges.Limits @@ -35,12 +36,12 @@ defmodule PhilomenaWeb.Image.TagControllerTest do end # Fills the user's Valkey tag bucket to the 50-change limit so the next - # multi-tag update trips Images.update_tags' check_limits step and takes the + # multi-tag update trips Images.update_image_tags' check_limits step and takes the # controller's rate-limited error branch. Registers cleanup of the counters # (they carry a 10-minute TTL and the SQL sandbox does not roll them back). defp fill_tag_bucket!(user) do ip = %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32} - :ok = Limits.update_tag_count_after_update(user, ip, 50) + :ok = Limits.record_action(user, ip, 50, 0) on_exit(fn -> reset_tag_change_limits(user: user, ip: ip) end) end @@ -69,6 +70,49 @@ defmodule PhilomenaWeb.Image.TagControllerTest do ) end + test "PATCH can remove an implied tag listed in old_tag_input", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + implied_tag = tag_fixture(name: "implied tag") + source_tag = tag_fixture(name: "source tag") + + source_tag = + source_tag + |> Repo.preload(:implied_tags) + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:implied_tags, [implied_tag]) + |> Repo.update!() + + image = image_fixture(tags: "safe, #{source_tag.name}, #{implied_tag.name}") + + conn = + patch(conn, ~p"/images/#{image}/tags", %{ + "image" => %{ + "old_tag_input" => "safe, #{source_tag.name}, #{implied_tag.name}", + "tag_input" => "safe, #{source_tag.name}, replacement tag" + } + }) + + assert html_response(conn, 200) + assert Enum.sort(tag_names(image)) == ["replacement tag", "safe", source_tag.name] + end + + test "PATCH adds the oc tag for an oc-namespaced tag", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + oc_tag = tag_fixture(name: "oc") + image = image_fixture() + + conn = + patch(conn, ~p"/images/#{image}/tags", %{ + "image" => %{ + "old_tag_input" => "safe", + "tag_input" => "safe, #{oc_tag.name}:test character" + } + }) + + assert html_response(conn, 200) + assert Enum.sort(tag_names(image)) == ["oc", "oc:test character", "safe"] + end + test "PUT behaves like PATCH", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) image = image_fixture() diff --git a/test/philomena_web/controllers/image/tag_lock_controller_test.exs b/test/philomena_web/controllers/image/tag_lock_controller_test.exs index e54d155c9..912654368 100644 --- a/test/philomena_web/controllers/image/tag_lock_controller_test.exs +++ b/test/philomena_web/controllers/image/tag_lock_controller_test.exs @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do use PhilomenaWeb.ConnCase, async: true import Philomena.ImagesFixtures + import Philomena.TagsFixtures alias Philomena.Repo @@ -56,17 +57,19 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do assert html_response(conn, 200) =~ "Editing locked tags on image ##{image.id}" end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = get(conn, ~p"/images/999999999/tag_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found + # IntegerId guard before authorization runs, so the flash is the not-found # message rather than the "You can't access that page." an unknown integer # id gets. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do @@ -123,17 +126,19 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do refute tags_editable?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/images/999999999/tag_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found + # IntegerId guard before authorization runs, so the flash is the not-found # message rather than the "You can't access that page." an unknown integer # id gets. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do @@ -172,6 +177,7 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do test "as a moderator updates the locked tag list", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() + tag_fixture(name: "solo") conn = put(conn, ~p"/images/#{image}/tag_lock", %{"image" => %{"tag_input" => "safe, solo"}}) @@ -187,6 +193,7 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do test "as an admin updates the locked tag list", %{conn: conn} do %{conn: conn} = register_and_log_in_admin(%{conn: conn}) image = image_fixture() + tag_fixture(name: "solo") conn = put(conn, ~p"/images/#{image}/tag_lock", %{"image" => %{"tag_input" => "solo"}}) @@ -196,11 +203,12 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do assert locked_tag_names(image) == ["solo"] end - # NOTE: an empty tag_input clears the locked tag list (get_or_create_tags - # returns []); this is a success, not a validation error. + # NOTE: an empty tag_input clears the locked tag list; this is a success, + # not a validation error. test "an empty tag_input clears the locked tag list", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() + tag_fixture(name: "solo") # Lock some tags first. put(conn, ~p"/images/#{image}/tag_lock", %{"image" => %{"tag_input" => "safe, solo"}}) @@ -214,18 +222,20 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do assert locked_tag_names(image) == [] end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = put(conn, ~p"/images/999999999/tag_lock", %{"image" => %{"tag_input" => "safe"}}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found + # IntegerId guard before authorization runs, so the flash is the not-found # message rather than the "You can't access that page." an unknown integer # id gets. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do @@ -282,17 +292,19 @@ defmodule PhilomenaWeb.Image.TagLockControllerTest do assert tags_editable?(image) end - test "for an unknown image_id redirects with the authorization flash", %{conn: conn} do + test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/images/999999999/tag_lock") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes, so the flash is the not-found + # IntegerId guard before authorization runs, so the flash is the not-found # message rather than the "You can't access that page." an unknown integer # id gets. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/image/tamper_controller_test.exs b/test/philomena_web/controllers/image/tamper_controller_test.exs index 3f8eb55c7..3025753f8 100644 --- a/test/philomena_web/controllers/image/tamper_controller_test.exs +++ b/test/philomena_web/controllers/image/tamper_controller_test.exs @@ -5,6 +5,7 @@ defmodule PhilomenaWeb.Image.TamperControllerTest do import Philomena.ImagesFixtures import Philomena.UsersFixtures + alias Philomena.Multi alias Philomena.ImageVotes alias Philomena.ImageVotes.ImageVote alias Philomena.Repo @@ -12,8 +13,9 @@ defmodule PhilomenaWeb.Image.TamperControllerTest do # Records `voter`'s (up/down) vote on `image` through the vote context. defp cast_vote(image, voter, up) do {:ok, _} = - ImageVotes.create_vote_transaction(image, voter, up) - |> Repo.transaction() + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, voter, up) + |> Multi.transact() :ok end @@ -83,8 +85,8 @@ defmodule PhilomenaWeb.Image.TamperControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Vote removed." end - # Failure path: an unknown user_id is loaded with load_resource, whose - # not-found handler fires here and redirects rather than crashing. + # Missing image locators resolve to not-found before authorization. + # redirecting rather than crashing. test "for an unknown user_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() @@ -98,7 +100,7 @@ defmodule PhilomenaWeb.Image.TamperControllerTest do end # NOTE: a non-integer image_id short-circuits to NotFoundPlug via the central - # IntegerId guard before Canary authorizes. + # IntegerId guard before authorization runs. test "for a non-integer image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) voter = confirmed_user_fixture() diff --git a/test/philomena_web/controllers/image/uploader_controller_test.exs b/test/philomena_web/controllers/image/uploader_controller_test.exs index 7269c6ad7..29120bb57 100644 --- a/test/philomena_web/controllers/image/uploader_controller_test.exs +++ b/test/philomena_web/controllers/image/uploader_controller_test.exs @@ -18,8 +18,8 @@ defmodule PhilomenaWeb.Image.UploaderControllerTest do assert redirected_to(conn) == ~p"/sessions/new" end - # NOTE: verify_authorized checks `:show, :ip_address`, which a regular - # user lacks, so they get the authorization redirect. + # NOTE: the context authorizes `:show, :identity_metadata`, which a regular user + # lacks, so they get the authorization redirect. test "rejects a regular user", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) image = image_fixture() @@ -90,21 +90,18 @@ defmodule PhilomenaWeb.Image.UploaderControllerTest do assert uploader_id(image) == original.id end - # NOTE: a request without the `image` param takes the fallback update/2 - # clause, which also answers 300 with the failure flash. - test "a missing image param answers 300 with the failure flash", %{conn: conn} do + test "a missing image param raises", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture() - conn = put(conn, ~p"/images/#{image}/uploader", %{}) - - assert response(conn, 300) == "" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Failed to update uploader!" + assert_raise Ecto.CastError, fn -> + put(conn, ~p"/images/#{image}/uploader", %{}) + end end - # NOTE: unlike the load_and_authorize_resource controllers, this one loads - # with plain load_resource, and Canary's not_found_handler runs on :update, - # so an unknown id redirects rather than crashing. + # NOTE: the context authorizes the loaded image on :update; an unknown + # Missing image locators resolve to not-found before authorization. + # returns not_found - an unknown id redirects rather than crashing. test "for an unknown image_id redirects with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/image/vote_controller_test.exs b/test/philomena_web/controllers/image/vote_controller_test.exs index 8e2460474..a1d58ee00 100644 --- a/test/philomena_web/controllers/image/vote_controller_test.exs +++ b/test/philomena_web/controllers/image/vote_controller_test.exs @@ -3,8 +3,10 @@ defmodule PhilomenaWeb.Image.VoteControllerTest do use PhilomenaWeb.SingletonToggleTests import Ecto.Query + import Philomena.FiltersFixtures import Philomena.ImagesFixtures + alias Philomena.Multi alias Philomena.ImageVotes alias Philomena.ImageVotes.ImageVote alias Philomena.Repo @@ -18,7 +20,10 @@ defmodule PhilomenaWeb.Image.VoteControllerTest do end defp upvote!(image, user) do - {:ok, _} = Repo.transaction(ImageVotes.create_vote_transaction(image, user, true)) + {:ok, _} = + Multi.new() + |> ImageVotes.put_vote_for_loaded_image(image, user, true) + |> Multi.transact() end describe "POST /images/:image_id/vote" do @@ -103,7 +108,7 @@ defmodule PhilomenaWeb.Image.VoteControllerTest do conn = post(conn, ~p"/images/#{image}/vote", %{}) - assert json_response(conn, 400) == %{} + assert json_response(conn, 400) == %{"errors" => %{"up" => ["can't be blank"]}} refute vote(image, user) end @@ -113,7 +118,7 @@ defmodule PhilomenaWeb.Image.VoteControllerTest do conn = post(conn, ~p"/images/#{image}/vote", %{"up" => "banana"}) - assert json_response(conn, 400) == %{} + assert json_response(conn, 400) == %{"errors" => %{"up" => ["is invalid"]}} refute vote(image, user) end @@ -132,6 +137,24 @@ defmodule PhilomenaWeb.Image.VoteControllerTest do assert %ImageVote{up: false} = vote(image, user) end + + test "a forced-filter match redirects without recording a vote", %{conn: conn, user: user} do + image = image_fixture() + filter = system_filter_fixture(hidden_complex_str: "id:#{image.id}") + + user + |> Ecto.Changeset.change(forced_filter_id: filter.id) + |> Repo.update!() + + conn = post(conn, ~p"/images/#{image}/vote", %{"up" => true}) + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "You have been blocked from performing this action on this image." + + refute vote(image, user) + end end describe "DELETE /images/:image_id/vote" do diff --git a/test/philomena_web/controllers/image_controller_test.exs b/test/philomena_web/controllers/image_controller_test.exs index 26e00a2d3..fd8a2b1c9 100644 --- a/test/philomena_web/controllers/image_controller_test.exs +++ b/test/philomena_web/controllers/image_controller_test.exs @@ -100,12 +100,51 @@ defmodule PhilomenaWeb.ImageControllerTest do assert html_response(conn, 200) =~ "##{image.id} - safe - Derpibooru" end - test "renders the deleted page for a hidden image", %{conn: conn} do + test "does not render mutation controls for a banned viewer", %{conn: conn} do + %{conn: conn} = register_and_log_in_banned_user(%{conn: conn}) + image = image_fixture() + + conn = get(conn, ~p"/images/#{image}") + response = html_response(conn, 200) + + refute response =~ ~s(id="edit-description") + refute response =~ ~s(id="edit-source") + refute response =~ ~s(id="edit-tags") + refute response =~ "interaction--fave" + refute response =~ "interaction--upvote" + refute response =~ "interaction--downvote" + end + + test "renders the deleted page for an anonymous viewer", %{conn: conn} do + image = image_fixture(hidden_from_users: true) + + conn = get(conn, ~p"/images/#{image}") + + response = html_response(conn, 200) + assert response =~ "This image has been deleted" + refute response =~ "Done by:" + end + + test "renders the deleted page for a regular viewer", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + image = image_fixture(hidden_from_users: true) + + conn = get(conn, ~p"/images/#{image}") + + response = html_response(conn, 200) + assert response =~ "This image has been deleted" + refute response =~ "Done by:" + end + + test "renders the deleted page for a moderator", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) image = image_fixture(hidden_from_users: true) conn = get(conn, ~p"/images/#{image}") - assert html_response(conn, 200) =~ "This image has been deleted" + response = html_response(conn, 200) + assert response =~ "This image has been deleted" + assert response =~ "Done by:" end test "redirects a merged duplicate to its target", %{conn: conn} do diff --git a/test/philomena_web/controllers/ip_profile/source_change_controller_test.exs b/test/philomena_web/controllers/ip_profile/source_change_controller_test.exs index 65acfe00e..1332a94b5 100644 --- a/test/philomena_web/controllers/ip_profile/source_change_controller_test.exs +++ b/test/philomena_web/controllers/ip_profile/source_change_controller_test.exs @@ -14,6 +14,13 @@ defmodule PhilomenaWeb.IpProfile.SourceChangeControllerTest do "You must log in to access this page." end + test "the route's login requirement runs before malformed IP validation", %{conn: conn} do + conn = get(conn, ~p"/ip_profiles/#{"not-an-ip"}/source_changes") + + assert redirected_to(conn) == ~p"/sessions/new" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You must log in" + end + test "redirects a regular user with the authorization flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) @@ -53,5 +60,14 @@ defmodule PhilomenaWeb.IpProfile.SourceChangeControllerTest do assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end + + test "redirects with an error flash for an invalid filter", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + + conn = get(conn, ~p"/ip_profiles/#{"203.0.113.1"}/source_changes?added=invalid") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid source change filter." + end end end diff --git a/test/philomena_web/controllers/notification/category_controller_test.exs b/test/philomena_web/controllers/notification/category_controller_test.exs index ec098e9f7..600c69e77 100644 --- a/test/philomena_web/controllers/notification/category_controller_test.exs +++ b/test/philomena_web/controllers/notification/category_controller_test.exs @@ -24,7 +24,7 @@ defmodule PhilomenaWeb.Notification.CategoryControllerTest do {:ok, _} = Forums.create_subscription(forum, user) author = confirmed_user_fixture() topic = topic_fixture(forum, author) - {:ok, 1} = Notifications.create_forum_topic_notification(author, topic) + {:ok, 1} = Notifications.broadcast_forum_topic(author, topic) response = html_response(get(conn, ~p"/notifications/categories/forum_topic"), 200) @@ -41,21 +41,14 @@ defmodule PhilomenaWeb.Notification.CategoryControllerTest do assert response =~ "You currently have no notifications of this category." end - test "GET with an unknown category id falls back to forum_post", %{conn: conn} do - # NOTE: the category parser defaults every unrecognized id to - # :forum_post rather than 404ing. - %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + test "GET with an unknown category id follows the site's not-found response", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) - forum = forum_fixture() - author = confirmed_user_fixture() - topic = topic_fixture(forum, author) - {:ok, _} = Philomena.Topics.create_subscription(topic, user) - post = Philomena.PostsFixtures.post_fixture(topic, author) - {:ok, 1} = Notifications.create_forum_post_notification(author, topic, post) + conn = get(conn, ~p"/notifications/categories/bogus-category") - response = html_response(get(conn, ~p"/notifications/categories/bogus-category"), 200) + assert redirected_to(conn) == ~p"/" - assert response =~ "New replies in topics" - assert response =~ topic.title + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end diff --git a/test/philomena_web/controllers/notification/unread_controller_test.exs b/test/philomena_web/controllers/notification/unread_controller_test.exs index 2a851e623..ae1c45cf4 100644 --- a/test/philomena_web/controllers/notification/unread_controller_test.exs +++ b/test/philomena_web/controllers/notification/unread_controller_test.exs @@ -34,7 +34,7 @@ defmodule PhilomenaWeb.Notification.UnreadControllerTest do {:ok, _} = Forums.create_subscription(forum, user) author = confirmed_user_fixture() topic = topic_fixture(forum, author) - {:ok, 1} = Notifications.create_forum_topic_notification(author, topic) + {:ok, 1} = Notifications.broadcast_forum_topic(author, topic) _conversation = conversation_fixture(confirmed_user_fixture(), user) diff --git a/test/philomena_web/controllers/notification_controller_test.exs b/test/philomena_web/controllers/notification_controller_test.exs index b300865b4..da4803552 100644 --- a/test/philomena_web/controllers/notification_controller_test.exs +++ b/test/philomena_web/controllers/notification_controller_test.exs @@ -38,13 +38,13 @@ defmodule PhilomenaWeb.NotificationControllerTest do {:ok, _} = Forums.create_subscription(forum, user) author = confirmed_user_fixture() topic = topic_fixture(forum, author) - {:ok, 1} = Notifications.create_forum_topic_notification(author, topic) + {:ok, 1} = Notifications.broadcast_forum_topic(author, topic) # image_comment: user watches an image, someone comments on it image = image_fixture() {:ok, _} = Images.create_subscription(image, user) comment = comment_fixture(image, author) - {:ok, 1} = Notifications.create_image_comment_notification(author, image, comment) + {:ok, 1} = Notifications.broadcast_image_comment(author, image, comment) response = html_response(get(conn, ~p"/notifications"), 200) @@ -63,7 +63,7 @@ defmodule PhilomenaWeb.NotificationControllerTest do {:ok, _} = Forums.create_subscription(forum, recipient) author = confirmed_user_fixture() topic = topic_fixture(forum, author) - {:ok, 1} = Notifications.create_forum_topic_notification(author, topic) + {:ok, 1} = Notifications.broadcast_forum_topic(author, topic) response = html_response(get(conn, ~p"/notifications"), 200) diff --git a/test/philomena_web/controllers/page/history_controller_test.exs b/test/philomena_web/controllers/page/history_controller_test.exs index 63a2781f5..871faaff2 100644 --- a/test/philomena_web/controllers/page/history_controller_test.exs +++ b/test/philomena_web/controllers/page/history_controller_test.exs @@ -2,6 +2,7 @@ defmodule PhilomenaWeb.Page.HistoryControllerTest do use PhilomenaWeb.ConnCase, async: true import Philomena.StaticPagesFixtures + import Philomena.AttributionFixtures, only: [actor: 1] import Philomena.UsersFixtures alias Philomena.StaticPages @@ -9,11 +10,11 @@ defmodule PhilomenaWeb.Page.HistoryControllerTest do describe "GET /pages/:page_id/history" do test "renders the revision history for anonymous users", %{conn: conn} do creator = user_fixture() - editor = user_fixture() + editor = admin_user_fixture() page = static_page_fixture(creator, %{body: "The original text"}) - {:ok, _} = - StaticPages.update_static_page(page, editor, %{ + {:ok, _page} = + StaticPages.update_page(actor(editor), page.slug, %{ title: page.title, slug: page.slug, body: "The updated text" @@ -61,9 +62,6 @@ defmodule PhilomenaWeb.Page.HistoryControllerTest do end test "redirects with the not-found flash for an unknown slug", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its - # not-found handler on this :index action - an unknown slug redirects - # instead of dereferencing a nil page. conn = get(conn, ~p"/pages/nonexistent-page/history") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/page_controller_test.exs b/test/philomena_web/controllers/page_controller_test.exs index 453d3d81e..eee4f4ebc 100644 --- a/test/philomena_web/controllers/page_controller_test.exs +++ b/test/philomena_web/controllers/page_controller_test.exs @@ -44,7 +44,7 @@ defmodule PhilomenaWeb.PageControllerTest do test "redirects to / for regular users", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - # NOTE: Canary authorizes :index against the StaticPage module, which + # NOTE: the context authorizes :index against the StaticPage module, which # only staff ability rules match - the pages index is staff-only even # though individual pages are public. conn = get(conn, ~p"/pages") @@ -107,7 +107,7 @@ defmodule PhilomenaWeb.PageControllerTest do conn = get(conn, ~p"/pages/nonexistent-page") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -250,10 +250,6 @@ defmodule PhilomenaWeb.PageControllerTest do end test "redirects with a not-found flash on an unknown slug for an admin", %{conn: conn} do - # NOTE: :edit is a Canary member action, so its not-found handler runs - # before the controller even without `persisted: true`. An admin (for - # whom can?(admin, _, nil) is true) sails past authorization and takes - # the not-found branch rather than crashing on change_static_page(nil). %{conn: conn} = register_and_log_in_admin(%{conn: conn}) conn = get(conn, ~p"/pages/no-such-slug/edit") diff --git a/test/philomena_web/controllers/password_controller_test.exs b/test/philomena_web/controllers/password_controller_test.exs index 26092a7ef..c498c0f85 100644 --- a/test/philomena_web/controllers/password_controller_test.exs +++ b/test/philomena_web/controllers/password_controller_test.exs @@ -88,7 +88,9 @@ defmodule PhilomenaWeb.PasswordControllerTest do assert redirected_to(conn) == ~p"/sessions/new" refute get_session(conn, :user_token) assert Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully" - assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1) + + assert {:ok, _} = + Users.fetch_user_by_email_and_password(user.email, "new valid password", & &1) end test "does not reset password on invalid data", %{conn: conn, token: token} do diff --git a/test/philomena_web/controllers/post_controller_test.exs b/test/philomena_web/controllers/post_controller_test.exs index b439b96fb..9340f9108 100644 --- a/test/philomena_web/controllers/post_controller_test.exs +++ b/test/philomena_web/controllers/post_controller_test.exs @@ -6,6 +6,7 @@ defmodule PhilomenaWeb.PostControllerTest do import Philomena.ForumsFixtures import Philomena.PostsFixtures import Philomena.TopicsFixtures + import Philomena.UsersFixtures alias PhilomenaQuery.Search alias PhilomenaQuery.SearchHelpers @@ -17,9 +18,9 @@ defmodule PhilomenaWeb.PostControllerTest do :ok end - defp topic_with_post(forum, body) do + defp topic_with_post(forum, body, user \\ nil) do topic = topic_fixture(forum) - post_fixture(topic, nil, %{"body" => body}) + post_fixture(topic, user, %{"body" => body}) end describe "GET /posts" do @@ -38,7 +39,7 @@ defmodule PhilomenaWeb.PostControllerTest do test "does not show restricted-forum posts to anonymous users", %{conn: conn} do staff_forum = forum_fixture(access_level: "staff") - _post = topic_with_post(staff_forum, "Test staff-only post body") + _post = topic_with_post(staff_forum, "Test staff-only post body", moderator_user_fixture()) SearchHelpers.reindex_all!(Post) conn = get(conn, ~p"/posts") @@ -50,7 +51,7 @@ defmodule PhilomenaWeb.PostControllerTest do test "shows restricted-forum posts to moderators", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) staff_forum = forum_fixture(access_level: "staff") - _post = topic_with_post(staff_forum, "Test staff-only post body") + _post = topic_with_post(staff_forum, "Test staff-only post body", moderator_user_fixture()) SearchHelpers.reindex_all!(Post) conn = get(conn, ~p"/posts") @@ -75,6 +76,82 @@ defmodule PhilomenaWeb.PostControllerTest do refute response =~ "Test hidden post body" end + test "does not render another author's pending or destroyed posts", %{conn: conn} do + forum = forum_fixture() + + pending = + topic_with_post( + forum, + "Test pending post body", + confirmed_user_fixture() + ) + |> Ecto.Changeset.change(approved: false) + |> Repo.update!() + + destroyed = + topic_with_post( + forum, + "Test destroyed post body", + confirmed_user_fixture() + ) + |> Ecto.Changeset.change(destroyed_content: true) + |> Repo.update!() + + SearchHelpers.reindex_all!(Post) + + response = conn |> get(~p"/posts") |> html_response(200) + + refute response =~ pending.body + refute response =~ destroyed.body + end + + test "renders pending and destroyed posts to moderators", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + forum = forum_fixture() + + pending = + topic_with_post( + forum, + "Moderator pending post body", + confirmed_user_fixture() + ) + |> Ecto.Changeset.change(approved: false) + |> Repo.update!() + + destroyed = + topic_with_post( + forum, + "Moderator destroyed post body", + confirmed_user_fixture() + ) + |> Ecto.Changeset.change(destroyed_content: true) + |> Repo.update!() + + SearchHelpers.reindex_all!(Post) + + response = conn |> get(~p"/posts") |> html_response(200) + + assert response =~ pending.body + assert response =~ destroyed.body + end + + test "renders a signed-in author's own pending post", %{conn: conn} do + author = confirmed_user_fixture() + conn = log_in_user(conn, author) + forum = forum_fixture() + + pending = + topic_with_post(forum, "Author pending post body", author) + |> Ecto.Changeset.change(approved: false) + |> Repo.update!() + + SearchHelpers.reindex_all!(Post) + + response = conn |> get(~p"/posts") |> html_response(200) + + assert response =~ pending.body + end + test "filters posts with the pq parameter", %{conn: conn} do forum = forum_fixture() _matching = topic_with_post(forum, "Test grapefruit post") diff --git a/test/philomena_web/controllers/profile/alias_controller_test.exs b/test/philomena_web/controllers/profile/alias_controller_test.exs index 4968b3d0f..5ea187857 100644 --- a/test/philomena_web/controllers/profile/alias_controller_test.exs +++ b/test/philomena_web/controllers/profile/alias_controller_test.exs @@ -68,15 +68,13 @@ defmodule PhilomenaWeb.Profile.AliasControllerTest do assert response =~ "Potential Aliases" end - # NOTE: same `load_and_authorize_resource` `:index` shape as ip/fp_history - - # an unknown slug takes the not-authorized redirect, not the not-found one. - test "redirects an unknown profile slug with the authorization flash", %{conn: conn} do + test "redirects an unknown profile slug with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = get(conn, ~p"/profiles/#{"nonexistent-slug"}/aliases") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end end diff --git a/test/philomena_web/controllers/profile/artist_link_controller_test.exs b/test/philomena_web/controllers/profile/artist_link_controller_test.exs index 8ab58f652..93c767a97 100644 --- a/test/philomena_web/controllers/profile/artist_link_controller_test.exs +++ b/test/philomena_web/controllers/profile/artist_link_controller_test.exs @@ -4,7 +4,6 @@ defmodule PhilomenaWeb.Profile.ArtistLinkControllerTest do import Philomena.TagsFixtures import Philomena.UsersFixtures - alias Philomena.ArtistLinks alias Philomena.ArtistLinks.ArtistLink alias Philomena.Repo @@ -13,13 +12,7 @@ defmodule PhilomenaWeb.Profile.ArtistLinkControllerTest do end defp artist_link_fixture(user) do - {:ok, link} = - ArtistLinks.create_artist_link(user, %{ - "tag_name" => artist_tag_fixture().name, - "uri" => "https://example.com/gallery-#{System.unique_integer([:positive])}" - }) - - link + Philomena.ArtistLinksFixtures.artist_link_fixture(user, artist_tag_fixture()) end describe "GET /profiles/:profile_id/artist_links" do diff --git a/test/philomena_web/controllers/profile/award_controller_test.exs b/test/philomena_web/controllers/profile/award_controller_test.exs index ad1fa43de..c06c933cb 100644 --- a/test/philomena_web/controllers/profile/award_controller_test.exs +++ b/test/philomena_web/controllers/profile/award_controller_test.exs @@ -127,6 +127,18 @@ defmodule PhilomenaWeb.Profile.AwardControllerTest do "Couldn't find what you were looking for!" end + test "redirects with not-found when the award belongs to another profile", %{conn: conn} do + %{conn: conn, user: mod} = register_and_log_in_moderator(%{conn: conn}) + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + award = badge_award_fixture(mod, owner) + + conn = get(conn, ~p"/profiles/#{other}/awards/#{award}/edit") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + end + test "redirects a regular user with the authorization flash", %{conn: conn} do %{conn: conn, user: mod} = register_and_log_in_moderator(%{conn: conn}) other = confirmed_user_fixture() @@ -203,6 +215,21 @@ defmodule PhilomenaWeb.Profile.AwardControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Couldn't find what you were looking for!" end + + test "does not update an award through another profile", %{conn: conn} do + %{conn: conn, user: mod} = register_and_log_in_moderator(%{conn: conn}) + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + award = badge_award_fixture(mod, owner, nil, %{label: "Before"}) + + conn = + patch(conn, ~p"/profiles/#{other}/awards/#{award}", %{ + "award" => %{"label" => "After"} + }) + + assert redirected_to(conn) == "/" + assert Repo.get!(Award, award.id).label == "Before" + end end describe "DELETE /profiles/:profile_id/awards/:id" do @@ -233,6 +260,18 @@ defmodule PhilomenaWeb.Profile.AwardControllerTest do "Couldn't find what you were looking for!" end + test "does not revoke an award through another profile", %{conn: conn} do + %{conn: conn, user: mod} = register_and_log_in_moderator(%{conn: conn}) + owner = confirmed_user_fixture() + other = confirmed_user_fixture() + award = badge_award_fixture(mod, owner) + + conn = delete(conn, ~p"/profiles/#{other}/awards/#{award}") + + assert redirected_to(conn) == "/" + assert Repo.get(Award, award.id) + end + test "redirects a regular user with the authorization flash", %{conn: conn} do %{conn: conn, user: mod} = register_and_log_in_moderator(%{conn: conn}) other = confirmed_user_fixture() diff --git a/test/philomena_web/controllers/profile/commission/item_controller_test.exs b/test/philomena_web/controllers/profile/commission/item_controller_test.exs index 87d8f0f7a..7498d62a3 100644 --- a/test/philomena_web/controllers/profile/commission/item_controller_test.exs +++ b/test/philomena_web/controllers/profile/commission/item_controller_test.exs @@ -38,17 +38,15 @@ defmodule PhilomenaWeb.Profile.Commission.ItemControllerTest do assert response =~ "New Item on Listing" end - test "redirects a moderator with the authorization flash", %{conn: conn} do - # NOTE: unlike Profile.CommissionController, :ensure_correct_user here - # has no moderator/admin bypass - items are strictly owner-only. + test "renders the form for a moderator", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) artist = confirmed_user_fixture() commission_fixture(artist) - conn = get(conn, ~p"/profiles/#{artist}/commission/items/new") + response = html_response(get(conn, ~p"/profiles/#{artist}/commission/items/new"), 200) - assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + assert response =~ "New Commission Item - Derpibooru" + assert response =~ "New Item on Listing" end test "redirects with the not-found flash when no commission exists", %{conn: conn} do @@ -116,15 +114,17 @@ defmodule PhilomenaWeb.Profile.Commission.ItemControllerTest do assert response =~ "Edit Item on Listing" end - test "404s for an item belonging to another commission", %{conn: conn} do + test "redirects as not found for a wrong-commission item ID", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) commission_fixture(user) other_commission = commission_fixture(confirmed_user_fixture()) item = commission_item_fixture(other_commission) - assert_error_sent 404, fn -> - get(conn, ~p"/profiles/#{user}/commission/items/#{item}/edit") - end + conn = get(conn, ~p"/profiles/#{user}/commission/items/#{item}/edit") + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end @@ -157,6 +157,21 @@ defmodule PhilomenaWeb.Profile.Commission.ItemControllerTest do assert html_response(conn, 200) =~ "Edit Item on Listing" assert Repo.get!(Item, item.id).description == item.description end + + test "does not update an item belonging to another commission", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + commission_fixture(user) + other_item = confirmed_user_fixture() |> commission_fixture() |> commission_item_fixture() + + conn = + patch(conn, "/profiles/#{user.slug}/commission/items/#{other_item.id}", %{ + "item" => %{"description" => "Cross-profile update"} + }) + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + assert Repo.get!(Item, other_item.id).description == other_item.description + end end describe "DELETE /profiles/:profile_id/commission/items/:id" do @@ -184,5 +199,17 @@ defmodule PhilomenaWeb.Profile.Commission.ItemControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." assert Repo.get(Item, item.id) end + + test "does not delete an item belonging to another commission", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + commission_fixture(user) + other_item = confirmed_user_fixture() |> commission_fixture() |> commission_item_fixture() + + conn = delete(conn, "/profiles/#{user.slug}/commission/items/#{other_item.id}") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + assert Repo.get(Item, other_item.id) + end end end diff --git a/test/philomena_web/controllers/profile/commission_controller_test.exs b/test/philomena_web/controllers/profile/commission_controller_test.exs index 2634f04fc..4e140bf5b 100644 --- a/test/philomena_web/controllers/profile/commission_controller_test.exs +++ b/test/philomena_web/controllers/profile/commission_controller_test.exs @@ -1,11 +1,11 @@ defmodule PhilomenaWeb.Profile.CommissionControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.ArtistLinksFixtures import Philomena.CommissionsFixtures import Philomena.TagsFixtures import Philomena.UsersFixtures - alias Philomena.ArtistLinks alias Philomena.Commissions.Commission alias Philomena.Repo @@ -14,13 +14,7 @@ defmodule PhilomenaWeb.Profile.CommissionControllerTest do defp verify_artist_link!(user) do tag = tag_fixture(name: "artist:test-commission-artist-#{System.unique_integer([:positive])}") - {:ok, link} = - ArtistLinks.create_artist_link(user, %{ - "tag_name" => tag.name, - "uri" => "https://example.com/gallery" - }) - - {:ok, _link} = ArtistLinks.verify_artist_link(link, user) + verified_artist_link_fixture(user, tag, %{"uri" => "https://example.com/gallery"}) :ok end diff --git a/test/philomena_web/controllers/profile/fp_history_controller_test.exs b/test/philomena_web/controllers/profile/fp_history_controller_test.exs index 52cd2bda8..5393f5bf4 100644 --- a/test/philomena_web/controllers/profile/fp_history_controller_test.exs +++ b/test/philomena_web/controllers/profile/fp_history_controller_test.exs @@ -37,7 +37,7 @@ defmodule PhilomenaWeb.Profile.FpHistoryControllerTest do response = html_response(get(conn, ~p"/profiles/#{subject}/fp_history"), 200) - assert response =~ "FP History for" + assert response =~ "Fingerprint History for" assert response =~ subject.name assert response =~ alias_user.name end @@ -48,19 +48,19 @@ defmodule PhilomenaWeb.Profile.FpHistoryControllerTest do response = html_response(get(conn, ~p"/profiles/#{subject}/fp_history"), 200) - assert response =~ "FP History for" + assert response =~ "Fingerprint History for" assert response =~ subject.name end - # NOTE: same `load_and_authorize_resource` `:index` shape as ip_history - - # an unknown slug takes the not-authorized redirect, not the not-found one. - test "redirects an unknown profile slug with the authorization flash", %{conn: conn} do + test "redirects an unknown profile slug with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = get(conn, ~p"/profiles/#{"nonexistent-slug"}/fp_history") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/profile/ip_history_controller_test.exs b/test/philomena_web/controllers/profile/ip_history_controller_test.exs index 21f70c5fa..06564a76d 100644 --- a/test/philomena_web/controllers/profile/ip_history_controller_test.exs +++ b/test/philomena_web/controllers/profile/ip_history_controller_test.exs @@ -51,16 +51,15 @@ defmodule PhilomenaWeb.Profile.IpHistoryControllerTest do assert response =~ subject.name end - # NOTE: `:index` loads the profile with `load_and_authorize_resource`, so an - # unknown slug authorizes a `nil` resource (no `:show_details` rule matches - # for `nil`) and takes the not-authorized redirect - not the not-found one. - test "redirects an unknown profile slug with the authorization flash", %{conn: conn} do + test "redirects an unknown profile slug with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = get(conn, ~p"/profiles/#{"nonexistent-slug"}/ip_history") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/profile/report_controller_test.exs b/test/philomena_web/controllers/profile/report_controller_test.exs index 2cd36c02f..00c09dfd7 100644 --- a/test/philomena_web/controllers/profile/report_controller_test.exs +++ b/test/philomena_web/controllers/profile/report_controller_test.exs @@ -26,11 +26,13 @@ defmodule PhilomenaWeb.Profile.ReportControllerTest do "Reporting User - Derpibooru" end - test "redirects to / with the authorization flash for an unknown profile", %{conn: conn} do + test "redirects to / with the not-found flash for an unknown profile", %{conn: conn} do conn = get(conn, ~p"/profiles/nonexistent-user/reports/new") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end diff --git a/test/philomena_web/controllers/profile/source_change_controller_test.exs b/test/philomena_web/controllers/profile/source_change_controller_test.exs index 78b3e6cae..88b5da01b 100644 --- a/test/philomena_web/controllers/profile/source_change_controller_test.exs +++ b/test/philomena_web/controllers/profile/source_change_controller_test.exs @@ -11,7 +11,7 @@ defmodule PhilomenaWeb.Profile.SourceChangeControllerTest do image = image_fixture() {:ok, _} = - Images.update_sources(image, attribution(user), %{ + Images.update_image_sources(actor(user), image.id, %{ "old_sources" => %{}, "sources" => %{"0" => %{"source" => source}} }) @@ -20,7 +20,16 @@ defmodule PhilomenaWeb.Profile.SourceChangeControllerTest do end describe "GET /profiles/:profile_id/source_changes" do - test "lists a user's source changes for anonymous users", %{conn: conn} do + test "allows anonymous viewers for a real profile", %{conn: conn} do + user = confirmed_user_fixture() + + conn = get(conn, ~p"/profiles/#{user}/source_changes") + + assert html_response(conn, 200) + end + + test "lists a user's source changes for moderators", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) user = confirmed_user_fixture() source_change!(user, "https://example.com/profile-source") @@ -33,6 +42,7 @@ defmodule PhilomenaWeb.Profile.SourceChangeControllerTest do end test "renders with no source changes", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) user = confirmed_user_fixture() conn = get(conn, ~p"/profiles/#{user}/source_changes") @@ -41,6 +51,7 @@ defmodule PhilomenaWeb.Profile.SourceChangeControllerTest do end test "filters to removals with added=0", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) user = confirmed_user_fixture() source_change!(user, "https://example.com/added-source") @@ -51,11 +62,30 @@ defmodule PhilomenaWeb.Profile.SourceChangeControllerTest do refute response =~ "https://example.com/added-source" end - test "redirects to / for an unknown profile", %{conn: conn} do + test "allows a regular user for a real profile", %{conn: conn} do + %{conn: conn} = register_and_log_in_user(%{conn: conn}) + user = confirmed_user_fixture() + + conn = get(conn, ~p"/profiles/#{user}/source_changes") + + assert html_response(conn, 200) + end + + test "uses the not-found response for an unknown profile", %{conn: conn} do conn = get(conn, ~p"/profiles/nonexistent-user/source_changes") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + end + + test "redirects with an error flash for an invalid filter", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + user = confirmed_user_fixture() + + conn = get(conn, ~p"/profiles/#{user}/source_changes?added=invalid") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid source change filter." end end end diff --git a/test/philomena_web/controllers/profile_controller_test.exs b/test/philomena_web/controllers/profile_controller_test.exs index aebbddec8..c4e325a29 100644 --- a/test/philomena_web/controllers/profile_controller_test.exs +++ b/test/philomena_web/controllers/profile_controller_test.exs @@ -37,6 +37,7 @@ defmodule PhilomenaWeb.ProfileControllerTest do assert response =~ "Test Profile User's profile - Derpibooru" assert response =~ "All about this test user." + assert response =~ "Source changes" end test "renders a profile for logged-in users", %{conn: conn} do @@ -47,6 +48,18 @@ defmodule PhilomenaWeb.ProfileControllerTest do response = html_response(conn, 200) assert response =~ "Test Profile User's profile - Derpibooru" + assert response =~ "Source changes" + end + + test "shows the source-history link to moderators", %{conn: conn} do + %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) + user = confirmed_user_fixture(%{name: "Test Profile User"}) + + conn = get(conn, ~p"/profiles/#{user}") + response = html_response(conn, 200) + + assert response =~ "Source changes" + assert response =~ ~p"/profiles/#{user}/source_changes" end test "shows recent uploads, comments, and posts", %{conn: conn} do @@ -74,7 +87,24 @@ defmodule PhilomenaWeb.ProfileControllerTest do conn = get(conn, ~p"/profiles/nonexistent-user") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" + end + + test "redirects to / for a deactivated profile", %{conn: conn} do + user = confirmed_user_fixture() + + user + |> Ecto.Changeset.change(deleted_at: DateTime.utc_now(:second)) + |> Repo.update!() + + conn = get(conn, ~p"/profiles/#{user}") + + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/reactivation_controller_test.exs b/test/philomena_web/controllers/reactivation_controller_test.exs index 67cd1d70e..aea27f25b 100644 --- a/test/philomena_web/controllers/reactivation_controller_test.exs +++ b/test/philomena_web/controllers/reactivation_controller_test.exs @@ -32,7 +32,7 @@ defmodule PhilomenaWeb.ReactivationControllerTest do conn = post(conn, ~p"/reactivations", %{"token" => token}) assert redirected_to(conn) == ~p"/" - user = Users.get_user!(user.id) + user = Users.fetch_user_for_worker!(user.id) assert user.deleted_by_user_id == nil assert not (UserToken.user_and_contexts_query(user, ["reactivate"]) |> Repo.exists?()) @@ -47,7 +47,7 @@ defmodule PhilomenaWeb.ReactivationControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If the token provided was valid, your account has been reactivated." - assert Users.get_user!(user.id).deleted_by_user_id + assert Users.fetch_user_for_worker!(user.id).deleted_by_user_id end test "raises without a token param", %{conn: conn} do diff --git a/test/philomena_web/controllers/registration/name_controller_test.exs b/test/philomena_web/controllers/registration/name_controller_test.exs index d7bfba728..7658b0953 100644 --- a/test/philomena_web/controllers/registration/name_controller_test.exs +++ b/test/philomena_web/controllers/registration/name_controller_test.exs @@ -50,7 +50,7 @@ defmodule PhilomenaWeb.Registration.NameControllerTest do conn = patch(conn, ~p"/registrations/name", %{"user" => %{"name" => new_name}}) - updated = Users.get_user!(user.id) + updated = Users.fetch_user_for_worker!(user.id) assert updated.name == new_name assert redirected_to(conn) == ~p"/profiles/#{updated}" assert Flash.get(conn.assigns.flash, :info) =~ "Name successfully updated." @@ -65,7 +65,7 @@ defmodule PhilomenaWeb.Registration.NameControllerTest do conn = patch(conn, ~p"/registrations/name", %{"user" => %{"name" => too_long}}) assert html_response(conn, 200) =~ "Oops, something went wrong!" - assert Users.get_user!(user.id).name == user.name + assert Users.fetch_user_for_worker!(user.id).name == user.name end test "re-renders on an empty name", %{conn: conn, user: user} do @@ -74,7 +74,7 @@ defmodule PhilomenaWeb.Registration.NameControllerTest do conn = patch(conn, ~p"/registrations/name", %{"user" => %{"name" => ""}}) assert html_response(conn, 200) =~ "Oops, something went wrong!" - assert Users.get_user!(user.id).name == user.name + assert Users.fetch_user_for_worker!(user.id).name == user.name end test "rejects a second rename within the window", %{conn: conn, user: user} do @@ -85,7 +85,7 @@ defmodule PhilomenaWeb.Registration.NameControllerTest do conn = patch(conn, ~p"/registrations/name", %{"user" => %{"name" => "another_name"}}) assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." - assert Users.get_user!(user.id).name == user.name + assert Users.fetch_user_for_worker!(user.id).name == user.name end test "redirects anonymous users to the login page" do @@ -103,7 +103,7 @@ defmodule PhilomenaWeb.Registration.NameControllerTest do conn = put(conn, ~p"/registrations/name", %{"user" => %{"name" => new_name}}) - updated = Users.get_user!(user.id) + updated = Users.fetch_user_for_worker!(user.id) assert updated.name == new_name assert redirected_to(conn) == ~p"/profiles/#{updated}" end diff --git a/test/philomena_web/controllers/registration/password_controller_test.exs b/test/philomena_web/controllers/registration/password_controller_test.exs index d0d9deb98..7bda7e6a5 100644 --- a/test/philomena_web/controllers/registration/password_controller_test.exs +++ b/test/philomena_web/controllers/registration/password_controller_test.exs @@ -21,7 +21,9 @@ defmodule PhilomenaWeb.Registration.PasswordControllerTest do assert redirected_to(new_password_conn) == ~p"/registrations/edit" assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) assert Flash.get(new_password_conn.assigns.flash, :info) =~ "Password updated successfully" - assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1) + + assert {:ok, _} = + Users.fetch_user_by_email_and_password(user.email, "new valid password", & &1) end test "does not update password on invalid data", %{conn: conn} do @@ -51,7 +53,9 @@ defmodule PhilomenaWeb.Registration.PasswordControllerTest do }) refute Users.get_user_by_session_token(old_token) - assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1) + + assert {:ok, _} = + Users.fetch_user_by_email_and_password(user.email, "new valid password", & &1) end end @@ -67,7 +71,9 @@ defmodule PhilomenaWeb.Registration.PasswordControllerTest do }) assert redirected_to(conn) == ~p"/registrations/edit" - assert Users.get_user_by_email_and_password(user.email, "new valid password", & &1) + + assert {:ok, _} = + Users.fetch_user_by_email_and_password(user.email, "new valid password", & &1) end test "raises without a current_password param", %{conn: conn} do diff --git a/test/philomena_web/controllers/registration/totp_controller_test.exs b/test/philomena_web/controllers/registration/totp_controller_test.exs index 8183a39cf..7ab03c4cd 100644 --- a/test/philomena_web/controllers/registration/totp_controller_test.exs +++ b/test/philomena_web/controllers/registration/totp_controller_test.exs @@ -25,7 +25,7 @@ defmodule PhilomenaWeb.Registration.TotpControllerTest do conn = get(conn, ~p"/registrations/totp/edit") assert redirected_to(conn) == ~p"/registrations/totp/edit" - assert Users.get_user!(user.id).encrypted_otp_secret + assert Users.fetch_user_for_worker!(user.id).encrypted_otp_secret end test "renders the setup page once a secret exists", %{conn: conn} do @@ -64,7 +64,7 @@ defmodule PhilomenaWeb.Registration.TotpControllerTest do assert redirected_to(conn) == ~p"/registrations/totp/edit" - user = Users.get_user!(user.id) + user = Users.fetch_user_for_worker!(user.id) assert user.otp_required_for_login assert length(user.otp_backup_codes) == 10 end @@ -81,7 +81,7 @@ defmodule PhilomenaWeb.Registration.TotpControllerTest do }) assert html_response(conn, 200) =~ "data:image/png;base64," - refute Users.get_user!(user.id).otp_required_for_login + refute Users.fetch_user_for_worker!(user.id).otp_required_for_login end test "re-renders on an invalid TOTP code", %{conn: conn, user: user} do @@ -99,7 +99,7 @@ defmodule PhilomenaWeb.Registration.TotpControllerTest do }) assert html_response(conn, 200) =~ "data:image/png;base64," - refute Users.get_user!(user.id).otp_required_for_login + refute Users.fetch_user_for_worker!(user.id).otp_required_for_login end end @@ -118,7 +118,7 @@ defmodule PhilomenaWeb.Registration.TotpControllerTest do assert redirected_to(conn) == ~p"/registrations/totp/edit" - user = Users.get_user!(user.id) + user = Users.fetch_user_for_worker!(user.id) refute user.otp_required_for_login assert user.otp_backup_codes == [] refute user.encrypted_otp_secret diff --git a/test/philomena_web/controllers/registration_controller_test.exs b/test/philomena_web/controllers/registration_controller_test.exs index 61bb517ff..a48f71457 100644 --- a/test/philomena_web/controllers/registration_controller_test.exs +++ b/test/philomena_web/controllers/registration_controller_test.exs @@ -1,8 +1,11 @@ defmodule PhilomenaWeb.RegistrationControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.BansFixtures import Philomena.UsersFixtures + @banned_fingerprint "d015c342859dde3" + describe "GET /registrations/new" do test "renders registration page", %{conn: conn} do conn = get(conn, ~p"/registrations/new") @@ -15,6 +18,18 @@ defmodule PhilomenaWeb.RegistrationControllerTest do assert redirected_to(conn) == "/" end + + test "rejects a banned actor", %{conn: conn} do + fingerprint_ban_fixture(%{"fingerprint" => @banned_fingerprint}) + + conn = + conn + |> put_req_cookie("_ses", @banned_fingerprint) + |> get(~p"/registrations/new") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are currently banned." + end end describe "POST /registrations" do @@ -44,6 +59,25 @@ defmodule PhilomenaWeb.RegistrationControllerTest do assert response =~ "must be valid (e.g., user@example.com)" assert response =~ "should be at least 12 character" end + + test "rejects a banned actor", %{conn: conn} do + fingerprint_ban_fixture(%{"fingerprint" => @banned_fingerprint}) + email = unique_user_email() + + conn = + conn + |> put_req_cookie("_ses", @banned_fingerprint) + |> post(~p"/registrations", %{ + "user" => %{ + "name" => email, + "email" => email, + "password" => valid_user_password() + } + }) + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are currently banned." + end end describe "GET /registration/edit" do diff --git a/test/philomena_web/controllers/rule_controller_test.exs b/test/philomena_web/controllers/rule_controller_test.exs index 5fb6293bd..1043c5bc7 100644 --- a/test/philomena_web/controllers/rule_controller_test.exs +++ b/test/philomena_web/controllers/rule_controller_test.exs @@ -89,8 +89,11 @@ defmodule PhilomenaWeb.RuleControllerTest do test "renders an AST pretty diff of a rule's edited description", %{conn: conn} do rule = rule_fixture(%{name: "Test Rule: diff", description: "The original rule text"}) + actor = + Philomena.AttributionFixtures.actor(Philomena.UsersFixtures.admin_user_fixture()) + {:ok, _} = - Philomena.Rules.update_rule_with_version(rule, nil, %{ + Philomena.Rules.update_rule(actor, rule.position, %{ "description" => "The updated rule text" }) @@ -109,18 +112,13 @@ defmodule PhilomenaWeb.RuleControllerTest do assert response =~ "rule text" end - test "redirects to /rules for a hidden rule as anonymous", %{conn: conn} do + test "rejects a hidden rule as unauthorized for an anonymous viewer", %{conn: conn} do rule = rule_fixture(%{name: "Test Hidden Rule", hidden: true}) - # NOTE: hidden/internal rules pass Canary (any %Rule{} is :show-able) - # and are caught by the controller's own check_permission plug, which - # redirects to /rules - not to / like most unauthorized pages. conn = get(conn, ~p"/rules/#{rule}") - assert redirected_to(conn) == ~p"/rules" - - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ - "You do not have permission to view that rule." + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end test "renders a hidden rule for admins", %{conn: conn} do @@ -133,16 +131,13 @@ defmodule PhilomenaWeb.RuleControllerTest do assert response =~ "Test Hidden Rule" end - test "redirects to / for an unknown position", %{conn: conn} do + test "redirects with not-found for an unknown position", %{conn: conn} do conn = get(conn, ~p"/rules/999999") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end - # NOTE: a non-integer position short-circuits to NotFoundPlug via the central - # IntegerId guard, so the flash is the not-found message rather than the - # "You can't access that page." an unknown integer position gets. test "redirects to / with the not-found flash for a non-integer position", %{conn: conn} do conn = get(conn, ~p"/rules/not-a-position") diff --git a/test/philomena_web/controllers/search/reverse_controller_test.exs b/test/philomena_web/controllers/search/reverse_controller_test.exs index d3040cbca..de9d50c7a 100644 --- a/test/philomena_web/controllers/search/reverse_controller_test.exs +++ b/test/philomena_web/controllers/search/reverse_controller_test.exs @@ -79,6 +79,29 @@ defmodule PhilomenaWeb.Search.ReverseControllerTest do assert html_response(conn, 200) =~ ~p"/images/#{match}" end + test "filters hidden matches by image visibility", %{conn: conn} do + visible = image_fixture() + insert_intensities(visible, @png_intensity) + + hidden = image_fixture(hidden_from_users: true) + insert_intensities(hidden, @png_intensity) + + conn = post(conn, ~p"/search/reverse", %{"image" => %{"image" => png_upload()}}) + response = html_response(conn, 200) + + assert response =~ ~p"/images/#{visible}" + refute response =~ ~p"/images/#{hidden}" + + %{conn: moderator_conn} = register_and_log_in_moderator(%{conn: build_conn()}) + + moderator_conn = + post(moderator_conn, ~p"/search/reverse", %{ + "image" => %{"image" => png_upload()} + }) + + assert html_response(moderator_conn, 200) =~ ~p"/images/#{hidden}" + end + test "renders the plain form when no image is submitted", %{conn: conn} do # NOTE: ScraperCachePlug injects an empty "image" params map, so a submit # with no upload takes the third create/2 clause and renders the form diff --git a/test/philomena_web/controllers/session/totp_controller_test.exs b/test/philomena_web/controllers/session/totp_controller_test.exs index 76594cbb9..1f2816063 100644 --- a/test/philomena_web/controllers/session/totp_controller_test.exs +++ b/test/philomena_web/controllers/session/totp_controller_test.exs @@ -62,7 +62,7 @@ defmodule PhilomenaWeb.Session.TotpControllerTest do assert redirected_to(conn) == "/" assert get_session(conn, :totp_token) - assert Users.get_user!(user.id).consumed_timestep == String.to_integer(token) + assert Users.fetch_user_for_worker!(user.id).consumed_timestep == String.to_integer(token) # The session now passes :ensure_totp routes. conn = get(conn, ~p"/registrations/edit") @@ -93,7 +93,7 @@ defmodule PhilomenaWeb.Session.TotpControllerTest do assert redirected_to(conn) == "/" assert get_session(conn, :totp_token) - assert length(Users.get_user!(user.id).otp_backup_codes) == 9 + assert length(Users.fetch_user_for_worker!(user.id).otp_backup_codes) == 9 end test "rejects an invalid token and logs the user out", %{conn: conn} do diff --git a/test/philomena_web/controllers/setting_controller_test.exs b/test/philomena_web/controllers/setting_controller_test.exs index 9bc46cbd1..a1d85d386 100644 --- a/test/philomena_web/controllers/setting_controller_test.exs +++ b/test/philomena_web/controllers/setting_controller_test.exs @@ -72,6 +72,18 @@ defmodule PhilomenaWeb.SettingControllerTest do assert Repo.get!(Settings, user.id).theme == "dark-green" end + test "banned users can update their settings", %{conn: conn} do + %{conn: conn, user: user} = register_and_log_in_banned_user(%{conn: conn}) + + conn = + patch(conn, ~p"/settings", %{ + "user" => %{"settings" => %{"images_per_page" => "30"}} + }) + + assert redirected_to(conn) == ~p"/settings/edit" + assert Repo.get!(Settings, user.id).images_per_page == 30 + end + test "falls back to dark-blue when only one theme component is submitted", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) diff --git a/test/philomena_web/controllers/staff_controller_test.exs b/test/philomena_web/controllers/staff_controller_test.exs index cf87dccc4..f1cc01718 100644 --- a/test/philomena_web/controllers/staff_controller_test.exs +++ b/test/philomena_web/controllers/staff_controller_test.exs @@ -25,22 +25,19 @@ defmodule PhilomenaWeb.StaffControllerTest do refute response =~ "Test Regular User" end - test "does not list staff who hide their default role", %{conn: conn} do + test "lists staff who hide their default role", %{conn: conn} do admin = - admin_user_fixture(%{name: "Test Hidden Admin"}) + admin_user_fixture(%{name: "Test Admin"}) |> Ecto.Changeset.change(hide_default_role: true) |> Repo.update!() conn = get(conn, ~p"/staff") response = html_response(conn, 200) - # NOTE: a hidden-role staff member with no secondary role matches none - # of the categories (Others requires a secondary role), so they vanish - # from the page entirely. - refute response =~ admin.name + assert response =~ admin.name end - test "categorizes non-admin staff by secondary role", %{conn: conn} do + test "categorizes staff by secondary role", %{conn: conn} do developer = moderator_user_fixture(%{name: "Test Site Developer"}) |> Ecto.Changeset.change(secondary_role: "Site Developer") diff --git a/test/philomena_web/controllers/tag/alias_controller_test.exs b/test/philomena_web/controllers/tag/alias_controller_test.exs index 6751d983e..eb87402ef 100644 --- a/test/philomena_web/controllers/tag/alias_controller_test.exs +++ b/test/philomena_web/controllers/tag/alias_controller_test.exs @@ -93,7 +93,7 @@ defmodule PhilomenaWeb.Tag.AliasControllerTest do assert Repo.get!(Tag, tag.id).aliased_tag_id == target.id end - test "aliasing into an unknown target re-renders the form with errors", %{conn: conn} do + test "aliasing into an unknown target redirects with an error", %{conn: conn} do # NOTE: a nonexistent target tag makes alias_changeset fail # validate_required(:aliased_tag), so the controller re-renders edit.html # at 200 - a genuine error branch. @@ -102,11 +102,8 @@ defmodule PhilomenaWeb.Tag.AliasControllerTest do conn = patch(conn, ~p"/tags/#{tag}/alias", %{"tag" => %{"target_tag" => "no such tag"}}) - # NOTE: the error-branch re-render doesn't pass a title assign, so pin the - # form heading and the validation-error alert instead. - response = html_response(conn, 200) - assert response =~ "Aliasing tag" - assert response =~ "something went wrong" + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" assert Repo.get!(Tag, tag.id).aliased_tag_id == nil end end @@ -121,7 +118,7 @@ defmodule PhilomenaWeb.Tag.AliasControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" end - test "an admin queues a dealias", %{conn: conn} do + test "an admin dealiases the tag", %{conn: conn} do target = tag_fixture(name: "target tag dealias") tag = @@ -133,27 +130,24 @@ defmodule PhilomenaWeb.Tag.AliasControllerTest do conn = delete(conn, ~p"/tags/#{tag}/alias") assert redirected_to(conn) == ~p"/tags/#{tag}" - assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Tag dealias queued" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Tag dealias successful" + assert Repo.get!(Tag, tag.id).aliased_tag_id == nil end - test "an unknown slug is the not-authorized redirect for a role_map moderator", %{ + test "an unknown slug is the not-found redirect for a role_map moderator", %{ conn: conn } do - # NOTE: a role-mod fails authorization on the nil resource, so the - # unauthorized handler fires - "can't access". An admin passes - # authorization and hits the not-found handler instead (next test). conn = log_in_role_moderator(conn, "Tag") conn = delete(conn, ~p"/tags/nonexistent-tag/alias") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "an unknown slug is the not-found redirect for an admin", %{conn: conn} do - # NOTE: can?(admin, _, nil) is true, but load_and_authorize_resource has - # persisted: true, so Canary's not_found_handler fires on the nil - # resource before delete/2 runs - a clean "Couldn't find" redirect, NOT - # a crash. + # NOTE: can?(admin, _, nil) is true, so the admin is authorized on the + # nil load and the context returns not_found before delete/2 runs - a + # clean "Couldn't find" redirect, NOT a crash. conn = log_in_user(conn, admin_user_fixture()) conn = delete(conn, ~p"/tags/nonexistent-tag/alias") diff --git a/test/philomena_web/controllers/tag/detail_controller_test.exs b/test/philomena_web/controllers/tag/detail_controller_test.exs index 15b079bd5..3f74ec97b 100644 --- a/test/philomena_web/controllers/tag/detail_controller_test.exs +++ b/test/philomena_web/controllers/tag/detail_controller_test.exs @@ -69,9 +69,9 @@ defmodule PhilomenaWeb.Tag.DetailControllerTest do test "redirects with the not-found flash for an unknown tag as moderator", %{conn: conn} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) - # NOTE: load_resource now uses required: true, so Canary runs its - # not-found handler on this :index action - an unknown slug redirects - # instead of dereferencing a nil tag. + # NOTE: the context authorizes the loaded tag on this :index action; an + # unknown slug loads nil, the moderator is authorized on it, so it + # redirects instead of dereferencing a nil tag. conn = get(conn, ~p"/tags/nonexistent-tag/details") assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/tag/image_controller_test.exs b/test/philomena_web/controllers/tag/image_controller_test.exs index 45e40ab69..fe9109a5c 100644 --- a/test/philomena_web/controllers/tag/image_controller_test.exs +++ b/test/philomena_web/controllers/tag/image_controller_test.exs @@ -95,12 +95,12 @@ defmodule PhilomenaWeb.Tag.ImageControllerTest do assert html_response(conn, 200) =~ "Update tag image" end - test "an unknown slug takes the not-authorized redirect", %{conn: conn} do + test "an unknown slug takes the not-found redirect", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) conn = patch(conn, ~p"/tags/nonexistent-tag/image", %{"tag" => %{"image" => png_upload()}}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -132,12 +132,12 @@ defmodule PhilomenaWeb.Tag.ImageControllerTest do assert Repo.get!(Tag, tag.id).image == nil end - test "an unknown slug takes the not-authorized redirect", %{conn: conn} do + test "an unknown slug takes the not-found redirect", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) conn = delete(conn, ~p"/tags/nonexistent-tag/image") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end end diff --git a/test/philomena_web/controllers/tag/reindex_controller_test.exs b/test/philomena_web/controllers/tag/reindex_controller_test.exs index 6c6d4c8fb..6f0b20ee4 100644 --- a/test/philomena_web/controllers/tag/reindex_controller_test.exs +++ b/test/philomena_web/controllers/tag/reindex_controller_test.exs @@ -53,26 +53,20 @@ defmodule PhilomenaWeb.Tag.ReindexControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Tag reindex started" end - test "an unknown slug is the not-authorized redirect for a role_map moderator", %{ + test "an unknown slug is the not-found redirect for a role_map moderator", %{ conn: conn } do - # NOTE: a role-mod fails authorization on the nil resource (no rule - # matches nil), so Canary's unauthorized handler fires - "can't access". - # An admin passes authorization and instead hits the not-found handler - # (see the next test), so the same unknown slug yields a different flash - # depending on role. conn = log_in_role_moderator(conn, "Tag") conn = post(conn, ~p"/tags/nonexistent-tag/reindex") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "an unknown slug is the not-found redirect for an admin", %{conn: conn} do - # NOTE: can?(admin, _, nil) is true, so an admin sails past the nil - # authorization - but load_and_authorize_resource has persisted: true, so - # Canary's not_found_handler fires on the nil resource before create/2 - # runs. The admin gets a clean "Couldn't find" redirect, NOT a 500. + # NOTE: can?(admin, _, nil) is true, so an admin is authorized on the nil + # load and the context returns not_found before create/2 runs. The admin + # gets a clean "Couldn't find" redirect, NOT a 500. conn = log_in_user(conn, admin_user_fixture()) conn = post(conn, ~p"/tags/nonexistent-tag/reindex") diff --git a/test/philomena_web/controllers/tag/watch_controller_test.exs b/test/philomena_web/controllers/tag/watch_controller_test.exs index 5cc82194e..1820b4ff0 100644 --- a/test/philomena_web/controllers/tag/watch_controller_test.exs +++ b/test/philomena_web/controllers/tag/watch_controller_test.exs @@ -1,10 +1,11 @@ defmodule PhilomenaWeb.Tag.WatchControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.AttributionFixtures import Philomena.TagsFixtures alias Philomena.Repo - alias Philomena.Users + alias Philomena.Tags test "anonymous POST redirects to the login page", %{conn: conn} do conn = post(conn, ~p"/tags/dummy-slug/watch") @@ -30,7 +31,7 @@ defmodule PhilomenaWeb.Tag.WatchControllerTest do test "POST when already watching keeps a single entry", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) tag = tag_fixture() - {:ok, _} = Users.watch_tag(user, tag) + {:ok, _} = Tags.create_tag_watch(actor(user), tag.slug) conn = post(conn, ~p"/tags/#{tag}/watch") @@ -41,7 +42,7 @@ defmodule PhilomenaWeb.Tag.WatchControllerTest do test "DELETE removes the tag from the user's watched tags", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) tag = tag_fixture() - {:ok, _} = Users.watch_tag(user, tag) + {:ok, _} = Tags.create_tag_watch(actor(user), tag.slug) conn = delete(conn, ~p"/tags/#{tag}/watch") @@ -59,9 +60,7 @@ defmodule PhilomenaWeb.Tag.WatchControllerTest do assert Repo.reload!(user).watched_tag_ids == [] end - test "banned users can still watch tags", %{conn: conn} do - # NOTE: no FilterBannedUsersPlug here, same as the subscription - # controllers + test "banned users can update their watched tags", %{conn: conn} do %{conn: conn, user: user} = register_and_log_in_banned_user(%{conn: conn}) tag = tag_fixture() @@ -72,9 +71,6 @@ defmodule PhilomenaWeb.Tag.WatchControllerTest do end test "POST for an unknown tag redirects with the not-found flash", %{conn: conn} do - # NOTE: load_resource now uses required: true, so Canary runs its not-found - # handler on :create - an unknown slug redirects instead of passing nil - # into Users.watch_tag/2. %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/tags/unknown-slug/watch") diff --git a/test/philomena_web/controllers/tag_change/full_revert_controller_test.exs b/test/philomena_web/controllers/tag_change/full_revert_controller_test.exs index fc2862f19..51cc7bce6 100644 --- a/test/philomena_web/controllers/tag_change/full_revert_controller_test.exs +++ b/test/philomena_web/controllers/tag_change/full_revert_controller_test.exs @@ -1,4 +1,4 @@ -defmodule PhilomenaWeb.TagChange.FullRevertControllerTest do +defmodule PhilomenaWeb.Profile.TagChange.RevertControllerTest do use PhilomenaWeb.ConnCase, async: true # full_revert only enqueues a (dead) TagChangeRevertWorker, so there is @@ -6,10 +6,10 @@ defmodule PhilomenaWeb.TagChange.FullRevertControllerTest do import Philomena.UsersFixtures - describe "POST /tag_changes/full_revert" do + describe "POST /profiles/:profile_id/tag_changes/revert" do test "is rejected for anonymous users", %{conn: conn} do user = confirmed_user_fixture() - conn = post(conn, ~p"/tag_changes/full_revert", %{"user_id" => "#{user.id}"}) + conn = post(conn, ~p"/profiles/#{user}/tag_changes/revert") assert redirected_to(conn) == ~p"/sessions/new" end @@ -17,7 +17,7 @@ defmodule PhilomenaWeb.TagChange.FullRevertControllerTest do test "is rejected for regular users", %{conn: conn} do user = confirmed_user_fixture() conn = log_in_user(conn, confirmed_user_fixture()) - conn = post(conn, ~p"/tag_changes/full_revert", %{"user_id" => "#{user.id}"}) + conn = post(conn, ~p"/profiles/#{user}/tag_changes/revert") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" @@ -26,7 +26,7 @@ defmodule PhilomenaWeb.TagChange.FullRevertControllerTest do test "a moderator enqueues a reversion for a user", %{conn: conn} do target = confirmed_user_fixture() conn = log_in_user(conn, moderator_user_fixture()) - conn = post(conn, ~p"/tag_changes/full_revert", %{"user_id" => "#{target.id}"}) + conn = post(conn, ~p"/profiles/#{target}/tag_changes/revert") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Reversion of tag changes enqueued" @@ -34,7 +34,7 @@ defmodule PhilomenaWeb.TagChange.FullRevertControllerTest do test "a moderator enqueues a reversion for an ip", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) - conn = post(conn, ~p"/tag_changes/full_revert", %{"ip" => "203.0.113.5"}) + conn = post(conn, ~p"/ip_profiles/203.0.113.5/tag_changes/revert") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Reversion of tag changes enqueued" @@ -42,21 +42,21 @@ defmodule PhilomenaWeb.TagChange.FullRevertControllerTest do test "a moderator enqueues a reversion for a fingerprint", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) - conn = post(conn, ~p"/tag_changes/full_revert", %{"fingerprint" => "c1774e9294a"}) + conn = post(conn, ~p"/fingerprint_profiles/c1774/tag_changes/revert") assert redirected_to(conn) == "/" assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Reversion of tag changes enqueued" end - test "a request with no target key redirects with the failure flash", %{conn: conn} do - # NOTE: a request naming none of user_id/ip/fingerprint now redirects to - # the referrer with the failure flash rather than raising CaseClauseError. + test "a malformed target redirects with the failure flash", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) - conn = post(conn, ~p"/tag_changes/full_revert", %{"something" => "else"}) + conn = post(conn, ~p"/ip_profiles/not-an-ip/tag_changes/revert") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Couldn't revert those tag changes!" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/tag_change/revert_controller_test.exs b/test/philomena_web/controllers/tag_change/revert_controller_test.exs index 58c5eb40c..c786f979c 100644 --- a/test/philomena_web/controllers/tag_change/revert_controller_test.exs +++ b/test/philomena_web/controllers/tag_change/revert_controller_test.exs @@ -1,10 +1,6 @@ defmodule PhilomenaWeb.TagChange.RevertControllerTest do use PhilomenaWeb.ConnCase, async: true - # mass_revert reads the tag changes from Postgres and re-tags through - # Images.batch_update; every reindex is a dead Exq enqueue, so this stays - # Postgres-only. - import Philomena.AttributionFixtures import Philomena.ImagesFixtures import Philomena.UsersFixtures @@ -28,7 +24,7 @@ defmodule PhilomenaWeb.TagChange.RevertControllerTest do image = image_fixture() {:ok, _} = - Images.update_tags(image, attribution(user), %{ + Images.update_image_tags(actor(user), image.id, %{ "old_tag_input" => "safe", "tag_input" => "safe, added test tag, other added tag" }) diff --git a/test/philomena_web/controllers/tag_change_controller_test.exs b/test/philomena_web/controllers/tag_change_controller_test.exs index 645c86641..8f1877601 100644 --- a/test/philomena_web/controllers/tag_change_controller_test.exs +++ b/test/philomena_web/controllers/tag_change_controller_test.exs @@ -13,6 +13,7 @@ defmodule PhilomenaWeb.TagChangeControllerTest do alias Philomena.Images alias Philomena.Repo alias Philomena.TagChanges.TagChange + alias Philomena.Tags.Tag alias PhilomenaQuery.Search alias PhilomenaQuery.SearchHelpers @@ -32,7 +33,7 @@ defmodule PhilomenaWeb.TagChangeControllerTest do # The tag changeset requires at least 3 tags, so the update keeps the # fixture's "safe" tag and adds two more. {:ok, _} = - Images.update_tags(image, attribution(user), %{ + Images.update_image_tags(actor(user), image.id, %{ "old_tag_input" => "safe", "tag_input" => "safe, #{added_tags}" }) @@ -83,18 +84,23 @@ defmodule PhilomenaWeb.TagChangeControllerTest do refute response =~ "added test tag" end - test "resource_type=image filters the listing to that image's changes", %{conn: conn} do + test "invalid query-form input redirects with an error", %{conn: conn} do + conn = get(conn, ~p"/tag_changes?#{[sf: "unknown"]}") + + assert redirected_to(conn) == ~p"/tag_changes" + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid tag change query." + end + + test "an image route filters the listing to that image's changes", %{conn: conn} do image = tag_change_fixture!(confirmed_user_fixture(), "filtered marker tag, second tag") _other_image = tag_change_fixture!(confirmed_user_fixture(), "unrelated marker tag, second tag") - conn = get(conn, ~p"/tag_changes?#{[resource_type: "image", resource_id: image.id]}") + conn = get(conn, ~p"/images/#{image}/tag_changes") response = html_response(conn, 200) - # The heading names the resource, as before... - assert response =~ "Showing tag changes for" - assert response =~ "image ##{image.id}" + assert response =~ "Tag Changes for Image ##{image.id}" # ...and the resource params now also filter the listing: only the # requested image's change appears, the unrelated one is absent. @@ -102,17 +108,68 @@ defmodule PhilomenaWeb.TagChangeControllerTest do refute response =~ "unrelated marker tag" end - test "resource_type=user filters by user name, case-insensitively", %{conn: conn} do + test "missing and malformed image resources use the not-found response", %{conn: conn} do + conn = get(conn, ~p"/images/not-an-id/tag_changes") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + + conn = get(conn, ~p"/images/2147483647/tag_changes") + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" + end + + test "a tag route filters the listing to that tag's changes", %{conn: conn} do + tag_change_fixture!(confirmed_user_fixture()) + tag = Repo.get_by!(Tag, name: "added test tag") + conn = log_in_user(conn, moderator_user_fixture()) + + response = html_response(get(conn, ~p"/tags/#{tag}/tag_changes"), 200) + + assert response =~ "Tag Changes for Tag" + assert response =~ "added test tag" + end + + test "hidden image history ignores image visibility", %{conn: conn} do + image = tag_change_fixture!(confirmed_user_fixture(), "hidden marker tag, second tag") + + image + |> Ecto.Changeset.change(hidden_from_users: true) + |> Repo.update!() + + SearchHelpers.reindex_all!(TagChange) + + response = html_response(get(conn, ~p"/tag_changes"), 200) + assert response =~ "hidden marker tag" + + conn = get(conn, ~p"/images/#{image}/tag_changes") + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + + moderator_conn = log_in_user(recycle(conn), moderator_user_fixture()) + + response = html_response(get(moderator_conn, ~p"/tag_changes"), 200) + assert response =~ "hidden marker tag" + + response = + html_response( + get( + moderator_conn, + ~p"/images/#{image}/tag_changes" + ), + 200 + ) + + assert response =~ "hidden marker tag" + end + + test "a profile route filters the listing to that user's changes", %{conn: conn} do user = confirmed_user_fixture() tag_change_fixture!(user, "filtered marker tag, second tag") tag_change_fixture!(confirmed_user_fixture(), "unrelated marker tag, second tag") - # The filter downcases the given name before the term match. - conn = - get( - conn, - ~p"/tag_changes?#{[resource_type: "user", resource_id: String.upcase(user.name)]}" - ) + conn = get(conn, ~p"/profiles/#{user}/tag_changes") response = html_response(conn, 200) @@ -120,7 +177,7 @@ defmodule PhilomenaWeb.TagChangeControllerTest do refute response =~ "unrelated marker tag" end - test "tcq composes with resource params as AND", %{conn: conn} do + test "tcq composes with an image route as AND", %{conn: conn} do user = confirmed_user_fixture() image = tag_change_fixture!(user) other_image = image_fixture() @@ -129,7 +186,7 @@ defmodule PhilomenaWeb.TagChangeControllerTest do conn1 = get( conn, - ~p"/tag_changes?#{[tcq: "image_id:#{image.id}", resource_type: "image", resource_id: image.id]}" + ~p"/images/#{image}/tag_changes?#{[tcq: "image_id:#{image.id}"]}" ) assert html_response(conn1, 200) =~ "added test tag" @@ -139,40 +196,65 @@ defmodule PhilomenaWeb.TagChangeControllerTest do conn2 = get( conn, - ~p"/tag_changes?#{[tcq: "image_id:#{image.id}", resource_type: "image", resource_id: other_image.id]}" + ~p"/images/#{other_image}/tag_changes?#{[tcq: "image_id:#{image.id}"]}" ) refute html_response(conn2, 200) =~ "added test tag" end - test "resource_type=ip lists nothing for non-staff viewers", %{conn: conn} do + test "an IP route is forbidden to non-staff viewers", %{conn: conn} do tag_change_fixture!(confirmed_user_fixture()) - # attribution/1 stamps every change with 203.0.113.1, but ip filtering - # is moderator/admin-only - anonymous viewers get match_none. - conn = get(conn, ~p"/tag_changes?#{[resource_type: "ip", resource_id: "203.0.113.1"]}") + conn = get(conn, ~p"/ip_profiles/203.0.113.1/tag_changes") - refute html_response(conn, 200) =~ "added test tag" + assert redirected_to(conn) == ~p"/sessions/new" end - test "a moderator can filter by ip; an invalid ip matches nothing", %{conn: conn} do + test "a moderator can filter by ip; an invalid ip is not found", %{conn: conn} do tag_change_fixture!(confirmed_user_fixture()) conn = log_in_user(conn, moderator_user_fixture()) - conn1 = get(conn, ~p"/tag_changes?#{[resource_type: "ip", resource_id: "203.0.113.1"]}") + conn1 = get(conn, ~p"/ip_profiles/203.0.113.1/tag_changes") assert html_response(conn1, 200) =~ "added test tag" - conn2 = get(conn, ~p"/tag_changes?#{[resource_type: "ip", resource_id: "not-an-ip"]}") - refute html_response(conn2, 200) =~ "added test tag" + conn2 = get(conn, ~p"/ip_profiles/not-an-ip/tag_changes") + assert redirected_to(conn2) == "/" + assert Phoenix.Flash.get(conn2.assigns.flash, :error) =~ "Couldn't find" end - test "an unknown resource_type lists nothing", %{conn: conn} do + test "fingerprint resources validate and require identity access", %{conn: conn} do tag_change_fixture!(confirmed_user_fixture()) - conn = get(conn, ~p"/tag_changes?#{[resource_type: "banana", resource_id: "1"]}") + conn = + get( + conn, + ~p"/fingerprint_profiles/d015c342859dde3/tag_changes" + ) + + assert redirected_to(conn) == ~p"/sessions/new" - refute html_response(conn, 200) =~ "added test tag" + moderator_conn = log_in_user(recycle(conn), moderator_user_fixture()) + + response = + html_response( + get( + moderator_conn, + ~p"/fingerprint_profiles/D015C342859DDE3/tag_changes" + ), + 200 + ) + + assert response =~ "added test tag" + + conn = + get( + moderator_conn, + ~p"/fingerprint_profiles/invalid/tag_changes" + ) + + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -183,8 +265,9 @@ defmodule PhilomenaWeb.TagChangeControllerTest do tc = tag_change_row!(image) # NOTE: /tag_changes sits in the Tor-authorized scope, not the - # login-required one, so an anonymous visitor is stopped by the Canary - # authorization (redirect to "/") rather than a login redirect. + # login-required one, so an anonymous visitor gets {:error, :unauthorized} + # from the context (redirect to "/" with the can't-access flash) rather + # than a login redirect. conn = delete(conn, ~p"/tag_changes/#{tc}", %{"redirect" => ~p"/images/#{image}"}) assert redirected_to(conn) == "/" @@ -220,15 +303,13 @@ defmodule PhilomenaWeb.TagChangeControllerTest do refute Repo.get(TagChange, tc.id) end - test "an unknown id takes the not-authorized redirect", %{conn: conn} do + test "an unknown id takes the not-found redirect", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) - # NOTE: load_and_authorize_resource authorizes a nil resource for a - # moderator (no :delete rule matches nil), so an unknown id redirects. conn = delete(conn, ~p"/tag_changes/#{123_456_789}", %{"redirect" => "/"}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "a non-integer id redirects with the not-found flash", %{conn: conn} do diff --git a/test/philomena_web/controllers/tag_controller_test.exs b/test/philomena_web/controllers/tag_controller_test.exs index c312a4e72..fa1e9b23f 100644 --- a/test/philomena_web/controllers/tag_controller_test.exs +++ b/test/philomena_web/controllers/tag_controller_test.exs @@ -87,11 +87,11 @@ defmodule PhilomenaWeb.TagControllerTest do ~s|This tag ("test aliased tag") has been aliased into the tag "test target tag".| end - test "redirects to / for an unknown slug", %{conn: conn} do + test "redirects to / with not-found for an unknown slug", %{conn: conn} do conn = get(conn, ~p"/tags/nonexistent-tag") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end end @@ -165,24 +165,19 @@ defmodule PhilomenaWeb.TagControllerTest do assert Repo.get!(Tag, tag.id).short_description == "short desc" end - test "an unknown slug is the not-authorized redirect for a moderator", %{conn: conn} do - # NOTE: update_tag's changeset has no required fields, so there is no - # reachable validation failure; the failure surface is the unknown slug. - # A moderator fails authorization on the nil resource, so the - # unauthorized handler fires - "can't access". + test "an unknown slug is the not-found redirect for a moderator", %{conn: conn} do conn = log_in_user(conn, moderator_user_fixture()) conn = put(conn, ~p"/tags/nonexistent-tag", %{"tag" => %{"category" => "character"}}) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "can't access" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" end test "an unknown slug is the not-found redirect for an admin", %{conn: conn} do - # NOTE: can?(admin, _, nil) is true, but load_and_authorize_resource has - # persisted: true, so Canary's not_found_handler fires on the nil - # resource before update/2 runs - a clean "Couldn't find" redirect, NOT - # a crash. Same different-flash-by-role split as the tag alias/reindex - # children. + # NOTE: can?(admin, _, nil) is true, so the admin is authorized on the + # nil load and the context returns not_found before update/2 runs - a + # clean "Couldn't find" redirect, NOT a crash. Same + # different-flash-by-role split as the tag alias/reindex children. conn = log_in_user(conn, admin_user_fixture()) conn = put(conn, ~p"/tags/nonexistent-tag", %{"tag" => %{"category" => "character"}}) @@ -245,9 +240,8 @@ defmodule PhilomenaWeb.TagControllerTest do test "an unknown slug is the not-found redirect for an admin", %{conn: conn} do # NOTE: the delete_tag failure surface is the unknown slug; an admin - # passes authorization on the nil resource but Canary's not_found_handler - # (persisted: true) fires before delete/2 - a clean "Couldn't find" - # redirect, not a crash. + # passes authorization on the nil load, so the context returns not_found + # before delete/2 - a clean "Couldn't find" redirect, not a crash. conn = log_in_user(conn, admin_user_fixture()) conn = delete(conn, ~p"/tags/nonexistent-tag") diff --git a/test/philomena_web/controllers/topic/hide_controller_test.exs b/test/philomena_web/controllers/topic/hide_controller_test.exs index bdd037f03..e657d226e 100644 --- a/test/philomena_web/controllers/topic/hide_controller_test.exs +++ b/test/philomena_web/controllers/topic/hide_controller_test.exs @@ -14,9 +14,16 @@ defmodule PhilomenaWeb.Topic.HideControllerTest do %{forum: forum, topic: topic} end - defp hidden_topic(topic) do - {:ok, topic} = - Topics.hide_topic(topic, "Spam", Philomena.UsersFixtures.moderator_user_fixture()) + defp hidden_topic(forum, topic) do + moderator = Philomena.UsersFixtures.moderator_user_fixture() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "Spam"} + ) topic end @@ -64,7 +71,7 @@ defmodule PhilomenaWeb.Topic.HideControllerTest do assert topic.deleted_by_id == user.id end - # Failure path: hide_changeset requires deletion_reason. hide_topic now + # Failure path: hide_changeset requires deletion_reason. create_topic_hide now # normalizes its Multi failure to {:error, changeset}, so a blank reason # redirects back with the "Unable to delete the topic!" flash instead of # raising CaseClauseError. @@ -101,7 +108,7 @@ defmodule PhilomenaWeb.Topic.HideControllerTest do describe "DELETE /forums/:forum_id/topics/:topic_id/hide" do test "redirects anonymous users to the login page", %{conn: conn, forum: forum, topic: topic} do - topic = hidden_topic(topic) + topic = hidden_topic(forum, topic) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/hide") @@ -109,12 +116,12 @@ defmodule PhilomenaWeb.Topic.HideControllerTest do assert Repo.reload!(topic).hidden_from_users end - # A regular user cannot even load a hidden topic (LoadTopicPlug rejects it - # before the authorize_resource :hide check), so the not-authorized redirect - # comes from the load plug. + # A regular user cannot even load a hidden topic - the context's topic load + # rejects it (show_hidden: false) before the :hide authorization, so the + # not-authorized result comes from the load-visibility step. test "rejects a regular user with the authorization flash", %{conn: conn, forum: forum, topic: topic} do - topic = hidden_topic(topic) + topic = hidden_topic(forum, topic) %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/hide") @@ -125,7 +132,7 @@ defmodule PhilomenaWeb.Topic.HideControllerTest do end test "as a moderator restores the topic", %{conn: conn, forum: forum, topic: topic} do - topic = hidden_topic(topic) + topic = hidden_topic(forum, topic) %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/hide") diff --git a/test/philomena_web/controllers/topic/lock_controller_test.exs b/test/philomena_web/controllers/topic/lock_controller_test.exs index 3325f2ef9..3391a9f26 100644 --- a/test/philomena_web/controllers/topic/lock_controller_test.exs +++ b/test/philomena_web/controllers/topic/lock_controller_test.exs @@ -74,12 +74,15 @@ defmodule PhilomenaWeb.Topic.LockControllerTest do end describe "DELETE /forums/:forum_id/topics/:topic_id/lock" do - setup %{topic: topic} do - {:ok, topic} = - Philomena.Topics.lock_topic( - topic, - %{"lock_reason" => "Off topic"}, - Philomena.UsersFixtures.moderator_user_fixture() + setup %{forum: forum, topic: topic} do + moderator = Philomena.UsersFixtures.moderator_user_fixture() + + {:ok, {_forum, topic}} = + Philomena.Topics.create_topic_lock( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"lock_reason" => "Off topic"} ) %{topic: topic} diff --git a/test/philomena_web/controllers/topic/move_controller_test.exs b/test/philomena_web/controllers/topic/move_controller_test.exs index 26eeb5b46..0177f5da9 100644 --- a/test/philomena_web/controllers/topic/move_controller_test.exs +++ b/test/philomena_web/controllers/topic/move_controller_test.exs @@ -19,7 +19,7 @@ defmodule PhilomenaWeb.Topic.MoveControllerTest do %{conn: conn, forum: forum, target_forum: target_forum, topic: topic} do conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/move", %{ - "topic" => %{"target_forum_id" => to_string(target_forum.id)} + "topic" => %{"target_forum" => target_forum.short_name} }) assert redirected_to(conn) == ~p"/sessions/new" @@ -36,7 +36,7 @@ defmodule PhilomenaWeb.Topic.MoveControllerTest do conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/move", %{ - "topic" => %{"target_forum_id" => to_string(target_forum.id)} + "topic" => %{"target_forum" => target_forum.short_name} }) assert redirected_to(conn) == "/" @@ -50,7 +50,7 @@ defmodule PhilomenaWeb.Topic.MoveControllerTest do conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/move", %{ - "topic" => %{"target_forum_id" => to_string(target_forum.id)} + "topic" => %{"target_forum" => target_forum.short_name} }) assert redirected_to(conn) == ~p"/forums/#{target_forum}/topics/#{topic}" @@ -58,51 +58,31 @@ defmodule PhilomenaWeb.Topic.MoveControllerTest do assert Repo.reload!(topic).forum_id == target_forum.id end - # NOTE: move_changeset now declares the FK constraint and move_topic - # normalizes the Multi failure, so a nonexistent target forum redirects back - # to the topic with the failure flash instead of raising Ecto.ConstraintError. - test "moving to a nonexistent forum id redirects back with the failure flash", + test "moving to a nonexistent forum id redirects with the failure flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/move", %{ - "topic" => %{"target_forum_id" => "999999999"} + "topic" => %{"target_forum" => "nonexistent-forum"} }) - assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to move the topic!" + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" assert Repo.reload!(topic).forum_id == forum.id end - # NOTE: a request without the target_forum_id param now takes the fallback - # create/2 clause and redirects back with the failure flash rather than + # NOTE: a request without the target_forum param now takes the fallback + # create/2 clause and redirects with the failure flash rather than # raising ActionClauseError. - test "a request without the target_forum_id param redirects back with the failure flash", + test "a request without the target_forum param redirects with the failure flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/move", %{}) - assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to move the topic!" - assert Repo.reload!(topic).forum_id == forum.id - end - - # NOTE: the target_forum_id is now parsed with IntegerId.parse, so a - # non-integer value redirects back with the failure flash rather than - # raising ArgumentError. - test "a non-integer target_forum_id redirects back with the failure flash", - %{conn: conn, forum: forum, topic: topic} do - %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) - - conn = - post(conn, ~p"/forums/#{forum}/topics/#{topic}/move", %{ - "topic" => %{"target_forum_id" => "not-a-number"} - }) - - assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Unable to move the topic!" + assert redirected_to(conn) == "/" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Couldn't find" assert Repo.reload!(topic).forum_id == forum.id end @@ -112,7 +92,7 @@ defmodule PhilomenaWeb.Topic.MoveControllerTest do conn = post(conn, ~p"/forums/#{forum}/topics/nonexistent-topic/move", %{ - "topic" => %{"target_forum_id" => to_string(target_forum.id)} + "topic" => %{"target_forum" => target_forum.short_name} }) assert redirected_to(conn) == "/" diff --git a/test/philomena_web/controllers/topic/poll/vote_controller_test.exs b/test/philomena_web/controllers/topic/poll/vote_controller_test.exs index 403e8aabe..e118aa04d 100644 --- a/test/philomena_web/controllers/topic/poll/vote_controller_test.exs +++ b/test/philomena_web/controllers/topic/poll/vote_controller_test.exs @@ -2,7 +2,7 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do use PhilomenaWeb.ConnCase, async: true # :create is public (any logged-in user); :index and :delete are - # moderator-only (verify_authorized gates on :hide of the topic). + # moderator-only (the context authorizes :hide of the topic). import Ecto.Query import Philomena.ForumsFixtures @@ -64,9 +64,9 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do assert Repo.reload!(poll).total_votes == 1 end - test "records only the first option on a single-vote poll", + test "rejects multiple options on a single-vote poll", %{conn: conn, forum: forum, topic: topic, option_a: option_a, option_b: option_b} do - %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) + %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes", %{ @@ -74,11 +74,8 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do }) assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - - assert [%{poll_option_id: recorded}] = - Repo.all(from pv in PollVote, where: pv.user_id == ^user.id) - - assert recorded == option_a.id + assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Your vote was not recorded." + assert Repo.aggregate(PollVote, :count) == 0 end test "does not record a second vote by the same user", @@ -117,21 +114,8 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do assert Repo.aggregate(PollVote, :count) == 0 end - test "redirects with the error flash when the poll parameter is missing", - %{conn: conn, forum: forum, topic: topic} do - %{conn: conn} = register_and_log_in_user(%{conn: conn}) - - conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes", %{}) - - assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Your vote was not recorded." - end - - test "drops a non-integer option id without crashing", + test "drops a non-integer option id", %{conn: conn, forum: forum, topic: topic} do - # A non-integer option id is unparsable, so filter_options drops it - # (rather than raising ArgumentError as it used to). No valid options - # remain, so no vote is recorded and the request still redirects cleanly. %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) conn = @@ -213,11 +197,12 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do end # Records a vote for `option` by a fresh voter and returns the PollVote row. - defp record_vote(poll, option) do + defp record_vote(forum, topic, option) do voter = Philomena.UsersFixtures.confirmed_user_fixture() + actor = Philomena.AttributionFixtures.actor(voter) - {:ok, _votes} = - PollVotes.create_poll_votes(voter, poll, %{"option_ids" => [to_string(option.id)]}) + {:ok, _ballot} = + PollVotes.create_votes(actor, forum.short_name, topic.slug, %{option_ids: [option.id]}) Repo.one!( from pv in PollVote, where: pv.poll_option_id == ^option.id and pv.user_id == ^voter.id @@ -246,8 +231,8 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do end test "as a moderator lists the voters for options with votes", - %{conn: conn, forum: forum, topic: topic, poll: poll, option_a: option_a} do - vote = record_vote(poll, option_a) + %{conn: conn, forum: forum, topic: topic, option_a: option_a} do + vote = record_vote(forum, topic, option_a) %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) response = html_response(get(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes"), 200) @@ -283,8 +268,8 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do describe "DELETE /forums/:forum_id/topics/:topic_id/poll/votes/:id" do test "redirects anonymous users to the login page", - %{conn: conn, forum: forum, topic: topic, poll: poll, option_a: option_a} do - vote = record_vote(poll, option_a) + %{conn: conn, forum: forum, topic: topic, option_a: option_a} do + vote = record_vote(forum, topic, option_a) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes/#{vote}") @@ -293,8 +278,8 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do end test "rejects a regular user with the authorization flash", - %{conn: conn, forum: forum, topic: topic, poll: poll, option_a: option_a} do - vote = record_vote(poll, option_a) + %{conn: conn, forum: forum, topic: topic, option_a: option_a} do + vote = record_vote(forum, topic, option_a) %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes/#{vote}") @@ -306,7 +291,7 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do test "as a moderator removes the vote row and decrements the cached tallies", %{conn: conn, forum: forum, topic: topic, poll: poll, option_a: option_a} do - vote = record_vote(poll, option_a) + vote = record_vote(forum, topic, option_a) %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes/#{vote}") @@ -324,26 +309,30 @@ defmodule PhilomenaWeb.Topic.Poll.VoteControllerTest do # NOTE: the vote is now loaded with get_poll_vote/1, so an unknown id # redirects back to the topic with the failure flash rather than raising. - test "for an unknown vote id redirects back with the failure flash", + test "for an unknown vote id redirects with the not-found flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes/999999999") - assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Vote was not removed." + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer id is parsed first (IntegerId.parse), so it takes the # same nil path and redirects back with the failure flash. - test "for a non-integer vote id redirects back with the failure flash", + test "for a non-integer vote id redirects with the not-found flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = delete(conn, ~p"/forums/#{forum}/topics/#{topic}/poll/votes/not-a-number") - assert redirected_to(conn) == ~p"/forums/#{forum}/topics/#{topic}" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Vote was not removed." + assert redirected_to(conn) == "/" + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/topic/poll_controller_test.exs b/test/philomena_web/controllers/topic/poll_controller_test.exs index 751fc4d21..864d61c1e 100644 --- a/test/philomena_web/controllers/topic/poll_controller_test.exs +++ b/test/philomena_web/controllers/topic/poll_controller_test.exs @@ -46,11 +46,9 @@ defmodule PhilomenaWeb.Topic.PollControllerTest do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." end - # PollController now maps edit/update to :show via CanaryMapPlug before - # load_and_authorize_resource, so the Forum is authorized against :show - # (which moderators have) rather than the raw :edit. The later - # verify_authorized plug gates on :hide of the topic, also a moderator - # capability, so moderators can now render the edit form. + # The context loads the topic (show_hidden: false) and authorizes :hide of + # the topic, both moderator capabilities - so a moderator renders the poll + # edit form even though the route action is :edit. test "renders the edit form for a moderator", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) diff --git a/test/philomena_web/controllers/topic/post/approve_controller_test.exs b/test/philomena_web/controllers/topic/post/approve_controller_test.exs index d46be44c4..648f46103 100644 --- a/test/philomena_web/controllers/topic/post/approve_controller_test.exs +++ b/test/philomena_web/controllers/topic/post/approve_controller_test.exs @@ -5,6 +5,7 @@ defmodule PhilomenaWeb.Topic.Post.ApproveControllerTest do import Philomena.PostsFixtures import Philomena.TopicsFixtures import Philomena.UsersFixtures + import Philomena.RulesFixtures alias Philomena.Repo @@ -15,9 +16,17 @@ defmodule PhilomenaWeb.Topic.Post.ApproveControllerTest do %{forum: forum, topic: topic} end + defp approval_rule! do + rule_fixture() + |> Ecto.Changeset.change(name: "Approval") + |> Repo.update!() + end + # A post authored by a fresh (untrusted) user containing an external link is # not auto-approved on creation (see Philomena.Schema.Approval). defp unapproved_post(topic) do + approval_rule!() + post = post_fixture(topic, confirmed_user_fixture(), %{ "body" => "check this out https://spam.example/" @@ -66,8 +75,6 @@ defmodule PhilomenaWeb.Topic.Post.ApproveControllerTest do assert Repo.reload!(post).approved end - # Approving an already-approved post still reports success (approve_changeset - # sets the column unconditionally; there is no verify_not_approved guard). test "approving an already-approved post still succeeds", %{conn: conn, forum: forum, topic: topic} do post = post_fixture(topic) @@ -76,21 +83,23 @@ defmodule PhilomenaWeb.Topic.Post.ApproveControllerTest do conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/posts/#{post}/approve") - assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Post successfully approved." + assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Post has already been approved." assert Repo.reload!(post).approved end # Failure path: the only reachable failure surface is an unknown post - - # load_and_authorize_resource authorizes the nil resource, which no - # moderator rule matches, so it redirects with the authorization flash. - test "for an unknown post_id redirects with the authorization flash", + # the context authorizes the nil load, which no moderator rule matches, so + # it returns unauthorized and redirects with the authorization flash. + test "for an unknown post_id redirects with the not-found flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/posts/999999999/approve") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer post_id short-circuits to NotFoundPlug via the central diff --git a/test/philomena_web/controllers/topic/post/delete_controller_test.exs b/test/philomena_web/controllers/topic/post/delete_controller_test.exs index b349200fd..e3f65deed 100644 --- a/test/philomena_web/controllers/topic/post/delete_controller_test.exs +++ b/test/philomena_web/controllers/topic/post/delete_controller_test.exs @@ -7,7 +7,9 @@ defmodule PhilomenaWeb.Topic.Post.DeleteControllerTest do import Philomena.ForumsFixtures import Philomena.PostsFixtures import Philomena.TopicsFixtures + import Philomena.UsersFixtures + alias Philomena.Posts alias Philomena.Repo setup do @@ -15,6 +17,15 @@ defmodule PhilomenaWeb.Topic.Post.DeleteControllerTest do topic = topic_fixture(forum) post = post_fixture(topic, nil, %{"body" => "Original post body"}) + {:ok, post} = + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator_user_fixture()), + forum.short_name, + topic.slug, + post.id, + %{deletion_reason: "Spam"} + ) + %{forum: forum, topic: topic, post: post} end @@ -57,17 +68,19 @@ defmodule PhilomenaWeb.Topic.Post.DeleteControllerTest do end # Failure path: destroy_changeset never fails, so the only reachable failure - # surface is an unknown post - load_and_authorize_resource authorizes the - # nil resource, which no moderator rule matches, so it redirects with the + # surface is an unknown post - the context authorizes the nil load, which no + # moderator rule matches, so it returns unauthorized and redirects with the # authorization flash. - test "for an unknown post_id redirects with the authorization flash", + test "for an unknown post_id redirects with the not-found flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/posts/999999999/delete") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer post_id short-circuits to NotFoundPlug via the central diff --git a/test/philomena_web/controllers/topic/post/hide_controller_test.exs b/test/philomena_web/controllers/topic/post/hide_controller_test.exs index b1341398f..0b52da725 100644 --- a/test/philomena_web/controllers/topic/post/hide_controller_test.exs +++ b/test/philomena_web/controllers/topic/post/hide_controller_test.exs @@ -21,11 +21,16 @@ defmodule PhilomenaWeb.Topic.Post.HideControllerTest do end defp hidden_post(post) do + post = Philomena.Repo.preload(post, topic: :forum) + moderator = Philomena.UsersFixtures.moderator_user_fixture() + {:ok, post} = - Posts.hide_post( - post, - %{"deletion_reason" => "Spam"}, - Philomena.UsersFixtures.moderator_user_fixture() + Posts.create_post_hide( + Philomena.AttributionFixtures.actor(moderator), + post.topic.forum.short_name, + post.topic.slug, + post.id, + %{"deletion_reason" => "Spam"} ) post @@ -74,7 +79,7 @@ defmodule PhilomenaWeb.Topic.Post.HideControllerTest do assert post.deletion_reason == "Rule violation" end - # Failure path: hide_changeset requires deletion_reason. hide_post now + # Failure path: hide_changeset requires deletion_reason. create_post_hide now # normalizes its Multi failure to {:error, changeset}, so a blank reason # redirects back with the "Unable to delete post!" flash instead of raising. test "with a blank deletion reason redirects back with the failure flash", @@ -91,7 +96,7 @@ defmodule PhilomenaWeb.Topic.Post.HideControllerTest do refute Repo.reload!(post).hidden_from_users end - test "for an unknown post_id redirects with the authorization flash", + test "for an unknown post_id redirects with the not-found flash", %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -101,7 +106,9 @@ defmodule PhilomenaWeb.Topic.Post.HideControllerTest do }) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end # NOTE: a non-integer post_id short-circuits to NotFoundPlug via the central diff --git a/test/philomena_web/controllers/topic/post/history_controller_test.exs b/test/philomena_web/controllers/topic/post/history_controller_test.exs index e13f98825..8aa4fa6a5 100644 --- a/test/philomena_web/controllers/topic/post/history_controller_test.exs +++ b/test/philomena_web/controllers/topic/post/history_controller_test.exs @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.Topic.Post.HistoryControllerTest do use PhilomenaWeb.ConnCase, async: true + import Philomena.AttributionFixtures, only: [actor: 1] import Philomena.ForumsFixtures import Philomena.TopicsFixtures import Philomena.UsersFixtures @@ -18,7 +19,7 @@ defmodule PhilomenaWeb.Topic.Post.HistoryControllerTest do [post] = topic.posts {:ok, _} = - Posts.update_post(post, author, %{ + Posts.update_post(actor(author), forum.short_name, topic.slug, post.id, %{ "body" => "Original post body plus an edit", "edit_reason" => "typo fix" }) @@ -67,7 +68,9 @@ defmodule PhilomenaWeb.Topic.Post.HistoryControllerTest do conn = get(conn, ~p"/forums/nonexistent/topics/nonexistent/posts/1/history") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end end end diff --git a/test/philomena_web/controllers/topic/post_controller_test.exs b/test/philomena_web/controllers/topic/post_controller_test.exs index b94300ca9..778010049 100644 --- a/test/philomena_web/controllers/topic/post_controller_test.exs +++ b/test/philomena_web/controllers/topic/post_controller_test.exs @@ -73,11 +73,14 @@ defmodule PhilomenaWeb.Topic.PostControllerTest do %{conn: conn, forum: forum, topic: topic} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) - {:ok, _topic} = - Topics.lock_topic( - topic, - %{"lock_reason" => "Test lock"}, - Philomena.UsersFixtures.moderator_user_fixture() + moderator = Philomena.UsersFixtures.moderator_user_fixture() + + {:ok, {_forum, _topic}} = + Topics.create_topic_lock( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"lock_reason" => "Test lock"} ) conn = diff --git a/test/philomena_web/controllers/topic/read_controller_test.exs b/test/philomena_web/controllers/topic/read_controller_test.exs index cfe34e336..1770729da 100644 --- a/test/philomena_web/controllers/topic/read_controller_test.exs +++ b/test/philomena_web/controllers/topic/read_controller_test.exs @@ -26,7 +26,7 @@ defmodule PhilomenaWeb.Topic.ReadControllerTest do {:ok, _} = Topics.create_subscription(topic, user) author = confirmed_user_fixture() post = hd(topic.posts) - {:ok, 1} = Notifications.create_forum_post_notification(author, topic, post) + {:ok, 1} = Notifications.broadcast_forum_post(author, topic, post) end, notification?: fn -> Repo.exists?( @@ -52,13 +52,21 @@ defmodule PhilomenaWeb.Topic.ReadControllerTest do end test "POST for a hidden topic still clears the notification", %{conn: conn} do - # LoadTopicPlug passes show_hidden: true here, so hidden topics can be - # marked read + # the context loads the topic with show_hidden: true here, so hidden topics + # can be marked read %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) forum = forum_fixture() topic = topic_fixture(forum) {:ok, _} = Topics.create_subscription(topic, user) - {:ok, topic} = Topics.hide_topic(topic, "test hiding", moderator_user_fixture()) + moderator = moderator_user_fixture() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "test hiding"} + ) conn = post(conn, ~p"/forums/#{forum}/topics/#{topic}/read") diff --git a/test/philomena_web/controllers/topic/stick_controller_test.exs b/test/philomena_web/controllers/topic/stick_controller_test.exs index e9eb14b2c..17ebb0aca 100644 --- a/test/philomena_web/controllers/topic/stick_controller_test.exs +++ b/test/philomena_web/controllers/topic/stick_controller_test.exs @@ -43,7 +43,8 @@ defmodule PhilomenaWeb.Topic.StickControllerTest do end # Failure path: stick_changeset never fails, so the only reachable failure - # surface is an unknown topic - LoadTopicPlug 404s and redirects to /. + # surface is an unknown topic - the context's show_forum_topic returns + # not_found and redirects to /. test "redirects to / with the not-found flash for an unknown topic", %{conn: conn, forum: forum} do %{conn: conn} = register_and_log_in_moderator(%{conn: conn}) @@ -58,8 +59,16 @@ defmodule PhilomenaWeb.Topic.StickControllerTest do end describe "DELETE /forums/:forum_id/topics/:topic_id/stick" do - setup %{topic: topic} do - {:ok, topic} = Philomena.Topics.stick_topic(topic) + setup %{forum: forum, topic: topic} do + moderator = Philomena.UsersFixtures.moderator_user_fixture() + + {:ok, {_forum, topic}} = + Philomena.Topics.create_topic_stick( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug + ) + %{topic: topic} end diff --git a/test/philomena_web/controllers/topic/subscription_controller_test.exs b/test/philomena_web/controllers/topic/subscription_controller_test.exs index 4be6b53f3..e313d8f47 100644 --- a/test/philomena_web/controllers/topic/subscription_controller_test.exs +++ b/test/philomena_web/controllers/topic/subscription_controller_test.exs @@ -32,14 +32,16 @@ defmodule PhilomenaWeb.Topic.SubscriptionControllerTest do subscription_toggle_tests() - test "POST for an unknown forum redirects to / with the authorization flash", + test "POST for an unknown forum redirects to / with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, ~p"/forums/nonexistent/topics/some-topic/subscription") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end test "POST for an unknown topic redirects to / with the not-found flash", %{conn: conn} do @@ -56,13 +58,21 @@ defmodule PhilomenaWeb.Topic.SubscriptionControllerTest do test "a hidden topic cannot be subscribed to but can be unsubscribed from", %{conn: conn} do - # NOTE: LoadTopicPlug passes show_hidden: true for :delete only, so the - # two actions diverge on hidden topics + # NOTE: the context's show_forum_topic passes show_hidden: true for + # unsubscribe (:delete) only, so the two actions diverge on hidden topics %{conn: conn, user: user} = register_and_log_in_user(%{conn: conn}) forum = forum_fixture() topic = topic_fixture(forum) {:ok, _} = Topics.create_subscription(topic, user) - {:ok, topic} = Topics.hide_topic(topic, "test hiding", moderator_user_fixture()) + moderator = moderator_user_fixture() + + {:ok, {_forum, topic}} = + Topics.create_topic_hide( + Philomena.AttributionFixtures.actor(moderator), + forum.short_name, + topic.slug, + %{"deletion_reason" => "test hiding"} + ) conn2 = post(conn, ~p"/forums/#{forum}/topics/#{topic}/subscription") assert redirected_to(conn2) == "/" diff --git a/test/philomena_web/controllers/topic_controller_test.exs b/test/philomena_web/controllers/topic_controller_test.exs index 82292ee37..d27c1d5b9 100644 --- a/test/philomena_web/controllers/topic_controller_test.exs +++ b/test/philomena_web/controllers/topic_controller_test.exs @@ -46,7 +46,9 @@ defmodule PhilomenaWeb.TopicControllerTest do conn = get(conn, ~p"/forums/nonexistent-forum/topics/#{topic}") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ + "Couldn't find what you were looking for!" end test "redirects to / for a hidden topic viewed anonymously", %{ @@ -120,7 +122,9 @@ defmodule PhilomenaWeb.TopicControllerTest do conn = get(conn, ~p"/forums/nonexistent-forum/topics/new") assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end end diff --git a/test/philomena_web/controllers/unlock_controller_test.exs b/test/philomena_web/controllers/unlock_controller_test.exs index 3eca2b72e..6bd36fd92 100644 --- a/test/philomena_web/controllers/unlock_controller_test.exs +++ b/test/philomena_web/controllers/unlock_controller_test.exs @@ -66,7 +66,7 @@ defmodule PhilomenaWeb.UnlockControllerTest do conn = get(conn, ~p"/unlocks/#{token}") assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :info) =~ "Account unlocked successfully" - refute Users.get_user!(user.id).locked_at + refute Users.fetch_user_for_worker!(user.id).locked_at refute get_session(conn, :user_token) assert Repo.all(Users.UserToken) == [] @@ -79,7 +79,7 @@ defmodule PhilomenaWeb.UnlockControllerTest do conn = get(conn, ~p"/unlocks/oops") assert redirected_to(conn) == "/" assert Flash.get(conn.assigns.flash, :error) =~ "Unlock link is invalid or it has expired" - assert Users.get_user!(user.id).locked_at + assert Users.fetch_user_for_worker!(user.id).locked_at end end diff --git a/test/philomena_web/plugs/image_filter_plug_test.exs b/test/philomena_web/plugs/image_filter_plug_test.exs new file mode 100644 index 000000000..8e9d7d30e --- /dev/null +++ b/test/philomena_web/plugs/image_filter_plug_test.exs @@ -0,0 +1,38 @@ +defmodule PhilomenaWeb.ImageFilterPlugTest do + use ExUnit.Case, async: true + + import Plug.Conn + import Plug.Test + + alias Philomena.Attribution.Actor + alias Philomena.Filters.Filter + alias Philomena.Filters.ImageFilter + alias PhilomenaWeb.ImageFilterPlug + + @actor %Actor{ip: %Postgrex.INET{address: {203, 0, 113, 1}, netmask: 32}} + + test "assigns the compiled domain filter state" do + conn = + conn(:get, "/") + |> assign(:actor, @actor) + |> assign(:current_filter, %Filter{}) + |> assign(:forced_filter, nil) + |> ImageFilterPlug.call([]) + + assert %ImageFilter{} = conn.assigns.image_filter + refute conn.halted + end + + test "assigns an all-hidden filter for an invalid stored filter" do + conn = + conn(:get, "/") + |> assign(:actor, @actor) + |> assign(:current_filter, %Filter{hidden_complex_str: "("}) + |> assign(:forced_filter, nil) + |> ImageFilterPlug.call([]) + + refute conn.halted + assert conn.assigns.image_filter.query == %{match_all: %{}} + assert conn.assigns.image_filter.display_query == %{match_all: %{}} + end +end diff --git a/test/philomena_web/plugs/user_attribution_plug_test.exs b/test/philomena_web/plugs/user_attribution_plug_test.exs index 9005ee4fb..bb3fa9a2a 100644 --- a/test/philomena_web/plugs/user_attribution_plug_test.exs +++ b/test/philomena_web/plugs/user_attribution_plug_test.exs @@ -2,17 +2,17 @@ defmodule PhilomenaWeb.UserAttributionPlugTest do @moduledoc """ Unit tests for `PhilomenaWeb.UserAttributionPlug`. - The plug now assigns BOTH the legacy `:attributes` - keyword list (`[ip:, fingerprint:, user:]`) and the typed - `%Philomena.Attribution.Actor{}` struct, built from the same three values. - These tests pin that both assigns are present and consistent, and that the - fingerprint source differs by path: the `_ses` cookie for normal requests, - and `"a" <> crc32(user-agent)` for `/api/...` requests. + The plug assigns the `%Philomena.Attribution.Actor{}` struct, + built from the the user, connecting IP address, and fingerprint. + These tests assert that the fingerprint source differs by path: + the `_ses` cookie for normal requests, and `"a" <> crc32(user-agent)` + for `/api/...` requests. """ use PhilomenaWeb.ConnCase, async: true import Philomena.UsersFixtures + import Philomena.BansFixtures alias PhilomenaWeb.UserAttributionPlug alias Philomena.Attribution.Actor @@ -27,59 +27,57 @@ defmodule PhilomenaWeb.UserAttributionPlugTest do build_conn() |> Map.put(:remote_ip, {10, 0, 0, 1}) |> Map.put(:path_info, path_info) - |> put_req_cookie("_ses", "test-session-fingerprint") |> put_req_header("user-agent", "TestAgent/1.0") + |> assign(:fingerprint, "test-session-fingerprint") |> assign(:current_user, current_user) end describe "call/2 for a normal (non-API) request" do - test "assigns both :attributes and :actor, consistent, with the _ses fingerprint" do + test "assigns :actor consistent with the fingerprint" do {:ok, expected_ip} = EctoNetwork.INET.cast({10, 0, 0, 1}) conn = build_attribution_conn(path_info: ["images"], current_user: nil) |> UserAttributionPlug.call([]) - attributes = conn.assigns.attributes - assert attributes[:ip] == expected_ip - assert attributes[:fingerprint] == "test-session-fingerprint" - assert attributes[:user] == nil - assert %Actor{} = actor = conn.assigns.actor - assert actor.ip == attributes[:ip] - assert actor.fingerprint == attributes[:fingerprint] - assert actor.user == attributes[:user] + assert actor.ip == expected_ip + assert actor.fingerprint == "test-session-fingerprint" + assert actor.user == nil end - test "carries the logged-in user through to both assigns" do + test "carries the logged-in user through to the actor" do user = confirmed_user_fixture() conn = build_attribution_conn(path_info: ["images"], current_user: user) |> UserAttributionPlug.call([]) - assert conn.assigns.attributes[:user] == user assert conn.assigns.actor.user == user # Fingerprint still comes from the cookie on a non-API path. assert conn.assigns.actor.fingerprint == "test-session-fingerprint" end end - describe "call/2 for an /api/... request" do - test "derives the fingerprint from the user-agent, not the cookie" do - expected_fingerprint = "a#{:erlang.crc32("TestAgent/1.0")}" + describe "call/2 ban field" do + test "sets the :current_ban assign" do + ban = fingerprint_ban_fixture(%{"fingerprint" => "test-session-fingerprint"}) conn = - build_attribution_conn(path_info: ["api", "v1", "json", "images"], current_user: nil) + build_attribution_conn(path_info: ["images"], current_user: nil) |> UserAttributionPlug.call([]) - # NOTE: the API fingerprint ignores the _ses cookie entirely and is a - # deterministic function of the user-agent string. - assert conn.assigns.attributes[:fingerprint] == expected_fingerprint - refute conn.assigns.attributes[:fingerprint] == "test-session-fingerprint" + assert conn.assigns.actor.ban.generated_ban_id == ban.generated_ban_id + assert conn.assigns.current_ban.generated_ban_id == ban.generated_ban_id + end + + test "is nil when there is no ban" do + conn = + build_attribution_conn(path_info: ["api", "v1", "json", "images"], current_user: nil) + |> UserAttributionPlug.call([]) - # Both assigns still agree. - assert conn.assigns.actor.fingerprint == conn.assigns.attributes[:fingerprint] + assert conn.assigns.actor.ban == nil + assert conn.assigns.current_ban == nil end end end diff --git a/test/philomena_web/user_auth_test.exs b/test/philomena_web/user_auth_test.exs index 3465111a4..b194298dc 100644 --- a/test/philomena_web/user_auth_test.exs +++ b/test/philomena_web/user_auth_test.exs @@ -171,7 +171,7 @@ defmodule PhilomenaWeb.UserAuthTest do test "stores the path to redirect to on GET", %{conn: conn} do halted_conn = - %{conn | request_path: "/foo?bar"} + %{conn | path_info: ["foo"], request_path: "/foo", query_string: "bar"} |> fetch_flash() |> UserAuth.require_authenticated_user([]) @@ -179,7 +179,7 @@ defmodule PhilomenaWeb.UserAuthTest do assert get_session(halted_conn, :user_return_to) == "/foo?bar" halted_conn = - %{conn | request_path: "/foo?bar", method: "POST"} + %{conn | path_info: ["foo"], request_path: "/foo", query_string: "bar", method: "POST"} |> fetch_flash() |> UserAuth.require_authenticated_user([]) diff --git a/test/route_coverage.txt b/test/route_coverage.txt index d33aa8cf2..b8bc91cb1 100644 --- a/test/route_coverage.txt +++ b/test/route_coverage.txt @@ -9,453 +9,462 @@ # docker compose exec -T -e MIX_ENV=test app \ # mix run --no-start -e 'PhilomenaWeb.RouteCoverage.regenerate()' # -[x] GET /sessions/new SessionController :new -[x] POST /sessions SessionController :create -[x] GET /sessions/totp/new Session.TotpController :new -[x] POST /sessions/totp Session.TotpController :create -[x] GET /reactivations/:id ReactivationController :show -[x] POST /reactivations ReactivationController :create -[x] GET /registrations/new RegistrationController :new -[x] POST /registrations RegistrationController :create -[x] GET /passwords/:id/edit PasswordController :edit -[x] GET /passwords/new PasswordController :new -[x] POST /passwords PasswordController :create -[x] PATCH /passwords/:id PasswordController :update -[x] PUT /passwords/:id PasswordController :update -[x] GET /confirmations/new ConfirmationController :new -[x] POST /confirmations ConfirmationController :create -[x] GET /unlocks/new UnlockController :new -[x] GET /unlocks/:id UnlockController :show -[x] POST /unlocks UnlockController :create -[x] GET /confirmations/:id ConfirmationController :show -[x] GET /registrations/edit RegistrationController :edit -[x] DELETE /sessions SessionController :delete -[x] GET /deactivations DeactivationController :show -[x] DELETE /deactivations DeactivationController :delete -[x] GET /registrations/totp/edit Registration.TotpController :edit -[x] PATCH /registrations/totp Registration.TotpController :update -[x] PUT /registrations/totp Registration.TotpController :update -[x] GET /registrations/name/edit Registration.NameController :edit -[x] PATCH /registrations/name Registration.NameController :update -[x] PUT /registrations/name Registration.NameController :update -[x] PATCH /registrations/password Registration.PasswordController :update -[x] PUT /registrations/password Registration.PasswordController :update -[x] GET /registrations/email/:id Registration.EmailController :show -[x] POST /registrations/email Registration.EmailController :create -[x] GET /api/v1/rss/watched Api.Rss.WatchedController :index -[x] GET /api/v1/json/images/featured Api.Json.Image.FeaturedController :show -[x] GET /api/v1/json/images/:id Api.Json.ImageController :show -[x] POST /api/v1/json/images Api.Json.ImageController :create -[x] POST /api/v1/json/search/reverse Api.Json.Search.ReverseController :create -[x] GET /api/v1/json/search/images Api.Json.Search.ImageController :index -[x] GET /api/v1/json/search/tags Api.Json.Search.TagController :index -[x] GET /api/v1/json/search/posts Api.Json.Search.PostController :index -[x] GET /api/v1/json/search/comments Api.Json.Search.CommentController :index -[x] GET /api/v1/json/search/galleries Api.Json.Search.GalleryController :index -[x] GET /api/v1/json/search/filters Api.Json.Search.FilterController :index -[x] GET /api/v1/json/search Api.Json.Search.ImageController :index -[x] GET /api/v1/json/oembed Api.Json.OembedController :index -[x] GET /api/v1/json/tags/:id Api.Json.TagController :show -[x] GET /api/v1/json/comments/:id Api.Json.CommentController :show -[x] GET /api/v1/json/posts/:id Api.Json.PostController :show -[x] GET /api/v1/json/profiles/:id Api.Json.ProfileController :show -[x] GET /api/v1/json/filters/user Api.Json.Filter.UserFilterController :index -[x] GET /api/v1/json/filters/system Api.Json.Filter.SystemFilterController :index -[x] GET /api/v1/json/filters/:id Api.Json.FilterController :show -[x] GET /api/v1/json/forums Api.Json.ForumController :index -[x] GET /api/v1/json/forums/:id Api.Json.ForumController :show -[x] GET /api/v1/json/forums/:forum_id/topics Api.Json.Forum.TopicController :index -[x] GET /api/v1/json/forums/:forum_id/topics/:id Api.Json.Forum.TopicController :show -[x] GET /api/v1/json/forums/:forum_id/topics/:topic_id/posts Api.Json.Forum.Topic.PostController :index -[x] GET /api/v1/json/forums/:forum_id/topics/:topic_id/posts/:id Api.Json.Forum.Topic.PostController :show -[x] POST /channels/nsfw Channel.NsfwController :create -[x] DELETE /channels/nsfw Channel.NsfwController :delete -[x] GET /notifications/unread Notification.UnreadController :index -[x] GET /notifications/categories/:id Notification.CategoryController :show -[x] GET /notifications NotificationController :index -[x] GET /conversations ConversationController :index -[x] GET /conversations/new ConversationController :new -[x] GET /conversations/:id ConversationController :show -[x] POST /conversations ConversationController :create -[x] GET /conversations/:conversation_id/reports/new Conversation.ReportController :new -[x] POST /conversations/:conversation_id/reports Conversation.ReportController :create -[x] POST /conversations/:conversation_id/messages Conversation.MessageController :create -[x] POST /conversations/:conversation_id/messages/:message_id/approve Conversation.Message.ApproveController :create -[x] POST /conversations/:conversation_id/read Conversation.ReadController :create -[x] DELETE /conversations/:conversation_id/read Conversation.ReadController :delete -[x] POST /conversations/:conversation_id/hide Conversation.HideController :create -[x] DELETE /conversations/:conversation_id/hide Conversation.HideController :delete -[x] POST /images/:image_id/vote Image.VoteController :create -[x] DELETE /images/:image_id/vote Image.VoteController :delete -[x] POST /images/:image_id/fave Image.FaveController :create -[x] DELETE /images/:image_id/fave Image.FaveController :delete -[x] POST /images/:image_id/hide Image.HideController :create -[x] DELETE /images/:image_id/hide Image.HideController :delete -[x] POST /images/:image_id/approve Image.ApproveController :create -[x] POST /images/:image_id/subscription Image.SubscriptionController :create -[x] DELETE /images/:image_id/subscription Image.SubscriptionController :delete -[x] POST /images/:image_id/read Image.ReadController :create -[x] GET /images/:image_id/comments/:id/edit Image.CommentController :edit -[x] PATCH /images/:image_id/comments/:id Image.CommentController :update -[x] PUT /images/:image_id/comments/:id Image.CommentController :update -[x] POST /images/:image_id/comments/:comment_id/hide Image.Comment.HideController :create -[x] DELETE /images/:image_id/comments/:comment_id/hide Image.Comment.HideController :delete -[x] POST /images/:image_id/comments/:comment_id/delete Image.Comment.DeleteController :create -[x] POST /images/:image_id/comments/:comment_id/approve Image.Comment.ApproveController :create -[x] POST /images/:image_id/delete Image.DeleteController :create -[x] PATCH /images/:image_id/delete Image.DeleteController :update -[x] PUT /images/:image_id/delete Image.DeleteController :update -[x] DELETE /images/:image_id/delete Image.DeleteController :delete -[x] POST /images/:image_id/tamper Image.TamperController :create -[x] DELETE /images/:image_id/hash Image.HashController :delete -[x] DELETE /images/:image_id/source_history Image.SourceHistoryController :delete -[x] POST /images/:image_id/repair Image.RepairController :create -[x] POST /images/:image_id/feature Image.FeatureController :create -[x] PATCH /images/:image_id/file Image.FileController :update -[x] PUT /images/:image_id/file Image.FileController :update -[x] GET /images/:image_id/scratchpad/edit Image.ScratchpadController :edit -[x] PATCH /images/:image_id/scratchpad Image.ScratchpadController :update -[x] PUT /images/:image_id/scratchpad Image.ScratchpadController :update -[x] PATCH /images/:image_id/uploader Image.UploaderController :update -[x] PUT /images/:image_id/uploader Image.UploaderController :update -[x] POST /images/:image_id/anonymous Image.AnonymousController :create -[x] DELETE /images/:image_id/anonymous Image.AnonymousController :delete -[x] POST /images/:image_id/destroy Image.DestroyController :create -[x] POST /images/:image_id/comment_lock Image.CommentLockController :create -[x] DELETE /images/:image_id/comment_lock Image.CommentLockController :delete -[x] POST /images/:image_id/description_lock Image.DescriptionLockController :create -[x] DELETE /images/:image_id/description_lock Image.DescriptionLockController :delete -[x] GET /images/:image_id/tag_lock Image.TagLockController :show -[x] POST /images/:image_id/tag_lock Image.TagLockController :create -[x] PATCH /images/:image_id/tag_lock Image.TagLockController :update -[x] PUT /images/:image_id/tag_lock Image.TagLockController :update -[x] DELETE /images/:image_id/tag_lock Image.TagLockController :delete -[x] GET /forums/:forum_id/topics/new TopicController :new -[x] POST /forums/:forum_id/topics TopicController :create -[x] PATCH /forums/:forum_id/topics/:id TopicController :update -[x] PUT /forums/:forum_id/topics/:id TopicController :update -[x] POST /forums/:forum_id/topics/:topic_id/subscription Topic.SubscriptionController :create -[x] DELETE /forums/:forum_id/topics/:topic_id/subscription Topic.SubscriptionController :delete -[x] POST /forums/:forum_id/topics/:topic_id/read Topic.ReadController :create -[x] POST /forums/:forum_id/topics/:topic_id/move Topic.MoveController :create -[x] POST /forums/:forum_id/topics/:topic_id/stick Topic.StickController :create -[x] DELETE /forums/:forum_id/topics/:topic_id/stick Topic.StickController :delete -[x] POST /forums/:forum_id/topics/:topic_id/lock Topic.LockController :create -[x] DELETE /forums/:forum_id/topics/:topic_id/lock Topic.LockController :delete -[x] POST /forums/:forum_id/topics/:topic_id/hide Topic.HideController :create -[x] DELETE /forums/:forum_id/topics/:topic_id/hide Topic.HideController :delete -[x] GET /forums/:forum_id/topics/:topic_id/posts/:id/edit Topic.PostController :edit -[x] PATCH /forums/:forum_id/topics/:topic_id/posts/:id Topic.PostController :update -[x] PUT /forums/:forum_id/topics/:topic_id/posts/:id Topic.PostController :update -[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/hide Topic.Post.HideController :create -[x] DELETE /forums/:forum_id/topics/:topic_id/posts/:post_id/hide Topic.Post.HideController :delete -[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/delete Topic.Post.DeleteController :create -[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/approve Topic.Post.ApproveController :create -[x] GET /forums/:forum_id/topics/:topic_id/poll/edit Topic.PollController :edit -[x] PATCH /forums/:forum_id/topics/:topic_id/poll Topic.PollController :update -[x] PUT /forums/:forum_id/topics/:topic_id/poll Topic.PollController :update -[x] GET /forums/:forum_id/topics/:topic_id/poll/votes Topic.Poll.VoteController :index -[x] POST /forums/:forum_id/topics/:topic_id/poll/votes Topic.Poll.VoteController :create -[x] DELETE /forums/:forum_id/topics/:topic_id/poll/votes/:id Topic.Poll.VoteController :delete -[x] POST /forums/:forum_id/subscription Forum.SubscriptionController :create -[x] DELETE /forums/:forum_id/subscription Forum.SubscriptionController :delete -[x] GET /profiles/:profile_id/commission/edit Profile.CommissionController :edit -[x] GET /profiles/:profile_id/commission/new Profile.CommissionController :new -[x] POST /profiles/:profile_id/commission Profile.CommissionController :create -[x] PATCH /profiles/:profile_id/commission Profile.CommissionController :update -[x] PUT /profiles/:profile_id/commission Profile.CommissionController :update -[x] DELETE /profiles/:profile_id/commission Profile.CommissionController :delete -[x] GET /profiles/:profile_id/commission/items/:id/edit Profile.Commission.ItemController :edit -[x] GET /profiles/:profile_id/commission/items/new Profile.Commission.ItemController :new -[x] POST /profiles/:profile_id/commission/items Profile.Commission.ItemController :create -[x] PATCH /profiles/:profile_id/commission/items/:id Profile.Commission.ItemController :update -[x] PUT /profiles/:profile_id/commission/items/:id Profile.Commission.ItemController :update -[x] DELETE /profiles/:profile_id/commission/items/:id Profile.Commission.ItemController :delete -[x] GET /profiles/:profile_id/commission/reports/new Profile.Commission.ReportController :new -[x] POST /profiles/:profile_id/commission/reports Profile.Commission.ReportController :create -[x] GET /profiles/:profile_id/description/edit Profile.DescriptionController :edit -[x] PATCH /profiles/:profile_id/description Profile.DescriptionController :update -[x] PUT /profiles/:profile_id/description Profile.DescriptionController :update -[x] GET /profiles/:profile_id/scratchpad/edit Profile.ScratchpadController :edit -[x] PATCH /profiles/:profile_id/scratchpad Profile.ScratchpadController :update -[x] PUT /profiles/:profile_id/scratchpad Profile.ScratchpadController :update -[x] GET /profiles/:profile_id/artist_links Profile.ArtistLinkController :index -[x] GET /profiles/:profile_id/artist_links/:id/edit Profile.ArtistLinkController :edit -[x] GET /profiles/:profile_id/artist_links/new Profile.ArtistLinkController :new -[x] GET /profiles/:profile_id/artist_links/:id Profile.ArtistLinkController :show -[x] POST /profiles/:profile_id/artist_links Profile.ArtistLinkController :create -[x] PATCH /profiles/:profile_id/artist_links/:id Profile.ArtistLinkController :update -[x] PUT /profiles/:profile_id/artist_links/:id Profile.ArtistLinkController :update -[x] GET /profiles/:profile_id/awards/:id/edit Profile.AwardController :edit -[x] GET /profiles/:profile_id/awards/new Profile.AwardController :new -[x] POST /profiles/:profile_id/awards Profile.AwardController :create -[x] PATCH /profiles/:profile_id/awards/:id Profile.AwardController :update -[x] PUT /profiles/:profile_id/awards/:id Profile.AwardController :update -[x] DELETE /profiles/:profile_id/awards/:id Profile.AwardController :delete -[x] GET /profiles/:profile_id/ip_history Profile.IpHistoryController :index -[x] GET /profiles/:profile_id/fp_history Profile.FpHistoryController :index -[x] GET /profiles/:profile_id/aliases Profile.AliasController :index -[x] PATCH /filters/spoiler_type Filter.SpoilerTypeController :update -[x] PUT /filters/spoiler_type Filter.SpoilerTypeController :update -[x] POST /filters/hide Filter.HideController :create -[x] DELETE /filters/hide Filter.HideController :delete -[x] POST /filters/spoiler Filter.SpoilerController :create -[x] DELETE /filters/spoiler Filter.SpoilerController :delete -[x] POST /tags/:tag_id/watch Tag.WatchController :create -[x] DELETE /tags/:tag_id/watch Tag.WatchController :delete -[x] GET /tags/:tag_id/details Tag.DetailController :index -[x] GET /avatar/edit AvatarController :edit -[x] PATCH /avatar AvatarController :update -[x] PUT /avatar AvatarController :update -[x] DELETE /avatar AvatarController :delete -[x] GET /reports ReportController :index -[x] GET /galleries/:id/edit GalleryController :edit -[x] GET /galleries/new GalleryController :new -[x] POST /galleries GalleryController :create -[x] PATCH /galleries/:id GalleryController :update -[x] PUT /galleries/:id GalleryController :update -[x] DELETE /galleries/:id GalleryController :delete -[x] POST /galleries/:gallery_id/images Gallery.ImageController :create -[x] DELETE /galleries/:gallery_id/images Gallery.ImageController :delete -[x] PATCH /galleries/:gallery_id/order Gallery.OrderController :update -[x] PUT /galleries/:gallery_id/order Gallery.OrderController :update -[x] POST /galleries/:gallery_id/read Gallery.ReadController :create -[x] POST /galleries/:gallery_id/subscription Gallery.SubscriptionController :create -[x] DELETE /galleries/:gallery_id/subscription Gallery.SubscriptionController :delete -[x] POST /channels/:channel_id/read Channel.ReadController :create -[x] POST /channels/:channel_id/subscription Channel.SubscriptionController :create -[x] DELETE /channels/:channel_id/subscription Channel.SubscriptionController :delete -[x] GET /dnp/:id/edit DnpEntryController :edit -[x] GET /dnp/new DnpEntryController :new -[x] POST /dnp DnpEntryController :create -[x] PATCH /dnp/:id DnpEntryController :update -[x] PUT /dnp/:id DnpEntryController :update -[x] GET /ip_profiles/:id IpProfileController :show -[x] GET /ip_profiles/:ip_profile_id/source_changes IpProfile.SourceChangeController :index -[x] GET /fingerprint_profiles/:id FingerprintProfileController :show -[x] GET /fingerprint_profiles/:fingerprint_profile_id/source_changes FingerprintProfile.SourceChangeController :index -[x] GET /moderation_logs ModerationLogController :index -[x] GET /admin/reports Admin.ReportController :index -[x] GET /admin/reports/:id Admin.ReportController :show -[x] POST /admin/reports/:report_id/claim Admin.Report.ClaimController :create -[x] DELETE /admin/reports/:report_id/claim Admin.Report.ClaimController :delete -[x] POST /admin/reports/:report_id/close Admin.Report.CloseController :create -[x] GET /admin/approvals Admin.ApprovalController :index -[x] GET /admin/artist_links Admin.ArtistLinkController :index -[x] POST /admin/artist_links/:artist_link_id/verification Admin.ArtistLink.VerificationController :create -[x] POST /admin/artist_links/:artist_link_id/contact Admin.ArtistLink.ContactController :create -[x] POST /admin/artist_links/:artist_link_id/reject Admin.ArtistLink.RejectController :create -[x] GET /admin/dnp_entries Admin.DnpEntryController :index -[x] POST /admin/dnp_entries/:dnp_entry_id/transition Admin.DnpEntry.TransitionController :create -[x] GET /admin/user_bans Admin.UserBanController :index -[x] GET /admin/user_bans/:id/edit Admin.UserBanController :edit -[x] GET /admin/user_bans/new Admin.UserBanController :new -[x] POST /admin/user_bans Admin.UserBanController :create -[x] PATCH /admin/user_bans/:id Admin.UserBanController :update -[x] PUT /admin/user_bans/:id Admin.UserBanController :update -[x] DELETE /admin/user_bans/:id Admin.UserBanController :delete -[x] GET /admin/subnet_bans Admin.SubnetBanController :index -[x] GET /admin/subnet_bans/:id/edit Admin.SubnetBanController :edit -[x] GET /admin/subnet_bans/new Admin.SubnetBanController :new -[x] POST /admin/subnet_bans Admin.SubnetBanController :create -[x] PATCH /admin/subnet_bans/:id Admin.SubnetBanController :update -[x] PUT /admin/subnet_bans/:id Admin.SubnetBanController :update -[x] DELETE /admin/subnet_bans/:id Admin.SubnetBanController :delete -[x] GET /admin/fingerprint_bans Admin.FingerprintBanController :index -[x] GET /admin/fingerprint_bans/:id/edit Admin.FingerprintBanController :edit -[x] GET /admin/fingerprint_bans/new Admin.FingerprintBanController :new -[x] POST /admin/fingerprint_bans Admin.FingerprintBanController :create -[x] PATCH /admin/fingerprint_bans/:id Admin.FingerprintBanController :update -[x] PUT /admin/fingerprint_bans/:id Admin.FingerprintBanController :update -[x] DELETE /admin/fingerprint_bans/:id Admin.FingerprintBanController :delete -[x] GET /admin/site_notices Admin.SiteNoticeController :index -[x] GET /admin/site_notices/:id/edit Admin.SiteNoticeController :edit -[x] GET /admin/site_notices/new Admin.SiteNoticeController :new -[x] POST /admin/site_notices Admin.SiteNoticeController :create -[x] PATCH /admin/site_notices/:id Admin.SiteNoticeController :update -[x] PUT /admin/site_notices/:id Admin.SiteNoticeController :update -[x] DELETE /admin/site_notices/:id Admin.SiteNoticeController :delete -[x] GET /admin/adverts Admin.AdvertController :index -[x] GET /admin/adverts/:id/edit Admin.AdvertController :edit -[x] GET /admin/adverts/new Admin.AdvertController :new -[x] POST /admin/adverts Admin.AdvertController :create -[x] PATCH /admin/adverts/:id Admin.AdvertController :update -[x] PUT /admin/adverts/:id Admin.AdvertController :update -[x] DELETE /admin/adverts/:id Admin.AdvertController :delete -[x] GET /admin/adverts/:advert_id/image/edit Admin.Advert.ImageController :edit -[x] PATCH /admin/adverts/:advert_id/image Admin.Advert.ImageController :update -[x] PUT /admin/adverts/:advert_id/image Admin.Advert.ImageController :update -[x] GET /admin/forums Admin.ForumController :index -[x] GET /admin/forums/:id/edit Admin.ForumController :edit -[x] GET /admin/forums/new Admin.ForumController :new -[x] POST /admin/forums Admin.ForumController :create -[x] PATCH /admin/forums/:id Admin.ForumController :update -[x] PUT /admin/forums/:id Admin.ForumController :update -[x] GET /admin/badges Admin.BadgeController :index -[x] GET /admin/badges/:id/edit Admin.BadgeController :edit -[x] GET /admin/badges/new Admin.BadgeController :new -[x] POST /admin/badges Admin.BadgeController :create -[x] PATCH /admin/badges/:id Admin.BadgeController :update -[x] PUT /admin/badges/:id Admin.BadgeController :update -[x] GET /admin/badges/:badge_id/users Admin.Badge.UserController :index -[x] GET /admin/badges/:badge_id/image/edit Admin.Badge.ImageController :edit -[x] PATCH /admin/badges/:badge_id/image Admin.Badge.ImageController :update -[x] PUT /admin/badges/:badge_id/image Admin.Badge.ImageController :update -[x] GET /admin/mod_notes Admin.ModNoteController :index -[x] GET /admin/mod_notes/:id/edit Admin.ModNoteController :edit -[x] GET /admin/mod_notes/new Admin.ModNoteController :new -[x] POST /admin/mod_notes Admin.ModNoteController :create -[x] PATCH /admin/mod_notes/:id Admin.ModNoteController :update -[x] PUT /admin/mod_notes/:id Admin.ModNoteController :update -[x] DELETE /admin/mod_notes/:id Admin.ModNoteController :delete -[x] GET /admin/users Admin.UserController :index -[x] GET /admin/users/:id/edit Admin.UserController :edit -[x] PATCH /admin/users/:id Admin.UserController :update -[x] PUT /admin/users/:id Admin.UserController :update -[x] DELETE /admin/users/:user_id/avatar Admin.User.AvatarController :delete -[x] POST /admin/users/:user_id/activation Admin.User.ActivationController :create -[x] DELETE /admin/users/:user_id/activation Admin.User.ActivationController :delete -[x] POST /admin/users/:user_id/verification Admin.User.VerificationController :create -[x] DELETE /admin/users/:user_id/verification Admin.User.VerificationController :delete -[x] POST /admin/users/:user_id/unlock Admin.User.UnlockController :create -[x] GET /admin/users/:user_id/erase/new Admin.User.EraseController :new -[x] POST /admin/users/:user_id/erase Admin.User.EraseController :create -[x] DELETE /admin/users/:user_id/api_key Admin.User.ApiKeyController :delete -[x] DELETE /admin/users/:user_id/downvotes Admin.User.DownvoteController :delete -[x] DELETE /admin/users/:user_id/votes Admin.User.VoteController :delete -[x] POST /admin/users/:user_id/wipe Admin.User.WipeController :create -[x] GET /admin/users/:user_id/force_filter/new Admin.User.ForceFilterController :new -[x] POST /admin/users/:user_id/force_filter Admin.User.ForceFilterController :create -[x] DELETE /admin/users/:user_id/force_filter Admin.User.ForceFilterController :delete -[x] PATCH /admin/batch/tags Admin.Batch.TagController :update -[x] PUT /admin/batch/tags Admin.Batch.TagController :update -[x] GET /admin/donations/user/:id Admin.Donation.UserController :show -[x] GET /admin/donations Admin.DonationController :index -[x] POST /admin/donations Admin.DonationController :create -[x] POST /duplicate_reports/:duplicate_report_id/accept DuplicateReport.AcceptController :create -[x] POST /duplicate_reports/:duplicate_report_id/accept_reverse DuplicateReport.AcceptReverseController :create -[x] POST /duplicate_reports/:duplicate_report_id/reject DuplicateReport.RejectController :create -[x] POST /duplicate_reports/:duplicate_report_id/claim DuplicateReport.ClaimController :create -[x] DELETE /duplicate_reports/:duplicate_report_id/claim DuplicateReport.ClaimController :delete -[x] GET /tags/:id/edit TagController :edit -[x] PATCH /tags/:id TagController :update -[x] PUT /tags/:id TagController :update -[x] DELETE /tags/:id TagController :delete -[x] GET /tags/:tag_id/image/edit Tag.ImageController :edit -[x] PATCH /tags/:tag_id/image Tag.ImageController :update -[x] PUT /tags/:tag_id/image Tag.ImageController :update -[x] DELETE /tags/:tag_id/image Tag.ImageController :delete -[x] GET /tags/:tag_id/alias/edit Tag.AliasController :edit -[x] PATCH /tags/:tag_id/alias Tag.AliasController :update -[x] PUT /tags/:tag_id/alias Tag.AliasController :update -[x] DELETE /tags/:tag_id/alias Tag.AliasController :delete -[x] POST /tags/:tag_id/reindex Tag.ReindexController :create -[x] POST /tag_changes/revert TagChange.RevertController :create -[x] POST /tag_changes/full_revert TagChange.FullRevertController :create -[x] GET /pages PageController :index -[x] GET /pages/:id/edit PageController :edit -[x] GET /pages/new PageController :new -[x] POST /pages PageController :create -[x] PATCH /pages/:id PageController :update -[x] PUT /pages/:id PageController :update -[x] GET /channels/:id/edit ChannelController :edit -[x] GET /channels/new ChannelController :new -[x] POST /channels ChannelController :create -[x] PATCH /channels/:id ChannelController :update -[x] PUT /channels/:id ChannelController :update -[x] DELETE /channels/:id ChannelController :delete -[x] GET /rules/:id/edit RuleController :edit -[x] GET /rules/new RuleController :new -[x] POST /rules RuleController :create -[x] PATCH /rules/:id RuleController :update -[x] PUT /rules/:id RuleController :update -[x] GET / ActivityController :index -[x] GET /activity ActivityController :index -[x] POST /images/scrape Image.ScrapeController :create -[x] GET /images/random Image.RandomController :index -[x] GET /images ImageController :index -[x] GET /images/new ImageController :new -[x] GET /images/:id ImageController :show -[x] POST /images ImageController :create -[x] GET /images/:image_id/related Image.RelatedController :index -[x] GET /images/:image_id/comments Image.CommentController :index -[x] GET /images/:image_id/comments/:id Image.CommentController :show -[x] POST /images/:image_id/comments Image.CommentController :create -[x] GET /images/:image_id/comments/:comment_id/reports/new Image.Comment.ReportController :new -[x] POST /images/:image_id/comments/:comment_id/reports Image.Comment.ReportController :create -[x] GET /images/:image_id/comments/:comment_id/history Image.Comment.HistoryController :index -[x] PATCH /images/:image_id/tags Image.TagController :update -[x] PUT /images/:image_id/tags Image.TagController :update -[x] PATCH /images/:image_id/sources Image.SourceController :update -[x] PUT /images/:image_id/sources Image.SourceController :update -[x] GET /images/:image_id/source_changes Image.SourceChangeController :index -[x] PATCH /images/:image_id/description Image.DescriptionController :update -[x] PUT /images/:image_id/description Image.DescriptionController :update -[x] GET /images/:image_id/navigate Image.NavigateController :index -[x] GET /images/:image_id/reports/new Image.ReportController :new -[x] POST /images/:image_id/reports Image.ReportController :create -[x] GET /images/:image_id/reporting Image.ReportingController :show -[x] GET /images/:image_id/favorites Image.FavoriteController :index -[x] GET /autocomplete/tags Autocomplete.TagController :show -[x] GET /autocomplete/compiled Autocomplete.CompiledController :show -[x] GET /fetch/tags Fetch.TagController :index -[x] GET /themes ThemeController :index -[x] GET /tags TagController :index -[x] GET /tags/:id TagController :show -[x] GET /tag_changes TagChangeController :index -[x] DELETE /tag_changes/:id TagChangeController :delete -[x] GET /search/reverse Search.ReverseController :index -[x] POST /search/reverse Search.ReverseController :create -[x] GET /search SearchController :index -[x] GET /forums ForumController :index -[x] GET /forums/:id ForumController :show -[x] GET /forums/:forum_id/topics/:id TopicController :show -[x] POST /forums/:forum_id/topics/:topic_id/posts Topic.PostController :create -[x] GET /forums/:forum_id/topics/:topic_id/posts/:post_id/reports/new Topic.Post.ReportController :new -[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/reports Topic.Post.ReportController :create -[x] GET /forums/:forum_id/topics/:topic_id/posts/:post_id/history Topic.Post.HistoryController :index -[x] GET /comments CommentController :index -[x] PATCH /filters/current Filter.CurrentController :update -[x] PUT /filters/current Filter.CurrentController :update -[x] DELETE /filters/clear_recent Filter.ClearRecentController :delete -[x] GET /filters FilterController :index -[x] GET /filters/:id/edit FilterController :edit -[x] GET /filters/new FilterController :new -[x] GET /filters/:id FilterController :show -[x] POST /filters FilterController :create -[x] PATCH /filters/:id FilterController :update -[x] PUT /filters/:id FilterController :update -[x] DELETE /filters/:id FilterController :delete -[x] POST /filters/:filter_id/public Filter.PublicController :create -[x] GET /profiles/:id ProfileController :show -[x] GET /profiles/:profile_id/reports/new Profile.ReportController :new -[x] POST /profiles/:profile_id/reports Profile.ReportController :create -[x] GET /profiles/:profile_id/commission Profile.CommissionController :show -[x] GET /profiles/:profile_id/source_changes Profile.SourceChangeController :index -[x] POST /posts/preview Post.PreviewController :create -[x] GET /posts PostController :index -[x] GET /commissions CommissionController :index -[x] GET /galleries GalleryController :index -[x] GET /galleries/:id GalleryController :show -[x] GET /galleries/:gallery_id/reports/new Gallery.ReportController :new -[x] POST /galleries/:gallery_id/reports Gallery.ReportController :create -[x] GET /adverts/:id AdvertController :show -[x] GET /rules RuleController :index -[x] GET /rules/:id RuleController :show -[x] GET /pages/:id PageController :show -[x] GET /pages/:page_id/history Page.HistoryController :index -[x] GET /dnp DnpEntryController :index -[x] GET /dnp/:id DnpEntryController :show -[x] GET /staff StaffController :index -[x] GET /channels ChannelController :index -[x] GET /channels/:id ChannelController :show -[x] GET /settings/edit SettingController :edit -[x] PATCH /settings SettingController :update -[x] PUT /settings SettingController :update -[x] GET /duplicate_reports DuplicateReportController :index -[x] GET /duplicate_reports/:id DuplicateReportController :show -[x] POST /duplicate_reports DuplicateReportController :create -[x] GET /:id ImageController :show -[x] GET /:forum_id/:id TopicController :show -[x] GET /:forum_id/:id/:page TopicController :show -[x] GET /:forum_id/:id/post/:post_id TopicController :show +[x] GET /sessions/new SessionController :new +[x] POST /sessions SessionController :create +[x] GET /sessions/totp/new Session.TotpController :new +[x] POST /sessions/totp Session.TotpController :create +[x] GET /registrations/new RegistrationController :new +[x] POST /registrations RegistrationController :create +[x] GET /confirmations/new ConfirmationController :new +[x] POST /confirmations ConfirmationController :create +[x] GET /passwords/:id/edit PasswordController :edit +[x] GET /passwords/new PasswordController :new +[x] POST /passwords PasswordController :create +[x] PATCH /passwords/:id PasswordController :update +[x] PUT /passwords/:id PasswordController :update +[x] GET /unlocks/new UnlockController :new +[x] GET /unlocks/:id UnlockController :show +[x] POST /unlocks UnlockController :create +[x] GET /reactivations/:id ReactivationController :show +[x] POST /reactivations ReactivationController :create +[x] GET /confirmations/:id ConfirmationController :show +[x] PATCH /confirmations/:id ConfirmationController :update +[x] PUT /confirmations/:id ConfirmationController :update +[x] GET /registrations/edit RegistrationController :edit +[x] DELETE /sessions SessionController :delete +[x] GET /deactivations DeactivationController :show +[x] DELETE /deactivations DeactivationController :delete +[x] GET /registrations/totp/edit Registration.TotpController :edit +[x] PATCH /registrations/totp Registration.TotpController :update +[x] PUT /registrations/totp Registration.TotpController :update +[x] GET /registrations/name/edit Registration.NameController :edit +[x] PATCH /registrations/name Registration.NameController :update +[x] PUT /registrations/name Registration.NameController :update +[x] PATCH /registrations/password Registration.PasswordController :update +[x] PUT /registrations/password Registration.PasswordController :update +[x] GET /registrations/email/:id Registration.EmailController :show +[x] POST /registrations/email Registration.EmailController :create +[x] GET /api/v1/rss/watched Api.Rss.WatchedController :index +[x] GET /api/v1/json/images/featured Api.Json.Image.FeaturedController :show +[x] GET /api/v1/json/images/:id Api.Json.ImageController :show +[x] POST /api/v1/json/images Api.Json.ImageController :create +[x] POST /api/v1/json/search/reverse Api.Json.Search.ReverseController :create +[x] GET /api/v1/json/search/images Api.Json.Search.ImageController :index +[x] GET /api/v1/json/search/tags Api.Json.Search.TagController :index +[x] GET /api/v1/json/search/posts Api.Json.Search.PostController :index +[x] GET /api/v1/json/search/comments Api.Json.Search.CommentController :index +[x] GET /api/v1/json/search/galleries Api.Json.Search.GalleryController :index +[x] GET /api/v1/json/search/filters Api.Json.Search.FilterController :index +[x] GET /api/v1/json/search Api.Json.Search.ImageController :index +[x] GET /api/v1/json/oembed Api.Json.OembedController :index +[x] GET /api/v1/json/tags/:id Api.Json.TagController :show +[x] GET /api/v1/json/comments/:id Api.Json.CommentController :show +[x] GET /api/v1/json/posts/:id Api.Json.PostController :show +[x] GET /api/v1/json/profiles/:id Api.Json.ProfileController :show +[x] GET /api/v1/json/filters/user Api.Json.Filter.UserFilterController :index +[x] GET /api/v1/json/filters/system Api.Json.Filter.SystemFilterController :index +[x] GET /api/v1/json/filters/:id Api.Json.FilterController :show +[x] GET /api/v1/json/forums Api.Json.ForumController :index +[x] GET /api/v1/json/forums/:id Api.Json.ForumController :show +[x] GET /api/v1/json/forums/:forum_id/topics Api.Json.Forum.TopicController :index +[x] GET /api/v1/json/forums/:forum_id/topics/:id Api.Json.Forum.TopicController :show +[x] GET /api/v1/json/forums/:forum_id/topics/:topic_id/posts Api.Json.Forum.Topic.PostController :index +[x] GET /api/v1/json/forums/:forum_id/topics/:topic_id/posts/:id Api.Json.Forum.Topic.PostController :show +[x] POST /channels/nsfw Channel.NsfwController :create +[x] DELETE /channels/nsfw Channel.NsfwController :delete +[x] GET /notifications/unread Notification.UnreadController :index +[x] GET /notifications/categories/:id Notification.CategoryController :show +[x] GET /notifications NotificationController :index +[x] GET /conversations ConversationController :index +[x] GET /conversations/new ConversationController :new +[x] GET /conversations/:id ConversationController :show +[x] POST /conversations ConversationController :create +[x] GET /conversations/:conversation_id/reports/new Conversation.ReportController :new +[x] POST /conversations/:conversation_id/reports Conversation.ReportController :create +[x] POST /conversations/:conversation_id/messages Conversation.MessageController :create +[x] POST /conversations/:conversation_id/messages/:message_id/approve Conversation.Message.ApproveController :create +[x] POST /conversations/:conversation_id/read Conversation.ReadController :create +[x] DELETE /conversations/:conversation_id/read Conversation.ReadController :delete +[x] POST /conversations/:conversation_id/hide Conversation.HideController :create +[x] DELETE /conversations/:conversation_id/hide Conversation.HideController :delete +[x] POST /images/:image_id/vote Image.VoteController :create +[x] DELETE /images/:image_id/vote Image.VoteController :delete +[x] POST /images/:image_id/fave Image.FaveController :create +[x] DELETE /images/:image_id/fave Image.FaveController :delete +[x] POST /images/:image_id/hide Image.HideController :create +[x] DELETE /images/:image_id/hide Image.HideController :delete +[x] POST /images/:image_id/approve Image.ApproveController :create +[x] POST /images/:image_id/subscription Image.SubscriptionController :create +[x] DELETE /images/:image_id/subscription Image.SubscriptionController :delete +[x] POST /images/:image_id/read Image.ReadController :create +[x] GET /images/:image_id/comments/:id/edit Image.CommentController :edit +[x] PATCH /images/:image_id/comments/:id Image.CommentController :update +[x] PUT /images/:image_id/comments/:id Image.CommentController :update +[x] POST /images/:image_id/comments/:comment_id/hide Image.Comment.HideController :create +[x] DELETE /images/:image_id/comments/:comment_id/hide Image.Comment.HideController :delete +[x] POST /images/:image_id/comments/:comment_id/delete Image.Comment.DeleteController :create +[x] POST /images/:image_id/comments/:comment_id/approve Image.Comment.ApproveController :create +[x] POST /images/:image_id/delete Image.DeleteController :create +[x] PATCH /images/:image_id/delete Image.DeleteController :update +[x] PUT /images/:image_id/delete Image.DeleteController :update +[x] DELETE /images/:image_id/delete Image.DeleteController :delete +[x] POST /images/:image_id/tamper Image.TamperController :create +[x] DELETE /images/:image_id/hash Image.HashController :delete +[x] DELETE /images/:image_id/source_history Image.SourceHistoryController :delete +[x] POST /images/:image_id/repair Image.RepairController :create +[x] POST /images/:image_id/feature Image.FeatureController :create +[x] PATCH /images/:image_id/file Image.FileController :update +[x] PUT /images/:image_id/file Image.FileController :update +[x] GET /images/:image_id/scratchpad/edit Image.ScratchpadController :edit +[x] PATCH /images/:image_id/scratchpad Image.ScratchpadController :update +[x] PUT /images/:image_id/scratchpad Image.ScratchpadController :update +[x] PATCH /images/:image_id/uploader Image.UploaderController :update +[x] PUT /images/:image_id/uploader Image.UploaderController :update +[x] POST /images/:image_id/anonymous Image.AnonymousController :create +[x] DELETE /images/:image_id/anonymous Image.AnonymousController :delete +[x] POST /images/:image_id/destroy Image.DestroyController :create +[x] POST /images/:image_id/comment_lock Image.CommentLockController :create +[x] DELETE /images/:image_id/comment_lock Image.CommentLockController :delete +[x] POST /images/:image_id/description_lock Image.DescriptionLockController :create +[x] DELETE /images/:image_id/description_lock Image.DescriptionLockController :delete +[x] GET /images/:image_id/tag_lock Image.TagLockController :show +[x] POST /images/:image_id/tag_lock Image.TagLockController :create +[x] PATCH /images/:image_id/tag_lock Image.TagLockController :update +[x] PUT /images/:image_id/tag_lock Image.TagLockController :update +[x] DELETE /images/:image_id/tag_lock Image.TagLockController :delete +[x] GET /forums/:forum_id/topics/new TopicController :new +[x] POST /forums/:forum_id/topics TopicController :create +[x] PATCH /forums/:forum_id/topics/:id TopicController :update +[x] PUT /forums/:forum_id/topics/:id TopicController :update +[x] POST /forums/:forum_id/topics/:topic_id/subscription Topic.SubscriptionController :create +[x] DELETE /forums/:forum_id/topics/:topic_id/subscription Topic.SubscriptionController :delete +[x] POST /forums/:forum_id/topics/:topic_id/read Topic.ReadController :create +[x] POST /forums/:forum_id/topics/:topic_id/move Topic.MoveController :create +[x] POST /forums/:forum_id/topics/:topic_id/stick Topic.StickController :create +[x] DELETE /forums/:forum_id/topics/:topic_id/stick Topic.StickController :delete +[x] POST /forums/:forum_id/topics/:topic_id/lock Topic.LockController :create +[x] DELETE /forums/:forum_id/topics/:topic_id/lock Topic.LockController :delete +[x] POST /forums/:forum_id/topics/:topic_id/hide Topic.HideController :create +[x] DELETE /forums/:forum_id/topics/:topic_id/hide Topic.HideController :delete +[x] GET /forums/:forum_id/topics/:topic_id/posts/:id/edit Topic.PostController :edit +[x] PATCH /forums/:forum_id/topics/:topic_id/posts/:id Topic.PostController :update +[x] PUT /forums/:forum_id/topics/:topic_id/posts/:id Topic.PostController :update +[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/hide Topic.Post.HideController :create +[x] DELETE /forums/:forum_id/topics/:topic_id/posts/:post_id/hide Topic.Post.HideController :delete +[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/delete Topic.Post.DeleteController :create +[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/approve Topic.Post.ApproveController :create +[x] GET /forums/:forum_id/topics/:topic_id/poll/edit Topic.PollController :edit +[x] PATCH /forums/:forum_id/topics/:topic_id/poll Topic.PollController :update +[x] PUT /forums/:forum_id/topics/:topic_id/poll Topic.PollController :update +[x] GET /forums/:forum_id/topics/:topic_id/poll/votes Topic.Poll.VoteController :index +[x] POST /forums/:forum_id/topics/:topic_id/poll/votes Topic.Poll.VoteController :create +[x] DELETE /forums/:forum_id/topics/:topic_id/poll/votes/:id Topic.Poll.VoteController :delete +[x] POST /forums/:forum_id/subscription Forum.SubscriptionController :create +[x] DELETE /forums/:forum_id/subscription Forum.SubscriptionController :delete +[x] GET /profiles/:profile_id/commission/edit Profile.CommissionController :edit +[x] GET /profiles/:profile_id/commission/new Profile.CommissionController :new +[x] POST /profiles/:profile_id/commission Profile.CommissionController :create +[x] PATCH /profiles/:profile_id/commission Profile.CommissionController :update +[x] PUT /profiles/:profile_id/commission Profile.CommissionController :update +[x] DELETE /profiles/:profile_id/commission Profile.CommissionController :delete +[x] GET /profiles/:profile_id/commission/items/:id/edit Profile.Commission.ItemController :edit +[x] GET /profiles/:profile_id/commission/items/new Profile.Commission.ItemController :new +[x] POST /profiles/:profile_id/commission/items Profile.Commission.ItemController :create +[x] PATCH /profiles/:profile_id/commission/items/:id Profile.Commission.ItemController :update +[x] PUT /profiles/:profile_id/commission/items/:id Profile.Commission.ItemController :update +[x] DELETE /profiles/:profile_id/commission/items/:id Profile.Commission.ItemController :delete +[x] GET /profiles/:profile_id/commission/reports/new Profile.Commission.ReportController :new +[x] POST /profiles/:profile_id/commission/reports Profile.Commission.ReportController :create +[x] GET /profiles/:profile_id/description/edit Profile.DescriptionController :edit +[x] PATCH /profiles/:profile_id/description Profile.DescriptionController :update +[x] PUT /profiles/:profile_id/description Profile.DescriptionController :update +[x] GET /profiles/:profile_id/scratchpad/edit Profile.ScratchpadController :edit +[x] PATCH /profiles/:profile_id/scratchpad Profile.ScratchpadController :update +[x] PUT /profiles/:profile_id/scratchpad Profile.ScratchpadController :update +[x] GET /profiles/:profile_id/artist_links Profile.ArtistLinkController :index +[x] GET /profiles/:profile_id/artist_links/:id/edit Profile.ArtistLinkController :edit +[x] GET /profiles/:profile_id/artist_links/new Profile.ArtistLinkController :new +[x] GET /profiles/:profile_id/artist_links/:id Profile.ArtistLinkController :show +[x] POST /profiles/:profile_id/artist_links Profile.ArtistLinkController :create +[x] PATCH /profiles/:profile_id/artist_links/:id Profile.ArtistLinkController :update +[x] PUT /profiles/:profile_id/artist_links/:id Profile.ArtistLinkController :update +[x] GET /profiles/:profile_id/awards/:id/edit Profile.AwardController :edit +[x] GET /profiles/:profile_id/awards/new Profile.AwardController :new +[x] POST /profiles/:profile_id/awards Profile.AwardController :create +[x] PATCH /profiles/:profile_id/awards/:id Profile.AwardController :update +[x] PUT /profiles/:profile_id/awards/:id Profile.AwardController :update +[x] DELETE /profiles/:profile_id/awards/:id Profile.AwardController :delete +[x] GET /profiles/:profile_id/ip_history Profile.IpHistoryController :index +[x] GET /profiles/:profile_id/fp_history Profile.FpHistoryController :index +[x] GET /profiles/:profile_id/aliases Profile.AliasController :index +[x] POST /profiles/:profile_id/tag_changes/revert Profile.TagChange.RevertController :create +[x] PATCH /filters/spoiler_type Filter.SpoilerTypeController :update +[x] PUT /filters/spoiler_type Filter.SpoilerTypeController :update +[x] POST /filters/hide Filter.HideController :create +[x] DELETE /filters/hide Filter.HideController :delete +[x] POST /filters/spoiler Filter.SpoilerController :create +[x] DELETE /filters/spoiler Filter.SpoilerController :delete +[x] POST /tags/:tag_id/watch Tag.WatchController :create +[x] DELETE /tags/:tag_id/watch Tag.WatchController :delete +[x] GET /tags/:tag_id/details Tag.DetailController :index +[x] GET /avatar/edit AvatarController :edit +[x] PATCH /avatar AvatarController :update +[x] PUT /avatar AvatarController :update +[x] DELETE /avatar AvatarController :delete +[x] GET /reports ReportController :index +[x] GET /galleries/:id/edit GalleryController :edit +[x] GET /galleries/new GalleryController :new +[x] POST /galleries GalleryController :create +[x] PATCH /galleries/:id GalleryController :update +[x] PUT /galleries/:id GalleryController :update +[x] DELETE /galleries/:id GalleryController :delete +[x] POST /galleries/:gallery_id/images Gallery.ImageController :create +[x] DELETE /galleries/:gallery_id/images Gallery.ImageController :delete +[x] PATCH /galleries/:gallery_id/order Gallery.OrderController :update +[x] PUT /galleries/:gallery_id/order Gallery.OrderController :update +[x] POST /galleries/:gallery_id/read Gallery.ReadController :create +[x] POST /galleries/:gallery_id/subscription Gallery.SubscriptionController :create +[x] DELETE /galleries/:gallery_id/subscription Gallery.SubscriptionController :delete +[x] POST /channels/:channel_id/read Channel.ReadController :create +[x] POST /channels/:channel_id/subscription Channel.SubscriptionController :create +[x] DELETE /channels/:channel_id/subscription Channel.SubscriptionController :delete +[x] GET /dnp/:id/edit DnpEntryController :edit +[x] GET /dnp/new DnpEntryController :new +[x] POST /dnp DnpEntryController :create +[x] PATCH /dnp/:id DnpEntryController :update +[x] PUT /dnp/:id DnpEntryController :update +[x] GET /ip_profiles/:id IpProfileController :show +[x] GET /ip_profiles/:ip_profile_id/source_changes IpProfile.SourceChangeController :index +[x] GET /ip_profiles/:ip_profile_id/tag_changes IpProfile.TagChangeController :index +[x] POST /ip_profiles/:ip_profile_id/tag_changes/revert IpProfile.TagChange.RevertController :create +[x] GET /fingerprint_profiles/:id FingerprintProfileController :show +[x] GET /fingerprint_profiles/:fingerprint_profile_id/source_changes FingerprintProfile.SourceChangeController :index +[x] GET /fingerprint_profiles/:fingerprint_profile_id/tag_changes FingerprintProfile.TagChangeController :index +[x] POST /fingerprint_profiles/:fingerprint_profile_id/tag_changes/revert FingerprintProfile.TagChange.RevertController :create +[x] GET /moderation_logs ModerationLogController :index +[x] GET /admin/reports Admin.ReportController :index +[x] GET /admin/reports/:id Admin.ReportController :show +[x] POST /admin/reports/:report_id/claim Admin.Report.ClaimController :create +[x] DELETE /admin/reports/:report_id/claim Admin.Report.ClaimController :delete +[x] POST /admin/reports/:report_id/close Admin.Report.CloseController :create +[x] GET /admin/approvals Admin.ApprovalController :index +[x] GET /admin/artist_links Admin.ArtistLinkController :index +[x] POST /admin/artist_links/:artist_link_id/verification Admin.ArtistLink.VerificationController :create +[x] POST /admin/artist_links/:artist_link_id/contact Admin.ArtistLink.ContactController :create +[x] POST /admin/artist_links/:artist_link_id/reject Admin.ArtistLink.RejectController :create +[x] GET /admin/dnp_entries Admin.DnpEntryController :index +[x] POST /admin/dnp_entries/:dnp_entry_id/transition Admin.DnpEntry.TransitionController :create +[x] GET /admin/user_bans Admin.UserBanController :index +[x] GET /admin/user_bans/:id/edit Admin.UserBanController :edit +[x] GET /admin/user_bans/new Admin.UserBanController :new +[x] POST /admin/user_bans Admin.UserBanController :create +[x] PATCH /admin/user_bans/:id Admin.UserBanController :update +[x] PUT /admin/user_bans/:id Admin.UserBanController :update +[x] DELETE /admin/user_bans/:id Admin.UserBanController :delete +[x] GET /admin/subnet_bans Admin.SubnetBanController :index +[x] GET /admin/subnet_bans/:id/edit Admin.SubnetBanController :edit +[x] GET /admin/subnet_bans/new Admin.SubnetBanController :new +[x] POST /admin/subnet_bans Admin.SubnetBanController :create +[x] PATCH /admin/subnet_bans/:id Admin.SubnetBanController :update +[x] PUT /admin/subnet_bans/:id Admin.SubnetBanController :update +[x] DELETE /admin/subnet_bans/:id Admin.SubnetBanController :delete +[x] GET /admin/fingerprint_bans Admin.FingerprintBanController :index +[x] GET /admin/fingerprint_bans/:id/edit Admin.FingerprintBanController :edit +[x] GET /admin/fingerprint_bans/new Admin.FingerprintBanController :new +[x] POST /admin/fingerprint_bans Admin.FingerprintBanController :create +[x] PATCH /admin/fingerprint_bans/:id Admin.FingerprintBanController :update +[x] PUT /admin/fingerprint_bans/:id Admin.FingerprintBanController :update +[x] DELETE /admin/fingerprint_bans/:id Admin.FingerprintBanController :delete +[x] GET /admin/site_notices Admin.SiteNoticeController :index +[x] GET /admin/site_notices/:id/edit Admin.SiteNoticeController :edit +[x] GET /admin/site_notices/new Admin.SiteNoticeController :new +[x] POST /admin/site_notices Admin.SiteNoticeController :create +[x] PATCH /admin/site_notices/:id Admin.SiteNoticeController :update +[x] PUT /admin/site_notices/:id Admin.SiteNoticeController :update +[x] DELETE /admin/site_notices/:id Admin.SiteNoticeController :delete +[x] GET /admin/adverts Admin.AdvertController :index +[x] GET /admin/adverts/:id/edit Admin.AdvertController :edit +[x] GET /admin/adverts/new Admin.AdvertController :new +[x] POST /admin/adverts Admin.AdvertController :create +[x] PATCH /admin/adverts/:id Admin.AdvertController :update +[x] PUT /admin/adverts/:id Admin.AdvertController :update +[x] DELETE /admin/adverts/:id Admin.AdvertController :delete +[x] GET /admin/adverts/:advert_id/image/edit Admin.Advert.ImageController :edit +[x] PATCH /admin/adverts/:advert_id/image Admin.Advert.ImageController :update +[x] PUT /admin/adverts/:advert_id/image Admin.Advert.ImageController :update +[x] GET /admin/forums Admin.ForumController :index +[x] GET /admin/forums/:id/edit Admin.ForumController :edit +[x] GET /admin/forums/new Admin.ForumController :new +[x] POST /admin/forums Admin.ForumController :create +[x] PATCH /admin/forums/:id Admin.ForumController :update +[x] PUT /admin/forums/:id Admin.ForumController :update +[x] GET /admin/badges Admin.BadgeController :index +[x] GET /admin/badges/:id/edit Admin.BadgeController :edit +[x] GET /admin/badges/new Admin.BadgeController :new +[x] POST /admin/badges Admin.BadgeController :create +[x] PATCH /admin/badges/:id Admin.BadgeController :update +[x] PUT /admin/badges/:id Admin.BadgeController :update +[x] GET /admin/badges/:badge_id/users Admin.Badge.UserController :index +[x] GET /admin/badges/:badge_id/image/edit Admin.Badge.ImageController :edit +[x] PATCH /admin/badges/:badge_id/image Admin.Badge.ImageController :update +[x] PUT /admin/badges/:badge_id/image Admin.Badge.ImageController :update +[x] GET /admin/mod_notes Admin.ModNoteController :index +[x] GET /admin/mod_notes/:id/edit Admin.ModNoteController :edit +[x] GET /admin/mod_notes/new Admin.ModNoteController :new +[x] POST /admin/mod_notes Admin.ModNoteController :create +[x] PATCH /admin/mod_notes/:id Admin.ModNoteController :update +[x] PUT /admin/mod_notes/:id Admin.ModNoteController :update +[x] DELETE /admin/mod_notes/:id Admin.ModNoteController :delete +[x] GET /admin/users Admin.UserController :index +[x] GET /admin/users/:id/edit Admin.UserController :edit +[x] PATCH /admin/users/:id Admin.UserController :update +[x] PUT /admin/users/:id Admin.UserController :update +[x] DELETE /admin/users/:user_id/avatar Admin.User.AvatarController :delete +[x] POST /admin/users/:user_id/activation Admin.User.ActivationController :create +[x] DELETE /admin/users/:user_id/activation Admin.User.ActivationController :delete +[x] POST /admin/users/:user_id/verification Admin.User.VerificationController :create +[x] DELETE /admin/users/:user_id/verification Admin.User.VerificationController :delete +[x] POST /admin/users/:user_id/unlock Admin.User.UnlockController :create +[x] GET /admin/users/:user_id/erase/new Admin.User.EraseController :new +[x] POST /admin/users/:user_id/erase Admin.User.EraseController :create +[x] DELETE /admin/users/:user_id/api_key Admin.User.ApiKeyController :delete +[x] DELETE /admin/users/:user_id/downvotes Admin.User.DownvoteController :delete +[x] DELETE /admin/users/:user_id/votes Admin.User.VoteController :delete +[x] POST /admin/users/:user_id/wipe Admin.User.WipeController :create +[x] GET /admin/users/:user_id/force_filter/new Admin.User.ForceFilterController :new +[x] POST /admin/users/:user_id/force_filter Admin.User.ForceFilterController :create +[x] DELETE /admin/users/:user_id/force_filter Admin.User.ForceFilterController :delete +[x] PATCH /admin/batch/tags Admin.Batch.TagController :update +[x] PUT /admin/batch/tags Admin.Batch.TagController :update +[x] GET /admin/donations/user/:id Admin.Donation.UserController :show +[x] GET /admin/donations Admin.DonationController :index +[x] POST /admin/donations Admin.DonationController :create +[x] POST /duplicate_reports/:duplicate_report_id/accept DuplicateReport.AcceptController :create +[x] POST /duplicate_reports/:duplicate_report_id/accept_reverse DuplicateReport.AcceptReverseController :create +[x] POST /duplicate_reports/:duplicate_report_id/reject DuplicateReport.RejectController :create +[x] POST /duplicate_reports/:duplicate_report_id/claim DuplicateReport.ClaimController :create +[x] DELETE /duplicate_reports/:duplicate_report_id/claim DuplicateReport.ClaimController :delete +[x] GET /tags/:id/edit TagController :edit +[x] PATCH /tags/:id TagController :update +[x] PUT /tags/:id TagController :update +[x] DELETE /tags/:id TagController :delete +[x] GET /tags/:tag_id/image/edit Tag.ImageController :edit +[x] PATCH /tags/:tag_id/image Tag.ImageController :update +[x] PUT /tags/:tag_id/image Tag.ImageController :update +[x] DELETE /tags/:tag_id/image Tag.ImageController :delete +[x] GET /tags/:tag_id/alias/edit Tag.AliasController :edit +[x] PATCH /tags/:tag_id/alias Tag.AliasController :update +[x] PUT /tags/:tag_id/alias Tag.AliasController :update +[x] DELETE /tags/:tag_id/alias Tag.AliasController :delete +[x] POST /tags/:tag_id/reindex Tag.ReindexController :create +[x] POST /tag_changes/revert TagChange.RevertController :create +[x] GET /pages PageController :index +[x] GET /pages/:id/edit PageController :edit +[x] GET /pages/new PageController :new +[x] POST /pages PageController :create +[x] PATCH /pages/:id PageController :update +[x] PUT /pages/:id PageController :update +[x] GET /channels/:id/edit ChannelController :edit +[x] GET /channels/new ChannelController :new +[x] POST /channels ChannelController :create +[x] PATCH /channels/:id ChannelController :update +[x] PUT /channels/:id ChannelController :update +[x] DELETE /channels/:id ChannelController :delete +[x] GET /rules/:id/edit RuleController :edit +[x] GET /rules/new RuleController :new +[x] POST /rules RuleController :create +[x] PATCH /rules/:id RuleController :update +[x] PUT /rules/:id RuleController :update +[x] GET / ActivityController :index +[x] GET /activity ActivityController :index +[x] POST /images/scrape Image.ScrapeController :create +[x] GET /images/random Image.RandomController :index +[x] GET /images ImageController :index +[x] GET /images/new ImageController :new +[x] GET /images/:id ImageController :show +[x] POST /images ImageController :create +[x] GET /images/:image_id/related Image.RelatedController :index +[x] GET /images/:image_id/comments Image.CommentController :index +[x] GET /images/:image_id/comments/:id Image.CommentController :show +[x] POST /images/:image_id/comments Image.CommentController :create +[x] GET /images/:image_id/comments/:comment_id/reports/new Image.Comment.ReportController :new +[x] POST /images/:image_id/comments/:comment_id/reports Image.Comment.ReportController :create +[x] GET /images/:image_id/comments/:comment_id/history Image.Comment.HistoryController :index +[x] PATCH /images/:image_id/tags Image.TagController :update +[x] PUT /images/:image_id/tags Image.TagController :update +[x] PATCH /images/:image_id/sources Image.SourceController :update +[x] PUT /images/:image_id/sources Image.SourceController :update +[x] GET /images/:image_id/source_changes Image.SourceChangeController :index +[x] GET /images/:image_id/tag_changes Image.TagChangeController :index +[x] PATCH /images/:image_id/description Image.DescriptionController :update +[x] PUT /images/:image_id/description Image.DescriptionController :update +[x] GET /images/:image_id/navigate Image.NavigateController :index +[x] GET /images/:image_id/reports/new Image.ReportController :new +[x] POST /images/:image_id/reports Image.ReportController :create +[x] GET /images/:image_id/reporting Image.ReportingController :show +[x] GET /images/:image_id/favorites Image.FavoriteController :index +[x] GET /autocomplete/tags Autocomplete.TagController :show +[x] GET /autocomplete/compiled Autocomplete.CompiledController :show +[x] GET /fetch/tags Fetch.TagController :index +[x] GET /themes ThemeController :index +[x] GET /tags TagController :index +[x] GET /tags/:id TagController :show +[x] GET /tags/:tag_id/tag_changes Tag.TagChangeController :index +[x] GET /tag_changes TagChangeController :index +[x] DELETE /tag_changes/:id TagChangeController :delete +[x] GET /search/reverse Search.ReverseController :index +[x] POST /search/reverse Search.ReverseController :create +[x] GET /search SearchController :index +[x] GET /forums ForumController :index +[x] GET /forums/:id ForumController :show +[x] GET /forums/:forum_id/topics/:id TopicController :show +[x] POST /forums/:forum_id/topics/:topic_id/posts Topic.PostController :create +[x] GET /forums/:forum_id/topics/:topic_id/posts/:post_id/reports/new Topic.Post.ReportController :new +[x] POST /forums/:forum_id/topics/:topic_id/posts/:post_id/reports Topic.Post.ReportController :create +[x] GET /forums/:forum_id/topics/:topic_id/posts/:post_id/history Topic.Post.HistoryController :index +[x] GET /comments CommentController :index +[x] PATCH /filters/current Filter.CurrentController :update +[x] PUT /filters/current Filter.CurrentController :update +[x] DELETE /filters/clear_recent Filter.ClearRecentController :delete +[x] GET /filters FilterController :index +[x] GET /filters/:id/edit FilterController :edit +[x] GET /filters/new FilterController :new +[x] GET /filters/:id FilterController :show +[x] POST /filters FilterController :create +[x] PATCH /filters/:id FilterController :update +[x] PUT /filters/:id FilterController :update +[x] DELETE /filters/:id FilterController :delete +[x] POST /filters/:filter_id/public Filter.PublicController :create +[x] GET /profiles/:id ProfileController :show +[x] GET /profiles/:profile_id/reports/new Profile.ReportController :new +[x] POST /profiles/:profile_id/reports Profile.ReportController :create +[x] GET /profiles/:profile_id/commission Profile.CommissionController :show +[x] GET /profiles/:profile_id/source_changes Profile.SourceChangeController :index +[x] GET /profiles/:profile_id/tag_changes Profile.TagChangeController :index +[x] POST /posts/preview Post.PreviewController :create +[x] GET /posts PostController :index +[x] GET /commissions CommissionController :index +[x] GET /galleries GalleryController :index +[x] GET /galleries/:id GalleryController :show +[x] GET /galleries/:gallery_id/reports/new Gallery.ReportController :new +[x] POST /galleries/:gallery_id/reports Gallery.ReportController :create +[x] GET /adverts/:id AdvertController :show +[x] GET /rules RuleController :index +[x] GET /rules/:id RuleController :show +[x] GET /pages/:id PageController :show +[x] GET /pages/:page_id/history Page.HistoryController :index +[x] GET /dnp DnpEntryController :index +[x] GET /dnp/:id DnpEntryController :show +[x] GET /staff StaffController :index +[x] GET /channels ChannelController :index +[x] GET /channels/:id ChannelController :show +[x] GET /settings/edit SettingController :edit +[x] PATCH /settings SettingController :update +[x] PUT /settings SettingController :update +[x] GET /duplicate_reports DuplicateReportController :index +[x] GET /duplicate_reports/:id DuplicateReportController :show +[x] POST /duplicate_reports DuplicateReportController :create +[x] GET /:id ImageController :show +[x] GET /:forum_id/:id TopicController :show +[x] GET /:forum_id/:id/:page TopicController :show +[x] GET /:forum_id/:id/post/:post_id TopicController :show diff --git a/test/support/concurrent_data_case.ex b/test/support/concurrent_data_case.ex new file mode 100644 index 000000000..8d418a343 --- /dev/null +++ b/test/support/concurrent_data_case.ex @@ -0,0 +1,45 @@ +defmodule Philomena.ConcurrentDataCase do + @moduledoc """ + Test case and helpers for tests that deliberately run database operations in + parallel. + + Concurrent tests use a shared SQL sandbox connection so their worker tasks + can participate in the same test transaction. Ordinary data tests should use + `Philomena.DataCase` instead. + """ + + use ExUnit.CaseTemplate + + using do + quote do + use Philomena.DataCase, async: false + import Philomena.ConcurrentDataCase + end + end + + @doc """ + Runs zero-argument functions concurrently, releasing all workers together. + + Each worker is explicitly allowed to use the test process's sandbox + connection before any worker is released. + """ + def concurrently(functions, timeout \\ 10_000) when is_list(functions) do + parent = self() + + tasks = + Enum.map(functions, fn function -> + task = + Task.async(fn -> + receive do + :go -> function.() + end + end) + + Ecto.Adapters.SQL.Sandbox.allow(Philomena.Repo, parent, task.pid) + task + end) + + Enum.each(tasks, &send(&1.pid, :go)) + Enum.map(tasks, &Task.await(&1, timeout)) + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 8291fdaaf..ed4990998 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,7 +35,7 @@ defmodule PhilomenaWeb.ConnCase do end %Philomena.Filters.Filter{name: "Default", system: true} - |> Philomena.Filters.change_filter() + |> Philomena.Filters.Filter.changeset(nil) |> Philomena.Repo.insert!() fingerprint = to_string(:io_lib.format(~c"d~14.16.0b", [:rand.uniform(2 ** 53)])) @@ -212,13 +212,13 @@ defmodule PhilomenaWeb.ConnCase do It returns an updated `conn`. - `PhilomenaWeb.LimitPlug` rate-limits writes on a Valkey counter keyed by the - current user id, `conn.remote_ip`, and the controller/action - so for - anonymous writes the IP is the only part that varies. The SQL sandbox does - not roll Valkey back, and the counter's TTL (a few seconds to a minute) - outlives a ~6 s suite run, so a fixed address inherits counters from earlier - tests and from earlier runs, and eventually trips the limit. Every anonymous - write test therefore needs its own address. + `Philomena.RateLimiter` rate-limits writes on a Valkey counter keyed per + operation and per acting identity - the actor's user when signed in, otherwise + its IP - so anonymous writes are IP-scoped and the IP is the only part that + varies. The SQL sandbox does not roll Valkey back, and the counter's TTL (a few + seconds to a minute) outlives a ~6 s suite run, so a fixed address inherits + counters from earlier tests and from earlier runs, and eventually trips the + limit. Every anonymous write test therefore needs its own address. """ def put_unique_ip(conn) do n = System.unique_integer([:positive]) diff --git a/test/support/context_boundary_check.ex b/test/support/context_boundary_check.ex new file mode 100644 index 000000000..a4d88cfe0 --- /dev/null +++ b/test/support/context_boundary_check.ex @@ -0,0 +1,323 @@ +defmodule Philomena.ContextBoundaryCheck do + @moduledoc """ + Static architectural checks for controller and domain-context boundaries. + + The check deliberately uses the Elixir AST instead of text matching so + comments, documentation examples, and similarly named modules do not create + false positives. It is used by the context-boundary test in the repository + test suite. + """ + + @excluded_modules [ + Philomena.Application, + Philomena.Attribution, + Philomena.Config, + Philomena.ExqSupervisor, + Philomena.IntegerId, + Philomena.Mailer, + Philomena.Maintenance, + Philomena.Markdown, + Philomena.Multi, + Philomena.Native, + Philomena.Release, + Philomena.Repo, + Philomena.SearchIndexer, + Philomena.SearchMigrator, + Philomena.SearchPolicy, + Philomena.SiteStatistics, + Philomena.Slug + ] + + @type violation :: %{ + file: String.t(), + line: pos_integer(), + message: String.t() + } + + @doc """ + Returns context-boundary violations beneath `root`. + + The check covers every top-level `Philomena` domain module except the + explicitly excluded infrastructure modules, every `Philomena` domain source + for direct Canada calls, and every controller for persistence and request-path + bang-loader calls. + + ## Examples + + iex> ContextBoundaryCheck.violations(File.cwd!()) + [] + + """ + @spec violations(String.t()) :: [violation()] + def violations(root) do + root + |> checks() + |> Enum.sort_by(&{&1.file, &1.line, &1.message}) + end + + defp checks(root) do + context_paths = + root + |> Path.join("lib/philomena/*.ex") + |> Path.wildcard() + |> Enum.reject(&excluded_module?/1) + + domain_paths = Path.wildcard(Path.join(root, "lib/philomena/**/*.ex")) + controller_paths = Path.wildcard(Path.join(root, "lib/philomena_web/controllers/**/*.ex")) + + undocumented_functions(context_paths, root) ++ + forbidden_canada_calls(domain_paths, root) ++ + forbidden_controller_calls(controller_paths, root) + end + + defp undocumented_functions(paths, root) do + Enum.flat_map(paths, fn path -> + path + |> parse!() + |> module_bodies() + |> Enum.flat_map(&undocumented_definitions(&1, relative(path, root))) + end) + end + + defp excluded_module?(path) do + module = + path + |> Path.basename(".ex") + |> Macro.camelize() + |> then(&Module.concat(Philomena, &1)) + + module in @excluded_modules + end + + defp undocumented_definitions(body, file) do + body + |> block_expressions() + |> Enum.reduce( + %{documented: MapSet.new(), pending_doc?: false, violations: []}, + fn + {:@, _meta, [{:doc, _, [_value]}]}, state -> + %{state | pending_doc?: true} + + {:def, meta, arguments}, state -> + key = definition_key(arguments) + + cond do + is_nil(key) -> + %{state | pending_doc?: false} + + state.pending_doc? or MapSet.member?(state.documented, key) -> + %{ + state + | documented: MapSet.put(state.documented, key), + pending_doc?: false + } + + true -> + {name, arity} = key + + violation = %{ + file: file, + line: meta[:line] || 1, + message: "public context function #{name}/#{arity} has no @doc" + } + + %{state | pending_doc?: false, violations: [violation | state.violations]} + end + + {:defp, _meta, _arguments}, state -> + %{state | pending_doc?: false} + + _expression, state -> + state + end + ) + |> Map.fetch!(:violations) + end + + defp forbidden_canada_calls(paths, root) do + Enum.flat_map(paths, &canada_violations(&1, root)) + end + + defp canada_violations(path, root) do + relative_path = relative(path, root) + + if relative_path in [ + "lib/philomena/authorization.ex", + "lib/philomena/users/ability.ex" + ] do + [] + else + ast = parse!(path) + aliases = aliases(ast) + + remote_calls(ast, &canada_violation(&1, &2, &3, aliases, relative_path)) + end + end + + defp canada_violation(module, function, meta, aliases, file) do + if resolve_module(module, aliases) == "Canada.Can" and function == :can? do + %{ + file: file, + line: meta[:line] || 1, + message: "contexts must call Philomena.Authorization.authorize/3" + } + end + end + + defp forbidden_controller_calls(paths, root) do + Enum.flat_map(paths, fn path -> + ast = parse!(path) + aliases = aliases(ast) + relative_path = relative(path, root) + + remote_calls(ast, fn module, function, meta -> + module = resolve_module(module, aliases) + + cond do + repo_module?(module) -> + %{ + file: relative_path, + line: meta[:line] || 1, + message: "controllers must not call Repo directly" + } + + context_bang_loader?(module, function) -> + %{ + file: relative_path, + line: meta[:line] || 1, + message: "controllers must not call bang loaders" + } + + true -> + nil + end + end) + end) + end + + # Paths come only from the fixed context inventory and repository-root + # wildcards assembled by this offline test, never from request input. + # sobelow_skip ["Traversal.FileModule"] + defp parse!(path) do + path + |> File.read!() + |> Code.string_to_quoted!(columns: true, token_metadata: true) + end + + defp module_bodies(ast) do + {_ast, bodies} = + Macro.prewalk(ast, [], fn + {:defmodule, _meta, [_module, [do: body]]} = node, bodies -> + {node, [body | bodies]} + + node, bodies -> + {node, bodies} + end) + + bodies + end + + defp block_expressions({:__block__, _meta, expressions}), do: expressions + defp block_expressions(expression), do: [expression] + + defp definition_key([head | _body]) do + head = + case head do + {:when, _meta, [guarded_head | _guards]} -> guarded_head + head -> head + end + + case head do + {name, _meta, arguments} when is_atom(name) and is_list(arguments) -> + {name, length(arguments)} + + {name, _meta, nil} when is_atom(name) -> + {name, 0} + + _other -> + nil + end + end + + defp definition_key(_arguments), do: nil + + defp aliases(ast) do + {_ast, aliases} = + Macro.prewalk(ast, %{}, fn + {:alias, _meta, + [ + {{:., _, [prefix_ast, :{}]}, _, module_asts} + ]} = node, + aliases -> + prefix = module_name(prefix_ast) + + aliases = + Enum.reduce(module_asts, aliases, fn module_ast, aliases -> + suffix = module_name(module_ast) + Map.put(aliases, short_name(suffix), prefix <> "." <> suffix) + end) + + {node, aliases} + + {:alias, _meta, [module_ast, options]} = node, aliases when is_list(options) -> + module = module_name(module_ast) + alias_name = options |> Keyword.get(:as, module_ast) |> module_name() |> short_name() + {node, Map.put(aliases, alias_name, module)} + + {:alias, _meta, [module_ast]} = node, aliases -> + module = module_name(module_ast) + {node, Map.put(aliases, short_name(module), module)} + + node, aliases -> + {node, aliases} + end) + + aliases + end + + defp remote_calls(ast, callback) do + {_ast, violations} = + Macro.prewalk(ast, [], fn + {{:., dot_meta, [module_ast, function]}, call_meta, arguments} = node, violations + when is_atom(function) and is_list(arguments) -> + case callback.(module_name(module_ast), function, call_meta || dot_meta) do + nil -> {node, violations} + violation -> {node, [violation | violations]} + end + + node, violations -> + {node, violations} + end) + + violations + end + + defp module_name({:__aliases__, _meta, parts}), do: Enum.map_join(parts, ".", &to_string/1) + defp module_name(atom) when is_atom(atom), do: Atom.to_string(atom) + defp module_name(_other), do: nil + + defp resolve_module(nil, _aliases), do: nil + + defp resolve_module(module, aliases) do + case String.split(module, ".", parts: 2) do + [first, rest] -> Map.get(aliases, first, first) <> "." <> rest + [first] -> Map.get(aliases, first, first) + end + end + + defp short_name(nil), do: nil + defp short_name(module), do: module |> String.split(".") |> List.last() + + defp repo_module?(nil), do: false + defp repo_module?(module), do: short_name(module) == "Repo" + + defp context_bang_loader?(module, function) do + function = Atom.to_string(function) + + is_binary(module) and String.starts_with?(module, "Philomena.") and + String.ends_with?(function, "!") and + String.match?(function, ~r/^(fetch|get|load)/) + end + + defp relative(path, root), do: Path.relative_to(path, root) +end diff --git a/test/support/fixtures/adverts_fixtures.ex b/test/support/fixtures/adverts_fixtures.ex index db7ff5099..336953879 100644 --- a/test/support/fixtures/adverts_fixtures.ex +++ b/test/support/fixtures/adverts_fixtures.ex @@ -6,6 +6,7 @@ defmodule Philomena.AdvertsFixtures do alias Philomena.Adverts.Advert alias Philomena.Repo + alias PhilomenaMedia.Upload @doc """ Creates an advert. @@ -60,4 +61,12 @@ defmodule Philomena.AdvertsFixtures do File.cp!(@undersized_png_fixture, path) %Plug.Upload{path: path, filename: "small.png", content_type: "image/png"} end + + def media_png_upload do + Upload.from_plug(png_upload()) + end + + def media_undersized_png_upload do + Upload.from_plug(undersized_png_upload()) + end end diff --git a/test/support/fixtures/artist_links_fixtures.ex b/test/support/fixtures/artist_links_fixtures.ex index 999cd77be..c05aa703a 100644 --- a/test/support/fixtures/artist_links_fixtures.ex +++ b/test/support/fixtures/artist_links_fixtures.ex @@ -4,7 +4,11 @@ defmodule Philomena.ArtistLinksFixtures do entities via the `Philomena.ArtistLinks` context. """ - alias Philomena.ArtistLinks + import Ecto.Query + + alias Philomena.{ArtistLinks, AttributionFixtures, ModerationLogs.ModerationLog, Repo} + alias Philomena.ModerationLogs.Paths + alias Philomena.UsersFixtures @doc """ Creates an unverified artist link for `user` pointing at `tag` (which must @@ -22,18 +26,37 @@ defmodule Philomena.ArtistLinksFixtures do "uri" => "https://example.com/artist#{System.unique_integer([:positive])}" }) - {:ok, artist_link} = ArtistLinks.create_artist_link(user, attrs) + {:ok, {_user, artist_link}} = + ArtistLinks.create_artist_link(AttributionFixtures.actor(user), user.slug, attrs) + artist_link end @doc """ Creates an artist link for `user`/`tag` and transitions it to the verified - state (attributed to `user`, the way an admin verifying it would be - recorded). The badge awarder tolerates the missing "Artist" badge in tests. + state through the moderator-facing context API. The badge awarder tolerates + the missing "Artist" badge in tests. """ def verified_artist_link_fixture(user, tag, attrs \\ %{}) do artist_link = artist_link_fixture(user, tag, attrs) - {:ok, artist_link} = ArtistLinks.verify_artist_link(artist_link, user) + moderator = UsersFixtures.moderator_user_fixture() + + {:ok, artist_link} = + ArtistLinks.create_artist_link_verification( + AttributionFixtures.actor(moderator), + artist_link.id + ) + + subject_path = Paths.artist_link_path(user, artist_link) + + Repo.delete_all( + from(log in ModerationLog, + where: + log.user_id == ^moderator.id and + log.subject_path == ^subject_path + ) + ) + artist_link end end diff --git a/test/support/fixtures/attribution_fixtures.ex b/test/support/fixtures/attribution_fixtures.ex index d42b72c29..e9bf1253d 100644 --- a/test/support/fixtures/attribution_fixtures.ex +++ b/test/support/fixtures/attribution_fixtures.ex @@ -9,6 +9,8 @@ defmodule Philomena.AttributionFixtures do which hardcodes the same values on the image row). """ + alias Philomena.Attribution.Actor + @doc """ Attribution keyword list for the given `user` (`nil` for anonymous). """ @@ -20,6 +22,38 @@ defmodule Philomena.AttributionFixtures do ] end + @doc """ + The same attribution as `attribution/1`, as the typed + `Philomena.Attribution.Actor` struct that actor-first context functions + take. + + The `:ban` and `:fingerprint` options override those struct fields. The ban + defaults to nil, matching how `PhilomenaWeb.UserAttributionPlug` builds the + actor when the request carries no active ban. + """ + def actor(user \\ nil, opts \\ []) do + attrs = attribution(user) + + %Philomena.Attribution.Actor{ + ip: Keyword.get(opts, :ip, attrs[:ip]), + fingerprint: Keyword.get(opts, :fingerprint, attrs[:fingerprint]), + user: attrs[:user], + ban: Keyword.get(opts, :ban) + } + end + + @doc """ + Generates a random IP address for use in fixtures which track rate limiting. + """ + def random_ip do + ip = List.to_tuple(for <>, do: u) + + %Postgrex.INET{ + address: ip, + netmask: 128 + } + end + @doc """ Clears the Valkey tag-change rate-limit counters for the given attribution. @@ -51,4 +85,50 @@ defmodule Philomena.AttributionFixtures do Redix.command!(:redix, ["DEL" | keys]) :ok end + + @doc """ + The Valkey counter key `Philomena.RateLimiter` scopes to `actor` for + `operation` - `u:` when signed in, `i:` when anonymous (the same + scheme `Philomena.RateLimiter.key/2` uses privately). + """ + def rate_limit_key(%Actor{user: nil, ip: ip}, operation), do: "rl:#{operation}:i:#{ip}" + def rate_limit_key(%Actor{user: user}, operation), do: "rl:#{operation}:u:#{user.id}" + + @doc """ + Reads `actor`'s raw `Philomena.RateLimiter` counter for `operation` from + Valkey (a decimal string, or `nil` when nothing has been recorded). + """ + def rate_limit_count(%Actor{} = actor, operation) do + Redix.command!(:redix, ["GET", rate_limit_key(actor, operation)]) + end + + @doc """ + Registers `on_exit` cleanup that deletes `actor`'s `Philomena.RateLimiter` + counter for `operation`. + + The SQL sandbox does not roll Valkey back, so any test that lets a counter be + recorded (or primes one itself) must clear it or it leaks into later tests and + runs. Use this when the function under test records the counter for you; use + `exceed_rate_limit/2` when you need to prime it over the limit. + """ + def track_rate_limit(%Actor{} = actor, operation) do + key = rate_limit_key(actor, operation) + ExUnit.Callbacks.on_exit(fn -> Redix.command!(:redix, ["DEL", key]) end) + :ok + end + + @doc """ + Primes `actor`'s `Philomena.RateLimiter` counter for `operation` past the + limit so the next `record_action/3` refuses it, and registers `on_exit` + cleanup of the key. + + The check boundary is inclusive at a limit of 1, so a counter of 2 is over the + limit. This only makes sense for a non-exempt actor (a plain user or an + anonymous IP); staff and `bypass_rate_limits` users are never limited. + """ + def exceed_rate_limit(%Actor{} = actor, operation) do + track_rate_limit(actor, operation) + Redix.command!(:redix, ["SET", rate_limit_key(actor, operation), "2"]) + :ok + end end diff --git a/test/support/fixtures/autocomplete_fixtures.ex b/test/support/fixtures/autocomplete_fixtures.ex index a90f387c5..a4a52d730 100644 --- a/test/support/fixtures/autocomplete_fixtures.ex +++ b/test/support/fixtures/autocomplete_fixtures.ex @@ -11,9 +11,8 @@ defmodule Philomena.AutocompleteFixtures do Inserts an autocomplete row with the given binary `content`. `Autocomplete.generate_autocomplete!/0` builds a real binary from the tag - table but does not return the row; controller tests only need a row present - whose bytes they can compare against, so this inserts one directly the way - the changeset would. + table; controller tests only need a row present whose bytes they can compare + against, so this inserts one directly the way the changeset would. """ def autocomplete_fixture(content \\ <<0, 1, 2, 3>>) do %Autocomplete{} diff --git a/test/support/fixtures/badges_fixtures.ex b/test/support/fixtures/badges_fixtures.ex index d5ffdc202..3bb0a0671 100644 --- a/test/support/fixtures/badges_fixtures.ex +++ b/test/support/fixtures/badges_fixtures.ex @@ -4,9 +4,10 @@ defmodule Philomena.BadgesFixtures do entities via the `Philomena.Badges` context. """ - alias Philomena.Badges + alias Philomena.Badges.Award alias Philomena.Badges.Badge alias Philomena.Repo + alias PhilomenaMedia.Upload def unique_badge_title, do: "Test Badge #{System.unique_integer([:positive])}" @@ -31,10 +32,9 @@ defmodule Philomena.BadgesFixtures do def badge_award_fixture(creator, user, badge \\ nil, attrs \\ %{}) do badge = badge || badge_fixture() - {:ok, award} = - Badges.create_badge_award(creator, user, Enum.into(attrs, %{badge_id: badge.id})) - - award + %Award{awarded_by_id: creator.id, user_id: user.id} + |> Award.changeset(Enum.into(attrs, %{badge_id: badge.id})) + |> Repo.insert!() end @svg_fixture Path.absname("test/support/fixtures/files/badge-test.svg") @@ -50,4 +50,8 @@ defmodule Philomena.BadgesFixtures do File.cp!(@svg_fixture, path) %Plug.Upload{path: path, filename: "badge.svg", content_type: "image/svg+xml"} end + + def media_svg_upload do + Upload.from_plug(svg_upload()) + end end diff --git a/test/support/fixtures/bans_fixtures.ex b/test/support/fixtures/bans_fixtures.ex index 00f381431..ef14b2c51 100644 --- a/test/support/fixtures/bans_fixtures.ex +++ b/test/support/fixtures/bans_fixtures.ex @@ -1,14 +1,16 @@ defmodule Philomena.BansFixtures do @moduledoc """ This module defines test helpers for creating - entities via the `Philomena.Bans` context. + ban rows for context tests without retaining moderation-log side effects. """ alias Philomena.Bans + alias Philomena.ModerationLogs.ModerationLog + alias Philomena.Repo - # Bans.create_* need a creator, the ban target, a reason, and a valid_until - # (a RelativeDate - a plain %DateTime{} casts fine). The user ban's automatic - # subnet ban is skipped in tests (no user_ips rows). + # Context writes create moderation logs. Fixtures remove only the log coupled + # to their own insert so tests can assert the exact count produced by the + # operation under test while still exercising the public context boundary. @doc """ Creates a user ban against `target` (a fresh `confirmed_user_fixture/0` @@ -17,16 +19,19 @@ defmodule Philomena.BansFixtures do def user_ban_fixture(target \\ nil, attrs \\ %{}) do target = target || Philomena.UsersFixtures.confirmed_user_fixture() + creator = Philomena.UsersFixtures.admin_user_fixture() + {:ok, ban} = - Bans.create_user( - Philomena.UsersFixtures.admin_user_fixture(), + Bans.create_user_ban( + Philomena.AttributionFixtures.actor(creator), + target.id, Enum.into(attrs, %{ - "user_id" => target.id, "reason" => "Test ban reason", "valid_until" => DateTime.add(DateTime.utc_now(:second), 365, :day) }) ) + delete_creation_log!(creator.id, "Admin.UserBan:create", ban.generated_ban_id) ban end @@ -34,9 +39,11 @@ defmodule Philomena.BansFixtures do Creates a subnet ban, created by a fresh admin. """ def subnet_ban_fixture(attrs \\ %{}) do + creator = Philomena.UsersFixtures.admin_user_fixture() + {:ok, ban} = - Bans.create_subnet( - Philomena.UsersFixtures.admin_user_fixture(), + Bans.create_subnet_ban( + Philomena.AttributionFixtures.actor(creator), Enum.into(attrs, %{ "specification" => "203.0.113.0/24", "reason" => "Test subnet reason", @@ -44,6 +51,7 @@ defmodule Philomena.BansFixtures do }) ) + delete_creation_log!(creator.id, "Admin.SubnetBan:create", ban.generated_ban_id) ban end @@ -51,16 +59,31 @@ defmodule Philomena.BansFixtures do Creates a fingerprint ban, created by a fresh admin. """ def fingerprint_ban_fixture(attrs \\ %{}) do + creator = Philomena.UsersFixtures.admin_user_fixture() + {:ok, ban} = - Bans.create_fingerprint( - Philomena.UsersFixtures.admin_user_fixture(), + Bans.create_fingerprint_ban( + Philomena.AttributionFixtures.actor(creator), Enum.into(attrs, %{ - "fingerprint" => "c1836fd10ff8f27a", + "fingerprint" => "d015c342859dde3", "reason" => "Test fingerprint reason", "valid_until" => DateTime.add(DateTime.utc_now(:second), 365, :day) }) ) + delete_creation_log!(creator.id, "Admin.FingerprintBan:create", ban.generated_ban_id) ban end + + defp delete_creation_log!(creator_id, type, generated_ban_id) do + body = "Created a #{ban_kind(type)} ban #{generated_ban_id}" + + ModerationLog + |> Repo.get_by!(user_id: creator_id, type: type, body: body) + |> Repo.delete!() + end + + defp ban_kind("Admin.UserBan:create"), do: "user" + defp ban_kind("Admin.SubnetBan:create"), do: "subnet" + defp ban_kind("Admin.FingerprintBan:create"), do: "fingerprint" end diff --git a/test/support/fixtures/channels_fixtures.ex b/test/support/fixtures/channels_fixtures.ex index 7a7414c8f..63de0c65b 100644 --- a/test/support/fixtures/channels_fixtures.ex +++ b/test/support/fixtures/channels_fixtures.ex @@ -4,6 +4,9 @@ defmodule Philomena.ChannelsFixtures do entities via the `Philomena.Channels` context. """ + import Philomena.AttributionFixtures, only: [actor: 1] + import Philomena.UsersFixtures, only: [moderator_user_fixture: 0] + alias Philomena.Channels def unique_channel_short_name, do: "test_channel_#{System.unique_integer([:positive])}" @@ -21,7 +24,28 @@ defmodule Philomena.ChannelsFixtures do "short_name" => unique_channel_short_name() }) - {:ok, channel} = Channels.create_channel(attrs) + {:ok, channel} = Channels.create_channel(actor(moderator_user_fixture()), attrs) + + channel + end + + @doc """ + Creates a channel the fetcher has stamped, so it appears on the livestreams + index (`Channels.load_channels/4` lists only channels with `last_fetched_at` + set). + + `create_attrs` are string-keyed the way the admin channel controller submits + them (`"type"`, `"short_name"`, `"artist_tag"`). `state_attrs` are the atom-keyed + fetcher-managed fields (`:title`, `:is_live`, `:nsfw`, `:viewers`, + `:thumbnail_url`, `:last_fetched_at`); `:last_fetched_at` defaults to now. + """ + def listed_channel_fixture(create_attrs \\ %{}, state_attrs \\ %{}) do + state_attrs = Enum.into(state_attrs, %{last_fetched_at: DateTime.utc_now(:second)}) + + {:ok, channel} = + create_attrs + |> channel_fixture() + |> Channels.update_fetch_state(state_attrs) channel end diff --git a/test/support/fixtures/comments_fixtures.ex b/test/support/fixtures/comments_fixtures.ex index 436ad8d0e..88509b217 100644 --- a/test/support/fixtures/comments_fixtures.ex +++ b/test/support/fixtures/comments_fixtures.ex @@ -15,7 +15,7 @@ defmodule Philomena.CommentsFixtures do def comment_fixture(image, user \\ nil, attrs \\ %{}) do attrs = Enum.into(attrs, %{"body" => "Test comment body"}) - {:ok, %{comment: comment}} = Comments.create_comment(image, attribution(user), attrs) + {:ok, comment} = Comments.create_comment(actor(user, ip: random_ip()), image.id, attrs) comment end diff --git a/test/support/fixtures/commissions_fixtures.ex b/test/support/fixtures/commissions_fixtures.ex index cd1327d8d..967b31346 100644 --- a/test/support/fixtures/commissions_fixtures.ex +++ b/test/support/fixtures/commissions_fixtures.ex @@ -1,10 +1,15 @@ defmodule Philomena.CommissionsFixtures do @moduledoc """ - This module defines test helpers for creating - entities via the `Philomena.Commissions` context. + Test-only commission builders. They persist schemas directly because the + production context intentionally exposes only actor-scoped APIs, whose + verified-link policy is unrelated to most fixtures using commission data. """ - alias Philomena.Commissions + import Ecto.Query + + alias Philomena.Commissions.Commission + alias Philomena.Commissions.Item + alias Philomena.Repo @doc """ Creates an open commission sheet for `user`. @@ -18,9 +23,10 @@ defmodule Philomena.CommissionsFixtures do open: true }) - {:ok, commission} = Commissions.create_commission(user, attrs) - - commission + user + |> Ecto.build_assoc(:commission) + |> Commission.changeset(attrs) + |> Repo.insert!() end @doc """ @@ -35,8 +41,23 @@ defmodule Philomena.CommissionsFixtures do base_price: 20 }) - {:ok, %{item: item}} = Commissions.create_item(commission, attrs) + Repo.transaction(fn -> + item = + commission + |> Ecto.build_assoc(:items) + |> Item.changeset(attrs) + |> Repo.insert!() + + {1, _rows} = + Repo.update_all( + where(Commission, id: ^commission.id), + inc: [commission_items_count: 1] + ) - item + item + end) + |> case do + {:ok, item} -> item + end end end diff --git a/test/support/fixtures/conversations_fixtures.ex b/test/support/fixtures/conversations_fixtures.ex index 3875a248a..3b89d8249 100644 --- a/test/support/fixtures/conversations_fixtures.ex +++ b/test/support/fixtures/conversations_fixtures.ex @@ -1,10 +1,14 @@ defmodule Philomena.ConversationsFixtures do @moduledoc """ - This module defines test helpers for creating - entities via the `Philomena.Conversations` context. + Test-only conversation builders. They persist schemas directly because the + production context intentionally exposes only actor-scoped request APIs. """ - alias Philomena.Conversations + alias Philomena.Conversations.Conversation + alias Philomena.Conversations.Message + alias Philomena.Multi + alias Philomena.Repo + alias Philomena.Reports def unique_conversation_title, do: "Test Conversation #{System.unique_integer([:positive])}" @@ -25,7 +29,12 @@ defmodule Philomena.ConversationsFixtures do "messages" => %{"0" => %{"body" => "Test message body"}} }) - {:ok, conversation} = Conversations.create_conversation(from, attrs) + conversation = + %Conversation{recipient: to.name} + |> Conversation.creation_changeset(from, to, attrs) + |> Repo.insert!() + + report_non_approved_message(List.first(conversation.messages)) conversation end @@ -36,8 +45,39 @@ defmodule Philomena.ConversationsFixtures do def message_fixture(conversation, user, attrs \\ %{}) do attrs = Enum.into(attrs, %{"body" => "Test reply body"}) - {:ok, message} = Conversations.create_message(conversation, user, attrs) + {:ok, message} = + Repo.transaction(fn -> + message = + conversation + |> Ecto.build_assoc(:messages) + |> Message.creation_changeset(attrs, user) + |> Repo.insert!() + + conversation + |> Conversation.new_message_changeset() + |> Repo.update!() + + message + end) + + report_non_approved_message(message) message end + + defp report_non_approved_message(nil), do: :ok + defp report_non_approved_message(%Message{approved: true}), do: :ok + + defp report_non_approved_message(%Message{} = message) do + Multi.new() + |> Reports.put_create_system_report( + "Approval", + "PM contains externally-embedded images", + :conversation_id, + message.conversation_id + ) + |> Multi.transact() + + :ok + end end diff --git a/test/support/fixtures/dnp_entries_fixtures.ex b/test/support/fixtures/dnp_entries_fixtures.ex index 65af7b5b7..a2daba77e 100644 --- a/test/support/fixtures/dnp_entries_fixtures.ex +++ b/test/support/fixtures/dnp_entries_fixtures.ex @@ -4,7 +4,8 @@ defmodule Philomena.DnpEntriesFixtures do entities via the `Philomena.DnpEntries` context. """ - alias Philomena.DnpEntries + alias Philomena.DnpEntries.DnpEntry + alias Philomena.Repo @doc """ Creates a DNP entry for `tag`, requested by `user`. Starts out in the @@ -26,15 +27,19 @@ defmodule Philomena.DnpEntriesFixtures do "conditions" => "Test DNP conditions" }) - {:ok, dnp_entry} = DnpEntries.create_dnp_entry(user, [tag], attrs) + dnp_entry = + %DnpEntry{} + |> DnpEntry.creation_changeset(attrs, user, [tag.id]) + |> Repo.insert!() case state do nil -> dnp_entry state -> - {:ok, dnp_entry} = DnpEntries.transition_dnp_entry(dnp_entry, user, state) dnp_entry + |> DnpEntry.transition_changeset(user, state) + |> Repo.update!() end end end diff --git a/test/support/fixtures/donations_fixtures.ex b/test/support/fixtures/donations_fixtures.ex index ef106f321..90170d679 100644 --- a/test/support/fixtures/donations_fixtures.ex +++ b/test/support/fixtures/donations_fixtures.ex @@ -5,6 +5,8 @@ defmodule Philomena.DonationsFixtures do """ alias Philomena.Donations + alias Philomena.AttributionFixtures + alias Philomena.UsersFixtures @doc """ Creates a donation, optionally attributed to `user` (the schema allows a @@ -22,7 +24,8 @@ defmodule Philomena.DonationsFixtures do }) |> maybe_put_user(user) - {:ok, donation} = Donations.create_donation(attrs) + actor = AttributionFixtures.actor(UsersFixtures.admin_user_fixture()) + {:ok, donation} = Donations.create_donation(actor, attrs) donation end diff --git a/test/support/fixtures/duplicate_reports_fixtures.ex b/test/support/fixtures/duplicate_reports_fixtures.ex index dc815f50a..7101abf5c 100644 --- a/test/support/fixtures/duplicate_reports_fixtures.ex +++ b/test/support/fixtures/duplicate_reports_fixtures.ex @@ -5,6 +5,7 @@ defmodule Philomena.DuplicateReportsFixtures do """ alias Philomena.DuplicateReports + alias Philomena.AttributionFixtures @doc """ Creates an open duplicate report claiming `source` duplicates `target`. @@ -17,7 +18,12 @@ defmodule Philomena.DuplicateReportsFixtures do attrs = Enum.into(attrs, %{"reason" => "These look identical"}) {:ok, duplicate_report} = - DuplicateReports.create_duplicate_report(source, target, %{user: user}, attrs) + DuplicateReports.create_duplicate_report( + AttributionFixtures.actor(user), + source.id, + target.id, + attrs + ) duplicate_report end diff --git a/test/support/fixtures/filters_fixtures.ex b/test/support/fixtures/filters_fixtures.ex index c9fea926c..effe880b8 100644 --- a/test/support/fixtures/filters_fixtures.ex +++ b/test/support/fixtures/filters_fixtures.ex @@ -15,7 +15,10 @@ defmodule Philomena.FiltersFixtures do """ def filter_fixture(user, attrs \\ %{}) do {:ok, filter} = - Filters.create_filter(user, Enum.into(attrs, %{name: unique_filter_name()})) + Filters.create_filter( + Philomena.AttributionFixtures.actor(user), + Enum.into(attrs, %{name: unique_filter_name()}) + ) filter end @@ -30,7 +33,7 @@ defmodule Philomena.FiltersFixtures do def system_filter_fixture(attrs \\ %{}) do %Filter{system: true} |> struct!(Enum.into(attrs, %{name: unique_filter_name()})) - |> Filters.change_filter() + |> Filter.changeset(nil) |> Repo.insert!() end end diff --git a/test/support/fixtures/forums_fixtures.ex b/test/support/fixtures/forums_fixtures.ex index c510398d6..725b788ef 100644 --- a/test/support/fixtures/forums_fixtures.ex +++ b/test/support/fixtures/forums_fixtures.ex @@ -4,6 +4,9 @@ defmodule Philomena.ForumsFixtures do entities via the `Philomena.Forums` context. """ + import Philomena.AttributionFixtures + import Philomena.UsersFixtures + alias Philomena.Forums @doc """ @@ -23,7 +26,7 @@ defmodule Philomena.ForumsFixtures do end def forum_fixture(attrs \\ %{}) do - {:ok, forum} = + attrs = attrs |> Enum.into(%{ name: "Test Forum", @@ -31,7 +34,8 @@ defmodule Philomena.ForumsFixtures do description: "A forum for testing", access_level: "normal" }) - |> Forums.create_forum() + + {:ok, forum} = Forums.create_forum(actor(admin_user_fixture()), attrs) forum end diff --git a/test/support/fixtures/galleries_fixtures.ex b/test/support/fixtures/galleries_fixtures.ex index ba611a667..4257cec63 100644 --- a/test/support/fixtures/galleries_fixtures.ex +++ b/test/support/fixtures/galleries_fixtures.ex @@ -5,6 +5,8 @@ defmodule Philomena.GalleriesFixtures do """ alias Philomena.Galleries + alias Philomena.Repo + alias Philomena.Users.User def unique_gallery_title, do: "Test Gallery #{System.unique_integer([:positive])}" @@ -20,8 +22,24 @@ defmodule Philomena.GalleriesFixtures do |> Enum.into(%{title: unique_gallery_title()}) |> Map.put_new_lazy(:thumbnail_id, fn -> Philomena.ImagesFixtures.image_fixture().id end) - {:ok, gallery} = Galleries.create_gallery(user, attrs) + {:ok, gallery} = Galleries.create_gallery(Philomena.AttributionFixtures.actor(user), attrs) gallery end + + @doc """ + Adds `image` to `gallery` through the owner-authorized context boundary. + """ + def gallery_image_fixture(gallery, image) do + user = Repo.get!(User, gallery.user_id) + + {:ok, result} = + Galleries.create_gallery_image( + Philomena.AttributionFixtures.actor(user), + gallery.id, + image.id + ) + + result + end end diff --git a/test/support/fixtures/images_fixtures.ex b/test/support/fixtures/images_fixtures.ex index 26d9e8c49..14059c906 100644 --- a/test/support/fixtures/images_fixtures.ex +++ b/test/support/fixtures/images_fixtures.ex @@ -14,7 +14,8 @@ defmodule Philomena.ImagesFixtures do alias Philomena.Images.Image alias Philomena.Images.Source alias Philomena.Repo - alias Philomena.Tags + alias Philomena.TagsFixtures + alias PhilomenaMedia.Upload alias PhilomenaMedia.Sha512 @doc """ @@ -24,7 +25,7 @@ defmodule Philomena.ImagesFixtures do separately: * `tags:` a tag list string (default `"safe"`), created on demand via - `Tags.get_or_create_tags/1` + `TagsFixtures.tag_list_fixture/1` * `sources:` a list of source URL strings (default `[]`) """ def image_fixture(attrs \\ %{}) do @@ -59,7 +60,7 @@ defmodule Philomena.ImagesFixtures do %Image{} |> change(Map.merge(defaults, attrs)) - |> put_assoc(:tags, Tags.get_or_create_tags(tag_input)) + |> put_assoc(:tags, TagsFixtures.tag_list_fixture(tag_input)) |> put_assoc(:sources, Enum.map(sources, &%Source{source: &1})) |> Repo.insert!() end @@ -79,6 +80,10 @@ defmodule Philomena.ImagesFixtures do %Plug.Upload{path: path, content_type: "image/png", filename: "upload-test.png"} end + def media_png_upload do + Upload.from_plug(png_upload()) + end + @doc """ The SHA-512 hash `analyze_upload` computes for `png_upload/0`'s file - i.e. the value an image's `image_orig_sha512_hash`/`image_sha512_hash` takes on diff --git a/test/support/fixtures/mod_notes_fixtures.ex b/test/support/fixtures/mod_notes_fixtures.ex index d4717060e..0093539c0 100644 --- a/test/support/fixtures/mod_notes_fixtures.ex +++ b/test/support/fixtures/mod_notes_fixtures.ex @@ -4,10 +4,12 @@ defmodule Philomena.ModNotesFixtures do entities via the `Philomena.ModNotes` context. """ + import Philomena.AttributionFixtures + alias Philomena.ModNotes @doc """ - Creates a mod note authored by `author` against a fresh + Creates a mod note authored by `author` (a `User.t()`) against a fresh `confirmed_user_fixture/0`. Notes are created through the context so `moderator_id` is set to the passed @@ -17,11 +19,21 @@ defmodule Philomena.ModNotesFixtures do def mod_note_fixture(author, attrs \\ %{}) do target = Philomena.UsersFixtures.confirmed_user_fixture() + mod_note_fixture_for(author, Enum.into(attrs, %{"user_id" => target.id})) + end + + @doc """ + Creates a mod note authored by `author` (a `User.t()`) with the given `attrs`. + + Notes are created through the context so `moderator_id` is set to the passed + `author`; the edit/update/delete abilities are scoped to that id (a + moderator may only touch their own notes, admins may touch any). + """ + def mod_note_fixture_for(author, attrs \\ %{}) do {:ok, note} = ModNotes.create_mod_note( - author, - Enum.into(attrs, %{"body" => "Keeping an eye on this one"}), - user_id: target.id + actor(author), + Enum.into(attrs, %{"body" => "Keeping an eye on this one"}) ) note diff --git a/test/support/fixtures/posts_fixtures.ex b/test/support/fixtures/posts_fixtures.ex index b36849257..6362f603c 100644 --- a/test/support/fixtures/posts_fixtures.ex +++ b/test/support/fixtures/posts_fixtures.ex @@ -5,7 +5,9 @@ defmodule Philomena.PostsFixtures do """ import Philomena.AttributionFixtures + import Philomena.UsersFixtures + alias Philomena.Repo alias Philomena.Posts @doc """ @@ -14,8 +16,22 @@ defmodule Philomena.PostsFixtures do """ def post_fixture(topic, user \\ nil, attrs \\ %{}) do attrs = Enum.into(attrs, %{"body" => "Test post body"}) + topic = Repo.preload(topic, :forum) - {:ok, %{post: post}} = Posts.create_post(topic, attribution(user), attrs) + {actor_user, attrs} = + if user do + {user, attrs} + else + {admin_user_fixture(), Map.put(attrs, "anonymous", "true")} + end + + {:ok, post} = + Posts.create_post( + actor(actor_user, ip: random_ip()), + topic.forum.short_name, + topic.slug, + attrs + ) post end diff --git a/test/support/fixtures/reports_fixtures.ex b/test/support/fixtures/reports_fixtures.ex index 123635a87..6190ae81d 100644 --- a/test/support/fixtures/reports_fixtures.ex +++ b/test/support/fixtures/reports_fixtures.ex @@ -7,6 +7,12 @@ defmodule Philomena.ReportsFixtures do import Philomena.AttributionFixtures alias Philomena.Reports + alias Philomena.Comments.Comment + alias Philomena.Commissions.Commission + alias Philomena.Conversations.Conversation + alias Philomena.Posts.Post + alias Philomena.Repo + alias Philomena.Users.User @doc """ Creates a report against the target named by `target`, a one-entry keyword @@ -25,8 +31,34 @@ defmodule Philomena.ReportsFixtures do }) |> Map.put_new_lazy("rule_id", fn -> Philomena.RulesFixtures.rule_fixture().id end) - {:ok, report} = Reports.create_report(attribution(user), attrs, target) + {:ok, report} = Reports.create_report(actor(user), target_locator(target), attrs) report end + + defp target_locator(image_id: id), do: {:image, id} + defp target_locator(gallery_id: id), do: {:gallery, id} + + defp target_locator(reported_user_id: id) do + {:user, Repo.get!(User, id).slug} + end + + defp target_locator(commission_id: id) do + commission = Repo.get!(Commission, id) |> Repo.preload(:user) + {:commission, commission.user.slug} + end + + defp target_locator(conversation_id: id) do + {:conversation, Repo.get!(Conversation, id).slug} + end + + defp target_locator(comment_id: id) do + comment = Repo.get!(Comment, id) + {:comment, comment.image_id, id} + end + + defp target_locator(post_id: id) do + post = Repo.get!(Post, id) |> Repo.preload(topic: :forum) + {:post, post.topic.forum.short_name, post.topic.slug, id} + end end diff --git a/test/support/fixtures/rules_fixtures.ex b/test/support/fixtures/rules_fixtures.ex index 3fa34edf9..581c37e1e 100644 --- a/test/support/fixtures/rules_fixtures.ex +++ b/test/support/fixtures/rules_fixtures.ex @@ -5,9 +5,11 @@ defmodule Philomena.RulesFixtures do """ alias Philomena.Rules + alias Philomena.AttributionFixtures + alias Philomena.UsersFixtures @doc """ - Creates a rule (with its initial system-attributed version). + Creates a rule with an initial version. Positions are unique because rules derive `Phoenix.Param` from `:position`, so duplicate positions would make routes ambiguous. @@ -21,7 +23,8 @@ defmodule Philomena.RulesFixtures do position: unique }) - {:ok, [rule, _version]} = Rules.create_rule_with_version(attrs, nil) + actor = AttributionFixtures.actor(UsersFixtures.admin_user_fixture()) + {:ok, [rule, _version]} = Rules.create_rule(actor, attrs) rule end diff --git a/test/support/fixtures/site_notices_fixtures.ex b/test/support/fixtures/site_notices_fixtures.ex index df4ed6145..83e3eefbc 100644 --- a/test/support/fixtures/site_notices_fixtures.ex +++ b/test/support/fixtures/site_notices_fixtures.ex @@ -13,7 +13,7 @@ defmodule Philomena.SiteNoticesFixtures do def site_notice_fixture(attrs \\ %{}) do {:ok, notice} = SiteNotices.create_site_notice( - Philomena.UsersFixtures.admin_user_fixture(), + Philomena.AttributionFixtures.actor(Philomena.UsersFixtures.admin_user_fixture()), Enum.into(attrs, %{ "title" => "Scheduled maintenance", "text" => "The site will be down.", diff --git a/test/support/fixtures/static_pages_fixtures.ex b/test/support/fixtures/static_pages_fixtures.ex index d0c6c1db9..c876ec9be 100644 --- a/test/support/fixtures/static_pages_fixtures.ex +++ b/test/support/fixtures/static_pages_fixtures.ex @@ -1,18 +1,20 @@ defmodule Philomena.StaticPagesFixtures do @moduledoc """ This module defines test helpers for creating - entities via the `Philomena.StaticPages` context. + static page and initial-version rows for tests. """ - alias Philomena.StaticPages + alias Philomena.Multi + alias Philomena.StaticPages.StaticPage + alias Philomena.StaticPages.Version def unique_static_page_slug, do: "test-page-#{System.unique_integer([:positive])}" @doc """ Creates a static page (with its initial version, attributed to `user`). - `StaticPages.create_static_page/2` requires a user for the version row, so - one must be provided. + The direct persistence is deliberate fixture infrastructure. Production + callers use the actor-scoped `Philomena.StaticPages.create_page/2` workflow. """ def static_page_fixture(user, attrs \\ %{}) do unique = System.unique_integer([:positive]) @@ -24,7 +26,14 @@ defmodule Philomena.StaticPagesFixtures do body: "Test page body" }) - {:ok, %{static_page: static_page}} = StaticPages.create_static_page(user, attrs) + {:ok, %{static_page: static_page}} = + Multi.new() + |> Multi.insert(:static_page, StaticPage.changeset(%StaticPage{}, attrs)) + |> Multi.insert(:version, fn %{static_page: static_page} -> + %Version{static_page_id: static_page.id, user_id: user.id} + |> Version.changeset(attrs) + end) + |> Multi.transact() static_page end diff --git a/test/support/fixtures/tags_fixtures.ex b/test/support/fixtures/tags_fixtures.ex index 2dd61f866..f927522fc 100644 --- a/test/support/fixtures/tags_fixtures.ex +++ b/test/support/fixtures/tags_fixtures.ex @@ -5,23 +5,36 @@ defmodule Philomena.TagsFixtures do """ alias Philomena.Repo + alias Philomena.Multi alias Philomena.Tags + alias Philomena.Tags.Tag def unique_tag_name, do: "test tag #{System.unique_integer([:positive])}" + def tag_list_fixture(tag_input) do + {:ok, %{canonical_tags: %{tags: tags}}} = + Multi.new() + |> Tags.put_canonicalize_tag_name_sets([ + {:tags, Tag.parse_tag_list(tag_input), allow_insert_new?: true} + ]) + |> Multi.transact() + + tags + end + @doc """ Creates a tag. - `Tags.create_tag/1` only accepts `:name` (slug, namespace, and namespace - category are derived from it - e.g. `"artist:foo"` gets the `origin` - category automatically). A non-namespace `category:` attr is applied with - a direct update afterwards, the way the tag controller would. + The creation changeset accepts only `:name` (slug, namespace, and namespace + category are derived from it). Fixture persistence is direct so production + does not expose raw CRUD solely for tests. A non-namespace `category:` attr + is applied with a direct update afterwards, the way the tag controller would. """ def tag_fixture(attrs \\ %{}) do attrs = Enum.into(attrs, %{name: unique_tag_name()}) {category, attrs} = Map.pop(attrs, :category) - {:ok, tag} = Tags.create_tag(attrs) + {:ok, tag} = %Tag{} |> Tag.creation_changeset(attrs) |> Repo.insert() case category do nil -> diff --git a/test/support/fixtures/topics_fixtures.ex b/test/support/fixtures/topics_fixtures.ex index 2e63c768c..73c8d724d 100644 --- a/test/support/fixtures/topics_fixtures.ex +++ b/test/support/fixtures/topics_fixtures.ex @@ -4,15 +4,20 @@ defmodule Philomena.TopicsFixtures do entities via the `Philomena.Topics` context. """ + import Ecto.Query + import Philomena.AttributionFixtures + import Philomena.UsersFixtures + alias Philomena.Polls.Poll + alias Philomena.Repo alias Philomena.Topics def unique_topic_title, do: "Test Topic #{System.unique_integer([:positive])}" @doc """ Creates a topic (with its required first post) in `forum`, authored by - `user` (anonymous attribution when `nil`). + `user` (an anonymously displayed topic when `nil`). `attrs` are merged into the string-keyed params map the way the topic controller would submit them; pass `"posts" => %{"0" => %{"body" => ...}}` @@ -21,15 +26,48 @@ defmodule Philomena.TopicsFixtures do Returns the topic with `posts: [first_post]` loaded. """ def topic_fixture(forum, user \\ nil, attrs \\ %{}) do + anonymous? = is_nil(user) + + user = + if anonymous? do + user = Repo.preload(admin_user_fixture(), :settings) + put_in(user.settings.watch_on_new_topic, false) + else + user + end + attrs = Enum.into(attrs, %{ "title" => unique_topic_title(), - "anonymous" => "false", + "anonymous" => to_string(anonymous?), "posts" => %{"0" => %{"body" => "Test topic body"}} }) - {:ok, %{topic: topic}} = Topics.create_topic(forum, attribution(user), attrs) + {:ok, %{topic: topic}} = + Topics.create_topic(actor(user, ip: random_ip()), forum.short_name, attrs) topic end + + @doc """ + Creates a topic in `forum` (authored by `user`, anonymous when `nil`) that + carries a poll, and returns `{topic, poll}`. + + `poll_attrs` are merged into the poll params the topic controller would + submit; the defaults produce a valid single-choice poll with two options. + """ + def topic_with_poll_fixture(forum, user \\ nil, poll_attrs \\ %{}) do + poll_params = + Enum.into(poll_attrs, %{ + "title" => "Best test option?", + "active_until" => DateTime.add(DateTime.utc_now(:second), 7, :day), + "vote_method" => "single", + "options" => %{"0" => %{"label" => "Option A"}, "1" => %{"label" => "Option B"}} + }) + + topic = topic_fixture(forum, user, %{"poll" => poll_params}) + poll = Repo.one!(from p in Poll, where: p.topic_id == ^topic.id) + + {topic, poll} + end end diff --git a/test/support/fixtures/users_fixtures.ex b/test/support/fixtures/users_fixtures.ex index 1c713c925..e17293ad7 100644 --- a/test/support/fixtures/users_fixtures.ex +++ b/test/support/fixtures/users_fixtures.ex @@ -4,7 +4,9 @@ defmodule Philomena.UsersFixtures do entities via the `Philomena.Users` context. """ + alias Philomena.AttributionFixtures alias Philomena.Bans + alias Philomena.ModerationLogs.ModerationLog alias Philomena.Users alias Philomena.Repo @@ -15,13 +17,14 @@ defmodule Philomena.UsersFixtures do email = unique_user_email() {:ok, user} = - attrs - |> Enum.into(%{ - name: email, - email: email, - password: valid_user_password() - }) - |> Users.register_user() + Users.create_registration( + AttributionFixtures.actor(), + Enum.into(attrs, %{ + name: email, + email: email, + password: valid_user_password() + }) + ) user end @@ -56,6 +59,20 @@ defmodule Philomena.UsersFixtures do |> Repo.update!() end + @doc """ + Fixture for a moderator granted the `resource_type` admin `role_map` entry + (e.g. `%{"Image" => %{"admin" => []}}`), the shape the auth pipeline computes + from a `users_roles` grant. Use where an ability keys on a resource-specific + admin grant rather than the plain moderator role. The `role_map` is set on the + returned struct the way a request-loaded actor carries it. + """ + def role_moderator_fixture(resource_type) do + user = moderator_user_fixture() + role = Repo.insert!(%Philomena.Roles.Role{name: "admin", resource_type: resource_type}) + Repo.insert_all("users_roles", [%{user_id: user.id, role_id: role.id}]) + %{user | role_map: %{resource_type => %{"admin" => []}}} + end + @doc """ Fixture for a confirmed user that has an avatar set (a bare filename in the `avatar` column; no object is actually uploaded). @@ -84,13 +101,22 @@ defmodule Philomena.UsersFixtures do def banned_user_fixture(banning_user \\ nil, attrs \\ %{}) do user = confirmed_user_fixture(attrs) - {:ok, _ban} = - Bans.create_user(banning_user || admin_user_fixture(), %{ - "user_id" => user.id, + banning_user = banning_user || admin_user_fixture() + + {:ok, ban} = + Bans.create_user_ban(AttributionFixtures.actor(banning_user), user.id, %{ "reason" => "Banned in test", "valid_until" => DateTime.add(DateTime.utc_now(:second), 365, :day) }) + ModerationLog + |> Repo.get_by!( + user_id: banning_user.id, + type: "Admin.UserBan:create", + body: "Created a user ban #{ban.generated_ban_id}" + ) + |> Repo.delete!() + user end diff --git a/test/support/singleton_toggle_tests.ex b/test/support/singleton_toggle_tests.ex index 77df9193d..cf60ddf57 100644 --- a/test/support/singleton_toggle_tests.ex +++ b/test/support/singleton_toggle_tests.ex @@ -25,8 +25,8 @@ defmodule PhilomenaWeb.SingletonToggleTests do defp anonymous_path, do: ~p"/images/1/subscription" `require_authenticated_user` runs in the router pipeline and halts before - the controller - and therefore before any `load_resource`/`LoadTopicPlug` - runs - so the ids in that path need not exist, and the anonymous tests + the controller - and therefore before the context loads or authorizes any + record - so the ids in that path need not exist, and the anonymous tests build no fixtures at all. They only ever assert the login redirect. ### Subscription controllers (`*.SubscriptionController`) @@ -241,21 +241,20 @@ defmodule PhilomenaWeb.SingletonToggleTests do assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You are currently banned." end - test "an unknown image redirects to / with the authorization flash", %{conn: conn} do - # Canary sends the nil resource down the unauthorized path + test "an unknown image redirects to / with the not-found flash", %{conn: conn} do %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, interaction_path(999_999_999)) assert redirected_to(conn) == "/" - assert Phoenix.Flash.get(conn.assigns.flash, :error) == "You can't access that page." + + assert Phoenix.Flash.get(conn.assigns.flash, :error) == + "Couldn't find what you were looking for!" end test "a non-integer image id redirects to / with the not-found flash", %{conn: conn} do - # the central IntegerId guard short-circuits a non-integer id to - # NotFoundPlug before Canary authorizes, so the flash is the - # not-found message rather than the "You can't access that page." an - # unknown integer id gets + # The member loader normalizes malformed and missing ids alike before + # authorization runs. %{conn: conn} = register_and_log_in_user(%{conn: conn}) conn = post(conn, interaction_path("not-a-number")) diff --git a/test/test_helper.exs b/test/test_helper.exs index 89c18ab9d..4daf993dd 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,15 +1,19 @@ ExUnit.start() -# Stop the advert batching server for the duration of the test run. It wakes every -# 10 seconds and flushes whatever impressions AdvertPlug has cast to it through -# Adverts.Recorder.run/1. An empty flush is free (Ecto skips Repo.insert_all on an -# empty list), but as soon as a controller test renders a page carrying an advert -# the flush issues a real write - from a process that owns no sandbox connection, -# so with the sandbox in :manual mode it raises DBConnection.OwnershipError. -# Terminating the child avoids the periodic noise. GenServer.cast to the now-dead -# name still returns :ok silently, so AdvertPlug's record_impression casts during -# controller tests are harmlessly dropped. +# Stop batching servers for the duration of the test run. They periodically wake +# and flush whatever updates have been cast to them. An empty flush is free, but +# as soon as a controller test runs then the servers will receive data and issue +# real writes - from a process that owns no sandbox connection, so with the +# sandbox in :manual mode it raises DBConnection.OwnershipError. Terminating the +# child avoids the periodic noise. GenServer.cast to the now-dead name still +# returns :ok silently, so casts during tests are harmlessly dropped. Supervisor.terminate_child(Philomena.Supervisor, Philomena.Adverts.Server) +Supervisor.terminate_child(Philomena.Supervisor, Philomena.UserIps.Server) +Supervisor.terminate_child(Philomena.Supervisor, Philomena.UserFingerprints.Server) + +# Use Exq's in-memory fake queue in tests. This keeps enqueue side effects +# observable to tests without writing jobs to the shared Valkey instance. +{:ok, _exq_mock} = Exq.Mock.start_link(mode: :fake) # Create every searchable index once, with the current mappings. Tests get # per-test isolation from PhilomenaQuery.Search.clear_index!/1, which only