mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375)
Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.
Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).
CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".
Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.
The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.
Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.
Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
53c318bac7
commit
5e4f2ca3dd
11 files changed
+319
-75
No files matched your search
@@ -89,9 +89,11 @@ state.
|
||||
- favorites snapshots
|
||||
- recently viewed snapshots
|
||||
|
||||
Explicitly excluded:
|
||||
Explicitly excluded — session state, as opposed to the connection definition:
|
||||
|
||||
- `stalkerToken`
|
||||
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
|
||||
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the profile-advertised cadence)
|
||||
- `stalkerAccountInfo`
|
||||
- playback positions in v1
|
||||
|
||||
|
||||
@@ -102,6 +102,13 @@ tests can assert the client re-handshakes and retries instead of surfacing an
|
||||
error; pinned device identity survives invalidation, as it does on a real
|
||||
portal.
|
||||
|
||||
Beside the portal endpoints, `main.ts` mounts a handful of non-portal routes:
|
||||
`GET /stalker` (the PWA CORS-proxy mirror), `GET /stream/gated/:file` (the
|
||||
credential-gated media fixtures for the `gated-stream` scenario),
|
||||
`GET /assets/marketing/poster/*` (the committed screenshot-safe posters) and
|
||||
the `/health` + `/reset` + `/invalidate-session` utilities. The full list with
|
||||
request shapes is in `apps/stalker-mock-server/README.md`.
|
||||
|
||||
## Data Generation Pipeline
|
||||
|
||||
```
|
||||
@@ -340,7 +347,9 @@ that no `create_link` request reaches the portal. See
|
||||
|
||||
Favorites are stored in a `Map<mac, Set<itemId>>` in `src/app/data-store.ts`. They persist for the lifetime of the server process and are shared across all requests for the same MAC.
|
||||
|
||||
Call `POST /reset` to clear all favorites (and regenerated data) between test runs.
|
||||
Call `POST /reset?macAddress=<mac>` to clear a MAC's favorites (and its cached
|
||||
generated data) between test runs — see "Test Isolation" for why the scoped
|
||||
form is the one specs should use.
|
||||
|
||||
## Playwright Integration
|
||||
|
||||
@@ -348,26 +357,53 @@ Call `POST /reset` to clear all favorites (and regenerated data) between test ru
|
||||
|
||||
```typescript
|
||||
webServer: [
|
||||
{
|
||||
command: 'pnpm nx run web:serve',
|
||||
url: 'http://localhost:4200',
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
},
|
||||
{ command: webServerCommand /* web:serve */, url: baseURL },
|
||||
{
|
||||
command: 'pnpm nx run stalker-mock-server:serve',
|
||||
url: 'http://localhost:3210/health',
|
||||
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
|
||||
reuseExistingServer: !process.env['CI'],
|
||||
},
|
||||
// plus the xtream mock (3211) and web-backend (3333) entries
|
||||
]
|
||||
```
|
||||
|
||||
Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance.
|
||||
Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance.
|
||||
|
||||
**`MOCK_PORT` moves the CLIENT side only** — Playwright's health-check URL and
|
||||
the `MOCK_SERVER` constants in the specs. The server's own port comes from
|
||||
`PORT` (`main.ts`), which the `serve` and `serve-with-watch` targets pin to
|
||||
`3210` in `project.json`, and nothing maps one variable to the other. Setting
|
||||
`MOCK_PORT` alone therefore points Playwright at a port nothing is listening
|
||||
on and the run times out waiting for `/health`. It is only useful against a
|
||||
mock you started yourself on that port (`reuseExistingServer` is on outside
|
||||
CI); relocating the Nx-managed one would need `MOCK_PORT` passed through as
|
||||
`PORT`.
|
||||
|
||||
### Test Isolation
|
||||
|
||||
Each stalker e2e test calls `POST http://localhost:3210/reset` in `beforeEach` to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.
|
||||
Mock state is keyed by MAC and one mock-server process is shared by every spec
|
||||
file running in parallel workers, so isolation is per-MAC rather than global:
|
||||
|
||||
`resetAll()` clears both the generated-content cache and in-memory favorites (`data-store.ts`). Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets.
|
||||
- `POST /reset` accepts one or more `?macAddress=` params and clears only those
|
||||
MACs. A bare `POST /reset` clears everything and is only safe when nothing
|
||||
else is talking to the server — a spec that used it would wipe a sibling
|
||||
spec's session mid-test.
|
||||
- `apps/web-e2e/src/stalker.e2e.ts` declares the MACs it owns in `OWNED_MACS`
|
||||
and clears exactly those in one batched request. The sibling specs that reach
|
||||
this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a disjoint
|
||||
`00:1A:79:5F:*` range, so neither file can clear the other's state.
|
||||
- Within the file, tests deliberately share scenario MACs (their fixture shapes
|
||||
are what the assertions are written against), so it pins itself to one worker
|
||||
with `test.describe.configure({ mode: 'serial' })`.
|
||||
- A few MACs are deliberately kept OUT of `OWNED_MACS`: the token-reuse test
|
||||
asserts that a session SURVIVES, so nothing may reset it, and it uses one MAC
|
||||
per browser project.
|
||||
|
||||
A reset drops the generated content cache, favorites (`data-store.ts`), the
|
||||
auth/session record (`auth-store.ts`, including any pinned device identity) and
|
||||
the watchdog ping counters. Because generation is seed-deterministic, the next
|
||||
request regenerates identical content, so the observable data does not change
|
||||
across resets.
|
||||
|
||||
### Recommended Test Structure
|
||||
|
||||
@@ -376,9 +412,16 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
const MOCK_URL = 'http://localhost:3210/portal.php';
|
||||
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario
|
||||
const OWNED_MACS = [MOCK_MAC];
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('http://localhost:3210/reset');
|
||||
// Scoped reset: only the MACs this file owns.
|
||||
const query = OWNED_MACS.map(
|
||||
(mac) => `macAddress=${encodeURIComponent(mac)}`
|
||||
).join('&');
|
||||
await request.post(`http://localhost:3210/reset?${query}`);
|
||||
});
|
||||
|
||||
test('browse VOD categories', async ({ page }) => {
|
||||
@@ -393,5 +436,5 @@ test('browse VOD categories', async ({ page }) => {
|
||||
- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`.
|
||||
- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`.
|
||||
- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers.
|
||||
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness).
|
||||
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Endpoints With Different Strictness](#endpoints-with-different-strictness).
|
||||
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.
|
||||
@@ -30,31 +30,71 @@ Stalker support covers:
|
||||
## Routing Structure
|
||||
|
||||
Primary route tree lives in
|
||||
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`.
|
||||
`libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`
|
||||
(`createStalkerRoutes()`), mounted under the workspace shell, so every path
|
||||
below is reached as `/workspace/stalker/:id/…`.
|
||||
|
||||
- `/stalker/:id/vod` (plus `vod/:categoryId` child)
|
||||
- `/stalker/:id/series` (plus `series/:categoryId` child)
|
||||
- `/stalker/:id/itv`
|
||||
- `/stalker/:id/radio`
|
||||
- `/stalker/:id/favorites`
|
||||
- `/stalker/:id/recent`
|
||||
- `/stalker/:id/search`
|
||||
- `/stalker/:id/actor/:personId`
|
||||
- `/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
|
||||
- `/stalker/:id/downloads/:downloadId` (focused local movie/series detail with
|
||||
no category context panel)
|
||||
- `/workspace/stalker/:id/vod` (plus `vod/:categoryId` child)
|
||||
- `/workspace/stalker/:id/series` (plus `series/:categoryId` child)
|
||||
- `/workspace/stalker/:id/itv`
|
||||
- `/workspace/stalker/:id/radio`
|
||||
- `/workspace/stalker/:id/favorites`
|
||||
- `/workspace/stalker/:id/recent`
|
||||
- `/workspace/stalker/:id/search`
|
||||
- `/workspace/stalker/:id/actor/:personId`
|
||||
- `/workspace/stalker/:id/downloads` (shared `DownloadsComponent` from `@iptvnator/portal/downloads/feature`)
|
||||
- `/workspace/stalker/:id/downloads/:downloadId` (focused local movie/series
|
||||
detail with no category context panel)
|
||||
|
||||
`/workspace/stalker/:id` itself redirects to `vod`.
|
||||
|
||||
## Runtime Architecture
|
||||
|
||||
1. Angular Stalker screens call methods/resources in `StalkerStore`.
|
||||
2. `StalkerStore` builds request params based on selected content type and current view state.
|
||||
3. Every portal API call funnels through `executeStalkerRequest()`
|
||||
3. Catalog, content and playback calls funnel through `executeStalkerRequest()`
|
||||
(`libs/portal/stalker/data-access/src/lib/stores/utils/stalker-request.utils.ts`),
|
||||
the single choke point that decides the transport per portal mode:
|
||||
the choke point that decides the transport per portal mode:
|
||||
full portals go through `StalkerSessionService` (handshake + Bearer token +
|
||||
retry), token-free panels call
|
||||
`DataService.sendIpcEvent(STALKER_REQUEST, ...)` directly. It also hooks
|
||||
the lazy portal repair (see "Portal Mode and Endpoint Discovery").
|
||||
|
||||
Four callers deliberately sit outside it and issue `STALKER_REQUEST`
|
||||
themselves, because each one runs *below* or *before* what it routes on:
|
||||
|
||||
- `StalkerAuthApi` — `handshake` / `get_profile` / `do_auth` are what the
|
||||
full-portal branch is implemented in terms of, so routing them back
|
||||
through it would recurse.
|
||||
- `StalkerPortalDiscoveryService` — probes run before a mode exists; the
|
||||
mode is what they are determining.
|
||||
- `StalkerAccountInfoService.fetchViaProfile()` — the full-mode refresh is
|
||||
a profile request, so it takes the same exemption as the auth layer.
|
||||
- `StreamResolverService`, for a collection item carrying its own portal
|
||||
coordinates with **no playlist row** — there is no meta to route or
|
||||
repair with. The playlist-backed branch beside it does use
|
||||
`executeStalkerRequest()`, and wins when a row exists, so a repaired
|
||||
endpoint beats a stale favorite's snapshot.
|
||||
|
||||
The exemption is from the routing, not from the repair it hooks — but only
|
||||
**one** of the four wires `StalkerPortalRepairService` itself, and the
|
||||
asymmetry is worth knowing before adding a fifth:
|
||||
|
||||
- `StalkerAccountInfoService.fetchViaProfile()` wires it explicitly,
|
||||
because opening the account dialog on a playlist with a stale endpoint
|
||||
must be able to fix it instead of waiting for an unrelated catalog
|
||||
request.
|
||||
- `StalkerPortalDiscoveryService` is what repair *drives*, so it cannot
|
||||
repair itself.
|
||||
- The auth layer wires nothing. It does not need to: a terminal handshake
|
||||
failure propagates out of the full-portal branch and is caught by
|
||||
whichever `executeStalkerRequest()` call triggered the authentication,
|
||||
which is exactly why "terminal handshake failures" is one of the repair
|
||||
triggers listed above.
|
||||
- `StreamResolverService`'s row-less branch has no playlist to repair.
|
||||
|
||||
Anything new that is not auth or discovery belongs on
|
||||
`executeStalkerRequest()`.
|
||||
4. Electron main process handles `STALKER_REQUEST` in
|
||||
`apps/electron-backend/src/app/events/stalker.events.ts`.
|
||||
5. Axios calls the portal's persisted API endpoint (`portal.php` on
|
||||
@@ -86,8 +126,34 @@ Two portal modes exist, persisted per playlist as
|
||||
periodic authenticated `watchdog/get_events` pings at the cadence the
|
||||
portal advertises (`watchdog_timeout`, default 120 s — see "Watchdog"
|
||||
below) whose failures are non-fatal.
|
||||
- **Simple portal** (reseller-style `portal.php` panels): no auth lifecycle
|
||||
at all — requests carry only the `mac=` cookie.
|
||||
- **Simple portal** (typically a reseller-style `portal.php` panel): no auth
|
||||
lifecycle at all — no handshake, no Bearer token, no watchdog. The requests
|
||||
are not stripped down to a bare cookie either: every Stalker request goes
|
||||
through the shared identity builder, so a simple portal receives everything
|
||||
that builder can derive from a MAC alone — the `mac`/`stb_lang`/`timezone`
|
||||
cookie, the MAG `User-Agent` / `X-User-Agent` pair and the
|
||||
`Accept`/`Accept-Language`/`Connection` set (see "Request Transport and
|
||||
`cmd` Encoding").
|
||||
|
||||
What it does **not** get is anything carried by the session. The direct
|
||||
branch of `dispatchStalkerRequest()` forwards only `url`, `macAddress` and
|
||||
`params`, so the builder never sees a token *or* a serial: no
|
||||
`Authorization: Bearer` (there is none to send), and no `SN` header or
|
||||
serial-derived `__cfduid` cookie **even when the playlist stores a serial**.
|
||||
The full-portal branch forwards both, because `makeAuthenticatedRequest()`
|
||||
passes `token` and `serialNumber`. This covers portal API requests only —
|
||||
playback headers are built from the playlist row by a different helper and
|
||||
are NOT mode-gated, so the same simple-mode playlist does send its serial
|
||||
with a portal-owned stream (see "Stalker Identity Policy").
|
||||
Whether a simple panel ought to receive
|
||||
the serial is unproven: no reference portal is known to require it, and the
|
||||
`sn` *parameter* only ever travels on `get_profile`, which a simple portal
|
||||
never calls. Treat it as an open question rather than a bug to fix blind.
|
||||
|
||||
Neither label is tied to a URL shape: mode follows OBSERVED behavior, so a
|
||||
`portal.php` panel that enforces the token is classified — and treated
|
||||
everywhere — as a full portal, and a canonical `server/load.php` endpoint that
|
||||
answers without one is a simple portal.
|
||||
|
||||
The single predicate lives in `@iptvnator/shared/interfaces`
|
||||
(`stalker-portal-mode.util.ts`): `isFullStalkerPortalPlaylist()` treats the
|
||||
@@ -209,13 +275,33 @@ Failure-handling rule:
|
||||
## Stalker Identity Policy
|
||||
|
||||
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
|
||||
import UI can capture optional serial number, device IDs, and signatures, but
|
||||
blank fields are not generated or forwarded to `get_profile`.
|
||||
import UI can capture optional serial number, device IDs, and signatures, but a
|
||||
field the user leaves blank stays blank — nothing is invented for it, and
|
||||
nothing empty is forwarded to `get_profile`. The one way a value appears
|
||||
without being typed is the explicit import-time opt-in described under
|
||||
"Deriving device IDs from the MAC" below, which writes into the visible fields
|
||||
first. (The fixed MAG250 description `get_profile` reports is separate: it
|
||||
describes the emulated box, not the account — see "Reported device profile".)
|
||||
|
||||
- User-provided `sn`, `device_id`, `device_id2`, `signature`, and `signature2`
|
||||
values are trimmed, persisted under the canonical `stalker*` playlist fields,
|
||||
and reused for initial auth, token refresh, retry auth, normal API requests,
|
||||
and same-origin playback headers.
|
||||
|
||||
Those last two reach the wire by different routes, and **only one is
|
||||
mode-gated** — the asymmetry is easy to get backwards:
|
||||
|
||||
- **Portal API requests** receive the serial through
|
||||
`makeAuthenticatedRequest()`, which only the full-portal branch of
|
||||
`dispatchStalkerRequest()` calls. A simple-mode playlist therefore sends
|
||||
no serial on any API call, whatever it has stored (see "Portal Mode and
|
||||
Endpoint Discovery").
|
||||
- **Same-origin playback headers** are built by
|
||||
`buildStalkerExternalPlaybackHeaders()`, which reads
|
||||
`playlist.stalkerSerialNumber` straight off the row with no mode check
|
||||
at all. So a simple-mode playlist holding a real serial *does* send `SN`
|
||||
and the serial-derived `__cfduid` on portal-owned streams — while its
|
||||
API calls do not.
|
||||
- Empty optional identity fields remain absent. IPTVnator must never generate a
|
||||
device ID behind the user's back, and must never duplicate `device_id2` from
|
||||
`device_id1` on its own.
|
||||
@@ -296,7 +382,10 @@ dialog. Newly typed values are still held to the format.
|
||||
|
||||
### Deriving device IDs from the MAC
|
||||
|
||||
`deriveStalkerDeviceIdsFromMac` (`stalker-identity.utils.ts`) returns the pair
|
||||
`deriveStalkerDeviceIdsFromMac`
|
||||
(`libs/shared/interfaces/src/lib/stalker-identity.utils.ts` — note the
|
||||
same-named file in `libs/portal/stalker/data-access` is a different module)
|
||||
returns the pair
|
||||
StbEmu and `stalker-to-m3u` generate: uppercase hex `SHA256` of the canonical
|
||||
MAC for `device_id`, and of that MAC plus a `stalker` salt for `device_id2`.
|
||||
The import dialog offers it behind an opt-in checkbox that fills both fields.
|
||||
@@ -407,7 +496,8 @@ nothing to them.
|
||||
|
||||
Full portals authenticate through `StalkerSessionService`
|
||||
(`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), which
|
||||
is a facade over three focused modules:
|
||||
is a thin facade over focused modules (it was split when the single file
|
||||
outgrew the `max-lines` budget; never re-add it to the baseline):
|
||||
|
||||
- `stalker-auth.api.ts` — the raw `handshake` / `get_profile` / `do_auth`
|
||||
requests and the `authenticate()` orchestration.
|
||||
@@ -478,7 +568,11 @@ every start.
|
||||
`msg`/`block_msg` carry the portal's own explanation; they are
|
||||
markup-stripped, combined, and thrown as `StalkerPortalError`. The kind is
|
||||
`device-conflict` when `isStalkerDeviceConflictMessage` matches the combined
|
||||
text, otherwise `blocked` — see "Device conflicts" below.
|
||||
text, otherwise `blocked` — see "Device conflicts" below. A **bare**
|
||||
`{status: 1}` with no message is a refusal too: it used to be read as success
|
||||
whenever the portal sent no `msg`, which imported dead sources (the stock
|
||||
MAC-format rejection is exactly that shape). A profile that carries refusal
|
||||
text without setting the status is likewise refused.
|
||||
- `status: 2` — login/password required. The client runs `do_auth`
|
||||
(`login`, `password`, plus `device_id`/`device_id2` when configured) and
|
||||
retries `get_profile` with `auth_second_step=1`. Only that retry claims the
|
||||
@@ -908,11 +1002,18 @@ streams it resolves, so a channel opened from a collection carries the same
|
||||
credentials as one opened from the portal.
|
||||
The resolved `ResolvedPortalPlayback.headers` feed both the external players
|
||||
(MPV/VLC/Embedded MPV via the launch IPC) and the built-in players via the
|
||||
scoped Electron request-header override (`ElectronStreamHeadersService`,
|
||||
applied by `WebPlayerViewComponent` for the video players and by the Stalker
|
||||
live layout for the radio audio player, which renders outside
|
||||
`WebPlayerViewComponent` — see `docs/architecture/electron-security.md`,
|
||||
"Scoped Request Header Overrides").
|
||||
scoped Electron request-header override (`ElectronStreamHeadersService` — see
|
||||
`docs/architecture/electron-security.md`, "Scoped Request Header Overrides").
|
||||
Three surfaces apply that override, because `WebPlayerViewComponent` owns it
|
||||
only for the video players and radio renders `AudioPlayerComponent` outside it:
|
||||
|
||||
- `WebPlayerViewComponent` — every built-in video player, on every route.
|
||||
- `StalkerLiveStreamLayoutComponent` — the Stalker radio route's audio player.
|
||||
- `UnifiedLiveTabComponent` (`libs/portal/shared/ui`) — the radio audio player
|
||||
of the Favorites / Recently Viewed collection routes.
|
||||
|
||||
Each owns a single scope slot and clears it only while it still owns it, so a
|
||||
handover between them cannot drop the other's credentials.
|
||||
|
||||
Two stream profiles exist, selected by one shared predicate:
|
||||
|
||||
@@ -1303,9 +1404,13 @@ Exported fields:
|
||||
- full-portal serial/device/signature fields when present
|
||||
- favorites and recently viewed collections
|
||||
|
||||
Excluded fields:
|
||||
Excluded fields — everything that describes a negotiated session rather than
|
||||
the connection:
|
||||
|
||||
- `stalkerToken`
|
||||
- `stalkerSessionIdentity` (the fingerprint the token was negotiated for)
|
||||
- `stalkerWatchdogTimeout` / `stalkerTimeslot` (the cadence the profile
|
||||
advertised)
|
||||
- `stalkerAccountInfo`
|
||||
- playback positions in backup v1
|
||||
|
||||
|
||||
Reference in new issue
Block a user