PhoenixKitProjects.Portal (PhoenixKitProjects v0.21.1)

Copy Markdown View Source

The public portal (Phase J): anonymous issue submission + a read-only public issue list + a status summary for one project, behind a random capability slug. Security model per the 2026-08-06 design doc + the external security panel's findings, folded in:

  • The slug is the grant — CSPRNG, ~22 chars, regenerable (rotate_slug/2); rotation broadcasts so LIVE portal sessions downgrade immediately (panel #7).
  • One whitelisting doorway — every public read goes through public_view/1, which returns plain DTO maps (never structs, so an LV can't over-assign internal fields — panel #5) and scopes every query from the portal row + public == true (panel #6).
  • Uniform failure — unknown slug, disabled extension, disabled capability all collapse to :error (panel #11).
  • Rate limiting lives in the submit path itself (panel #2), keyed on the PEER address (never XFF — panel #3), IPv6 bucketed at /64 and a per-project global ceiling on top (panel #8). A limiter failure DENIES (fail-closed).
  • No submitter email in v1 — the notify-submitter feature needs double-opt-in (panel #1); collecting nothing eliminates the mail-bomb + header-injection surface. Submissions notify project MEMBERS through the Phase H fan-out instead.
  • ip_hash is a truncated peppered HMAC (panel #12) — abuse telemetry only.

Host requirement for per-IP limits

The peer address comes from LiveView's get_connect_info(socket, :peer_data), which is only populated when the HOST endpoint's socket declares it — socket "/live", ..., websocket: [connect_info: [:peer_data, session: ...]]. Without it every submitter shares one bucket (still fail-closed — stricter, not weaker — and the per-project ceiling is IP-independent). Hosts behind a proxy additionally need RemoteIp (or equivalent) terminating XFF BEFORE Phoenix; this module never parses forwarded headers itself.

Summary

Functions

The access mode behind a slug, for decisions that must be made BEFORE the page renders — the response headers, chiefly.

How many images one report may carry, and how large each may be.

Tasks already flagged public that a switch to public mode would expose — the number an admin has to see BEFORE flipping.

Whether a portal capability flag is on for the project.

Why anyone currently behaves as members for commenting — shown in the admin UI so the setting doesn't lie about what it does.

Lets a project member sign a portal comment with the project's name instead of their own — or returns nil, which is what most people get.

Deletes the images attached to an issue's submission.

The comments namespace a PUBLIC board discussion lives in.

Ensures the project has a portal row (fresh slug on first enable). The extension's on_enable callback — idempotent, keeps the existing slug on re-enable (the link keeps working across toggles; rotation is the explicit revoke).

The portal row for a project, or nil.

The portal and project behind a slug, but ONLY when it is a public board.

May this viewer COMMENT on a board issue?

May this viewer SUBMIT an issue through the portal?

Typeahead candidates for the composer on a PUBLIC portal page.

ONE public issue, or :error.

The whole public read surface as ONE plain-map DTO — the whitelist (panel #5/#6). Returns {:ok, view} or the uniform :error.

Resolves a slug to {:ok, portal, project} iff the portal extension is enabled for that project. EVERY failure mode is the same :error (panel #11 — errors must not enumerate portals or capabilities).

What a reviewer needs to see about a pending submission, in one read.

The images a stranger attached to this issue, for the STAFF review of it.

Regenerates the slug — the revoke: the old link 404s and every LIVE portal session on the old slug downgrades (the :portal_rotated broadcast, panel #7).

Switches a portal's access mode.

Puts one task on the public board, or takes it off.

Sets the two participation policies. Values outside the vocabulary are refused by the changeset rather than silently coerced.

Flips an assignment's public flag — the ONLY write path for it (server-set, never cast from params). Caller gates :edit_tasks.

Turns uploaded temp files into storage uuids, or refuses.

Anonymous issue submission — the full guard chain, in order: resolve → capability → honeypot → min-fill-time → rate limits (peer bucket + project ceiling; limiter failure DENIES) → size caps → create. The created issue lands in the project's FIRST status with source: "portal", public: false (nothing self-publishes), and the activity entry fans out to every project member (the Phase H pipeline).

Functions

access_mode_of(slug)

@spec access_mode_of(String.t()) :: String.t()

The access mode behind a slug, for decisions that must be made BEFORE the page renders — the response headers, chiefly.

Returns "link" for anything it cannot confirm: an unknown slug, a database error, a portal whose extension is off. The restrictive answer is the safe one, so it is also the default.

attachment_limits()

@spec attachment_limits() :: %{count: pos_integer(), bytes: pos_integer()}

How many images one report may carry, and how large each may be.

Small on purpose. A bug report needs a screenshot or three, and every megabyte past that is storage someone else is paying for on a page that accepts writes from strangers.

board_exposure_count(project_uuid)

@spec board_exposure_count(binary()) :: non_neg_integer()

Tasks already flagged public that a switch to public mode would expose — the number an admin has to see BEFORE flipping.

They were flagged for an audience of "whoever holds the link". Publishing them to the open web is a different decision, and this is what makes it one someone takes rather than one that happens to them.

capability?(project, cap)

@spec capability?(map(), :submit | :list | :status) :: boolean()

Whether a portal capability flag is on for the project.

comment_access_note()

@spec comment_access_note() :: String.t()

Why anyone currently behaves as members for commenting — shown in the admin UI so the setting doesn't lie about what it does.

comment_attribution(portal, project, viewer)

@spec comment_attribution(PhoenixKitProjects.Schemas.Portal.t(), map(), term()) ::
  map() | nil

Lets a project member sign a portal comment with the project's name instead of their own — or returns nil, which is what most people get.

Someone answering on their employer's board may be speaking personally or on the project's behalf, and those are different acts. Offering the choice also gives an affiliated person who does not want their own name on an indexed page a legitimate way to take part.

Deliberately not offered on a link board. The slug is the grant there, so a project-signed comment tells everyone holding the link who is affiliated with the project — the one thing a capability URL is not supposed to reveal. public boards are already open, and members boards face people who signed in.

The verify function is re-run by the comments component at submit, because the composer may have been rendered long before the send and membership can be revoked in between.

Defaults to CHECKED on a public board: someone replying on a company's open issue tracker is usually doing it as the company, and the safe option should also be the lazy one.

delete_attachments_for(assignment_uuid)

@spec delete_attachments_for(binary()) :: :ok

Deletes the images attached to an issue's submission.

Call before deleting the issue itself. The submission row cascades with the assignment, but the storage rows do not — nothing points at them once the submission is gone, so without this every rejected report leaves its screenshots behind forever, owned by the project owner and invisible to everyone.

Never blocks the delete: losing the issue matters more than reclaiming a file, and an orphaned file is recoverable where a blocked delete is a stuck queue.

discussion_resource_type()

@spec discussion_resource_type() :: String.t()

The comments namespace a PUBLIC board discussion lives in.

Deliberately NOT the "assignment" type the admin hub uses for staff discussion on the same task. They are two conversations about one piece of work: one internal, one with the outside world. If these two strings ever converge, every internal note staff have written lands on a public page — so the value is named here rather than typed into a template.

ensure_portal(project_uuid, config \\ %{})

@spec ensure_portal(binary(), map()) :: :ok

Ensures the project has a portal row (fresh slug on first enable). The extension's on_enable callback — idempotent, keeps the existing slug on re-enable (the link keeps working across toggles; rotation is the explicit revoke).

get_portal(project_uuid)

@spec get_portal(binary()) :: PhoenixKitProjects.Schemas.Portal.t() | nil

The portal row for a project, or nil.

get_public_board(slug)

@spec get_public_board(String.t()) ::
  {:ok, PhoenixKitProjects.Schemas.Portal.t(), map()} | :none

The portal and project behind a slug, but ONLY when it is a public board.

For the response-header and social-preview decisions, which are made before the page renders and must be conservative: anything this cannot confirm is a portal to be treated as secret.

may_comment?(portal, project, viewer)

@spec may_comment?(PhoenixKitProjects.Schemas.Portal.t(), map(), term()) :: boolean()

May this viewer COMMENT on a board issue?

Separate from submitting on purpose: a submission is invisible until staff act on it, while a comment is live the moment it lands. Defaults to nobody, so discussion is something a project turns on rather than something it discovers it had.

anyone is deliberately NOT honoured yet — see comment_access_note/0.

may_submit?(portal, project, viewer)

@spec may_submit?(PhoenixKitProjects.Schemas.Portal.t(), map(), term()) :: boolean()

May this viewer SUBMIT an issue through the portal?

Two gates, both of which must pass: the project's portal_submit capability (does the portal offer submission at all) and the portal's submit_access policy (who specifically). Fail-closed on anything unexpected.

mention_candidates(kind, query, portal, opts \\ [])

@spec mention_candidates(
  :user | :resource,
  String.t(),
  PhoenixKitProjects.Schemas.Portal.t(),
  term()
) ::
  [map()]

Typeahead candidates for the composer on a PUBLIC portal page.

The rule this encodes: a mention may only name something the page already shows. Nothing here discloses anything a reader could not already have read by scrolling, which is what makes it safe to offer the menu to an outsider at all.

  • # — issues listed on THIS board, via the very same issues_query/1 the board renders from. Sharing the query is the point: a typeahead with its own copy of the scoping rule is a typeahead that will eventually disagree with the page and offer a title that was never published.
  • @ — people who have already COMMENTED on this issue. Their names are on the page. The site's user directory is not, and the ordinary Mentions.search/3 would hand it to anyone with a comment box. Deliberately per-ISSUE, not per-board: board-wide would reveal that somebody took part in a discussion the reader has not opened.

Refuses outright for a viewer who may not comment. The composer is already hidden from them, but a hidden control is not a control — this is reachable by anyone who can send a LiveView event.

public_issue(slug, issue_uuid, viewer \\ nil)

@spec public_issue(String.t(), String.t(), term()) :: {:ok, map()} | :error

ONE public issue, or :error.

Goes through the same doorway as the list: resolve the slug, check the list capability, and scope from the portal row. An issue that isn't published to this board is indistinguishable from one that doesn't exist — the uniform failure that stops the page being an oracle.

Returns the issue DTO plus its uuid, which the LIST deliberately omits: here it is the thing being addressed, and the reader already has it in their URL bar.

public_view(slug, viewer \\ nil)

The whole public read surface as ONE plain-map DTO — the whitelist (panel #5/#6). Returns {:ok, view} or the uniform :error.

The DTO contains exactly: project_name, project_status, started_at, completed_at, capabilities, issue_counts, and issues (each: title, status, status_label, inserted_at, updated_at). Nothing else — no assignees, estimates, money, AI figures, or internal notes, ever.

resolve(slug, viewer \\ nil)

Resolves a slug to {:ok, portal, project} iff the portal extension is enabled for that project. EVERY failure mode is the same :error (panel #11 — errors must not enumerate portals or capabilities).

review_details(assignment_uuid)

@spec review_details(binary()) :: %{images: [map()], submitted_by: String.t() | nil}

What a reviewer needs to see about a pending submission, in one read.

Images and — when the person was signed in — who sent it. The dialog used to state flatly that every sender was anonymous, which was false for everyone who submitted while logged in, including colleagues.

review_images(assignment_uuid)

@spec review_images(binary()) :: [map()]

The images a stranger attached to this issue, for the STAFF review of it.

Deliberately not board_images/2: that one is gated on the board already being public, which is exactly backwards for triage. The person deciding whether to publish has to see the files before they are published, and until this existed nobody ever saw them at all — the text went through a human and the images went straight from an anonymous stranger onto an indexable page. "A person approves it" was the entire argument for accepting anonymous uploads, so it has to be true of the part that can actually carry something harmful.

Caller must have already checked the viewer may read the assignment.

rotate_slug(project_uuid, opts \\ [])

@spec rotate_slug(
  binary(),
  keyword()
) :: {:ok, PhoenixKitProjects.Schemas.Portal.t()} | {:error, term()}

Regenerates the slug — the revoke: the old link 404s and every LIVE portal session on the old slug downgrades (the :portal_rotated broadcast, panel #7).

set_access_mode(project_uuid, mode, opts \\ [])

@spec set_access_mode(binary(), String.t(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.Portal.t()} | {:error, term()}

Switches a portal's access mode.

Changing mode MINTS A NEW SLUG in both directions, and that is the point:

  • going to public, the old slug is a 22-char CSPRNG secret that has been passed around as one. Promoting it to a permanent public URL would publish the secret and give the board an unreadable address.
  • leaving public, the old slug is a name that has been indexed, linked and cached. Keeping it as a capability would mean the "secret" is written down in a search engine.

:publish_existing decides what happens to tasks already flagged public when moving TO a public board: true puts them on it, false (the default) leaves the board empty until staff publish each one. There is no third option, because "do nothing and hope" is how the surprising version of this feature would ship.

set_board_published(assignment_uuid, published?)

@spec set_board_published(binary(), boolean()) :: {:ok, integer()} | {:error, term()}

Puts one task on the public board, or takes it off.

Independent of public — a task can be visible to link-holders without being on the open-web board, and taking it off the board leaves the link-holder view untouched.

set_participation(project_uuid, attrs, opts \\ [])

@spec set_participation(binary(), map(), keyword()) ::
  {:ok, PhoenixKitProjects.Schemas.Portal.t()} | {:error, term()}

Sets the two participation policies. Values outside the vocabulary are refused by the changeset rather than silently coerced.

set_public(assignment, public?, opts \\ [])

Flips an assignment's public flag — the ONLY write path for it (server-set, never cast from params). Caller gates :edit_tasks.

store_attachments(files, project_uuid \\ nil)

@spec store_attachments([%{path: String.t(), name: String.t()}], binary() | nil) ::
  {:ok, [String.t()]} | {:error, :invalid}

Turns uploaded temp files into storage uuids, or refuses.

Every file is RE-ENCODED before it is stored — what ends up on disk is our encoder's output, not the uploader's bytes — so a polyglot, an EXIF payload or a trailing archive does not survive the trip. Anything that cannot be decoded as an image is refused whatever it was called.

All-or-nothing: if one file can't be processed the whole report is refused, because a report that silently loses the screenshot it refers to is worse than one that says so.

submit(slug, attrs, meta)

@spec submit(term(), map(), map()) ::
  {:ok, :submitted} | {:error, :rate_limited} | {:error, :invalid} | :error

Anonymous issue submission — the full guard chain, in order: resolve → capability → honeypot → min-fill-time → rate limits (peer bucket + project ceiling; limiter failure DENIES) → size caps → create. The created issue lands in the project's FIRST status with source: "portal", public: false (nothing self-publishes), and the activity entry fans out to every project member (the Phase H pipeline).

meta carries :peer_ip (a :inet.ip_address() tuple or nil), :honeypot (the hidden field value) and :mounted_ms (monotonic ms at form mount). Returns {:ok, :submitted}, {:error, :rate_limited}, {:error, :invalid} (caps/blank), or the uniform :error.