Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b731d8c8c0 | ||
|
|
7d4b35f0d7 | ||
|
|
fd2f2e7c65 | ||
|
|
e4414f1ac9 |
No files matched your search
@@ -368,6 +368,3 @@ FodyWeavers.xsd
|
||||
.tools/
|
||||
wwwroot/css/app.css
|
||||
|
||||
# the e2e tests: the published app they serve and the browser state they keep
|
||||
tests/e2e/.publish/
|
||||
tests/e2e/.state/
|
||||
@@ -14,8 +14,6 @@ columns: navigation, the feed with a tab per kind of feed, and a 3D globe that f
|
||||
in tooltips wherever there is data. Development builds point at a local PrivaPub on `http://127.0.0.1:6970`
|
||||
(`wwwroot/appsettings.Development.json`, never published).
|
||||
|
||||
`Docs/Insomnia_APIs.json` is an old API collection for a localhost backend. Don't treat it as PrivaPub's API.
|
||||
|
||||
## Sibling repo dependency
|
||||
|
||||
The project references `../SocialPub/PrivaPub.ClientModels/PrivaPub.ClientModels.csproj`, so SocialPub must be
|
||||
@@ -45,63 +43,21 @@ dotnet publish decePubClient.csproj -c Release -o publish # static site lands
|
||||
|
||||
`global.json` pins SDK 10.0.100 with `rollForward: latestFeature`. There is no linter.
|
||||
|
||||
## Testing: end to end against the pasture
|
||||
## Verifying
|
||||
|
||||
`tests/e2e` (Playwright for .NET 1.61.0 on xunit v3) runs the published app in Chromium against SocialPub's pasture: a
|
||||
private fediverse of PrivaPub and its peers (GoToSocial, Mastodon, Misskey, Sharkey, Akkoma, Lemmy) seeded with a fake
|
||||
community, the town (see `../SocialPub/CLAUDE.md`, "The town"). Never in CI: it needs podman and the pasture.
|
||||
There is no test project (owner decision 2026-10-17: `tests/e2e` was removed, as was SocialPub's `PrivaPub.Tests`). Don't
|
||||
add one back unless asked. Verify by building, running against a local PrivaPub, and in SocialPub's pasture:
|
||||
|
||||
```sh
|
||||
../SocialPub/tools/pasture/run.sh up gts mastodon misskey sharkey akkoma lemmy # PASTURE_PORT if 6971 is taken
|
||||
../SocialPub/tools/pasture/town.sh seed village
|
||||
tests/e2e/run.sh [--filter ...] # publishes with WasmApplicationEnvironmentName=Pasture, serves https://decepub.test, runs
|
||||
dotnet publish decePubClient.csproj -c Release -p:WasmApplicationEnvironmentName=Pasture -o publish
|
||||
../SocialPub/tools/pasture/run.sh up gts decepub # serves publish/wwwroot as https://decepub.test (peers/decepub.sh)
|
||||
```
|
||||
|
||||
- **The app under test is the deploy's own build** with the environment baked in: Blazor on .NET 10 ignores the
|
||||
`Blazor-Environment` header, so `wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is chosen at publish
|
||||
time and published only then; the deploy fails if it ever ships.
|
||||
- Chromium resolves `*.test` to Caddy on `127.0.0.1:6443` (`--host-resolver-rules`) and ignores the pasture CA. The
|
||||
tests use the Chromium headless shell 1228 cached in `~/.cache/ms-playwright` and never install a browser.
|
||||
- **Guards on every test** (`Support/Browser.cs`): the app talks only to its own site and to PrivaPub, a root JWT never
|
||||
goes to `/api` nor a persona token to `/clientapi`, no 5xx, no Blazor error bar.
|
||||
- **The tests ask the town** (`Support/Pasture.cs`) which accounts exist and what each object holds (`town.sh expect`),
|
||||
and check the other side of the wire through `town.sh stored`. Each render and action test is also a cell of the
|
||||
town's report (`out/client.jsonl`).
|
||||
- Cards carry `data-uri` (their ActivityPub id) and the empty feed `data-feed-empty`, for the tests. The feed renders
|
||||
only the cards near the view, so a test looks for recent cards, and counts what is loaded by `data-feed-count`.
|
||||
- **Phones** (`Mobile/`, `Category=Mobile`): `Browser.Open(state, Phone)` opens Playwright's own device presets
|
||||
(shipped in its package): 360×800, iPhone SE (3rd gen), Pixel 7, and the Pixel 7 held sideways, with touch, a coarse
|
||||
pointer and reduced motion; a phone session fails at once if the page doesn't see a touch screen.
|
||||
`SignIn.As(browser, persona, phone)` reuses the desktop sign-in, and any saved sign-in that has expired is renewed.
|
||||
- `RouteSweepTests` measures every page, one screen at a time down its main scroller (`Mobile/fit.js`). It checks:
|
||||
- sideways scroll, unless the strip is marked `data-scroll-x`;
|
||||
- boxes past the screen's sides;
|
||||
- open menus, sheets and dialogs that are cut or outside the screen;
|
||||
- targets without a 44px hit area: hit-tested at their centre and 21px around it, so a crowding neighbour fails
|
||||
them too. Inline links in post text, native media controls and disabled controls are exempt, a fixed floating
|
||||
control covering a target doesn't count, and `data-touch-exempt` exempts one explicitly;
|
||||
- control text under 12px.
|
||||
|
||||
Screenshots, findings and a contact sheet go to `tests/e2e/out/mobile/<run>/`. Cells are `client.mobile.fit.*` and
|
||||
`client.mobile.touch.*`, with the phone as the ref.
|
||||
- `FlowTests` drives the narrowest phone through the menu, a dialog, a new post, a reply, a reaction, a deletion, the
|
||||
full-screen globe, a phone held sideways, a tap into a thread, a picture and the Back button (`client.mobile.flow.*`).
|
||||
- `DesktopUnchangedTests` holds desktop's control sizes.
|
||||
- **The globe** (`Render/GlobeTests.cs`): the instruments are built, with the Sun drawn over its subsolar point.
|
||||
- On desktop, a located post written from Milano is pinned, and the globe is photographed in both themes and again
|
||||
turned to the subsolar point.
|
||||
- On a phone, the banner draws little, full screen draws everything with the camera moved back, and closing returns
|
||||
to the banner.
|
||||
- An idle globe draws no frame.
|
||||
|
||||
Photos and stats go to `tests/e2e/out/globe/`.
|
||||
- **Restore** (`Category=Restore`, in `Actions/BackupsTests.cs`): a real restore of the pasture from Administration. It
|
||||
ends every session, the town's too, so it runs only when asked (`--filter "Category=Restore"`, never next to other
|
||||
tests) and signs the town in again (`town.sh renew privapub`). Normal runs use `--filter "Category!=Perf&Category!=Restore"`.
|
||||
- **Perf** (`Perf/`, `Category=Perf`, left out of the normal runs with `--filter "Category!=Perf"`): `FeedPerfTests`
|
||||
scrolls load0's home (thousands of posts from SocialPub's `load.sh`) to 2000 posts and writes, per step, the time,
|
||||
DOM nodes, JS and .NET heaps and long tasks to `perf.json`.
|
||||
- The root project globs `**/*.cs`, so it excludes `tests/**` (`DefaultItemExcludes`), and Tailwind does not scan it.
|
||||
- Blazor on .NET 10 ignores the `Blazor-Environment` header, so the environment is chosen at publish time:
|
||||
`wwwroot/appsettings.Pasture.json` (API `https://privapub.test`) is published only then, and the deploy fails if it
|
||||
ever ships.
|
||||
- A browser reaches `*.test` through the pasture's Caddy on `127.0.0.1:6443` (Chromium: `--host-resolver-rules="MAP *.test
|
||||
127.0.0.1:6443"`), and must accept the pasture's CA.
|
||||
|
||||
## CSS: Tailwind 4 + daisyUI 5, vendored, offline
|
||||
|
||||
@@ -125,7 +81,7 @@ without network access. NuGet restore still uses nuget.org.
|
||||
clean-clone publish would ship `app.css` without its `.gz`/`.br` copies and outside `service-worker-assets.js`.
|
||||
The deploy workflow checks for `css/app.css.gz`.
|
||||
- `bash tools/tailwind.sh watch` rebuilds the CSS live next to `dotnet watch`.
|
||||
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/`, `wwwroot/vendor/`, `Docs/`), and only
|
||||
- Tailwind scans every file `.gitignore` doesn't exclude (minus `tools/` and `wwwroot/vendor/`), and only
|
||||
generates the classes it finds written out **whole**. Never build a class name by concatenation or interpolation (`"btn-" + size`, `$"btn-{size}"`). Write
|
||||
every variant as a literal, e.g. in a switch arm.
|
||||
|
||||
@@ -145,7 +101,14 @@ Ionicons 4.5 (`wwwroot/vendor/ionicons.min.css`, `<i class="ion-md-...">`) is lo
|
||||
- picking a hue leaves grey.
|
||||
- `AppStatusService.IsDarkTheme()` tells which variant is showing.
|
||||
- Settings → General edits every theme setting. NavMenu's toggle and slider are shortcuts to the dark and hue ones.
|
||||
The other sections of Settings belong to the persona (see **Settings** under PrivaPub wiring).
|
||||
The other sections of Settings belong to the login or the persona (see **Settings** under PrivaPub wiring).
|
||||
- **The theme is the login's** while `PageSettings.SyncWithLogin` is on (the default; Settings → General turns it off
|
||||
for this device). `CascadingState` takes the login's theme (`ViewUserSettings`; never its language, which would reload
|
||||
the app) at sign-in from the `JwtUser` (`SignedIn`, which Login, Setup and Join call instead of `LoadSession`) and at
|
||||
start-up from `/clientapi/user/settings`, once `savedAt` says a client saved one; while none did, it gives the login
|
||||
this device's. `UpdatePageSettings` saves them to `/clientapi/user/update/settings` 1.5 s after the last change, one
|
||||
save waiting at a time, only when they differ from what the login is known to keep (so taking them never sends them
|
||||
back), with the language as two letters and the icons' hue at 25; a failure is logged, never shown.
|
||||
- `Styles/neo.css` holds the neumorphic layer:
|
||||
- `neo-raised` / `neo-inset` / `neo-flat`, with sizes `neo-2xs` / `neo-xs` / `neo-sm` / `neo-md` (these replace the
|
||||
old `neomorph is-nxsmall` classes);
|
||||
@@ -167,7 +130,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
||||
- Layout differences between a phone and a desktop use the width variants at the call site (`max-md:`, `md:`).
|
||||
- How big a thing is to a finger uses the pointer: `Styles/touch.css` under `@media (pointer: coarse)`, and
|
||||
`pointer-coarse:` at a call site.
|
||||
- A mouse matches none of it, so desktop keeps daisyUI's sizes (`Mobile/DesktopUnchangedTests` holds them).
|
||||
- A mouse matches none of it, so desktop keeps daisyUI's sizes.
|
||||
- A window at most 30rem tall (a phone held sideways) is the `short:` variant.
|
||||
- **`touch.css`** is plain CSS in Tailwind's utilities layer, so like `neo.css` it beats daisyUI's sublayers. It sets
|
||||
daisyUI's own size variables:
|
||||
@@ -180,8 +143,7 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized
|
||||
Its `touch-target` utility gives a small link or button a 44px hit area. It can't be used on something that clips
|
||||
its own overflow (a `truncate`d name takes `pointer-coarse:py-3.5` instead), nor on a daisyUI tooltip, which draws
|
||||
its arrow with `::after` (NerdTip takes minimum sizes).
|
||||
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`). The
|
||||
phone sweep fails anything less.
|
||||
- **Every target has a 44px hit area on touch,** with 8px between targets in a row (`pointer-coarse:gap-2`).
|
||||
- **Overlays on a phone rise from the bottom.**
|
||||
- `DModal Placement="ResponsiveBottom"` is a sheet below md and a centred dialog from md. Every DModal has a visible
|
||||
close button (`ShowClose`, named by `CloseText`).
|
||||
@@ -257,7 +219,7 @@ supplies:
|
||||
- `AfterRenderAsyncJobs`, run once after the next render;
|
||||
- `WhenOnline` / `StartOnlinePolling`.
|
||||
|
||||
`PagesBase` is an empty subclass of it that nothing uses. Never redeclare the base's members in a component.
|
||||
Never redeclare the base's members in a component.
|
||||
|
||||
**UI feedback.**
|
||||
- `Services/ToastService.cs` is the snackbar: the SUtility extensions `AddResponseError(WebResult)` (silent on
|
||||
@@ -277,8 +239,8 @@ supplies:
|
||||
`SUtility.DefaultSerializer` (camelCase, enums by name like PrivaPub, `ClientJsonContext`) for `clientapi/`. A payload
|
||||
can be an object (JSON), name/value pairs (a form) or a `Func<HttpContent>` (multipart), built once per attempt.
|
||||
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
|
||||
them; `GetAnon` / `PostAnon` add them when there are some; `*TotallyAnon` send none (the login must not carry a JWT:
|
||||
PrivaPub redirects it).
|
||||
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
|
||||
not carry a JWT: PrivaPub redirects them).
|
||||
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
|
||||
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
|
||||
`HttpClient`'s own is off.
|
||||
@@ -295,7 +257,19 @@ supplies:
|
||||
- **One sign-in** (`Services/AuthService.cs`, `Pages/Login.razor`): the root signs in on `/clientapi/user/login`, then
|
||||
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
|
||||
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
|
||||
persona tokens; logout revokes every persona token, then forgets the session and IndexedDB.
|
||||
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
|
||||
forgets the session.
|
||||
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
|
||||
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
|
||||
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
|
||||
persona token, this device's too: `AuthService.ChangePassword` and `TotpEnable` keep the new `JwtUser` PrivaPub
|
||||
answers with (`ContinueSession`), drop the stored persona tokens and exchange one for the current persona at once
|
||||
(the others when next used), so the device stays signed in.
|
||||
- **Two-factor sign-in** (TOTP, `/clientapi/user/totp/*`): with it on, a right password is answered with a 428
|
||||
(`AuthService.NeedsOneTimeCode`). Login keeps the name and the password in the form, out of sight, and sends it again
|
||||
with `LoginForm.OneTimeCode`: the authenticator app's six digits (`DOtp`, `data-login-code`) or, after "Use a recovery
|
||||
code instead", a recovery code like `abcde-fghjk` (a text field, `data-login-recovery`). A wrong code is a 400, like a
|
||||
wrong password, with PrivaPub's message. It is turned on and off in Settings (`TotpEditor`, see **Settings**).
|
||||
- **The ways in** (sign-up is closed on PrivaPub; owner decision 2026-10-16):
|
||||
- **Setup** (`Pages/Setup.razor` at `/setup`): while PrivaPub has no administrator (`/clientapi/user/setup`), the first
|
||||
login is made there, first come, first served, with its first persona. It becomes the administrator, and setup
|
||||
@@ -304,6 +278,13 @@ supplies:
|
||||
the group (`/clientapi/user/invitation/signup`). Login links there, and a group's page shows the link with its code.
|
||||
- Both sign in at once like Login (`AuthService.SetUp` / `Join`, sent with no credentials). A login without a
|
||||
persona is sent from Login to Settings to make one.
|
||||
- **A forgotten password** (`Pages/PasswordRecovery.razor` at `/password-recovery`, linked from Login): the login's
|
||||
name or its address asks for a link (`/clientapi/user/recover/password`), and the page shows PrivaPub's answer, the
|
||||
same sentence for everyone, so it never tells whether a login or an address exists. PrivaPub mails only a verified
|
||||
address. The link comes back as `/password-recovery?rc=`: the page keeps the code, takes it out of the address bar
|
||||
and the history at once (`replace`), checks it (`/recover/valid`, the code as a JSON string), and sets the new
|
||||
password with it (`/recover/update/password`), which ends every session of the login; then the reader signs in.
|
||||
All of it goes with no credentials.
|
||||
- **Services** (`Services/`): `TimelineService` (every feed as `FeedPage`s of `FeedPost`s, paged by `max_id`/`min_id`;
|
||||
circles are read as ids from `/clientapi` and hydrated through `/api/v1/statuses?id[]=`; located posts come from
|
||||
`/clientapi/post/nearby` and have no Mastodon actions), `StatusService` (publish, favourite, boost, bookmark, delete,
|
||||
@@ -377,15 +358,25 @@ supplies:
|
||||
(`/api/v2/search`, never resolving), once typing pauses.
|
||||
An edited post's card says so, and its versions open from there (`/statuses/:id/history`, newest first; PrivaPub
|
||||
keeps a remote post's earlier versions from its edits and from Pleroma's `formerRepresentations`).
|
||||
- **Threads** (`Pages/ExpandMessage.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
|
||||
- **Threads** (`Pages/Thread.razor` at `/expand/{id}`): opening a remote thread makes PrivaPub read its replies
|
||||
from their servers (its FEP-7888 `context`, else `replies`), so the page reads the thread again after 4 and 15 seconds
|
||||
and adds what was found, in the thread's order.
|
||||
- **Interaction policies** (GoToSocial's, and FEP-5624's `canReply` on a PeerTube video whose comments are moderated): a
|
||||
reply or boost whose target's author must approve it shows so on its card (`privapub.approval`: pending, then nothing
|
||||
once approved, or rejected); one the policy shuts out is refused by PrivaPub (422) and shown as an error.
|
||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its password changed,
|
||||
or the whole of it deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`;
|
||||
the client then signs out), the persona's profile (`ProfileEditor`:
|
||||
- **Settings** (`Pages/Settings.razor`, `Components/Settings/`): the login itself (`AccountEditor`: its email address
|
||||
(`/clientapi/user/account`), set only with the password and a 6-digit code mailed to the new address (`/user/email`,
|
||||
then `/email/verify` through `DOtp`; the old `/user/update` is refused), shown verified or not and with an address
|
||||
waiting for its code, and removed with the password (`/email/remove`); a wrong password is PrivaPub's 403, read as
|
||||
such; its password changed; its two-factor sign-in (`TotpEditor`, a block of AccountEditor that reads the login itself:
|
||||
turned on with the password (`/totp/setup`), which shows the secret as a QR image, as selectable text and as an
|
||||
`otpauth://` link an authenticator app opens, then the app's first code (`/totp/enable`, a 409 once the hour-long
|
||||
setup expired), after which the ten recovery codes are shown once, with Copy (`AppStatusService.CopyText`) and "I saved
|
||||
them"; on, it shows how many recovery codes are left, warns from three down, and is turned off, asked first, with the
|
||||
password and a code from the app or a recovery code (`/totp/disable`)); every session of it ended ("Sign out
|
||||
everywhere", see **One sign-in**); or the whole of it
|
||||
deleted, every persona with it, through `/clientapi/user/update/password` and `/clientapi/user/delete`; the client then
|
||||
signs out), the persona's profile (`ProfileEditor`:
|
||||
`update_credentials` as multipart, its words as written from `Account.Source`, pictures (streamed from the file,
|
||||
and removed with `DELETE /api/v1/profile/avatar|header`), fields, locked, bot,
|
||||
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
|
||||
@@ -487,17 +478,14 @@ supplies:
|
||||
- Lookup order: this repo's `Resources/AllStrings.resx` (+ `.it.resx`), then `FieldsNameResource`, then
|
||||
`ErrorsResource`.
|
||||
- A missing key renders as the key itself, formatted with its arguments, so a typo fails silently.
|
||||
- The class lives in namespace `collAnon.Client.Services`, a leftover from another project.
|
||||
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`. A new key
|
||||
needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||||
- `Resources/ErrorMessages.resx` is read by `[Required]` / `[StringLength]` through `ErrorMessageResourceType`, and by the
|
||||
D fields' parse errors (`DInputBase`, `DCalendar`) through the static `ErrorMessages.InvalidValue` / `InvalidDate`. A new
|
||||
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
|
||||
|
||||
**Client storage** comes in two layers:
|
||||
- **localStorage** (Blazored.LocalStorage, camelCase JSON) holds `AuthData`, `PublicCacheData`, `FeedFilter` and the
|
||||
server cache `ServerInfos`, keyed by `nameof(Type)`.
|
||||
- **IndexedDB** (DnetIndexedDb, database `data`) is reached through `IStorage`. Its schema is declared in
|
||||
`GenericExtensions.AddIndexedDb()`, and you must bump `WithVersion` when you change it.
|
||||
- The schema is at version 3: the `ClientLogs` store that `IStorage.AddLog` writes (version 2 added it; version 3 left
|
||||
the mock feed's `Message` store out).
|
||||
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`
|
||||
and the server cache `ServerInfos`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
|
||||
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
|
||||
still holds it.
|
||||
|
||||
**Auth.** `TokenAuthStateProvider` holds `AuthData` (localStorage, cached in memory) and builds the auth state from
|
||||
it: the root's name, its policies as claims, and the current persona. Pages that need a session carry
|
||||
@@ -507,10 +495,12 @@ it: the root's name, its policies as claims, and the current persona. Pages that
|
||||
- `wwwroot/main.js` defines `window.*` helpers. `Services/AppStatusService.cs` calls them, mostly through the
|
||||
synchronous `IJSInProcessRuntime`.
|
||||
- Each helper reports its errors back through `window.logFromJs`.
|
||||
- The other scripts (IndexedDB, HeadElement, rxjs) are loaded by `<script>` tags in `wwwroot/index.html`; the feed's
|
||||
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
|
||||
`wwwroot/index.html`; the feed's
|
||||
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`.
|
||||
|
||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version.
|
||||
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
|
||||
published worker names its cache after the assets manifest's version, so each publish drops the previous cache.
|
||||
|
||||
## Development rules
|
||||
|
||||
|
||||
+14
-2
@@ -27,8 +27,6 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(ServerInfo))]
|
||||
[JsonSerializable(typeof(List<ServerInfo>))]
|
||||
[JsonSerializable(typeof(UploadMedia))]
|
||||
[JsonSerializable(typeof(ClientLogs))]
|
||||
[JsonSerializable(typeof(ClientLogException))]
|
||||
[JsonSerializable(typeof(ValidationErrors))]
|
||||
[JsonSerializable(typeof(WebResult))]
|
||||
[JsonSerializable(typeof(LoginForm))]
|
||||
@@ -37,6 +35,20 @@ namespace decePubClient;
|
||||
[JsonSerializable(typeof(ViewServerSetup))]
|
||||
[JsonSerializable(typeof(UserPasswordForm))]
|
||||
[JsonSerializable(typeof(RemoveSelfForm))]
|
||||
[JsonSerializable(typeof(PasswordRecoveryForm))]
|
||||
[JsonSerializable(typeof(NewPasswordForm))]
|
||||
[JsonSerializable(typeof(ViewAccount))]
|
||||
[JsonSerializable(typeof(EmailChangeForm))]
|
||||
[JsonSerializable(typeof(EmailCodeForm))]
|
||||
[JsonSerializable(typeof(PasswordForm))]
|
||||
[JsonSerializable(typeof(TotpCodeForm))]
|
||||
[JsonSerializable(typeof(TotpDisableForm))]
|
||||
[JsonSerializable(typeof(ViewTotpSetup))]
|
||||
[JsonSerializable(typeof(ViewTotpEnabled))]
|
||||
[JsonSerializable(typeof(ViewUserSettings))]
|
||||
// recover/valid takes the code as a JSON string ([FromBody] string) and answers a JSON bool
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(JwtUser))]
|
||||
[JsonSerializable(typeof(ViewAvatar))]
|
||||
[JsonSerializable(typeof(List<ViewAvatar>))]
|
||||
|
||||
+21
-21
@@ -167,17 +167,17 @@
|
||||
@if (!form.IsLocated && GroupId is null && Editing is null)
|
||||
{
|
||||
<DDropdown @bind-IsOpen="form.IsScopeOptionsOpen" TriggerClass="@(form.IsScopeOptionsOpen ? "btn btn-sm btn-active" : "btn btn-sm")" SheetOnNarrow="true"
|
||||
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]"
|
||||
aria-label="@Localizer["Message scope type: {0}", Localizer[form.MessageType.ToString()]]">
|
||||
ContentClass="mt-2 flex flex-col gap-2 p-2 rounded-box" title="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]"
|
||||
aria-label="@Localizer["Message scope type: {0}", Localizer[form.Visibility.ToString()]]">
|
||||
<Trigger>
|
||||
<i class="@form.MessageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
||||
<i class="@form.Visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
|
||||
</Trigger>
|
||||
<ChildContent>
|
||||
@foreach (var messageType in Enum.GetValues<MessageType>())
|
||||
@foreach (var visibility in Enum.GetValues<Visibility>())
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.MessageType == messageType" OnClick="() => SetMessageType(messageType)">
|
||||
<i class="@messageType.GetMessageTypeIcon() text-base" aria-hidden="true"></i>
|
||||
<span>@Localizer[messageType.ToString()]</span>
|
||||
<DButton Size="DSize.Sm" class="justify-start whitespace-nowrap" Active="form.Visibility == visibility" OnClick="() => SetVisibility(visibility)">
|
||||
<i class="@visibility.GetVisibilityIcon() text-base" aria-hidden="true"></i>
|
||||
<span>@Localizer[visibility.ToString()]</span>
|
||||
</DButton>
|
||||
}
|
||||
</ChildContent>
|
||||
@@ -391,11 +391,11 @@
|
||||
: System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
||||
|
||||
// a new post starts with the visibility the persona chose in Settings
|
||||
MessageType DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
||||
Visibility DefaultVisibility => CascadingState.Persona?.Source?.Privacy switch
|
||||
{
|
||||
"private" => MessageType.FollowersOnly,
|
||||
"unlisted" => MessageType.Unlisted,
|
||||
_ => MessageType.Public
|
||||
"private" => Visibility.FollowersOnly,
|
||||
"unlisted" => Visibility.Unlisted,
|
||||
_ => Visibility.Public
|
||||
};
|
||||
|
||||
DTextarea<string> textarea;
|
||||
@@ -413,7 +413,7 @@
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
form.GroupId = GroupId;
|
||||
form.MessageType = DefaultVisibility;
|
||||
form.Visibility = DefaultVisibility;
|
||||
form.Language = DefaultLanguage;
|
||||
form.Sensitive = CascadingState.Persona?.Source?.Sensitive == true;
|
||||
form.QuotedStatusId = Quoting?.Id;
|
||||
@@ -446,12 +446,12 @@
|
||||
return;
|
||||
|
||||
form.InReplyToId = InReplyTo.Id;
|
||||
form.MessageType = InReplyTo.Visibility switch
|
||||
form.Visibility = InReplyTo.Visibility switch
|
||||
{
|
||||
"direct" => MessageType.Direct,
|
||||
"private" => MessageType.FollowersOnly,
|
||||
"unlisted" => MessageType.Unlisted,
|
||||
_ => MessageType.Public
|
||||
"direct" => Visibility.Direct,
|
||||
"private" => Visibility.FollowersOnly,
|
||||
"unlisted" => Visibility.Unlisted,
|
||||
_ => Visibility.Public
|
||||
};
|
||||
// a reply is written in its thread's language, as a rule
|
||||
form.Language = InReplyTo.Language is { Length: 2 } language && Languages.All.Any(l => l.Code == language) ? language : form.Language;
|
||||
@@ -462,9 +462,9 @@
|
||||
form.Content = string.Join(' ', mentions) is { Length: > 0 } prefix ? prefix + " " : null;
|
||||
}
|
||||
|
||||
void SetMessageType(MessageType messageType)
|
||||
void SetVisibility(Visibility visibility)
|
||||
{
|
||||
form.MessageType = messageType;
|
||||
form.Visibility = visibility;
|
||||
form.IsScopeOptionsOpen = false;
|
||||
}
|
||||
|
||||
@@ -688,14 +688,14 @@
|
||||
if (result.Data is ScheduledStatus scheduled)
|
||||
{
|
||||
Toast.AddResponseSuccess(Localizer["Scheduled for {0}.", scheduled.ScheduledAt.ToLocalTime().ToString(SUtility.dateTimeFormatExt)]);
|
||||
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
||||
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
|
||||
await OnPublished.InvokeAsync(null);
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(form.IsLocated ? Localizer["Located post published."] : Localizer["Published."]);
|
||||
var published = result.Data as FeedPost;
|
||||
form = new() { GroupId = GroupId, MessageType = DefaultVisibility, Language = DefaultLanguage };
|
||||
form = new() { GroupId = GroupId, Visibility = DefaultVisibility, Language = DefaultLanguage };
|
||||
await OnPublished.InvokeAsync(published);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
The card does its own Mastodon actions (favourite, boost, bookmark, reply, mute, block, delete) and tells the page
|
||||
about replies and deletions. data-post-* attributes tell the globe where the post is (wwwroot/js/globe.js); a
|
||||
quoted post (Compact) has none, since it is not an item of the feed. A CDN-fronted server's post has no point, only
|
||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id,
|
||||
which the e2e tests (tests/e2e) find it by. The persona's filters apply where the page says the card is (FilterIn): a
|
||||
its CDN's group (data-cdn); data-post-at is when it was written, for the globe's Sun; data-uri is its ActivityPub id.
|
||||
The persona's filters apply where the page says the card is (FilterIn): a
|
||||
hide filter leaves it out, a warn filter folds it behind the filter's title, a blur filter blurs its media. Located posts have no Mastodon actions (PrivaPub keeps them off the Mastodon API); their author deletes them through /clientapi. Nerd stats are on
|
||||
the time, the author and its server's history, the place or the CDN, the media and the counts. *@
|
||||
@if (!IsFilteredOut)
|
||||
@@ -572,8 +572,8 @@
|
||||
|
||||
// A card re-renders when something it shows may have changed: its own actions and toggles (its events), a new copy of
|
||||
// its post (an edit, a grouped notification, a place found), where it is shown, or the persona and connection. Without
|
||||
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll to
|
||||
// (tests/e2e/Perf/FeedPerfTests).
|
||||
// this every card re-rendered whenever its list did: a feed of a thousand cards took seven minutes to scroll
|
||||
// through.
|
||||
object renderedWith;
|
||||
int handlingEvents;
|
||||
|
||||
@@ -1129,10 +1129,10 @@
|
||||
|
||||
string VisibilityText(string visibility) => visibility switch
|
||||
{
|
||||
"public" => Localizer[MessageType.Public.ToString()],
|
||||
"unlisted" => Localizer[MessageType.Unlisted.ToString()],
|
||||
"private" => Localizer[MessageType.FollowersOnly.ToString()],
|
||||
"direct" => Localizer[MessageType.Direct.ToString()],
|
||||
"public" => Localizer[Visibility.Public.ToString()],
|
||||
"unlisted" => Localizer[Visibility.Unlisted.ToString()],
|
||||
"private" => Localizer[Visibility.FollowersOnly.ToString()],
|
||||
"direct" => Localizer[Visibility.Direct.ToString()],
|
||||
_ => Localizer["Located"]
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Globalization;
|
||||
|
||||
using decePubClient.Resources;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
@@ -39,7 +41,7 @@ public abstract class DInputBase<TValue> : InputBase<TValue>
|
||||
return true;
|
||||
}
|
||||
|
||||
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not valid.";
|
||||
validationErrorMessage = string.Format(ErrorMessages.InvalidValue, DisplayName ?? FieldIdentifier.FieldName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
result = date;
|
||||
return true;
|
||||
}
|
||||
validationErrorMessage = $"The {DisplayName ?? FieldIdentifier.FieldName} field is not a valid date.";
|
||||
validationErrorMessage = string.Format(ErrorMessages.InvalidDate, DisplayName ?? FieldIdentifier.FieldName);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,112 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* The login itself (the root, /clientapi/user): its password changed, or the whole of it deleted, every persona with
|
||||
it. Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
|
||||
@* The login itself (the root, /clientapi/user): its email address, changed only with the password and a code sent to the
|
||||
new one, since a forgotten password is recovered through it; its password changed (every other session ends, this
|
||||
device's goes on with a new JWT); its two-factor sign-in (TotpEditor); every session of it ended at once; or the whole
|
||||
of it deleted, every persona with it.
|
||||
Deleting it deletes everything public its personas had, and PrivaPub tells the servers that hold it. *@
|
||||
<div class="flex flex-col gap-4 w-full" data-account>
|
||||
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-email>
|
||||
<b class="text-sm">@Localizer["Email address"]</b>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else if (account is not null)
|
||||
{
|
||||
@if (account.Email is { Length: > 0 } email)
|
||||
{
|
||||
<p class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="break-all" data-account-email-address>@email</span>
|
||||
@if (account.IsEmailValidated)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-account-email-verified>@Localizer["verified"]</DBadge>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning" data-account-email-unverified>@Localizer["not verified"]</DBadge>
|
||||
}
|
||||
</p>
|
||||
@if (!account.IsEmailValidated)
|
||||
{
|
||||
<p class="text-xs opacity-70">@Localizer["Only a verified address can recover a forgotten password."]</p>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-sm" data-account-email-none>@Localizer["No email address yet: without one, a forgotten password can't be recovered."]</p>
|
||||
}
|
||||
|
||||
@if (account.PendingEmail is { Length: > 0 } pending && !isChangingEmail)
|
||||
{
|
||||
<EditForm Model="code" OnValidSubmit="VerifyEmail" class="flex flex-col gap-2" data-account-email-code>
|
||||
<DataAnnotationsValidator />
|
||||
@if (codeSentence is not null)
|
||||
{
|
||||
<p class="text-sm opacity-70">@codeSentence</p>
|
||||
}
|
||||
<p class="text-sm break-all">@Localizer["Enter the code sent to {0}.", pending]</p>
|
||||
<DOtp @bind-Value="code.Code" Immediate="true" aria-label="@Localizer["Code"]" data-account-email-code-digits />
|
||||
<ErrorViewer />
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-verify>
|
||||
<i class="ion-md-checkmark"></i><span>@Localizer["Verify"]</span>
|
||||
</DButton>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@if (isChangingEmail)
|
||||
{
|
||||
<EditForm Model="change" OnValidSubmit="ChangeEmail" class="flex flex-col gap-2" data-account-email-change>
|
||||
<DataAnnotationsValidator />
|
||||
<DInput TValue="string" Type="email" @bind-Value="change.Email" Immediate="true" autocomplete="email" inputmode="email" autocapitalize="none"
|
||||
spellcheck="false" placeholder="@Localizer["New email address"]" data-account-email-new />
|
||||
<DInput TValue="string" Type="password" @bind-Value="change.Password" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-account-email-password />
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-send>
|
||||
<i class="ion-md-mail"></i><span>@Localizer["Send the code"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else if (isRemovingEmail)
|
||||
{
|
||||
<EditForm Model="emailRemoval" OnValidSubmit="RemoveEmail" class="flex flex-col gap-2" data-account-email-remove>
|
||||
<DataAnnotationsValidator />
|
||||
<DInput TValue="string" Type="password" @bind-Value="emailRemoval.Password" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-account-email-remove-password />
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseEmailForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-account-email-remove-confirm>
|
||||
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
@if (account.Email is { Length: > 0 })
|
||||
{
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailRemoval" data-account-email-remove-open>
|
||||
<i class="ion-md-trash"></i><span>@Localizer["Remove the address"]</span>
|
||||
</DButton>
|
||||
}
|
||||
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenEmailChange" data-account-email-change-open>
|
||||
<i class="ion-md-create"></i><span>@(account.Email is { Length: > 0 } ? Localizer["Change"] : Localizer["Add an address"])</span>
|
||||
</DButton>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditForm Model="password" OnValidSubmit="ChangePassword" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-password>
|
||||
<DataAnnotationsValidator />
|
||||
<b class="text-sm">@Localizer["Change the password"]</b>
|
||||
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
|
||||
<DInput TValue="string" type="password" @bind-Value="password.OldPassword" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-account-current />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -20,6 +121,16 @@
|
||||
</DButton>
|
||||
</EditForm>
|
||||
|
||||
<TotpEditor />
|
||||
|
||||
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-everywhere>
|
||||
<b class="text-sm">@Localizer["Sign out everywhere"]</b>
|
||||
<p class="text-sm">@Localizer["Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours."]</p>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Warning" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="LogoutEverywhere" data-account-everywhere-confirm>
|
||||
<i class="ion-md-log-out"></i><span>@Localizer["Sign out everywhere"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
|
||||
<EditForm Model="removal" OnValidSubmit="Delete" class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-account-delete>
|
||||
<DataAnnotationsValidator />
|
||||
<b class="text-sm text-error">@Localizer["Delete this login"]</b>
|
||||
@@ -36,17 +147,125 @@
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
|
||||
ViewAccount account;
|
||||
EmailChangeForm change = new();
|
||||
EmailCodeForm code = new();
|
||||
PasswordForm emailRemoval = new();
|
||||
UserPasswordForm password = new();
|
||||
RemoveSelfForm removal = new();
|
||||
/// <summary>PrivaPub's answer to the change asked here, the same whether or not the address is free.</summary>
|
||||
string codeSentence;
|
||||
bool isChangingEmail;
|
||||
bool isRemovingEmail;
|
||||
bool understood;
|
||||
bool isSaving;
|
||||
|
||||
protected override void OnInitialized() => IsLoading = false;
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => account = await AuthService.Account();
|
||||
|
||||
void OpenEmailChange()
|
||||
{
|
||||
change = new();
|
||||
isRemovingEmail = false;
|
||||
isChangingEmail = true;
|
||||
}
|
||||
|
||||
void OpenEmailRemoval()
|
||||
{
|
||||
emailRemoval = new();
|
||||
isChangingEmail = false;
|
||||
isRemovingEmail = true;
|
||||
}
|
||||
|
||||
void CloseEmailForms()
|
||||
{
|
||||
isChangingEmail = false;
|
||||
isRemovingEmail = false;
|
||||
}
|
||||
|
||||
async Task ChangeEmail()
|
||||
{
|
||||
isSaving = true;
|
||||
change.Email = change.Email.Trim();
|
||||
var result = await AuthService.ChangeEmail(change);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
codeSentence = result.Data is string { Length: > 0 } said
|
||||
? said
|
||||
: Localizer["If that address can be used, a code is on its way to it. It works for one hour."];
|
||||
// as PrivaPub keeps it while it waits for its code
|
||||
account.PendingEmail = change.Email.ToLowerInvariant();
|
||||
change = new();
|
||||
code = new();
|
||||
isChangingEmail = false;
|
||||
}
|
||||
|
||||
async Task VerifyEmail()
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await AuthService.VerifyEmail(code.Code.Trim());
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
// five wrong codes, or an hour, and the address no longer waits
|
||||
account = await AuthService.Account() ?? account;
|
||||
return;
|
||||
}
|
||||
|
||||
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||
code = new();
|
||||
codeSentence = default;
|
||||
Toast.AddResponseSuccess(Localizer["Email address verified."]);
|
||||
}
|
||||
|
||||
async Task RemoveEmail()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Remove the email address?"], Localizer["Without one, a forgotten password can't be recovered."],
|
||||
Localizer["Remove the address"], true))
|
||||
return;
|
||||
|
||||
isSaving = true;
|
||||
var result = await AuthService.RemoveEmail(emailRemoval.Password);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||
emailRemoval = new();
|
||||
codeSentence = default;
|
||||
isRemovingEmail = false;
|
||||
Toast.AddResponseSuccess(Localizer["Email address removed."]);
|
||||
}
|
||||
|
||||
async Task ChangePassword()
|
||||
{
|
||||
isSaving = true;
|
||||
// the session goes on with the JWT PrivaPub answers with, and the persona with a new token
|
||||
var result = await AuthService.ChangePassword(password);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
@@ -59,6 +278,26 @@
|
||||
Toast.AddResponseSuccess(Localizer["Password changed."]);
|
||||
}
|
||||
|
||||
async Task LogoutEverywhere()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Sign out everywhere?"],
|
||||
Localizer["Every device and every persona of this login signs out, this one too. Signing in again takes the password."],
|
||||
Localizer["Sign out everywhere"], true))
|
||||
return;
|
||||
|
||||
isSaving = true;
|
||||
var result = await AuthService.LogoutEverywhere();
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.SignOut();
|
||||
Navigation.NavigateTo("login");
|
||||
}
|
||||
|
||||
async Task Delete()
|
||||
{
|
||||
isSaving = true;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
data-export-kind="@data">
|
||||
@if (exporting == data)
|
||||
{
|
||||
<DLoading Size="DSize.Xs" />
|
||||
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -84,7 +84,7 @@ else
|
||||
<legend class="text-sm font-semibold pb-1">@Localizer["New posts"]</legend>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span>@Localizer["Visibility"]</span>
|
||||
<DSelect TValue="MessageType" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
||||
<DSelect TValue="Visibility" Size="DSize.Sm" @bind-Value="form.Privacy" data-profile-privacy>
|
||||
@foreach (var type in Visibilities)
|
||||
{
|
||||
<option value="@type">@Localizer[type.ToString()]</option>
|
||||
@@ -130,7 +130,7 @@ else
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
[Inject] ILoggingService Logger { get; set; }
|
||||
|
||||
static readonly MessageType[] Visibilities = [MessageType.Public, MessageType.Unlisted, MessageType.FollowersOnly];
|
||||
static readonly Visibility[] Visibilities = [Visibility.Public, Visibility.Unlisted, Visibility.FollowersOnly];
|
||||
|
||||
Account account;
|
||||
ProfileForm form = new();
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
@inherits LocalizableComponentBase
|
||||
|
||||
@* Two-factor sign-in (TOTP, /clientapi/user/totp), a block of the login's settings (AccountEditor). Off: turned on with
|
||||
the password (setup), then the secret shown as a QR code, as text and as an otpauth:// link an authenticator app opens,
|
||||
and the app's first code (enable); then the ten recovery codes, shown this once. Turning it on ends every other session
|
||||
and every persona token, this device's too, and AuthService.TotpEnable goes on with the JWT PrivaPub answers. On: the
|
||||
recovery codes left, and turned off with the password and a code from the app or a recovery code, asked first. *@
|
||||
<div class="flex flex-col gap-2 p-3 rounded-xl neo-inset neo-2xs" data-totp>
|
||||
<p class="flex flex-wrap items-center gap-2">
|
||||
<b class="text-sm">@Localizer["Two-factor sign-in"]</b>
|
||||
@if (account is { TwoFactorEnabled: true })
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Success" data-totp-on>@Localizer["active"]</DBadge>
|
||||
}
|
||||
else if (account is not null)
|
||||
{
|
||||
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" data-totp-off>@Localizer["not active"]</DBadge>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<DLoading Variant="DLoadingVariant.Dots" Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else if (recoveryCodes.Count > 0)
|
||||
{
|
||||
<div class="flex flex-col gap-3" data-totp-codes>
|
||||
<DAlert Color="DAlertColor.Success" Variant="DAlertVariant.Soft" role="status">
|
||||
<Icon><i class="ion-md-checkmark-circle text-xl" aria-hidden="true"></i></Icon>
|
||||
<ChildContent>@Localizer["Two-factor sign-in is on. Every other device signed in to this login was signed out."]</ChildContent>
|
||||
</DAlert>
|
||||
<p class="text-sm">
|
||||
<b>@Localizer["Save these recovery codes now: they won't be shown again."]</b>
|
||||
@Localizer["Each one signs you in once in place of the app's code, if you lose your phone."]
|
||||
</p>
|
||||
<ol class="grid grid-cols-2 gap-x-4 gap-y-1 p-3 rounded-lg neo-inset neo-2xs font-mono text-sm select-all" data-totp-recovery-codes>
|
||||
@foreach (var recoveryCode in recoveryCodes)
|
||||
{
|
||||
<li>@recoveryCode</li>
|
||||
}
|
||||
</ol>
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CopyCodes" data-totp-copy>
|
||||
<i class="ion-md-copy"></i><span>@Localizer["Copy"]</span>
|
||||
</DButton>
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="CodesSaved" data-totp-saved>
|
||||
<i class="ion-md-checkmark"></i><span>@Localizer["I saved them"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (account is { TwoFactorEnabled: true })
|
||||
{
|
||||
<p class="text-sm">@Localizer["Signing in takes the password and a code from your authenticator app, or one of your recovery codes."]</p>
|
||||
<p class="text-sm" data-totp-codes-left>@Localizer["Recovery codes left: {0}", account.RecoveryCodesLeft]</p>
|
||||
@if (account.RecoveryCodesLeft <= FewRecoveryCodes)
|
||||
{
|
||||
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" role="status" data-totp-codes-few>
|
||||
<Icon><i class="ion-md-warning text-xl" aria-hidden="true"></i></Icon>
|
||||
<ChildContent>@Localizer["Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones."]</ChildContent>
|
||||
</DAlert>
|
||||
}
|
||||
|
||||
@if (isDisabling)
|
||||
{
|
||||
<EditForm Model="disable" OnValidSubmit="Disable" class="flex flex-col gap-2" data-totp-disable>
|
||||
<DataAnnotationsValidator />
|
||||
<DInput TValue="string" Type="password" @bind-Value="disable.Password" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-totp-disable-password />
|
||||
@if (disableWithRecoveryCode)
|
||||
{
|
||||
<DInput TValue="string" @bind-Value="disable.Code" Immediate="true" required="@true" autocomplete="one-time-code" inputmode="text"
|
||||
autocapitalize="none" spellcheck="false" placeholder="@Localizer["Recovery code"]" data-totp-disable-recovery />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
|
||||
<DOtp @bind-Value="disable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-disable-code />
|
||||
}
|
||||
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-start" OnClick="ToggleDisableRecoveryCode" data-totp-disable-use-recovery>
|
||||
@(disableWithRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
|
||||
</DButton>
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Error" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-disable-confirm>
|
||||
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenDisable" data-totp-disable-open>
|
||||
<i class="ion-md-unlock"></i><span>@Localizer["Turn off"]</span>
|
||||
</DButton>
|
||||
}
|
||||
}
|
||||
else if (account is not null)
|
||||
{
|
||||
<p class="text-sm">@Localizer["Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough."]</p>
|
||||
|
||||
@if (setup is not null)
|
||||
{
|
||||
<div class="flex flex-col gap-3" data-totp-scan>
|
||||
<p class="text-sm">@Localizer["Scan this QR code with your authenticator app, or type the key into it."]</p>
|
||||
<img src="@setup.QrDataUri" alt="@Localizer["QR code of the key, for your authenticator app"]"
|
||||
class="self-center w-48 h-48 p-2 rounded-lg bg-white" data-totp-qr />
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Key"]</span>
|
||||
<code class="p-2 rounded-lg neo-inset neo-2xs font-mono text-sm break-all select-all" data-totp-secret>@setup.Secret</code>
|
||||
</div>
|
||||
@* an <a href> to otpauth://, which a phone with an authenticator app opens in it *@
|
||||
<DButton Href="@setup.OtpAuthUri" Size="DSize.Sm" class="self-center" data-totp-uri>
|
||||
<i class="ion-md-open"></i><span>@Localizer["Open in the authenticator app"]</span>
|
||||
</DButton>
|
||||
<EditForm Model="enable" OnValidSubmit="Enable" class="flex flex-col gap-2" data-totp-enable-form>
|
||||
<DataAnnotationsValidator />
|
||||
<span class="text-sm">@Localizer["Then enter the 6-digit code the app shows."]</span>
|
||||
<DOtp @bind-Value="enable.Code" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-totp-code />
|
||||
<ErrorViewer />
|
||||
<p class="text-xs opacity-70">@Localizer["Every other device signed in to this login is signed out."]</p>
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-enable>
|
||||
<i class="ion-md-lock"></i><span>@Localizer["Turn on"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
}
|
||||
else if (isAskingPassword)
|
||||
{
|
||||
<EditForm Model="setupPassword" OnValidSubmit="Setup" class="flex flex-col gap-2" data-totp-setup>
|
||||
<DataAnnotationsValidator />
|
||||
<DInput TValue="string" Type="password" @bind-Value="setupPassword.Password" Immediate="true" autocomplete="current-password"
|
||||
placeholder="@Localizer["Current password"]" data-totp-setup-password />
|
||||
<ErrorViewer />
|
||||
<div class="flex flex-wrap justify-end gap-2 pointer-coarse:gap-2">
|
||||
<DButton Size="DSize.Sm" OnClick="CloseForms">@Localizer["Cancel"]</DButton>
|
||||
<DButton Type="submit" Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || isSaving)" data-totp-setup-confirm>
|
||||
<i class="ion-md-arrow-forward"></i><span>@Localizer["Continue"]</span>
|
||||
</DButton>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
else
|
||||
{
|
||||
<DButton Size="DSize.Sm" Color="DColor.Primary" class="self-end" Disabled="@(IsDefaultDisabled || isSaving)" OnClick="OpenSetup"
|
||||
data-totp-setup-open>
|
||||
<i class="ion-md-lock"></i><span>@Localizer["Turn on two-factor sign-in"]</span>
|
||||
</DButton>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
[Inject] ConfirmService Confirm { get; set; }
|
||||
|
||||
/// <summary>From this many recovery codes left down, the block warns.</summary>
|
||||
const int FewRecoveryCodes = 3;
|
||||
|
||||
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the block stays empty).</summary>
|
||||
ViewAccount account;
|
||||
PasswordForm setupPassword = new();
|
||||
/// <summary>What the authenticator app needs while PrivaPub waits for its first code (an hour); null otherwise.</summary>
|
||||
ViewTotpSetup setup;
|
||||
TotpCodeForm enable = new();
|
||||
TotpDisableForm disable = new();
|
||||
/// <summary>The recovery codes PrivaPub made as two-factor sign-in was turned on, shown this once; empty otherwise.</summary>
|
||||
List<string> recoveryCodes = [];
|
||||
bool isAskingPassword;
|
||||
bool isDisabling;
|
||||
/// <summary>Turned off with a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
|
||||
bool disableWithRecoveryCode;
|
||||
bool isSaving;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData() => account = await AuthService.Account();
|
||||
|
||||
void OpenSetup()
|
||||
{
|
||||
setupPassword = new();
|
||||
isAskingPassword = true;
|
||||
}
|
||||
|
||||
void OpenDisable()
|
||||
{
|
||||
disable = new();
|
||||
disableWithRecoveryCode = false;
|
||||
isDisabling = true;
|
||||
}
|
||||
|
||||
/// <summary>Back to the block's state; a setup PrivaPub keeps expires by itself after an hour.</summary>
|
||||
void CloseForms()
|
||||
{
|
||||
isAskingPassword = false;
|
||||
isDisabling = false;
|
||||
setup = default;
|
||||
setupPassword = new();
|
||||
enable = new();
|
||||
disable = new();
|
||||
}
|
||||
|
||||
void ToggleDisableRecoveryCode()
|
||||
{
|
||||
disableWithRecoveryCode = !disableWithRecoveryCode;
|
||||
disable.Code = default;
|
||||
}
|
||||
|
||||
async Task Setup()
|
||||
{
|
||||
isSaving = true;
|
||||
var result = await AuthService.TotpSetup(setupPassword.Password);
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
// a 409: two-factor sign-in was turned on meanwhile, from another device
|
||||
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
CloseForms();
|
||||
account = await AuthService.Account() ?? account;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setup = result.Data as ViewTotpSetup;
|
||||
setupPassword = new();
|
||||
enable = new();
|
||||
isAskingPassword = false;
|
||||
}
|
||||
|
||||
async Task Enable()
|
||||
{
|
||||
isSaving = true;
|
||||
// every other session ends here, and this device goes on with a new JWT and a new token for the persona
|
||||
var result = await AuthService.TotpEnable(enable.Code.Trim());
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
enable = new();
|
||||
// a 409: the setup expired (an hour), so it begins again with the password
|
||||
if (result.StatusCode is (int)System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
CloseForms();
|
||||
account = await AuthService.Account() ?? account;
|
||||
isAskingPassword = account is { TwoFactorEnabled: false };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
recoveryCodes = result.Data as List<string> ?? [];
|
||||
setup = default;
|
||||
enable = new();
|
||||
account.TwoFactorEnabled = true;
|
||||
account.RecoveryCodesLeft = recoveryCodes.Count;
|
||||
}
|
||||
|
||||
async Task CopyCodes()
|
||||
{
|
||||
// a refusal is reported by main.js; the codes can still be selected and copied by hand
|
||||
if (!await CascadingState.Status.CopyText(string.Join('\n', recoveryCodes)))
|
||||
return;
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["Recovery codes copied."]);
|
||||
}
|
||||
|
||||
void CodesSaved() => recoveryCodes = [];
|
||||
|
||||
async Task Disable()
|
||||
{
|
||||
if (!await Confirm.Ask(Localizer["Turn off two-factor sign-in?"],
|
||||
Localizer["Signing in takes only the password again, and the recovery codes stop working."], Localizer["Turn off"], true))
|
||||
return;
|
||||
|
||||
isSaving = true;
|
||||
var result = await AuthService.TotpDisable(disable.Password, disable.Code.Trim());
|
||||
isSaving = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
Toast.AddResponseError(result);
|
||||
disable.Code = default;
|
||||
return;
|
||||
}
|
||||
|
||||
account = result.Data as ViewAccount ?? await AuthService.Account();
|
||||
CloseForms();
|
||||
Toast.AddResponseSuccess(Localizer["Two-factor sign-in is off."]);
|
||||
}
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
{
|
||||
"_type": "export",
|
||||
"__export_format": 4,
|
||||
"__export_date": "2022-02-14T20:02:43.810Z",
|
||||
"__export_source": "insomnia.desktop.app:v2021.7.2",
|
||||
"resources": [
|
||||
{
|
||||
"_id": "req_db7df93225be442e89a167ae594c5551",
|
||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"modified": 1644868863001,
|
||||
"created": 1643816994345,
|
||||
"url": "https://localhost:5000/api/v1/message/add",
|
||||
"name": "Add message",
|
||||
"description": "rootMessageId => un susseguirsi di risposte ad un messaggio si portera' dietro sempre l'id del messaggio di partenza\n\ntitle => titolo opzionale\n\ncontent => testo del messaggio, con supporto di markdown\n\nResponse JSON:\n\n>\t\t{\n>\t\t\t\"user\": {\n\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n\t\t\t\t\"displayName\": \"loosy\",\n\t\t\t\t\"pictureUrl\": \"https://...\",\n\t\t\t\t\"backgroundUrl\": \"https://...\",\n\t\t\t\t\"profileUrl\": \"https://...\"\n\t\t\t},\n\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n\t\t\t\"rootMessageId\": null,\n\t\t\t\"messageId\": \"guid/uuid/whatever\",\n\t\t\t\"title\": \"...\",\n\t\t\t\"content\": \"...\",\n\t\t\t\"isFavourite\": false,\n\t\t\t\"isBoosted\": false,\n\t\t\t\"media\": [{\n\t\t\t\t\t\"fileName\": \"ciao.png\",\n\t\t\t\t\t\"altText\": \"descrizione immagine\",\n\t\t\t\t\t\"contentType\": \"image/png\",\n\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n\t\t\t}],\n\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t}\n",
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"mimeType": "application/json",
|
||||
"text": " {\n\t\"contentType\": \"1||2||3\",//PlainText,HTML,Markdown\n\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public,\n\t\"mediaType\": \"1||2||3||4\",//Images,Video,Audio,Documents\n\t\"rootMessageId\": null,\n\t\"title\": \"...\",\n\t\"content\": \"...\",\n\t\"media\": [{\n\t\t\"fileName\": \"ciao.png\",\n \"altText\": \"descrizione opzionale immagine\",\n\t\t\"contentType\": \"image/png\",//mime\n\t\t\"blob\": [23,33,11,...]//byteArray || base64?\n\t}],\n\t\"createdAt\": \"UTC or ticks\"\n}\n\n"
|
||||
},
|
||||
"parameters": [],
|
||||
"headers": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/json",
|
||||
"id": "pair_b95faf0515f9453595110e7895330c5f"
|
||||
}
|
||||
],
|
||||
"authentication": {},
|
||||
"metaSortKey": -1643816957190,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1643829443147,
|
||||
"created": 1643816769446,
|
||||
"name": "APIs",
|
||||
"description": "",
|
||||
"environment": {},
|
||||
"environmentPropertyOrder": {},
|
||||
"metaSortKey": -1643816769446,
|
||||
"_type": "request_group"
|
||||
},
|
||||
{
|
||||
"_id": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"parentId": null,
|
||||
"modified": 1608987862911,
|
||||
"created": 1608987862911,
|
||||
"name": "Insomnia",
|
||||
"description": "",
|
||||
"scope": "collection",
|
||||
"_type": "workspace"
|
||||
},
|
||||
{
|
||||
"_id": "req_b33056a8e8a64c838662a60a4909537c",
|
||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"modified": 1643829255295,
|
||||
"created": 1643817054991,
|
||||
"url": "https://localhost:5000/api/v1/message/messageId/delete",
|
||||
"name": "Remove message",
|
||||
"description": "[queryParam] messageId => id del messaggio da eliminare\n\nResponse 200\n",
|
||||
"method": "DELETE",
|
||||
"body": {},
|
||||
"parameters": [],
|
||||
"headers": [],
|
||||
"authentication": {
|
||||
"type": "oauth2",
|
||||
"grantType": "authorization_code"
|
||||
},
|
||||
"metaSortKey": -1643816957177.5,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "req_95c442071ee247f1a97d823c6e503db2",
|
||||
"parentId": "fld_2fca219979e448c997a6788d2b539ee7",
|
||||
"modified": 1644868763642,
|
||||
"created": 1643816956965,
|
||||
"url": "https://localhost:5000/api/v1/messages",
|
||||
"name": "Get messages list",
|
||||
"description": "Response JSON:\n>\t\t[\n>\t\t\t{\n>\t\t\t\t\"user\": {\n>\t\t\t\t\t\"userId\": \"guid/uuid/whatever\",\n>\t\t\t\t\t\"userName\": \"@loosy@letsrulethe.world\",\n>\t\t\t\t\t\"displayName\": \"loosy\",\n>\t\t\t\t\t\"pictureUrl\": \"https://...\",\n>\t\t\t\t\t\"backgroundUrl\": \"https://...\",\n>\t\t\t\t\t\"profileUrl\": \"https://...\"\n>\t\t\t\t},\n>\t\t\t\t\"messageType\": \"1||2||3||4\",//Direct,FollowersOnly,Unlisted,Public\n>\t\t\t\t\"rootMessageId\": null,\n>\t\t\t\t\"messageId\": \"guid/uuid/whatever\",\n>\t\t\t\t\"title\": \"...\",\n>\t\t\t\t\"content\": \"...\",\n>\t\t\t\t\"isFavourite\": false,\n>\t\t\t\t\"isBoosted\": false,\n>\t\t\t\t\"media\": [{\n>\t\t\t\t\t\"fileName\": \"ciao.png\",\n>\t\t\t\t\t\"altText\": \"descrizione immagine\",\n>\t\t\t\t\t\"contentType\": \"image/png\",\n>\t\t\t\t\t\"url\": \"https://bbs.keinpfusch.net/files/webpublic-ee9faea1-6786-4bf4-9a42-7a1f07de2dc5\"\n>\t\t\t\t}],\n>\t\t\t\t\"createdAt\": \"UTC or ticks\"\n>\t\t\t}, {...}\n>\t\t]",
|
||||
"method": "GET",
|
||||
"body": {},
|
||||
"parameters": [
|
||||
{
|
||||
"id": "pair_6e5222c6a78341bbb24f9b18d236df6c",
|
||||
"name": "since",
|
||||
"value": "UTC/ticks/messageId?",
|
||||
"description": ""
|
||||
}
|
||||
],
|
||||
"headers": [],
|
||||
"authentication": {},
|
||||
"metaSortKey": -1643816956965,
|
||||
"isPrivate": false,
|
||||
"settingStoreCookies": true,
|
||||
"settingSendCookies": true,
|
||||
"settingDisableRenderRequestBody": false,
|
||||
"settingEncodeUrl": true,
|
||||
"settingRebuildPath": true,
|
||||
"settingFollowRedirects": "global",
|
||||
"_type": "request"
|
||||
},
|
||||
{
|
||||
"_id": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1642377560788,
|
||||
"created": 1608987862952,
|
||||
"name": "Base Environment",
|
||||
"data": {},
|
||||
"dataPropertyOrder": {},
|
||||
"color": null,
|
||||
"isPrivate": false,
|
||||
"metaSortKey": 1608987862952,
|
||||
"_type": "environment"
|
||||
},
|
||||
{
|
||||
"_id": "jar_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1642378408249,
|
||||
"created": 1608987862953,
|
||||
"name": "Default Jar",
|
||||
"cookies": [
|
||||
{
|
||||
"key": "oc_sessionPassphrase",
|
||||
"value": "nMmK2Vb0FhKNaSQiPteel3EhA5swjs9%2FYJwU79BzPrq1cgNmCdL2w88ktlfpy8BXU1Yuj7MKSmCLNYMB0NIDFZ%2B7AnbhqpW0am5cFolB4eRzT2uSUSg16nKnAPVXAkGa",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.279Z",
|
||||
"lastAccessed": "2021-05-04T10:48:03.279Z",
|
||||
"id": "06476282363234076"
|
||||
},
|
||||
{
|
||||
"key": "occ7de4yt74j",
|
||||
"value": "dsleqfmojuisnmgcq63p0nrk77",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.279Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "9557040429295196"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookielax",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.280Z",
|
||||
"lastAccessed": "2021-05-04T10:48:03.280Z",
|
||||
"id": "4410522677170672"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookiestrict",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=strict" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:48:03.280Z",
|
||||
"lastAccessed": "2021-05-04T10:48:03.280Z",
|
||||
"id": "7173600438498109"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2021-05-04T11:49:33.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.818Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "3533626334860853"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.818Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "06043516223469947"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "1905708786875855"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/apps/dashboard",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T10:49:33.582Z",
|
||||
"id": "4672408440036"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "7693972008479257"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "9731704342625882"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2021-05-04T10:49:20.819Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "3557084085645068"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2021-05-04T11:51:28.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:28.651Z",
|
||||
"lastAccessed": "2021-05-04T10:51:28.651Z",
|
||||
"id": "8645799753636227"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:44.619Z",
|
||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
||||
"id": "9029869294914612"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:44.619Z",
|
||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
||||
"id": "6422488233461934"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/remote.php/dav/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T10:51:44.619Z",
|
||||
"lastAccessed": "2021-05-04T10:51:44.619Z",
|
||||
"id": "7557066093242031"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2021-05-04T12:01:26.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:26.192Z",
|
||||
"lastAccessed": "2021-05-04T11:01:26.192Z",
|
||||
"id": "7093910949861533"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:42.223Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.223Z",
|
||||
"id": "677709203943275"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:42.224Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "9477816718956802"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "nuvola.xyz",
|
||||
"path": "/index.php/login",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2021-05-04T11:01:42.224Z",
|
||||
"lastAccessed": "2021-05-04T11:01:42.224Z",
|
||||
"id": "41804757195232756"
|
||||
},
|
||||
{
|
||||
"key": "oc_sessionPassphrase",
|
||||
"value": "pUIH26mXSdmGTfubB4dUzw8F8MQsUHdwnHCeo7ElPWIGDxXte2XC1M0EMyN5eriarJcMmkfh4kSGFI9%2BRcs%2BHt2gdDygddkz5I7Tl8KtjkwC%2BawIYuL8z3lobWTL%2BCy%2B",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.727Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.727Z",
|
||||
"id": "06313521114695941"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookielax",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
||||
"id": "860532957782806"
|
||||
},
|
||||
{
|
||||
"key": "__Host-nc_sameSiteCookiestrict",
|
||||
"value": "true",
|
||||
"expires": "2100-12-31T23:59:59.000Z",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=strict" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
||||
"id": "31117326197847706"
|
||||
},
|
||||
{
|
||||
"key": "ocl7d3yrw74j",
|
||||
"value": "uecghev7rleu4uptmruhptuvto",
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"extensions": [ "SameSite=Lax" ],
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:13:28.249Z",
|
||||
"id": "5917940161261273"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2022-01-17T01:03:09.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:03:10.728Z",
|
||||
"lastAccessed": "2022-01-17T00:03:10.728Z",
|
||||
"id": "009292942805962934"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login/v2",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:04:18.252Z",
|
||||
"lastAccessed": "2022-01-17T00:04:18.252Z",
|
||||
"id": "5470762369580981"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login/v2",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:04:18.252Z",
|
||||
"lastAccessed": "2022-01-17T00:04:18.252Z",
|
||||
"id": "4385020724400406"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/index.php/login/v2",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:04:18.253Z",
|
||||
"id": "05297254853120181"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "46009278671166176"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "05812364381235913"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"creation": "2022-01-17T00:04:18.253Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "3772361407666647"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2022-01-17T01:05:17.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "08277815359810625"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "7962572269421415"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "8604909194077435"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/apps/files",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:05:18.388Z",
|
||||
"lastAccessed": "2022-01-17T00:05:18.388Z",
|
||||
"id": "6043027104701622"
|
||||
},
|
||||
{
|
||||
"key": "cookie_test",
|
||||
"value": "test",
|
||||
"expires": "2022-01-17T01:11:28.000Z",
|
||||
"maxAge": 3600,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:11:28.872Z",
|
||||
"lastAccessed": "2022-01-17T00:11:28.872Z",
|
||||
"id": "014769824799037545"
|
||||
},
|
||||
{
|
||||
"key": "nc_username",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:12:52.404Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "7141536696124962"
|
||||
},
|
||||
{
|
||||
"key": "nc_token",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:12:52.404Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "08455474112707173"
|
||||
},
|
||||
{
|
||||
"key": "nc_session_id",
|
||||
"value": "deleted",
|
||||
"expires": "1970-01-01T00:00:01.000Z",
|
||||
"maxAge": 0,
|
||||
"domain": "cloud.zigzag.academy",
|
||||
"path": "/ocs/v1.php/cloud",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"hostOnly": true,
|
||||
"pathIsDefault": true,
|
||||
"creation": "2022-01-17T00:12:52.404Z",
|
||||
"lastAccessed": "2022-01-17T00:12:52.404Z",
|
||||
"id": "141765916939931"
|
||||
}
|
||||
],
|
||||
"_type": "cookie_jar"
|
||||
},
|
||||
{
|
||||
"_id": "spc_f832b28de5204b4a88374d08ba93a8e2",
|
||||
"parentId": "wrk_da47ff9b3d7f40f3bef9e90b46d6610b",
|
||||
"modified": 1608987862912,
|
||||
"created": 1608987862912,
|
||||
"fileName": "Insomnia",
|
||||
"contents": "",
|
||||
"contentType": "yaml",
|
||||
"_type": "api_spec"
|
||||
},
|
||||
{
|
||||
"_id": "env_77dfa781871a430f8e5014b07c9f9061",
|
||||
"parentId": "env_b33db616c4a51618900a0a3da66d2d7b6ebaec2b",
|
||||
"modified": 1642377578797,
|
||||
"created": 1642377566379,
|
||||
"name": "Nextcloud API",
|
||||
"data": {},
|
||||
"dataPropertyOrder": {},
|
||||
"color": null,
|
||||
"isPrivate": false,
|
||||
"metaSortKey": 1642377566379,
|
||||
"_type": "environment"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,22 +1,15 @@
|
||||
using Blazored.LocalStorage;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Helpers;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
using decePubClient.Services;
|
||||
|
||||
using Markdig;
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
@@ -28,24 +21,6 @@ namespace decePubClient.Extensions
|
||||
{
|
||||
public static partial class ExtensionMethods
|
||||
{
|
||||
#region Navigation Manager
|
||||
|
||||
public static NameValueCollection QueryString(this NavigationManager navigationManager) =>
|
||||
HttpUtility.ParseQueryString(new Uri(navigationManager.Uri).Query);
|
||||
|
||||
public static string QueryString(this NavigationManager navigationManager, string key) =>
|
||||
navigationManager.QueryString()[key];
|
||||
|
||||
public static T QueryString<T>(this NavigationManager navigationManager, string key)
|
||||
{
|
||||
var value = navigationManager.QueryString()[key];
|
||||
if (!TypeDescriptor.GetConverter(typeof(T)).IsValid(value))
|
||||
return default;
|
||||
return (T)Convert.ChangeType(value, typeof(T));
|
||||
}
|
||||
|
||||
#endregion Navigation Manager
|
||||
|
||||
#region Setups
|
||||
|
||||
/// <summary>The languages with resources (Resources/AllStrings.*.resx); any other browser language gets English.</summary>
|
||||
@@ -82,30 +57,6 @@ namespace decePubClient.Extensions
|
||||
|
||||
#endregion Setups
|
||||
|
||||
public static string GetLogLevelClass(this string logType) => logType switch
|
||||
{
|
||||
"Debug" => "text-base-content/60",
|
||||
"Information" => "text-info",
|
||||
"Warning" => "text-warning",
|
||||
"Error" => "text-error",
|
||||
"Fatal" => "text-error font-bold",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
|
||||
#region Formats
|
||||
|
||||
public static MarkdownPipeline Pipeline =>
|
||||
new MarkdownPipelineBuilder().UseGenericAttributes().Build();
|
||||
|
||||
public static string ToJson(this object value) =>
|
||||
JsonSerializer.Serialize(value);
|
||||
|
||||
public static string ToBase64PngImage(this string base64Image) =>
|
||||
$"data:image/png;base64,{base64Image}";
|
||||
|
||||
#endregion Formats
|
||||
|
||||
#region Models
|
||||
|
||||
public static string ToString(this WebResult webResult, CoalescingStringLocalizer localizer) =>
|
||||
@@ -240,25 +191,6 @@ namespace decePubClient.Extensions
|
||||
}
|
||||
}
|
||||
|
||||
public static string Timeframe(this DateTime creationDate, CoalescingStringLocalizer localizer)
|
||||
{
|
||||
var timeframe = DateTime.Now - creationDate.ToLocalTime();
|
||||
switch ((int)timeframe.TotalHours)
|
||||
{
|
||||
case >= 24:
|
||||
return localizer["{0} day/s", (int)timeframe.TotalDays];
|
||||
|
||||
case >= 1 and < 24:
|
||||
return localizer["{0} hour/s", (int)timeframe.TotalHours];
|
||||
|
||||
case 0:
|
||||
return localizer["{0} minute/s", (int)timeframe.TotalMinutes];
|
||||
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static AuthorizationPolicy IsAdminPolicy() =>
|
||||
new AuthorizationPolicyBuilder().RequireAuthenticatedUser()
|
||||
.RequireClaim(Policies.IsAdmin, "true")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using collAnon.Client.Services;
|
||||
using System.Collections.Specialized;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
@@ -12,12 +11,6 @@ using decePubClient.Models.Types;
|
||||
using decePubClient.Resources;
|
||||
using decePubClient.Services;
|
||||
|
||||
using DnetIndexedDb;
|
||||
using DnetIndexedDb.Fluent;
|
||||
using DnetIndexedDb.Models;
|
||||
|
||||
using Markdig;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using Microsoft.Extensions.Localization;
|
||||
@@ -26,26 +19,6 @@ namespace decePubClient.Extensions;
|
||||
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static IServiceCollection AddIndexedDb(this IServiceCollection services)
|
||||
{
|
||||
return services.AddIndexedDbDatabase<IndexedDb>(options =>
|
||||
{
|
||||
var model = new IndexedDbDatabaseModel()
|
||||
.WithName("data")
|
||||
.WithVersion(3)
|
||||
.WithModelId(1);
|
||||
|
||||
// IStorage.AddLog numbers entries itself (max key + 1), so the key is Id rather than generated. Version 2
|
||||
// added this store; version 3 left the mock feed's Message store out of the model.
|
||||
model.AddStore(nameof(ClientLogs))
|
||||
.WithKey(nameof(ClientLogs.Id))
|
||||
.AddUniqueIndex(nameof(ClientLogs.Id))
|
||||
.AddIndex(nameof(ClientLogs.TimeStamp));
|
||||
|
||||
options.UseDatabase(model);
|
||||
});
|
||||
}
|
||||
|
||||
public static string GetPassedTime(this DateTime dateTime, CoalescingStringLocalizer localizer)
|
||||
{
|
||||
var timeframe = DateTime.Now - dateTime.ToLocalTime();
|
||||
@@ -72,17 +45,17 @@ public static class GenericExtensions
|
||||
_ => localizer["{0}KB", size / 1_024]
|
||||
};
|
||||
|
||||
public static string GetMessageTypeIcon(this MessageType messageType)
|
||||
public static string GetVisibilityIcon(this Visibility visibility)
|
||||
{
|
||||
switch (messageType)
|
||||
switch (visibility)
|
||||
{
|
||||
case MessageType.Public:
|
||||
case Visibility.Public:
|
||||
return "ion-md-globe";
|
||||
case MessageType.Unlisted:
|
||||
case Visibility.Unlisted:
|
||||
return "ion-md-unlock";
|
||||
case MessageType.FollowersOnly:
|
||||
case Visibility.FollowersOnly:
|
||||
return "ion-md-lock";
|
||||
case MessageType.Direct:
|
||||
case Visibility.Direct:
|
||||
return "ion-md-paper-plane";
|
||||
default:
|
||||
return default;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Mail;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
@@ -108,58 +107,5 @@ namespace decePubClient.Helpers
|
||||
|
||||
public static T IfTrueThen<T>(bool conditionIsTrue, T ifTrue, T ifFalse = default) =>
|
||||
conditionIsTrue ? ifTrue : ifFalse;
|
||||
|
||||
public static bool TimeBetween(this DateTime datetime, TimeSpan start, TimeSpan end)
|
||||
{
|
||||
// convert datetime to a TimeSpan
|
||||
var now = datetime.TimeOfDay;
|
||||
// see if start comes before end
|
||||
if (start < end)
|
||||
return start <= now && now <= end;
|
||||
// start is after end, so do the inverse comparison
|
||||
return !(end < now && now < start);
|
||||
}
|
||||
|
||||
public static string GetFileIcon(string fileName)
|
||||
{
|
||||
switch (Path.GetExtension(fileName))
|
||||
{
|
||||
case ".odp":
|
||||
case ".pptx":
|
||||
return "file-powerpoint";
|
||||
|
||||
case ".ods":
|
||||
case ".xlsx":
|
||||
return "file-excel";
|
||||
|
||||
case ".odt":
|
||||
case ".docx":
|
||||
return "file-word";
|
||||
|
||||
case ".pdf":
|
||||
return "file-pdf";
|
||||
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
case ".png":
|
||||
return "file-image";
|
||||
|
||||
default:
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new MailAddress(email);
|
||||
return addr.Address == email;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
@* The app-wide state, cascaded to every page and component (LocalizableComponentBase.CascadingState): the saved
|
||||
PublicCacheData with its theme settings, the session (the root's personas and the Mastodon account of the one the
|
||||
client acts as), and whether the app is online, polled every 10 seconds. A change re-renders every subscriber; pages
|
||||
compare PersonaId to reload what belongs to the persona. *@
|
||||
compare PersonaId to reload what belongs to the persona. With PageSettings.SyncWithLogin the theme is the login's:
|
||||
taken at sign-in and at start-up, saved there a moment after it changes here. *@
|
||||
<CascadingValue IsFixed=false Value=this>
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
@@ -11,6 +12,8 @@
|
||||
@code {
|
||||
/// <summary>How often the JWT's expiry is checked; AuthService renews it two days before.</summary>
|
||||
static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
|
||||
/// <summary>How long a change of the page settings waits before it is saved as the login's: a slider sends many.</summary>
|
||||
static readonly TimeSpan SettingsSaveDelay = TimeSpan.FromMilliseconds(1500);
|
||||
|
||||
[Parameter] public RenderFragment ChildContent { get; set; }
|
||||
[Inject] ILocalStorageService Storage { get; set; }
|
||||
@@ -42,6 +45,11 @@
|
||||
PeriodicTimer onlineTimer;
|
||||
PeriodicTimer refreshTimer;
|
||||
readonly CancellationTokenSource disposeCancellation = new();
|
||||
/// <summary>What the login is known to keep, as LoginFingerprint reads it (null when unknown), so settings that already
|
||||
/// match it, the login's own just taken among them, aren't saved again.</summary>
|
||||
(string, short, short, bool, bool, bool, bool)? loginSettings;
|
||||
/// <summary>The one save of the page settings waiting for SettingsSaveDelay to pass.</summary>
|
||||
CancellationTokenSource pendingSettingsSave;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -59,6 +67,8 @@
|
||||
_ = PollOnline(cancellationToken);
|
||||
|
||||
await LoadSession(cancellationToken);
|
||||
if (IsSignedIn && IsOnline && PublicCacheData.PageSettings.SyncWithLogin)
|
||||
await KeepThemeWithLogin(await AuthService.Settings(cancellationToken), cancellationToken);
|
||||
refreshTimer = new(RefreshInterval);
|
||||
_ = KeepSessionFresh(cancellationToken);
|
||||
}
|
||||
@@ -77,13 +87,98 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache.</summary>
|
||||
/// <summary>Changes the page settings, then saves and applies them like UpdatePublicCache, and saves them as the
|
||||
/// login's a moment later (SaveSettingsSoon).</summary>
|
||||
public ValueTask UpdatePageSettings(Action<PageSettings> change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
change(PublicCacheData.PageSettings);
|
||||
SaveSettingsSoon();
|
||||
return UpdatePublicCache(PublicCacheData, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the session a login, a setup or an invitation has just started (AuthService keeps its JWT, which carries the
|
||||
/// login's settings), then keeps the theme with the login's (KeepThemeWithLogin).
|
||||
/// </summary>
|
||||
public async Task SignedIn(JwtUser jwtUser, CancellationToken cancellationToken = default)
|
||||
{
|
||||
loginSettings = null;
|
||||
await LoadSession(cancellationToken);
|
||||
await KeepThemeWithLogin(jwtUser?.UserSettings, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When this device keeps its theme with the login: takes the login's theme (never its language, which would reload the
|
||||
/// app) once a client saved one, or gives the login this device's while none did. Unknown settings change nothing.
|
||||
/// </summary>
|
||||
async Task KeepThemeWithLogin(ViewUserSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
if (settings is null || !IsSignedIn || !PublicCacheData.PageSettings.SyncWithLogin)
|
||||
return;
|
||||
|
||||
if (settings.SavedAt is null)
|
||||
{
|
||||
SaveSettingsSoon();
|
||||
return;
|
||||
}
|
||||
|
||||
PublicCacheData.PageSettings.TakeLoginTheme(settings);
|
||||
// as the login keeps them now, so taking them isn't followed by saving them back
|
||||
loginSettings = LoginFingerprint(PublicCacheData.PageSettings.ToLoginSettings());
|
||||
await UpdatePublicCache(PublicCacheData, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the page settings as the login's once SettingsSaveDelay passed without another change: signed in, with
|
||||
/// SyncWithLogin, and only when they differ from what the login is known to keep. One save waits at a time; a failure
|
||||
/// is logged (AuthService.SaveSettings), never shown.
|
||||
/// </summary>
|
||||
void SaveSettingsSoon()
|
||||
{
|
||||
CancelSettingsSave();
|
||||
var settings = PublicCacheData.PageSettings;
|
||||
if (!IsSignedIn || !settings.SyncWithLogin || LoginFingerprint(settings.ToLoginSettings()) == loginSettings)
|
||||
return;
|
||||
|
||||
pendingSettingsSave = new();
|
||||
_ = SaveSettingsLater(pendingSettingsSave.Token);
|
||||
}
|
||||
|
||||
async Task SaveSettingsLater(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(SettingsSaveDelay, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsSignedIn)
|
||||
return;
|
||||
|
||||
var settings = PublicCacheData.PageSettings.ToLoginSettings();
|
||||
var result = await AuthService.SaveSettings(settings, cancellationToken);
|
||||
if (result.IsInvalid)
|
||||
return;
|
||||
|
||||
loginSettings = LoginFingerprint(settings);
|
||||
}
|
||||
|
||||
void CancelSettingsSave()
|
||||
{
|
||||
var pending = pendingSettingsSave;
|
||||
pendingSettingsSave = null;
|
||||
pending?.Cancel();
|
||||
pending?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>What of the login's settings this client reads and writes: not the icons' hue, nor when they were saved.</summary>
|
||||
static (string, short, short, bool, bool, bool, bool) LoginFingerprint(ViewUserSettings settings) =>
|
||||
(settings.LanguageCode, settings.LightThemeIndexColour, settings.DarkThemeIndexColour, settings.PreferSystemTheming,
|
||||
settings.ThemeIsDarkMode, settings.ThemeIsLightGray, settings.ThemeIsDarkGray);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the session after start-up or a login: renews the JWT when it is about to expire, lists the personas and
|
||||
/// acts as the last one used (the first one otherwise).
|
||||
@@ -161,6 +256,7 @@
|
||||
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary>
|
||||
public async Task SignOut(CancellationToken cancellationToken = default)
|
||||
{
|
||||
CancelSettingsSave();
|
||||
await AuthService.Logout(cancellationToken);
|
||||
ClearSession();
|
||||
StateHasChanged();
|
||||
@@ -168,6 +264,8 @@
|
||||
|
||||
void ClearSession()
|
||||
{
|
||||
CancelSettingsSave();
|
||||
loginSettings = null;
|
||||
IsSignedIn = false;
|
||||
RootUserName = null;
|
||||
Personas = [];
|
||||
@@ -236,6 +334,7 @@
|
||||
{
|
||||
await disposeCancellation.CancelAsync();
|
||||
disposeCancellation.Dispose();
|
||||
CancelSettingsSave();
|
||||
onlineTimer?.Dispose();
|
||||
refreshTimer?.Dispose();
|
||||
cascadingStateReference?.Dispose();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace decePubClient.LayerComponents
|
||||
{
|
||||
public class PagesBase : LocalizableComponentBase
|
||||
{
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -9,7 +9,6 @@ public static class APIs
|
||||
{
|
||||
public const string ClientApiPrefix = "clientapi/";
|
||||
public const string MastodonPrefix = "api/";
|
||||
public const string OAuthPrefix = "oauth/";
|
||||
|
||||
public static class ClientApi
|
||||
{
|
||||
@@ -20,8 +19,20 @@ public static class APIs
|
||||
public const string GET_Logout = "clientapi/user/logout";
|
||||
public const string GET_SniffAgain = "clientapi/user/sniff/again";
|
||||
public const string POST_UpdatePassword = "clientapi/user/update/password";
|
||||
public const string POST_LogoutEverywhere = "clientapi/user/logout/everywhere";
|
||||
public const string POST_RecoverPassword = "clientapi/user/recover/password";
|
||||
public const string POST_RecoverValid = "clientapi/user/recover/valid";
|
||||
public const string POST_RecoverUpdatePassword = "clientapi/user/recover/update/password";
|
||||
public const string GET_Account = "clientapi/user/account";
|
||||
public const string POST_Email = "clientapi/user/email";
|
||||
public const string POST_EmailVerify = "clientapi/user/email/verify";
|
||||
public const string POST_EmailRemove = "clientapi/user/email/remove";
|
||||
public const string POST_TotpSetup = "clientapi/user/totp/setup";
|
||||
public const string POST_TotpEnable = "clientapi/user/totp/enable";
|
||||
public const string POST_TotpDisable = "clientapi/user/totp/disable";
|
||||
public const string GET_UserSettings = "clientapi/user/settings";
|
||||
public const string POST_UpdateUserSettings = "clientapi/user/update/settings";
|
||||
public const string POST_DeleteAccount = "clientapi/user/delete";
|
||||
public const string GET_CurrentVersion = "clientapi/data/current-version";
|
||||
public const string GET_Personas = "clientapi/avatar/private/list";
|
||||
public const string POST_InsertPersona = "clientapi/avatar/private/insert";
|
||||
public const string GET_Groups = "clientapi/group/list";
|
||||
@@ -91,7 +102,6 @@ public static class APIs
|
||||
public const string GET_Conversations = "api/v1/conversations";
|
||||
public static string POST_M_ReadConversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}/read";
|
||||
public static string DELETE_M_Conversation(string conversationId) => $"api/v1/conversations/{Uri.EscapeDataString(conversationId)}";
|
||||
public const string GET_Notifications = "api/v1/notifications";
|
||||
public const string POST_Media = "api/v2/media";
|
||||
public static string GET_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||
public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}";
|
||||
@@ -159,7 +169,6 @@ public static class APIs
|
||||
public static class PrivaPub
|
||||
{
|
||||
public const string GET_Instances = "api/privapub/v1/instances";
|
||||
public const string GET_Cdns = "api/privapub/v1/cdns";
|
||||
|
||||
public static string GET_M_InstanceHistory(string host) => $"api/privapub/v1/instances/{Uri.EscapeDataString(host)}/history";
|
||||
public static string GET_M_Cdn(string key) => $"api/privapub/v1/cdns/{Uri.EscapeDataString(key)}";
|
||||
|
||||
@@ -2,7 +2,6 @@ namespace decePubClient.Models;
|
||||
|
||||
public class AppConfiguration
|
||||
{
|
||||
public string Version { get; set; }
|
||||
public string ApiBaseAddress { get; set; }
|
||||
/// <summary>PrivaPub's first-party application, the only one allowed to exchange the root JWT for persona tokens.</summary>
|
||||
public string ClientId { get; set; } = "decepub";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class ClientLogs
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Where { get; set; }
|
||||
public string WarningMessage { get; set; }
|
||||
public ClientLogException Exception { get; set; }
|
||||
public DateTime TimeStamp { get; set; } = DateTime.Now;
|
||||
/// <summary>The entry as HTML for a log viewer: the warning text, or the exception's details.</summary>
|
||||
public string ErrorMessage()
|
||||
{
|
||||
if (Exception is null)
|
||||
return WarningMessage;
|
||||
|
||||
var details = @$"<b>Type</b>: {Exception.Type ?? "N/A"} <b>HelpLink</b>: {Exception.HelpLink ?? "N/A"} <b>HResult</b>: {Exception.HResult ?? "N/A"}<br>
|
||||
<b>Source</b>: {Exception.Source ?? "N/A"} <b>StackTrace</b>: {Exception.StackTrace ?? "N/A"} <b>MemberName</b>: {Exception.TargetSiteName ?? "N/A"}<br>
|
||||
<b>Message</b>: {Exception.Message ?? "N/A"}";
|
||||
if (Exception.InnerExceptionMessage is null)
|
||||
return details;
|
||||
|
||||
return $"{details}<br>\n<b>InnerExceptionMessage</b>:{Exception.InnerExceptionMessage}";
|
||||
}
|
||||
}
|
||||
|
||||
public class ClientLogException
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string HelpLink { get; set; }
|
||||
public string HResult { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string StackTrace { get; set; }
|
||||
public string TargetSiteName { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string InnerExceptionMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class ComposeForm
|
||||
public string GroupId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = ValidationNames.Required, ErrorMessageResourceType = typeof(ErrorMessages))]
|
||||
public MessageType MessageType { get; set; } = MessageType.Public;
|
||||
public Visibility Visibility { get; set; } = Visibility.Public;
|
||||
public bool HasContentWarning { get; set; }
|
||||
// its media marked sensitive without a warning: the persona's default ("always mark media as sensitive"), or the post's own when edited
|
||||
public bool Sensitive { get; set; }
|
||||
@@ -64,12 +64,12 @@ public class ComposeForm
|
||||
[JsonIgnore, Bindable(false)]
|
||||
public bool IsScopeOptionsOpen { get; set; }
|
||||
|
||||
/// <summary>The Mastodon visibility of MessageType.</summary>
|
||||
public string Visibility => MessageType switch
|
||||
/// <summary>The Mastodon visibility of Visibility.</summary>
|
||||
public string MastodonVisibility => Visibility switch
|
||||
{
|
||||
MessageType.Direct => "direct",
|
||||
MessageType.FollowersOnly => "private",
|
||||
MessageType.Unlisted => "unlisted",
|
||||
Visibility.Direct => "direct",
|
||||
Visibility.FollowersOnly => "private",
|
||||
Visibility.Unlisted => "unlisted",
|
||||
_ => "public"
|
||||
};
|
||||
}
|
||||
+31
-5
@@ -1,5 +1,6 @@
|
||||
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
using PrivaPub.ClientModels.User;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
@@ -10,22 +11,19 @@ namespace decePubClient.Models
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "LanguageId", ResourceType = typeof(FieldsNameResource))]
|
||||
public string CurrentLanguageCode { get; set; } = "en";
|
||||
//public bool NativeNotificationsEnabled { get; set; } = false;
|
||||
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public short LightThemeIndexColour { get; set; } = 25;
|
||||
[Range(0, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public short DarkThemeIndexColour { get; set; } = 215;
|
||||
[Range(-2, 359, ErrorMessageResourceName = nameof(Range), ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public short IconsThemeIndexColour { get; set; } = 25;
|
||||
|
||||
public bool PreferSystemTheming { get; set; } = true;
|
||||
public bool ThemeIsDarkMode { get; set; } = false;
|
||||
public bool ThemeIsLightGray { get; set; } = true;
|
||||
public bool ThemeIsDarkGray { get; set; } = false;
|
||||
|
||||
public bool ShowDonatorBadge { get; set; } = false;
|
||||
// before publishing pictures without a description, the composer asks once
|
||||
public bool RemindAltText { get; set; } = true;
|
||||
// the theme is the login's (PrivaPub's user settings): taken at sign-in, saved there whenever it changes here
|
||||
public bool SyncWithLogin { get; set; } = true;
|
||||
|
||||
// How the theme flags combine is in wwwroot/js/theme.js: with PreferSystemTheming the OS picks light or dark;
|
||||
// otherwise ThemeIsDarkGray, then ThemeIsLightGray, then ThemeIsDarkMode decide; either grey flag drops the
|
||||
@@ -61,5 +59,33 @@ namespace decePubClient.Models
|
||||
LightThemeIndexColour = hue;
|
||||
SetGray(false, isDark);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// These settings as the login keeps them: the language as the two letters of the culture the app runs in
|
||||
/// (ExtensionMethods.ToCulture: "it" is it-IT, anything else en-GB), the icons' hue at its default, since the client
|
||||
/// has no such setting.
|
||||
/// </summary>
|
||||
public ViewUserSettings ToLoginSettings() => new()
|
||||
{
|
||||
LanguageCode = CurrentLanguageCode is "it" ? "it" : "en",
|
||||
LightThemeIndexColour = LightThemeIndexColour,
|
||||
DarkThemeIndexColour = DarkThemeIndexColour,
|
||||
IconsThemeIndexColour = DefaultLightThemeIndexColour,
|
||||
PreferSystemTheming = PreferSystemTheming,
|
||||
ThemeIsDarkMode = ThemeIsDarkMode,
|
||||
ThemeIsLightGray = ThemeIsLightGray,
|
||||
ThemeIsDarkGray = ThemeIsDarkGray
|
||||
};
|
||||
|
||||
/// <summary>Takes the login's theme; its language stays this device's, since changing it reloads the app.</summary>
|
||||
public void TakeLoginTheme(ViewUserSettings settings)
|
||||
{
|
||||
LightThemeIndexColour = Math.Clamp(settings.LightThemeIndexColour, (short)0, (short)359);
|
||||
DarkThemeIndexColour = Math.Clamp(settings.DarkThemeIndexColour, (short)0, (short)359);
|
||||
PreferSystemTheming = settings.PreferSystemTheming;
|
||||
ThemeIsDarkMode = settings.ThemeIsDarkMode;
|
||||
ThemeIsLightGray = settings.ThemeIsLightGray;
|
||||
ThemeIsDarkGray = settings.ThemeIsDarkGray;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ namespace decePubClient.Models
|
||||
public bool Bot { get; set; }
|
||||
public bool Discoverable { get; set; }
|
||||
public bool Indexable { get; set; }
|
||||
public MessageType Privacy { get; set; } = MessageType.Public;
|
||||
public Visibility Privacy { get; set; } = Visibility.Public;
|
||||
public bool Sensitive { get; set; }
|
||||
public string Language { get; set; }
|
||||
public QuotePolicy QuotePolicy { get; set; } = QuotePolicy.Public;
|
||||
@@ -43,9 +43,9 @@ namespace decePubClient.Models
|
||||
Indexable = account.Indexable == true,
|
||||
Privacy = source.Privacy switch
|
||||
{
|
||||
"private" => MessageType.FollowersOnly,
|
||||
"unlisted" => MessageType.Unlisted,
|
||||
_ => MessageType.Public
|
||||
"private" => Visibility.FollowersOnly,
|
||||
"unlisted" => Visibility.Unlisted,
|
||||
_ => Visibility.Public
|
||||
},
|
||||
Sensitive = source.Sensitive,
|
||||
Language = source.Language,
|
||||
@@ -71,7 +71,7 @@ namespace decePubClient.Models
|
||||
{ new StringContent(Flag(Bot)), "bot" },
|
||||
{ new StringContent(Flag(Discoverable)), "discoverable" },
|
||||
{ new StringContent(Flag(Indexable)), "indexable" },
|
||||
{ new StringContent(Privacy switch { MessageType.FollowersOnly => "private", MessageType.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
||||
{ new StringContent(Privacy switch { Visibility.FollowersOnly => "private", Visibility.Unlisted => "unlisted", _ => "public" }), "source[privacy]" },
|
||||
{ new StringContent(Flag(Sensitive)), "source[sensitive]" },
|
||||
{ new StringContent(Language ?? ""), "source[language]" },
|
||||
{ new StringContent(QuotePolicy switch { QuotePolicy.Followers => "followers", QuotePolicy.Nobody => "nobody", _ => "public" }), "source[quote_policy]" }
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
using BlazorZXingJs;
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace decePubClient.Models
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class PublicCacheData
|
||||
{
|
||||
public IReadOnlyList<ViewLanguage> Languages { get; set; } = [];
|
||||
public List<MediaDeviceInfo> QrCodeDevices { get; set; } = [];
|
||||
public string LastPage { get; set; }
|
||||
public string CurrentSubPage { get; set; }
|
||||
public PageSettings PageSettings { get; set; } = new();
|
||||
public bool ShowLogs { get; set; } = false;
|
||||
public IReadOnlyDictionary<string, string> Policies { get; set; } = ReadOnlyDictionary<string, string>.Empty;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class SettingsOptions
|
||||
{
|
||||
public bool IsOpen { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace decePubClient.Models.Types
|
||||
{
|
||||
public enum MessageType
|
||||
{
|
||||
Direct = 1,
|
||||
FollowersOnly,
|
||||
Unlisted,
|
||||
Public
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace decePubClient.Models.Types
|
||||
{
|
||||
/// <summary>Who a post is for; the members are resource keys too (Localizer[value.ToString()]).</summary>
|
||||
public enum Visibility
|
||||
{
|
||||
Direct = 1,
|
||||
FollowersOnly,
|
||||
Unlisted,
|
||||
Public
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public static class VConstants
|
||||
{
|
||||
public const string HideClass = "hidden";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace decePubClient.Models
|
||||
{
|
||||
public class ViewLanguage
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string International2Code { get; set; }
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -91,8 +91,7 @@
|
||||
</p>
|
||||
}
|
||||
|
||||
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty
|
||||
(tests/e2e/Perf/FeedPerfTests) *@
|
||||
@* only the cards near the view are rendered: a feed of thousands stays as light as one of forty *@
|
||||
<Virtualize Items="posts" Context="post" ItemSize="320" OverscanCount="8">
|
||||
<Content @key="post.Key" Post="post" FilterIn="FilterIn" OnDeleted="Remove" OnReplied="Replied" OnTag="OpenTag" />
|
||||
</Virtualize>
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.LoadSession();
|
||||
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||
isWorking = false;
|
||||
Toast.AddResponseSuccess(Localizer["Welcome: your persona is in the group."]);
|
||||
Navigation.NavigateTo("");
|
||||
|
||||
+79
-4
@@ -4,7 +4,9 @@
|
||||
|
||||
@* One sign-in for both of PrivaPub's APIs: the root signs in here, then picks the persona to act as when it has more than
|
||||
one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers
|
||||
its setup (Setup); anyone else joins through a group's invitation (Join). *@
|
||||
its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through
|
||||
the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a
|
||||
428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode). *@
|
||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||
|
||||
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||
@@ -25,6 +27,33 @@
|
||||
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
|
||||
@if (needsCode)
|
||||
{
|
||||
@* The name and the password stay in the form, out of sight, and go again with the code. *@
|
||||
<p class="text-sm" data-login-code-asked>@Localizer["Enter the code from your authenticator app, or a recovery code."]</p>
|
||||
@if (useRecoveryCode)
|
||||
{
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Recovery code"]</span>
|
||||
<DInput TValue="string" class="w-full" @bind-Value="form.OneTimeCode" required="@true" autocomplete="one-time-code" inputmode="text"
|
||||
autocapitalize="none" spellcheck="false" data-login-recovery>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Code from the authenticator app"]</span>
|
||||
<DOtp @bind-Value="form.OneTimeCode" Immediate="true" aria-label="@Localizer["Code from the authenticator app"]" data-login-code />
|
||||
</div>
|
||||
}
|
||||
<DButton Variant="DButtonVariant.Link" Size="DSize.Sm" class="self-center" OnClick="ToggleRecoveryCode" data-login-use-recovery>
|
||||
@(useRecoveryCode ? Localizer["Use the authenticator app instead"] : Localizer["Use a recovery code instead"])
|
||||
</DButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-sm opacity-70">@Localizer["One login for all of your personas: each one is signed in separately and never linked to the others."]</p>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
@@ -39,6 +68,7 @@
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
}
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
@@ -49,6 +79,16 @@
|
||||
}
|
||||
<span>@Localizer["Login"]</span>
|
||||
</DButton>
|
||||
@if (needsCode)
|
||||
{
|
||||
<DButton Variant="DButtonVariant.Ghost" Size="DSize.Sm" class="self-center" Disabled="isWorking" OnClick="Back" data-login-back>
|
||||
<i class="ion-md-arrow-back"></i><span>@Localizer["Back"]</span>
|
||||
</DButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a class="link text-sm self-center touch-target" href="password-recovery" data-recovery-offer>@Localizer["Forgot your password?"]</a>
|
||||
}
|
||||
</EditForm>
|
||||
<p class="text-sm text-center">
|
||||
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
|
||||
@@ -99,6 +139,10 @@
|
||||
string error;
|
||||
bool isWorking;
|
||||
bool setupPending;
|
||||
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
|
||||
bool needsCode;
|
||||
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
|
||||
bool useRecoveryCode;
|
||||
|
||||
[SupplyParameterFromQuery] public string ReturnUrl { get; set; }
|
||||
|
||||
@@ -122,16 +166,30 @@
|
||||
{
|
||||
error = default;
|
||||
isWorking = true;
|
||||
form.OneTimeCode = needsCode ? form.OneTimeCode?.Trim() : default;
|
||||
var result = await AuthService.Login(form);
|
||||
if (result.IsInvalid)
|
||||
if (AuthService.NeedsOneTimeCode(result))
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
needsCode = true;
|
||||
form.OneTimeCode = default;
|
||||
isWorking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.LoadSession();
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
// a wrong code is PrivaPub's 400, like a wrong password, with its own message; the code is typed again
|
||||
error = result.ErrorMessage;
|
||||
form.OneTimeCode = default;
|
||||
isWorking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||
isWorking = false;
|
||||
needsCode = false;
|
||||
useRecoveryCode = false;
|
||||
form.OneTimeCode = default;
|
||||
if (CascadingState.Personas.Count == 1 && CascadingState.Persona is not null)
|
||||
Continue();
|
||||
}
|
||||
@@ -151,6 +209,23 @@
|
||||
Continue();
|
||||
}
|
||||
|
||||
void ToggleRecoveryCode()
|
||||
{
|
||||
useRecoveryCode = !useRecoveryCode;
|
||||
form.OneTimeCode = default;
|
||||
error = default;
|
||||
}
|
||||
|
||||
/// <summary>Back to the name and the password, for another login: the password is typed again.</summary>
|
||||
void Back()
|
||||
{
|
||||
needsCode = false;
|
||||
useRecoveryCode = false;
|
||||
form.OneTimeCode = default;
|
||||
form.Password = default;
|
||||
error = default;
|
||||
}
|
||||
|
||||
async Task SignOut() => await CascadingState.SignOut();
|
||||
|
||||
/// <summary>Back where the reader was sent from, never off the app.</summary>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
@page "/password-recovery"
|
||||
@inherits LocalizableComponentBase
|
||||
<Title>@Localizer["Forgot your password?"]</Title>
|
||||
|
||||
@* A forgotten password (PrivaPub's /clientapi/user/recover): the login's name, or its verified address, asks for a link,
|
||||
and PrivaPub answers everyone with the same sentence, so the page never tells whether a login or an address exists.
|
||||
The link comes back here with its code (?rc=), which leaves the address bar and the history at once and stays in the
|
||||
page; a new password set with it ends every session the login had, and the reader signs in with it. *@
|
||||
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
|
||||
|
||||
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
|
||||
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-signed-in>
|
||||
<h1 class="text-xl font-bold">@Localizer["You are signed in"]</h1>
|
||||
<DButton Href="" Color="DColor.Primary">@Localizer["Home"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCode is null && sentence is not null)
|
||||
{
|
||||
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-sent>
|
||||
<h1 class="text-xl font-bold">@Localizer["Check your inbox"]</h1>
|
||||
<p class="text-sm">@sentence</p>
|
||||
<DButton Href="login" Color="DColor.Primary">@Localizer["Login"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else if (recoveryCode is null)
|
||||
{
|
||||
<EditForm Model="request" OnValidSubmit="Request" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-request>
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Forgot your password?"]</h1>
|
||||
<p class="text-sm opacity-70">@Localizer["Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it."]</p>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Username"]</span>
|
||||
<DInput TValue="string" class="w-full" Value="request.UserName" ValueChanged="v => request.UserName = NullIfBlank(v)" ValueExpression="() => request.UserName"
|
||||
Immediate="true" disabled="@request.IsUsernameDisabled" autocomplete="username" autocapitalize="none" spellcheck="false" data-recovery-username>
|
||||
<Prefix><i class="ion-md-person"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
<span class="text-sm text-center opacity-70">@Localizer["or"]</span>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Email address"]</span>
|
||||
<DInput TValue="string" Type="email" class="w-full" Value="request.Email" ValueChanged="v => request.Email = NullIfBlank(v)" ValueExpression="() => request.Email"
|
||||
Immediate="true" disabled="@request.IsEmailDisabled" autocomplete="email" inputmode="email" autocapitalize="none" spellcheck="false" data-recovery-email>
|
||||
<Prefix><i class="ion-md-mail"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-submit>
|
||||
@if (isWorking)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Send the recovery link"]</span>
|
||||
</DButton>
|
||||
<a class="link text-sm self-center touch-target" href="login">@Localizer["I remember it: sign in"]</a>
|
||||
</EditForm>
|
||||
}
|
||||
else if (IsLoading)
|
||||
{
|
||||
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
else if (isCodeValid is false)
|
||||
{
|
||||
<div class="flex flex-col gap-3 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-invalid>
|
||||
<h1 class="text-xl font-bold">@Localizer["This recovery link doesn't work"]</h1>
|
||||
<p class="text-sm">@Localizer["A link works for one hour, and only once. Ask for a new one."]</p>
|
||||
<DButton Color="DColor.Primary" OnClick="AskAgain" data-recovery-again>@Localizer["Ask for a new link"]</DButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* also while the code couldn't be checked (offline, or the check failed): the answer to the new password says *@
|
||||
<EditForm Model="newPassword" OnValidSubmit="Recover" class="flex flex-col gap-4 w-full max-w-sm mt-6 md:mt-16 p-5 rounded-xl neo-raised neo-sm" data-recovery-new>
|
||||
<DataAnnotationsValidator />
|
||||
<h1 class="text-xl font-bold">@Localizer["Choose a new password"]</h1>
|
||||
<p class="text-sm opacity-70">@Localizer["Every device signed in to this login is signed out."]</p>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["New password"]</span>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.NewPassword" autocomplete="new-password" data-recovery-password>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">@Localizer["Repeat the new password"]</span>
|
||||
<DInput TValue="string" Type="password" class="w-full" @bind-Value="newPassword.RepeatedPassword" autocomplete="new-password" data-recovery-repeat>
|
||||
<Prefix><i class="ion-md-key"></i></Prefix>
|
||||
</DInput>
|
||||
</label>
|
||||
|
||||
<ErrorViewer AdditionalError="@error" />
|
||||
|
||||
<DButton Type="submit" Color="DColor.Primary" Disabled="IsDefaultDisabled || isWorking" data-recovery-new-submit>
|
||||
@if (isWorking)
|
||||
{
|
||||
<DLoading Size="DSize.Sm" LabelText="@Localizer["loading"]" />
|
||||
}
|
||||
<span>@Localizer["Change the password"]</span>
|
||||
</DButton>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Inject] AuthService AuthService { get; set; }
|
||||
|
||||
readonly PasswordRecoveryForm request = new();
|
||||
NewPasswordForm newPassword = new();
|
||||
/// <summary>The code of the link the page was opened from, kept here once it left the address bar.</summary>
|
||||
string recoveryCode;
|
||||
/// <summary>Whether the code works: null until PrivaPub said.</summary>
|
||||
bool? isCodeValid;
|
||||
/// <summary>PrivaPub's answer to a request, the same for everyone.</summary>
|
||||
string sentence;
|
||||
string error;
|
||||
bool isWorking;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "rc")] public string LinkCode { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (LinkCode is { Length: > 0 } code)
|
||||
{
|
||||
recoveryCode = code.Trim();
|
||||
newPassword.RecoveryCode = recoveryCode;
|
||||
// the code is as good as the password for an hour: out of the address bar and the history at once
|
||||
Navigation.NavigateTo("password-recovery", replace: true);
|
||||
}
|
||||
|
||||
if (CascadingState.IsOffline)
|
||||
{
|
||||
WhenOnline(LoadData);
|
||||
StartOnlinePolling();
|
||||
IsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadData();
|
||||
IsLoading = false;
|
||||
}
|
||||
|
||||
async Task LoadData()
|
||||
{
|
||||
if (recoveryCode is null)
|
||||
return;
|
||||
|
||||
var result = await AuthService.CheckRecoveryCode(recoveryCode);
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
isCodeValid = result.Data is true;
|
||||
}
|
||||
|
||||
static string NullIfBlank(string value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
async Task Request()
|
||||
{
|
||||
error = default;
|
||||
isWorking = true;
|
||||
request.UserName = request.UserName?.Trim();
|
||||
request.Email = request.Email?.Trim().ToLowerInvariant();
|
||||
var result = await AuthService.RequestRecovery(request);
|
||||
isWorking = false;
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
sentence = result.Data is string { Length: > 0 } said
|
||||
? said
|
||||
: Localizer["If that account has an email address, a recovery link is on its way. It works for one hour."];
|
||||
}
|
||||
|
||||
void AskAgain()
|
||||
{
|
||||
recoveryCode = null;
|
||||
isCodeValid = null;
|
||||
newPassword = new();
|
||||
error = default;
|
||||
}
|
||||
|
||||
async Task Recover()
|
||||
{
|
||||
error = default;
|
||||
isWorking = true;
|
||||
var result = await AuthService.Recover(newPassword);
|
||||
isWorking = false;
|
||||
if (result.StatusCode is (int)System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// used or expired meanwhile
|
||||
isCodeValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.IsInvalid)
|
||||
{
|
||||
error = result.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
Toast.AddResponseSuccess(Localizer["Password changed: sign in with the new one."]);
|
||||
Navigation.NavigateTo("login");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@
|
||||
ValueChanged="v => Update(s => s.SetDarkMode(v))" ValueExpression="() => IsDark" />
|
||||
<DToggle LabelText="@Localizer["Grey"]" Value="IsGray"
|
||||
ValueChanged="v => Update(s => s.SetGray(v, IsDark))" ValueExpression="() => IsGray" />
|
||||
@* The theme is the login's: taken at sign-in, saved to it as it changes here (CascadingState.SaveSettingsSoon). *@
|
||||
<DToggle LabelText="@Localizer["Keep the theme the same on every device signed in to this login"]" Value="CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
|
||||
ValueChanged="v => Update(s => s.SyncWithLogin = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.SyncWithLogin"
|
||||
data-sync-with-login />
|
||||
<DToggle LabelText="@Localizer["Remind me to describe pictures before publishing"]" Value="CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||
ValueChanged="v => Update(s => s.RemindAltText = v)" ValueExpression="() => CascadingState.PublicCacheData.PageSettings.RemindAltText"
|
||||
data-remind-alt-text />
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await CascadingState.LoadSession();
|
||||
await CascadingState.SignedIn(result.Data as JwtUser);
|
||||
isWorking = false;
|
||||
Toast.AddResponseSuccess(Localizer["PrivaPub is set up: you are its administrator."]);
|
||||
Navigation.NavigateTo("");
|
||||
|
||||
File renamed without changes.
+1
-4
@@ -11,7 +11,6 @@ using Toolbelt.Blazor.Extensions.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using PrivaPub.ClientModels;
|
||||
using collAnon.Client.Services;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
@@ -63,9 +62,7 @@ builder.Services.AddOptions()
|
||||
.AddTransient<IHttpService, HttpService>()
|
||||
.AddHeadElementHelper(options => options.DisableClientScriptAutoInjection = true)
|
||||
.AddLocalization()
|
||||
.AddTransient<IStorage, Storage>()
|
||||
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug))
|
||||
.AddIndexedDb();
|
||||
.AddLogging(lb => lb.SetMinimumLevel(LogLevel.Debug));
|
||||
|
||||
var apiBaseAddress = builder.Configuration["AppConfiguration:ApiBaseAddress"];
|
||||
builder.Services.AddHttpClient("default", client =>
|
||||
|
||||
+202
-22
@@ -83,9 +83,6 @@
|
||||
<data name="Direct" xml:space="preserve">
|
||||
<value>Diretto</value>
|
||||
</data>
|
||||
<data name="Documents" xml:space="preserve">
|
||||
<value>Documenti</value>
|
||||
</data>
|
||||
<data name="Emoji?" xml:space="preserve">
|
||||
<value>Emoji?</value>
|
||||
</data>
|
||||
@@ -98,9 +95,6 @@
|
||||
<data name="Favourite" xml:space="preserve">
|
||||
<value>Preferito</value>
|
||||
</data>
|
||||
<data name="Federation" xml:space="preserve">
|
||||
<value>Federazione</value>
|
||||
</data>
|
||||
<data name="Follow the system's light or dark setting" xml:space="preserve">
|
||||
<value>Segui l'impostazione chiara o scura del sistema</value>
|
||||
</data>
|
||||
@@ -260,15 +254,6 @@
|
||||
<data name="{0} blocked." xml:space="preserve">
|
||||
<value>Hai bloccato {0}.</value>
|
||||
</data>
|
||||
<data name="{0} day/s" xml:space="preserve">
|
||||
<value>{0} giorno/i</value>
|
||||
</data>
|
||||
<data name="{0} hour/s" xml:space="preserve">
|
||||
<value>{0} ora/e</value>
|
||||
</data>
|
||||
<data name="{0} minute/s" xml:space="preserve">
|
||||
<value>{0} minuto/i</value>
|
||||
</data>
|
||||
<data name="{0} muted." xml:space="preserve">
|
||||
<value>Hai silenziato {0}.</value>
|
||||
</data>
|
||||
@@ -462,7 +447,7 @@
|
||||
<value>condivisioni per preferito</value>
|
||||
</data>
|
||||
<data name="camera" xml:space="preserve">
|
||||
<value>camera</value>
|
||||
<value>punto di vista</value>
|
||||
</data>
|
||||
<data name="centre of view" xml:space="preserve">
|
||||
<value>centro della vista</value>
|
||||
@@ -677,12 +662,6 @@
|
||||
<data name="solar time {0}" xml:space="preserve">
|
||||
<value>ora solare {0}</value>
|
||||
</data>
|
||||
<data name="northern hemisphere" xml:space="preserve">
|
||||
<value>emisfero boreale</value>
|
||||
</data>
|
||||
<data name="southern hemisphere" xml:space="preserve">
|
||||
<value>emisfero australe</value>
|
||||
</data>
|
||||
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
||||
<value>{0} a nord, {1} a sud</value>
|
||||
</data>
|
||||
@@ -2306,4 +2285,205 @@
|
||||
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
||||
<value>Link d'invito, per chi non ha un accesso qui</value>
|
||||
</data>
|
||||
<data name="Forgot your password?" xml:space="preserve">
|
||||
<value>Hai dimenticato la password?</value>
|
||||
</data>
|
||||
<data name="Check your inbox" xml:space="preserve">
|
||||
<value>Controlla la posta in arrivo</value>
|
||||
</data>
|
||||
<data name="Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it." xml:space="preserve">
|
||||
<value>Indica il nome utente del tuo accesso o il suo indirizzo email: se l'accesso ha un indirizzo verificato, a quell'indirizzo arriva un link per scegliere una nuova password.</value>
|
||||
</data>
|
||||
<data name="or" xml:space="preserve">
|
||||
<value>oppure</value>
|
||||
</data>
|
||||
<data name="Email address" xml:space="preserve">
|
||||
<value>Indirizzo email</value>
|
||||
</data>
|
||||
<data name="Send the recovery link" xml:space="preserve">
|
||||
<value>Invia il link di recupero</value>
|
||||
</data>
|
||||
<data name="I remember it: sign in" xml:space="preserve">
|
||||
<value>Me la ricordo: accedi</value>
|
||||
</data>
|
||||
<data name="This recovery link doesn't work" xml:space="preserve">
|
||||
<value>Questo link di recupero non funziona</value>
|
||||
</data>
|
||||
<data name="A link works for one hour, and only once. Ask for a new one." xml:space="preserve">
|
||||
<value>Un link funziona per un'ora, e una volta sola. Chiedine uno nuovo.</value>
|
||||
</data>
|
||||
<data name="Ask for a new link" xml:space="preserve">
|
||||
<value>Chiedi un nuovo link</value>
|
||||
</data>
|
||||
<data name="Choose a new password" xml:space="preserve">
|
||||
<value>Scegli una nuova password</value>
|
||||
</data>
|
||||
<data name="Every device signed in to this login is signed out." xml:space="preserve">
|
||||
<value>Ogni dispositivo collegato a questo accesso viene disconnesso.</value>
|
||||
</data>
|
||||
<data name="If that account has an email address, a recovery link is on its way. It works for one hour." xml:space="preserve">
|
||||
<value>Se quell'accesso ha un indirizzo email, un link di recupero è in arrivo. Funziona per un'ora.</value>
|
||||
</data>
|
||||
<data name="Password changed: sign in with the new one." xml:space="preserve">
|
||||
<value>Password cambiata: accedi con quella nuova.</value>
|
||||
</data>
|
||||
<data name="verified" xml:space="preserve">
|
||||
<value>verificato</value>
|
||||
</data>
|
||||
<data name="not verified" xml:space="preserve">
|
||||
<value>non verificato</value>
|
||||
</data>
|
||||
<data name="Only a verified address can recover a forgotten password." xml:space="preserve">
|
||||
<value>Solo un indirizzo verificato permette di recuperare una password dimenticata.</value>
|
||||
</data>
|
||||
<data name="No email address yet: without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||
<value>Ancora nessun indirizzo email: senza, una password dimenticata non si può recuperare.</value>
|
||||
</data>
|
||||
<data name="Enter the code sent to {0}." xml:space="preserve">
|
||||
<value>Inserisci il codice inviato a {0}.</value>
|
||||
</data>
|
||||
<data name="Code" xml:space="preserve">
|
||||
<value>Codice</value>
|
||||
</data>
|
||||
<data name="Verify" xml:space="preserve">
|
||||
<value>Verifica</value>
|
||||
</data>
|
||||
<data name="New email address" xml:space="preserve">
|
||||
<value>Nuovo indirizzo email</value>
|
||||
</data>
|
||||
<data name="Send the code" xml:space="preserve">
|
||||
<value>Invia il codice</value>
|
||||
</data>
|
||||
<data name="Remove the address" xml:space="preserve">
|
||||
<value>Togli l'indirizzo</value>
|
||||
</data>
|
||||
<data name="Change" xml:space="preserve">
|
||||
<value>Cambia</value>
|
||||
</data>
|
||||
<data name="Add an address" xml:space="preserve">
|
||||
<value>Aggiungi un indirizzo</value>
|
||||
</data>
|
||||
<data name="Every other device signed in to this login is signed out." xml:space="preserve">
|
||||
<value>Ogni altro dispositivo collegato a questo accesso viene disconnesso.</value>
|
||||
</data>
|
||||
<data name="Sign out everywhere" xml:space="preserve">
|
||||
<value>Esci ovunque</value>
|
||||
</data>
|
||||
<data name="Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours." xml:space="preserve">
|
||||
<value>Si esce da questo accesso su ogni dispositivo, anche questo, con ogni persona: per un dispositivo perso, o una sessione che non è tua.</value>
|
||||
</data>
|
||||
<data name="If that address can be used, a code is on its way to it. It works for one hour." xml:space="preserve">
|
||||
<value>Se quell'indirizzo si può usare, un codice è in arrivo. Funziona per un'ora.</value>
|
||||
</data>
|
||||
<data name="Email address verified." xml:space="preserve">
|
||||
<value>Indirizzo email verificato.</value>
|
||||
</data>
|
||||
<data name="Remove the email address?" xml:space="preserve">
|
||||
<value>Togliere l'indirizzo email?</value>
|
||||
</data>
|
||||
<data name="Without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||
<value>Senza, una password dimenticata non si può recuperare.</value>
|
||||
</data>
|
||||
<data name="Email address removed." xml:space="preserve">
|
||||
<value>Indirizzo email tolto.</value>
|
||||
</data>
|
||||
<data name="Sign out everywhere?" xml:space="preserve">
|
||||
<value>Uscire ovunque?</value>
|
||||
</data>
|
||||
<data name="Every device and every persona of this login signs out, this one too. Signing in again takes the password." xml:space="preserve">
|
||||
<value>Ogni dispositivo e ogni persona di questo accesso escono, anche questo. Per rientrare serve la password.</value>
|
||||
</data>
|
||||
<data name="Wrong password." xml:space="preserve">
|
||||
<value>Password errata.</value>
|
||||
</data>
|
||||
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
|
||||
<value>Mantieni lo stesso tema su ogni dispositivo collegato a questo accesso</value>
|
||||
</data>
|
||||
<data name="Enter the code from your authenticator app, or a recovery code." xml:space="preserve">
|
||||
<value>Inserisci il codice dell'app di autenticazione, o un codice di recupero.</value>
|
||||
</data>
|
||||
<data name="Recovery code" xml:space="preserve">
|
||||
<value>Codice di recupero</value>
|
||||
</data>
|
||||
<data name="Code from the authenticator app" xml:space="preserve">
|
||||
<value>Codice dell'app di autenticazione</value>
|
||||
</data>
|
||||
<data name="Use the authenticator app instead" xml:space="preserve">
|
||||
<value>Usa invece l'app di autenticazione</value>
|
||||
</data>
|
||||
<data name="Use a recovery code instead" xml:space="preserve">
|
||||
<value>Usa invece un codice di recupero</value>
|
||||
</data>
|
||||
<data name="Two-factor sign-in" xml:space="preserve">
|
||||
<value>Verifica in due passaggi</value>
|
||||
</data>
|
||||
<data name="active" xml:space="preserve">
|
||||
<value>attiva</value>
|
||||
</data>
|
||||
<data name="not active" xml:space="preserve">
|
||||
<value>non attiva</value>
|
||||
</data>
|
||||
<data name="Two-factor sign-in is on. Every other device signed in to this login was signed out." xml:space="preserve">
|
||||
<value>La verifica in due passaggi è attiva. Ogni altro dispositivo collegato a questo accesso è stato disconnesso.</value>
|
||||
</data>
|
||||
<data name="Save these recovery codes now: they won't be shown again." xml:space="preserve">
|
||||
<value>Salva subito questi codici di recupero: non verranno più mostrati.</value>
|
||||
</data>
|
||||
<data name="Each one signs you in once in place of the app's code, if you lose your phone." xml:space="preserve">
|
||||
<value>Ognuno vale per un accesso al posto del codice dell'app, se perdi il telefono.</value>
|
||||
</data>
|
||||
<data name="Copy" xml:space="preserve">
|
||||
<value>Copia</value>
|
||||
</data>
|
||||
<data name="I saved them" xml:space="preserve">
|
||||
<value>Li ho salvati</value>
|
||||
</data>
|
||||
<data name="Signing in takes the password and a code from your authenticator app, or one of your recovery codes." xml:space="preserve">
|
||||
<value>Per accedere servono la password e un codice dell'app di autenticazione, o uno dei codici di recupero.</value>
|
||||
</data>
|
||||
<data name="Recovery codes left: {0}" xml:space="preserve">
|
||||
<value>Codici di recupero rimasti: {0}</value>
|
||||
</data>
|
||||
<data name="Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones." xml:space="preserve">
|
||||
<value>Restano pochi codici di recupero: disattiva e riattiva la verifica in due passaggi per averne dieci nuovi.</value>
|
||||
</data>
|
||||
<data name="Turn off" xml:space="preserve">
|
||||
<value>Disattiva</value>
|
||||
</data>
|
||||
<data name="Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough." xml:space="preserve">
|
||||
<value>Per accedere serve, oltre alla password, un codice da un'app di autenticazione sul telefono: così una password rubata non basta.</value>
|
||||
</data>
|
||||
<data name="Scan this QR code with your authenticator app, or type the key into it." xml:space="preserve">
|
||||
<value>Inquadra questo codice QR con l'app di autenticazione, o inserisci la chiave a mano.</value>
|
||||
</data>
|
||||
<data name="QR code of the key, for your authenticator app" xml:space="preserve">
|
||||
<value>Codice QR della chiave, per l'app di autenticazione</value>
|
||||
</data>
|
||||
<data name="Key" xml:space="preserve">
|
||||
<value>Chiave</value>
|
||||
</data>
|
||||
<data name="Open in the authenticator app" xml:space="preserve">
|
||||
<value>Apri nell'app di autenticazione</value>
|
||||
</data>
|
||||
<data name="Then enter the 6-digit code the app shows." xml:space="preserve">
|
||||
<value>Poi inserisci il codice di 6 cifre che mostra l'app.</value>
|
||||
</data>
|
||||
<data name="Turn on" xml:space="preserve">
|
||||
<value>Attiva</value>
|
||||
</data>
|
||||
<data name="Turn on two-factor sign-in" xml:space="preserve">
|
||||
<value>Attiva la verifica in due passaggi</value>
|
||||
</data>
|
||||
<data name="Recovery codes copied." xml:space="preserve">
|
||||
<value>Codici di recupero copiati.</value>
|
||||
</data>
|
||||
<data name="Turn off two-factor sign-in?" xml:space="preserve">
|
||||
<value>Disattivare la verifica in due passaggi?</value>
|
||||
</data>
|
||||
<data name="Signing in takes only the password again, and the recovery codes stop working." xml:space="preserve">
|
||||
<value>Per accedere tornerà a bastare la password, e i codici di recupero smetteranno di funzionare.</value>
|
||||
</data>
|
||||
<data name="Two-factor sign-in is off." xml:space="preserve">
|
||||
<value>La verifica in due passaggi è disattivata.</value>
|
||||
</data>
|
||||
</root>
|
||||
+201
-21
@@ -83,9 +83,6 @@
|
||||
<data name="Direct" xml:space="preserve">
|
||||
<value>Direct</value>
|
||||
</data>
|
||||
<data name="Documents" xml:space="preserve">
|
||||
<value>Documents</value>
|
||||
</data>
|
||||
<data name="Emoji?" xml:space="preserve">
|
||||
<value>Emoji?</value>
|
||||
</data>
|
||||
@@ -98,9 +95,6 @@
|
||||
<data name="Favourite" xml:space="preserve">
|
||||
<value>Favourite</value>
|
||||
</data>
|
||||
<data name="Federation" xml:space="preserve">
|
||||
<value>Federation</value>
|
||||
</data>
|
||||
<data name="Follow the system's light or dark setting" xml:space="preserve">
|
||||
<value>Follow the system's light or dark setting</value>
|
||||
</data>
|
||||
@@ -260,15 +254,6 @@
|
||||
<data name="{0} blocked." xml:space="preserve">
|
||||
<value>{0} blocked.</value>
|
||||
</data>
|
||||
<data name="{0} day/s" xml:space="preserve">
|
||||
<value>{0} day/s</value>
|
||||
</data>
|
||||
<data name="{0} hour/s" xml:space="preserve">
|
||||
<value>{0} hour/s</value>
|
||||
</data>
|
||||
<data name="{0} minute/s" xml:space="preserve">
|
||||
<value>{0} minute/s</value>
|
||||
</data>
|
||||
<data name="{0} muted." xml:space="preserve">
|
||||
<value>{0} muted.</value>
|
||||
</data>
|
||||
@@ -677,12 +662,6 @@
|
||||
<data name="solar time {0}" xml:space="preserve">
|
||||
<value>solar time {0}</value>
|
||||
</data>
|
||||
<data name="northern hemisphere" xml:space="preserve">
|
||||
<value>northern hemisphere</value>
|
||||
</data>
|
||||
<data name="southern hemisphere" xml:space="preserve">
|
||||
<value>southern hemisphere</value>
|
||||
</data>
|
||||
<data name="{0} in the north, {1} in the south" xml:space="preserve">
|
||||
<value>{0} in the north, {1} in the south</value>
|
||||
</data>
|
||||
@@ -2306,4 +2285,205 @@
|
||||
<data name="Invitation link, for someone without a login here" xml:space="preserve">
|
||||
<value>Invitation link, for someone without a login here</value>
|
||||
</data>
|
||||
<data name="Forgot your password?" xml:space="preserve">
|
||||
<value>Forgot your password?</value>
|
||||
</data>
|
||||
<data name="Check your inbox" xml:space="preserve">
|
||||
<value>Check your inbox</value>
|
||||
</data>
|
||||
<data name="Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it." xml:space="preserve">
|
||||
<value>Give your login's username or its email address: if the login has a verified address, a link to choose a new password is sent to it.</value>
|
||||
</data>
|
||||
<data name="or" xml:space="preserve">
|
||||
<value>or</value>
|
||||
</data>
|
||||
<data name="Email address" xml:space="preserve">
|
||||
<value>Email address</value>
|
||||
</data>
|
||||
<data name="Send the recovery link" xml:space="preserve">
|
||||
<value>Send the recovery link</value>
|
||||
</data>
|
||||
<data name="I remember it: sign in" xml:space="preserve">
|
||||
<value>I remember it: sign in</value>
|
||||
</data>
|
||||
<data name="This recovery link doesn't work" xml:space="preserve">
|
||||
<value>This recovery link doesn't work</value>
|
||||
</data>
|
||||
<data name="A link works for one hour, and only once. Ask for a new one." xml:space="preserve">
|
||||
<value>A link works for one hour, and only once. Ask for a new one.</value>
|
||||
</data>
|
||||
<data name="Ask for a new link" xml:space="preserve">
|
||||
<value>Ask for a new link</value>
|
||||
</data>
|
||||
<data name="Choose a new password" xml:space="preserve">
|
||||
<value>Choose a new password</value>
|
||||
</data>
|
||||
<data name="Every device signed in to this login is signed out." xml:space="preserve">
|
||||
<value>Every device signed in to this login is signed out.</value>
|
||||
</data>
|
||||
<data name="If that account has an email address, a recovery link is on its way. It works for one hour." xml:space="preserve">
|
||||
<value>If that account has an email address, a recovery link is on its way. It works for one hour.</value>
|
||||
</data>
|
||||
<data name="Password changed: sign in with the new one." xml:space="preserve">
|
||||
<value>Password changed: sign in with the new one.</value>
|
||||
</data>
|
||||
<data name="verified" xml:space="preserve">
|
||||
<value>verified</value>
|
||||
</data>
|
||||
<data name="not verified" xml:space="preserve">
|
||||
<value>not verified</value>
|
||||
</data>
|
||||
<data name="Only a verified address can recover a forgotten password." xml:space="preserve">
|
||||
<value>Only a verified address can recover a forgotten password.</value>
|
||||
</data>
|
||||
<data name="No email address yet: without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||
<value>No email address yet: without one, a forgotten password can't be recovered.</value>
|
||||
</data>
|
||||
<data name="Enter the code sent to {0}." xml:space="preserve">
|
||||
<value>Enter the code sent to {0}.</value>
|
||||
</data>
|
||||
<data name="Code" xml:space="preserve">
|
||||
<value>Code</value>
|
||||
</data>
|
||||
<data name="Verify" xml:space="preserve">
|
||||
<value>Verify</value>
|
||||
</data>
|
||||
<data name="New email address" xml:space="preserve">
|
||||
<value>New email address</value>
|
||||
</data>
|
||||
<data name="Send the code" xml:space="preserve">
|
||||
<value>Send the code</value>
|
||||
</data>
|
||||
<data name="Remove the address" xml:space="preserve">
|
||||
<value>Remove the address</value>
|
||||
</data>
|
||||
<data name="Change" xml:space="preserve">
|
||||
<value>Change</value>
|
||||
</data>
|
||||
<data name="Add an address" xml:space="preserve">
|
||||
<value>Add an address</value>
|
||||
</data>
|
||||
<data name="Every other device signed in to this login is signed out." xml:space="preserve">
|
||||
<value>Every other device signed in to this login is signed out.</value>
|
||||
</data>
|
||||
<data name="Sign out everywhere" xml:space="preserve">
|
||||
<value>Sign out everywhere</value>
|
||||
</data>
|
||||
<data name="Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours." xml:space="preserve">
|
||||
<value>Every device signed in to this login signs out, this one too, with every persona: for a lost device, or a session that isn't yours.</value>
|
||||
</data>
|
||||
<data name="If that address can be used, a code is on its way to it. It works for one hour." xml:space="preserve">
|
||||
<value>If that address can be used, a code is on its way to it. It works for one hour.</value>
|
||||
</data>
|
||||
<data name="Email address verified." xml:space="preserve">
|
||||
<value>Email address verified.</value>
|
||||
</data>
|
||||
<data name="Remove the email address?" xml:space="preserve">
|
||||
<value>Remove the email address?</value>
|
||||
</data>
|
||||
<data name="Without one, a forgotten password can't be recovered." xml:space="preserve">
|
||||
<value>Without one, a forgotten password can't be recovered.</value>
|
||||
</data>
|
||||
<data name="Email address removed." xml:space="preserve">
|
||||
<value>Email address removed.</value>
|
||||
</data>
|
||||
<data name="Sign out everywhere?" xml:space="preserve">
|
||||
<value>Sign out everywhere?</value>
|
||||
</data>
|
||||
<data name="Every device and every persona of this login signs out, this one too. Signing in again takes the password." xml:space="preserve">
|
||||
<value>Every device and every persona of this login signs out, this one too. Signing in again takes the password.</value>
|
||||
</data>
|
||||
<data name="Wrong password." xml:space="preserve">
|
||||
<value>Wrong password.</value>
|
||||
</data>
|
||||
<data name="Keep the theme the same on every device signed in to this login" xml:space="preserve">
|
||||
<value>Keep the theme the same on every device signed in to this login</value>
|
||||
</data>
|
||||
<data name="Enter the code from your authenticator app, or a recovery code." xml:space="preserve">
|
||||
<value>Enter the code from your authenticator app, or a recovery code.</value>
|
||||
</data>
|
||||
<data name="Recovery code" xml:space="preserve">
|
||||
<value>Recovery code</value>
|
||||
</data>
|
||||
<data name="Code from the authenticator app" xml:space="preserve">
|
||||
<value>Code from the authenticator app</value>
|
||||
</data>
|
||||
<data name="Use the authenticator app instead" xml:space="preserve">
|
||||
<value>Use the authenticator app instead</value>
|
||||
</data>
|
||||
<data name="Use a recovery code instead" xml:space="preserve">
|
||||
<value>Use a recovery code instead</value>
|
||||
</data>
|
||||
<data name="Two-factor sign-in" xml:space="preserve">
|
||||
<value>Two-factor sign-in</value>
|
||||
</data>
|
||||
<data name="active" xml:space="preserve">
|
||||
<value>active</value>
|
||||
</data>
|
||||
<data name="not active" xml:space="preserve">
|
||||
<value>not active</value>
|
||||
</data>
|
||||
<data name="Two-factor sign-in is on. Every other device signed in to this login was signed out." xml:space="preserve">
|
||||
<value>Two-factor sign-in is on. Every other device signed in to this login was signed out.</value>
|
||||
</data>
|
||||
<data name="Save these recovery codes now: they won't be shown again." xml:space="preserve">
|
||||
<value>Save these recovery codes now: they won't be shown again.</value>
|
||||
</data>
|
||||
<data name="Each one signs you in once in place of the app's code, if you lose your phone." xml:space="preserve">
|
||||
<value>Each one signs you in once in place of the app's code, if you lose your phone.</value>
|
||||
</data>
|
||||
<data name="Copy" xml:space="preserve">
|
||||
<value>Copy</value>
|
||||
</data>
|
||||
<data name="I saved them" xml:space="preserve">
|
||||
<value>I saved them</value>
|
||||
</data>
|
||||
<data name="Signing in takes the password and a code from your authenticator app, or one of your recovery codes." xml:space="preserve">
|
||||
<value>Signing in takes the password and a code from your authenticator app, or one of your recovery codes.</value>
|
||||
</data>
|
||||
<data name="Recovery codes left: {0}" xml:space="preserve">
|
||||
<value>Recovery codes left: {0}</value>
|
||||
</data>
|
||||
<data name="Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones." xml:space="preserve">
|
||||
<value>Few recovery codes are left: turn two-factor sign-in off and on again for ten new ones.</value>
|
||||
</data>
|
||||
<data name="Turn off" xml:space="preserve">
|
||||
<value>Turn off</value>
|
||||
</data>
|
||||
<data name="Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough." xml:space="preserve">
|
||||
<value>Signing in takes a code from an authenticator app on your phone as well as the password, so a stolen password isn't enough.</value>
|
||||
</data>
|
||||
<data name="Scan this QR code with your authenticator app, or type the key into it." xml:space="preserve">
|
||||
<value>Scan this QR code with your authenticator app, or type the key into it.</value>
|
||||
</data>
|
||||
<data name="QR code of the key, for your authenticator app" xml:space="preserve">
|
||||
<value>QR code of the key, for your authenticator app</value>
|
||||
</data>
|
||||
<data name="Key" xml:space="preserve">
|
||||
<value>Key</value>
|
||||
</data>
|
||||
<data name="Open in the authenticator app" xml:space="preserve">
|
||||
<value>Open in the authenticator app</value>
|
||||
</data>
|
||||
<data name="Then enter the 6-digit code the app shows." xml:space="preserve">
|
||||
<value>Then enter the 6-digit code the app shows.</value>
|
||||
</data>
|
||||
<data name="Turn on" xml:space="preserve">
|
||||
<value>Turn on</value>
|
||||
</data>
|
||||
<data name="Turn on two-factor sign-in" xml:space="preserve">
|
||||
<value>Turn on two-factor sign-in</value>
|
||||
</data>
|
||||
<data name="Recovery codes copied." xml:space="preserve">
|
||||
<value>Recovery codes copied.</value>
|
||||
</data>
|
||||
<data name="Turn off two-factor sign-in?" xml:space="preserve">
|
||||
<value>Turn off two-factor sign-in?</value>
|
||||
</data>
|
||||
<data name="Signing in takes only the password again, and the recovery codes stop working." xml:space="preserve">
|
||||
<value>Signing in takes only the password again, and the recovery codes stop working.</value>
|
||||
</data>
|
||||
<data name="Two-factor sign-in is off." xml:space="preserve">
|
||||
<value>Two-factor sign-in is off.</value>
|
||||
</data>
|
||||
</root>
|
||||
Generated
+18
@@ -60,6 +60,24 @@ namespace decePubClient.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The {0} field is not a valid date..
|
||||
/// </summary>
|
||||
public static string InvalidDate {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidDate", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The {0} field is not valid..
|
||||
/// </summary>
|
||||
public static string InvalidValue {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidValue", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The field {0} can be at most {1} characters long..
|
||||
/// </summary>
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="InvalidDate" xml:space="preserve">
|
||||
<value>Il campo {0} non è una data valida.</value>
|
||||
</data>
|
||||
<data name="InvalidValue" xml:space="preserve">
|
||||
<value>Il campo {0} non è valido.</value>
|
||||
</data>
|
||||
<data name="MaxLength" xml:space="preserve">
|
||||
<value>Il campo {0} può contenere al massimo {1} caratteri.</value>
|
||||
</data>
|
||||
|
||||
@@ -117,6 +117,12 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="InvalidDate" xml:space="preserve">
|
||||
<value>The {0} field is not a valid date.</value>
|
||||
</data>
|
||||
<data name="InvalidValue" xml:space="preserve">
|
||||
<value>The {0} field is not valid.</value>
|
||||
</data>
|
||||
<data name="MaxLength" xml:space="preserve">
|
||||
<value>The field {0} can be at most {1} characters long.</value>
|
||||
</data>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models;
|
||||
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
@@ -12,29 +12,12 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
||||
public async ValueTask<bool> IsOnline(CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeAsync<bool>("isOnline", cancellationToken);
|
||||
|
||||
public bool IsServiceWorkerAvailable() => JsSyncRuntime.Invoke<bool>("isServiceWorkerAvailable");
|
||||
|
||||
public string Language() => JsSyncRuntime.Invoke<string>("getLanguage");
|
||||
|
||||
public void Language(string language) => JsSyncRuntime.InvokeVoid("setLanguage", language);
|
||||
|
||||
public long GetHeight(string classId) => JsSyncRuntime.Invoke<long>("getHeight", classId);
|
||||
|
||||
public void SetAppBadge(int counter) => JsSyncRuntime.InvokeVoid("setAppBadge", counter);
|
||||
|
||||
public void ClearAppBadge() => JsSyncRuntime.InvokeVoid("clearAppBadge");
|
||||
|
||||
public async ValueTask ClearCache(CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeVoidAsync("clearCache", cancellationToken);
|
||||
|
||||
public async ValueTask ClearLocalStorage(CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeVoidAsync("clearLocalStorage", cancellationToken);
|
||||
|
||||
public void ReloadPage()
|
||||
{
|
||||
JsSyncRuntime.InvokeVoid("reloadPage");
|
||||
}
|
||||
|
||||
// wwwroot/js/theme.js
|
||||
public void ApplyTheme(PageSettings pageSettings) => JsSyncRuntime.InvokeVoid("neoTheme.apply", pageSettings);
|
||||
|
||||
@@ -53,14 +36,10 @@ public class AppStatusService(IJSRuntime jsRuntime)
|
||||
|
||||
public bool PrefersReducedMotion() => reducedMotion ??= JsSyncRuntime.Invoke<bool>("prefersReducedMotion");
|
||||
|
||||
// puts the text on the clipboard; false when the browser refused it, which main.js reports through logFromJs
|
||||
public async ValueTask<bool> CopyText(string text, CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeAsync<bool>("copyText", cancellationToken, text);
|
||||
|
||||
// a sideways-scrolling strip of tabs shows its selected tab
|
||||
public void RevealActive(ElementReference strip) => JsSyncRuntime.InvokeVoid("revealActive", strip);
|
||||
|
||||
public bool CanShare() => JsSyncRuntime.Invoke<bool>("canShareStuff");
|
||||
|
||||
public async ValueTask ShareTextUrl(string title, string text, Uri uri, CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeVoidAsync("shareTextUrl", cancellationToken, title, text, uri.ToString());
|
||||
|
||||
public async ValueTask ShareText(string title, string text, CancellationToken cancellationToken = default) =>
|
||||
await jsRuntime.InvokeVoidAsync("shareText", cancellationToken, title, text);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
|
||||
|
||||
+249
-5
@@ -1,6 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
using System.Text.Json;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Helpers;
|
||||
@@ -24,13 +23,25 @@ namespace decePubClient.Services
|
||||
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
|
||||
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
|
||||
|
||||
/// <summary>Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one).</summary>
|
||||
/// <summary>
|
||||
/// Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one). With two-factor
|
||||
/// sign-in on, a right password without LoginForm.OneTimeCode comes back invalid with StatusCode 428
|
||||
/// (NeedsOneTimeCode): the caller asks for the code and sends the same form again with it. A wrong code is a 400, like a
|
||||
/// wrong password, with PrivaPub's own message.
|
||||
/// </summary>
|
||||
public async Task<WebResult> Login(LoginForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken);
|
||||
if (response.StatusCode is HttpStatusCode.PreconditionRequired)
|
||||
return new WebResult().Invalidate(localizer["Enter the code from your authenticator app, or a recovery code."],
|
||||
(int)HttpStatusCode.PreconditionRequired);
|
||||
|
||||
return await StartSession(response, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Whether a sign-in failed only for want of the authenticator app's code or a recovery code (PrivaPub's 428).</summary>
|
||||
public static bool NeedsOneTimeCode(WebResult result) => result.IsInvalid && result.StatusCode is (int)HttpStatusCode.PreconditionRequired;
|
||||
|
||||
/// <summary>Whether the server waits for its first login, which becomes its administrator; false when it can't say.</summary>
|
||||
public async Task<bool> SetupPending(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -147,13 +158,220 @@ namespace decePubClient.Services
|
||||
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Changes the root's password, the current one given.</summary>
|
||||
/// <summary>
|
||||
/// Changes the root's password, the current one given. PrivaPub ends every session of the login with it, and revokes
|
||||
/// every persona's token, this device's too; it answers with a new JWT, so the session goes on with it and the current
|
||||
/// persona gets a new token at once (the others when they are next used).
|
||||
/// </summary>
|
||||
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
// a server from before sessions answered with nothing, and ended none
|
||||
var jwtUser = await response.DefaultReadFromJsonAsync<JwtUser>(cancellationToken);
|
||||
if (string.IsNullOrEmpty(jwtUser?.Token))
|
||||
return new WebResult();
|
||||
|
||||
await ContinueSession(jwtUser, nameof(ChangePassword), cancellationToken);
|
||||
return new WebResult { Data = jwtUser };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins two-factor sign-in, the password given (a wrong one is PrivaPub's 403, read as such). Data is what the
|
||||
/// authenticator app needs (ViewTotpSetup): the secret as text, as an otpauth:// URI and as that URI's QR code. PrivaPub
|
||||
/// keeps it an hour for TotpEnable; a login with two-factor on already is a 409.
|
||||
/// </summary>
|
||||
public async Task<WebResult> TotpSetup(string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_TotpSetup, new PasswordForm { Password = password },
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await ReadPasswordFailure(response, cancellationToken);
|
||||
|
||||
var setup = await response.DefaultReadFromJsonAsync<ViewTotpSetup>(cancellationToken);
|
||||
if (string.IsNullOrEmpty(setup?.Secret))
|
||||
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
|
||||
|
||||
return new WebResult { Data = setup };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns two-factor sign-in on with the app's first code (a wrong one is a 400; a setup older than an hour a 409, to
|
||||
/// begin again). Data is the ten recovery codes (List<string>), which PrivaPub never shows again. PrivaPub ends every
|
||||
/// session of the login with it and revokes every persona's token, this device's too, and answers with this device's new
|
||||
/// JWT: the session goes on with it, as after a password change (ContinueSession).
|
||||
/// </summary>
|
||||
public async Task<WebResult> TotpEnable(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_TotpEnable, new TotpCodeForm { Code = code },
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
var enabled = await response.DefaultReadFromJsonAsync<ViewTotpEnabled>(cancellationToken);
|
||||
if (string.IsNullOrEmpty(enabled?.Session?.Token))
|
||||
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway);
|
||||
|
||||
await ContinueSession(enabled.Session, nameof(TotpEnable), cancellationToken);
|
||||
return new WebResult { Data = enabled.RecoveryCodes };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns two-factor sign-in off with the password (a wrong one is PrivaPub's 403) and a code: the app's, or a recovery
|
||||
/// code (a wrong one is a 400). Data is the login as it is now (ViewAccount).
|
||||
/// </summary>
|
||||
public async Task<WebResult> TotpDisable(string password, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_TotpDisable, new TotpDisableForm { Password = password, Code = code },
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await ReadPasswordFailure(response, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes on with the JWT PrivaPub answered after it ended every session of the login and revoked every persona's token,
|
||||
/// this device's too (a password change, two-factor sign-in turned on): the stored persona tokens are dropped and the
|
||||
/// current persona gets a new one at once, the others when they are next used.
|
||||
/// </summary>
|
||||
async Task ContinueSession(JwtUser jwtUser, string caller, CancellationToken cancellationToken)
|
||||
{
|
||||
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
||||
await authStateProvider.ForgetPersonaTokens(cancellationToken);
|
||||
var session = await authStateProvider.GetAuthData(cancellationToken);
|
||||
if (session.PersonaId is null)
|
||||
return;
|
||||
|
||||
using var exchanged = await httpService.ExchangePersonaToken(session.PersonaId, cancellationToken);
|
||||
if (!exchanged.IsSuccessStatusCode)
|
||||
await LogFailure(exchanged, caller, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks PrivaPub to mail a recovery link to the login named, or to the login whose verified address is given. Data is
|
||||
/// PrivaPub's answer, the same sentence whether or not there is such a login. Sent with no credentials.
|
||||
/// </summary>
|
||||
public async Task<WebResult> RequestRecovery(PasswordRecoveryForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverPassword, form, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await ReadSentence(response, cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Whether a recovery link's code still works (Data, a bool): it lasts an hour and is used once.</summary>
|
||||
public async Task<WebResult> CheckRecoveryCode(string recoveryCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// [FromBody] string: the code goes as a JSON string
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverValid, recoveryCode, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<bool>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Sets a new password through a recovery link's code; PrivaPub ends every session the login had.</summary>
|
||||
public async Task<WebResult> Recover(NewPasswordForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_RecoverUpdatePassword, form, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult();
|
||||
}
|
||||
|
||||
/// <summary>The login itself (its name, its email address and whether it is verified, one waiting for its code, whether
|
||||
/// two-factor sign-in is on and how many recovery codes are left), or null.</summary>
|
||||
public async Task<ViewAccount> Account(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.ClientApi.GET_Account, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await LogFailure(response, nameof(Account), cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the login's address to become another, the password given: PrivaPub mails a code to it, which VerifyEmail
|
||||
/// brings back. Data is PrivaPub's answer, the same sentence whether or not the address is free.
|
||||
/// </summary>
|
||||
public async Task<WebResult> ChangeEmail(EmailChangeForm form, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_Email, form, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await ReadPasswordFailure(response, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await ReadSentence(response, cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Makes the address waiting for its code the login's, verified; Data is the login as it is now.</summary>
|
||||
public async Task<WebResult> VerifyEmail(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_EmailVerify, new EmailCodeForm { Code = code }, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>Removes the login's address, the password given; without one a forgotten password can't be recovered.
|
||||
/// Data is the login as it is now.</summary>
|
||||
public async Task<WebResult> RemoveEmail(string password, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_EmailRemove, new PasswordForm { Password = password },
|
||||
cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await ReadPasswordFailure(response, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAccount>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends every session of the login, this device's too, and PrivaPub revokes every persona's token with them. The client
|
||||
/// forgets its session at once; the caller signs out, which then has nothing left to tell PrivaPub.
|
||||
/// </summary>
|
||||
public async Task<WebResult> LogoutEverywhere(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_LogoutEverywhere, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
await authStateProvider.LogoutAsync(cancellationToken);
|
||||
return new WebResult();
|
||||
}
|
||||
|
||||
/// <summary>The theme and language the login keeps for every device, or null; SavedAt is null until a client saved them.</summary>
|
||||
public async Task<ViewUserSettings> Settings(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Get(APIs.ClientApi.GET_UserSettings, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await LogFailure(response, nameof(Settings), cancellationToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.DefaultReadFromJsonAsync<ViewUserSettings>(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Saves the theme and language as the login's, for every device signed in to it. A failure is logged here.</summary>
|
||||
public async Task<WebResult> SaveSettings(ViewUserSettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await httpService.Post(APIs.ClientApi.POST_UpdateUserSettings, settings, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
if (result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(SaveSettings)}", cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
return new WebResult();
|
||||
}
|
||||
|
||||
@@ -190,7 +408,33 @@ namespace decePubClient.Services
|
||||
await LogFailure(response, nameof(Logout), cancellationToken);
|
||||
}
|
||||
|
||||
await authStateProvider.LogoutAsync(deleteDb: true, cancellationToken);
|
||||
await authStateProvider.LogoutAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The failure of what only the password may do: PrivaPub refuses a wrong one with 403, which ReadWebResult would
|
||||
/// take for a missing permission.
|
||||
/// </summary>
|
||||
async Task<WebResult> ReadPasswordFailure(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult().Invalidate(localizer["Wrong password."], (int)HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
/// <summary>A sentence PrivaPub answered with: a JSON string, or plain text (ASP.NET writes a string so unless JSON is asked for).</summary>
|
||||
static async Task<string> ReadSentence(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return text.StartsWith('"') ? JsonSerializer.Deserialize<string>(text, SUtility.DefaultSerializer) : text;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
||||
|
||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Localization;
|
||||
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
namespace collAnon.Client.Services
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// The app's localizer: looks a key up in Resources/AllStrings, then in PrivaPub.ClientModels' FieldsNameResource and
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Helpers;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
@@ -35,8 +33,6 @@ namespace decePubClient.Services
|
||||
|
||||
Task<HttpResponseMessage> Post(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<HttpResponseMessage> Put(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default);
|
||||
@@ -47,9 +43,6 @@ namespace decePubClient.Services
|
||||
Task<HttpResponseMessage> Delete(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges the root's JWT for the persona's Mastodon token (PrivaPub's PersonaExchange) and makes the persona
|
||||
/// current. A failure comes back like any other.
|
||||
@@ -88,10 +81,6 @@ namespace decePubClient.Services
|
||||
public Task<HttpResponseMessage> Patch(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(Patch), () => BuildRequest(HttpMethod.Patch, uri, payload, forceContent: true), SendAuthRequest, retryOnError, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> PostAnon(string uri, object payload = default, bool retryOnError = false, CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(PostAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAuthIfAvailableRequest, retryOnError,
|
||||
cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> PostTotallyAnon(string uri, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(PostTotallyAnon), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), SendAnonymousRequest, retryOnError,
|
||||
@@ -101,11 +90,6 @@ namespace decePubClient.Services
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> DeleteTotallyAnon(string uri, string[] queryParams = default, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(DeleteTotallyAnon), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAnonymousRequest, retryOnError,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jwt = await authStateProvider.GetToken(cancellationToken);
|
||||
|
||||
@@ -11,51 +11,27 @@ public interface ILoggingService
|
||||
void Process(string message, string where = default, LogLevel level = LogLevel.Information);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes to the browser console through ILogger and keeps errors and warnings in the IndexedDB client log, so they
|
||||
/// survive a reload. Never throws: logging must not break the code that is reporting a failure.
|
||||
/// </summary>
|
||||
public class LoggingService(IStorage storage, ILogger<LoggingService> logger) : ILoggingService
|
||||
/// <summary>Writes to the browser console through ILogger.</summary>
|
||||
public class LoggingService(ILogger<LoggingService> logger) : ILoggingService
|
||||
{
|
||||
public void Process(string message, string where = default, LogLevel level = LogLevel.Information) =>
|
||||
logger.Log(level, "[{Where}] {Message}", where ?? "N/A", message);
|
||||
|
||||
public async ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
public ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogError(exception, "[{Where}]", where);
|
||||
await storage.AddLog(exception, where, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
public ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogError("[{Where}] {Message}", where, message);
|
||||
await storage.AddLog(message, where, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
public ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogWarning("[{Where}] {Message}", where, message);
|
||||
await storage.AddLog(message, where, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessWarning)}");
|
||||
}
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
using decePubClient.Helpers;
|
||||
using decePubClient.Models;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// The IndexedDB database "data" (schema in GenericExtensions.AddIndexedDb): the client log. Lists come back empty on
|
||||
/// failure, mutations as an invalid WebResult; nothing throws. Failures are logged with ILogger only, because
|
||||
/// ILoggingService writes its log through this class.
|
||||
/// </summary>
|
||||
public interface IStorage
|
||||
{
|
||||
Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddLog(string message, string where, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class Storage(IndexedDb db, ILogger<Storage> logger) : IStorage
|
||||
{
|
||||
/// <summary>The client log keeps at most this many entries; past it the oldest half is dropped.</summary>
|
||||
const int MaxLogs = 100;
|
||||
|
||||
#region Logs
|
||||
|
||||
public async Task<List<ClientLogs>> GetClientLogs(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await db.OpenIndexedDb();
|
||||
var logs = await db.GetAll<ClientLogs>(nameof(ClientLogs)) ?? [];
|
||||
return [.. logs.OrderByDescending(log => log.TimeStamp)];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, $"{nameof(Storage)}.{nameof(GetClientLogs)}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public Task AddLog(Exception exception, string where, CancellationToken cancellationToken = default) =>
|
||||
AddLog(new ClientLogs
|
||||
{
|
||||
Where = where,
|
||||
Exception = new()
|
||||
{
|
||||
Type = exception.GetType().ToString(),
|
||||
HelpLink = exception.HelpLink,
|
||||
HResult = exception.HResult.ToString(),
|
||||
InnerExceptionMessage = exception.InnerException?.Message,
|
||||
Message = exception.Message,
|
||||
Source = exception.Source,
|
||||
StackTrace = exception.StackTrace,
|
||||
TargetSiteName = exception.TargetSite?.Name
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
public Task AddLog(string message, string where, CancellationToken cancellationToken = default) =>
|
||||
AddLog(new ClientLogs { Where = where, WarningMessage = message }, cancellationToken);
|
||||
|
||||
/// <summary>Numbers the entry after the newest one and trims the log; never throws.</summary>
|
||||
async Task AddLog(ClientLogs log, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await db.OpenIndexedDb();
|
||||
|
||||
var maxKey = await db.GetMaxKey<long>(nameof(ClientLogs));
|
||||
var minKey = await db.GetMinKey<long>(nameof(ClientLogs));
|
||||
if (maxKey - minKey >= MaxLogs)
|
||||
for (var key = minKey; key < minKey + MaxLogs / 2; key++)
|
||||
_ = await db.DeleteByKey(nameof(ClientLogs), key);
|
||||
|
||||
log.Id = maxKey + 1;
|
||||
var result = await db.AddItems<ClientLogs>(nameof(ClientLogs), [log]);
|
||||
logger.LogDebug($"{nameof(AddLog)}() add logs result = {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, $"{nameof(Storage)}.{nameof(AddLog)}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Task<WebResult> RemoveAll(bool includeClientLogs = false, CancellationToken cancellationToken = default) =>
|
||||
Run(nameof(RemoveAll), async () =>
|
||||
{
|
||||
if (!includeClientLogs)
|
||||
return "nothing to remove";
|
||||
return await db.DeleteAll(nameof(ClientLogs));
|
||||
}, cancellationToken);
|
||||
|
||||
/// <summary>Runs a write and turns its outcome into a WebResult.</summary>
|
||||
async Task<WebResult> Run(string caller, Func<Task<string>> write, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new WebResult();
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await db.OpenIndexedDb();
|
||||
logger.LogInformation("{Caller}: {Result}", caller, await write());
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return SUtility.DefaultTaskCancelledResult;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, $"{nameof(Storage)}.{caller}");
|
||||
return result.Invalidate(ex.Message, exception: ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using DnetIndexedDb;
|
||||
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
public class IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions<IndexedDb> options) : IndexedDbInterop(jsRuntime, options)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -41,12 +41,6 @@ namespace decePubClient.Services
|
||||
|
||||
public ServerInfo Known(string host) => host is not null && servers.TryGetValue(host, out var server) && !server.Unknown ? server : null;
|
||||
|
||||
public async Task<ServerInfo> Self(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Resolve([SelfHost], cancellationToken);
|
||||
return Known(SelfHost);
|
||||
}
|
||||
|
||||
/// <summary>Reads the servers not known yet (or known for too long) from PrivaPub.</summary>
|
||||
public async Task Resolve(IEnumerable<string> hosts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using collAnon.Client.Services;
|
||||
using System.Text;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
|
||||
@@ -45,16 +45,6 @@ namespace decePubClient.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The newest request to a route template, or null.</summary>
|
||||
public RequestStat LastOf(string route) => Latest().FirstOrDefault(stat => stat.Route == route);
|
||||
|
||||
/// <summary>The mean duration of the successful requests recorded, in milliseconds.</summary>
|
||||
public double? AverageMs()
|
||||
{
|
||||
var answered = Latest().Where(stat => stat.Status is >= 200 and < 400).ToList();
|
||||
return answered.Count == 0 ? null : answered.Average(stat => stat.DurationMs);
|
||||
}
|
||||
|
||||
/// <summary>The route without its query, with ids replaced by :id, so requests to the same endpoint group.</summary>
|
||||
public static string Template(string uri)
|
||||
{
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
@@ -53,7 +51,7 @@ namespace decePubClient.Services
|
||||
InReplyToId = form.InReplyToId,
|
||||
Sensitive = form.HasContentWarning || form.Sensitive && form.Media.Count > 0,
|
||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||
Visibility = form.Visibility,
|
||||
Visibility = form.MastodonVisibility,
|
||||
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
||||
ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture),
|
||||
Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null,
|
||||
@@ -188,10 +186,10 @@ namespace decePubClient.Services
|
||||
AnsweringToPostId = form.InReplyToId,
|
||||
HasContentWarning = form.HasContentWarning,
|
||||
SpoilerText = form.HasContentWarning ? form.SpoilerText : null,
|
||||
Visibility = form.MessageType switch
|
||||
Visibility = form.Visibility switch
|
||||
{
|
||||
MessageType.FollowersOnly => "followersonly",
|
||||
MessageType.Unlisted => "unlisted",
|
||||
Visibility.FollowersOnly => "followersonly",
|
||||
Visibility.Unlisted => "unlisted",
|
||||
_ => "public"
|
||||
},
|
||||
Latitude = form.IsLocated ? form.Latitude : null,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Net;
|
||||
|
||||
using collAnon.Client.Services;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace decePubClient.Services
|
||||
/// The session (AuthData in localStorage, cached in memory) and the authentication state built from it. It only
|
||||
/// stores and hands out tokens; signing in, exchanging and revoking them is AuthService's job.
|
||||
/// </summary>
|
||||
public class TokenAuthStateProvider(ILocalStorageService storage, IStorage dbStorage, ILogger<TokenAuthStateProvider> logger)
|
||||
public class TokenAuthStateProvider(ILocalStorageService storage, ILogger<TokenAuthStateProvider> logger)
|
||||
: AuthenticationStateProvider
|
||||
{
|
||||
/// <summary>The claim naming the persona the client acts as (an avatar id).</summary>
|
||||
@@ -89,14 +89,22 @@ namespace decePubClient.Services
|
||||
await Save(session, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(bool deleteDb = false, CancellationToken cancellationToken = default)
|
||||
/// <summary>Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.</summary>
|
||||
public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await GetAuthData(cancellationToken);
|
||||
if (session.PersonaTokens.Count == 0)
|
||||
return;
|
||||
session.PersonaTokens.Clear();
|
||||
await Save(session, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
logger.LogInformation($"set null({nameof(LogoutAsync)})");
|
||||
authData = null;
|
||||
loaded = true;
|
||||
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
|
||||
if (deleteDb)
|
||||
await dbStorage.RemoveAll(includeClientLogs: true, cancellationToken: cancellationToken);
|
||||
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
|
||||
@@ -224,12 +224,6 @@
|
||||
var isDark = IsDark;
|
||||
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark,
|
||||
isDark ? PageSettings.DefaultDarkThemeIndexColour : PageSettings.DefaultLightThemeIndexColour));
|
||||
|
||||
// if (AuthData?.User != null)
|
||||
// {
|
||||
// AuthData.User.UserSettings.ThemeIndexColour = ThemeIndexColour;
|
||||
// await Storage.SetItemAsync(nameof(AuthData), AuthData);
|
||||
// }
|
||||
}
|
||||
|
||||
// Picking a hue also leaves the grey themes, which otherwise ignore it.
|
||||
@@ -237,23 +231,11 @@
|
||||
{
|
||||
var isDark = IsDark;
|
||||
await CascadingState.UpdatePageSettings(settings => settings.SetHue(isDark, indexColour));
|
||||
|
||||
// if (AuthData?.User != null)
|
||||
// {
|
||||
// AuthData.User.UserSettings.ThemeIndexColour = ThemeIndexColour;
|
||||
// await Storage.SetItemAsync(nameof(AuthData), AuthData);
|
||||
// }
|
||||
}
|
||||
|
||||
// Choosing light or dark here leaves system theming; Settings can turn it back on.
|
||||
protected async Task UpdateThemeDarkMode(bool isDarkMode)
|
||||
{
|
||||
await CascadingState.UpdatePageSettings(settings => settings.SetDarkMode(isDarkMode));
|
||||
|
||||
// if (AuthData?.User != null)
|
||||
// {
|
||||
// AuthData.User.UserSettings.ThemeIsDarkMode = ThemeIsDarkMode;
|
||||
// await Storage.SetItemAsync(nameof(AuthData), AuthData);
|
||||
// }
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -1,12 +1,10 @@
|
||||
/* Entry point compiled by Tailwind CSS standalone (tools/tailwind.sh, TailwindCompile in decePubClient.csproj) into
|
||||
wwwroot/css/app.css. Tailwind scans every file under the project that .gitignore doesn't exclude, except tools/
|
||||
(daisyui-llms.txt names every daisyUI class), tests/ and the vendored CSS. */
|
||||
(daisyui-llms.txt names every daisyUI class) and the vendored CSS. */
|
||||
@import "tailwindcss" source("../");
|
||||
|
||||
@source not "../wwwroot/vendor";
|
||||
@source not "../Docs";
|
||||
@source not "../tools";
|
||||
@source not "../tests";
|
||||
|
||||
@plugin "../tools/tailwind/daisyui.mjs" {
|
||||
themes: false;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
daisyUI's sizes. The rules are plain CSS in Tailwind's utilities layer: like neo.css's @utility overrides they beat
|
||||
daisyUI's rules, which sit in sublayers of it, and they set daisyUI's own size variables where it has one.
|
||||
|
||||
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5), which tests/e2e/Mobile measures. Buttons, tabs and
|
||||
A target gets a 44px hit area (Apple's guideline, WCAG 2.5.5). Buttons, tabs and
|
||||
fields are 44px tall; the extra small buttons and the badges that act stay 36px to the eye and reach 44px through a
|
||||
transparent ::after, so the rows that hold them leave 8px between them on touch (pointer-coarse:gap-2), or two hit
|
||||
areas would overlap. */
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
@using Toolbelt.Blazor.HeadElement
|
||||
@using Blazored.LocalStorage
|
||||
@using Blazor.DownloadFileFast.Interfaces
|
||||
@using Markdig
|
||||
@using decePubClient
|
||||
@using decePubClient.Services
|
||||
@using collAnon.Client.Services
|
||||
@using decePubClient.Shared
|
||||
@using decePubClient.Models
|
||||
@using decePubClient.Models.Types
|
||||
|
||||
+3
-32
@@ -6,20 +6,15 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
|
||||
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
||||
<!-- tests/e2e is its own project; without this the app would compile its .cs files too -->
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);tests/**</DefaultItemExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BlazorZXingJs" Version="0.5.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageReference Include="BlazorDownloadFileFast" Version="1.0.0.1" />
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||
<PackageReference Include="DnetIndexedDb" Version="2.4.1" />
|
||||
<PackageReference Include="Markdig" Version="0.30.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.9" />
|
||||
<PackageReference Include="Toolbelt.Blazor.HeadElement" Version="7.3.1" />
|
||||
</ItemGroup>
|
||||
@@ -40,7 +35,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**;tests/**" />
|
||||
<TailwindSource Include="Styles/**/*.css;**/*.razor;**/*.cs;wwwroot/index.html" Exclude="bin/**;obj/**;.tools/**;tools/**" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Unpacks the vendored binary when it is missing or the vendored archive changed (an upgrade). -->
|
||||
@@ -69,7 +64,8 @@
|
||||
<ItemGroup>
|
||||
<!-- Points the development build at a local PrivaPub; the published app reads appsettings.json only. -->
|
||||
<Content Update="wwwroot\appsettings.Development.json" CopyToPublishDirectory="Never" />
|
||||
<!-- Points the app at the pasture's PrivaPub (tests/e2e/run.sh publishes with WasmApplicationEnvironmentName=Pasture);
|
||||
<!-- Points the app at the pasture's PrivaPub (a publish with WasmApplicationEnvironmentName=Pasture, which SocialPub's
|
||||
tools/pasture/peers/decepub.sh serves);
|
||||
any other publish leaves it out, and the deploy checks that it did. -->
|
||||
<Content Update="wwwroot\appsettings.Pasture.json" CopyToPublishDirectory="Never" Condition="'$(WasmApplicationEnvironmentName)' != 'Pasture'" />
|
||||
</ItemGroup>
|
||||
@@ -80,31 +76,6 @@
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>ErrorMessages.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Content Remove="wwwroot\vendor\fontawesome.css" />
|
||||
<None Include="wwwroot\vendor\fontawesome.css" />
|
||||
<Content Remove="wwwroot\vendor\ionicons.css" />
|
||||
<Content Remove="wwwroot\vendor\open-iconic.css" />
|
||||
<Content Remove="wwwroot\vendor\solid.css" />
|
||||
<None Include="wwwroot\vendor\solid.css" />
|
||||
<Content Remove="wwwroot\vendor\open-iconic.min.css" />
|
||||
<Content Remove="wwwroot\webfonts\fa-solid-900.eot" />
|
||||
<None Include="wwwroot\webfonts\fa-solid-900.eot" />
|
||||
<Content Remove="wwwroot\webfonts\fa-solid-900.ttf" />
|
||||
<None Include="wwwroot\webfonts\fa-solid-900.ttf" />
|
||||
<Content Remove="wwwroot\webfonts\fa-solid-900.woff" />
|
||||
<None Include="wwwroot\webfonts\fa-solid-900.woff" />
|
||||
<Content Remove="wwwroot\webfonts\fa-solid-900.woff2" />
|
||||
<None Include="wwwroot\webfonts\fa-solid-900.woff2" />
|
||||
<Content Remove="wwwroot\fonts\open-iconic.eot" />
|
||||
<None Include="wwwroot\fonts\open-iconic.eot" />
|
||||
<Content Remove="wwwroot\fonts\open-iconic.otf" />
|
||||
<None Include="wwwroot\fonts\open-iconic.otf" />
|
||||
<Content Remove="wwwroot\fonts\open-iconic.svg" />
|
||||
<None Include="wwwroot\fonts\open-iconic.svg" />
|
||||
<Content Remove="wwwroot\fonts\open-iconic.ttf" />
|
||||
<None Include="wwwroot\fonts\open-iconic.ttf" />
|
||||
<Content Remove="wwwroot\fonts\open-iconic.woff" />
|
||||
<None Include="wwwroot\fonts\open-iconic.woff" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-23
@@ -5,13 +5,7 @@ VisualStudioVersion = 17.0.32112.339
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "decePubClient", "decePubClient.csproj", "{EBE69805-3005-49C2-81E4-A314A19F68B6}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SocialPub.ClientModels", "..\SocialPub\SocialPub\SocialPub.ClientModels\SocialPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "e2e", "e2e", "{0EB607AC-6329-6575-AD8B-E5558B048B88}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "decePubClient.E2E", "tests\e2e\decePubClient.E2E.csproj", "{DB238929-3C3E-4841-B782-3BF0CC53B20E}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PrivaPub.ClientModels", "..\SocialPub\PrivaPub.ClientModels\PrivaPub.ClientModels.csproj", "{5DA9311B-B6BC-4EC6-A26E-10367288F52B}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -47,26 +41,10 @@ Global
|
||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5DA9311B-B6BC-4EC6-A26E-10367288F52B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{0EB607AC-6329-6575-AD8B-E5558B048B88} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{DB238929-3C3E-4841-B782-3BF0CC53B20E} = {0EB607AC-6329-6575-AD8B-E5558B048B88}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {1B399226-FEE0-4572-A4A7-783DA83E7DDD}
|
||||
EndGlobalSection
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A login of its own, made for the test: its password changed in Settings, then the whole of it deleted there, each
|
||||
// checked against PrivaPub's own login
|
||||
public sealed class AccountTests(Browser browser)
|
||||
{
|
||||
const string FirstPassword = "Pasture-Pass-1!";
|
||||
const string SecondPassword = "Pasture-Pass-2!";
|
||||
|
||||
static async Task<HttpStatusCode> Login(string root, string password)
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
using var response = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_logins_password_is_changed_and_the_login_deleted_from_settings()
|
||||
{
|
||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
||||
var root = $"e2eroot{stamp}";
|
||||
using (var http = Pasture.Http())
|
||||
{
|
||||
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = root, password = FirstPassword });
|
||||
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
|
||||
var jwt = JsonNode.Parse(await signup.Content.ReadAsStringAsync())!["token"]!.GetValue<string>();
|
||||
using var insert = new HttpRequestMessage(HttpMethod.Post, Pasture.Api + "/clientapi/avatar/private/insert")
|
||||
{
|
||||
Content = JsonContent.Create(new { userName = $"e2eacct{stamp}", name = "an e2e persona", biography = "for the account test" })
|
||||
};
|
||||
insert.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
using var inserted = await http.SendAsync(insert);
|
||||
Assert.True(inserted.IsSuccessStatusCode, $"the persona could not be made ({(int)inserted.StatusCode})");
|
||||
}
|
||||
var persona = new Pasture.Persona($"e2eacct{stamp}", "an e2e persona", root, FirstPassword, null, null, null);
|
||||
await using var session = await SignIn.As(browser, persona, fresh: true);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.account", "privapub", root, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
await page.Locator("[data-settings-account]").ClickAsync();
|
||||
await page.Locator("input[data-account-current]").FillAsync(FirstPassword);
|
||||
await page.Locator("input[data-account-new]").FillAsync(SecondPassword);
|
||||
await page.Locator("input[data-account-repeat]").FillAsync(SecondPassword);
|
||||
await page.Locator("[data-account-password-save]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) == HttpStatusCode.OK, 15), "the new password never worked");
|
||||
Assert.NotEqual(HttpStatusCode.OK, await Login(root, FirstPassword));
|
||||
|
||||
await page.Locator("input[data-account-delete-password]").FillAsync(SecondPassword);
|
||||
await page.Locator("input[data-account-delete-understood]").CheckAsync();
|
||||
await page.Locator("[data-account-delete-confirm]").ClickAsync();
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await Login(root, SecondPassword) != HttpStatusCode.OK, 15), "the deleted login still signs in");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// What a reader does to a post from each platform, done in decePub and checked on the post's own server: a favourite
|
||||
// and a boost arrive there (and leave when undone), a reply lands under the post and is gone once deleted. town.sh
|
||||
// stored reads the origin's database, so nothing here trusts decePub's own view of what it did.
|
||||
public sealed class ActionMatrixTests(Browser browser)
|
||||
{
|
||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
||||
|
||||
public static TheoryData<string, string> Targets()
|
||||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
||||
foreach (var origin in new[] { "gts", "mastodon", "misskey", "sharkey", "akkoma", "lemmy" })
|
||||
{
|
||||
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin
|
||||
&& i["visibility"]!.GetValue<string>() is "public" or "community"
|
||||
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
||||
if (item is not null)
|
||||
data.Add(origin, item["ref"]!.GetValue<string>());
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static (JsonNode Item, Pasture.Persona Reader) Target(string reference)
|
||||
{
|
||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
||||
var personas = Pasture.Personas();
|
||||
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
||||
.First(p => p is not null);
|
||||
return (item, reader);
|
||||
}
|
||||
|
||||
static async Task<int> Count(string origin, string uri, string field)
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, uri))[uri]!;
|
||||
return row[field]?.GetValue<int?>() ?? 0;
|
||||
}
|
||||
|
||||
static async Task<bool> IsActive(ILocator button) =>
|
||||
((await button.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active");
|
||||
|
||||
async Task<(Session Session, ILocator Card)> Open(JsonNode item, Pasture.Persona reader)
|
||||
{
|
||||
var session = await SignIn.As(browser, reader);
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
return (session, card);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public async Task A_favourite_reaches_the_author_and_its_undo_too(string origin, string reference)
|
||||
{
|
||||
var (item, reader) = Target(reference);
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
await Results.Cell("client.action.favourite", origin, reference, async () =>
|
||||
{
|
||||
var (session, card) = await Open(item, reader);
|
||||
await using var _ = session;
|
||||
var favourite = card.Locator("footer button[title='Favourite']").First;
|
||||
// the town may have done it already as this persona: start from the undone state
|
||||
if (await IsActive(favourite))
|
||||
{
|
||||
var done = await Count(origin, uri, "likes");
|
||||
await favourite.ClickAsync();
|
||||
await Pasture.Eventually(async () => await Count(origin, uri, "likes") < done, 60);
|
||||
await Assertions.Expect(favourite).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
|
||||
}
|
||||
var before = await Count(origin, uri, "likes");
|
||||
await favourite.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") > before),
|
||||
$"the favourite never reached {origin}");
|
||||
await favourite.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "likes") == before, 60),
|
||||
$"undoing the favourite never reached {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public async Task A_boost_reaches_the_author_and_its_undo_too(string origin, string reference)
|
||||
{
|
||||
Assert.SkipWhen(origin == "lemmy", "Lemmy keeps no boosts of a thread");
|
||||
var (item, reader) = Target(reference);
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
await Results.Cell("client.action.boost", origin, reference, async () =>
|
||||
{
|
||||
var (session, card) = await Open(item, reader);
|
||||
await using var _ = session;
|
||||
var boost = card.Locator("footer button[title='Boost']").First;
|
||||
// the town may have done it already as this persona: start from the undone state
|
||||
if (await IsActive(boost))
|
||||
{
|
||||
var done = await Count(origin, uri, "boosts");
|
||||
await boost.ClickAsync();
|
||||
await Pasture.Eventually(async () => await Count(origin, uri, "boosts") < done, 60);
|
||||
await Assertions.Expect(boost).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"));
|
||||
}
|
||||
var before = await Count(origin, uri, "boosts");
|
||||
await boost.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") > before),
|
||||
$"the boost never reached {origin}");
|
||||
await boost.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await Count(origin, uri, "boosts") == before, 60),
|
||||
$"undoing the boost never reached {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public async Task A_reply_lands_under_the_post_and_its_delete_removes_it(string origin, string reference)
|
||||
{
|
||||
var (item, reader) = Target(reference);
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
await Results.Cell("client.action.reply", origin, reference, async () =>
|
||||
{
|
||||
var (session, card) = await Open(item, reader);
|
||||
await using var _ = session;
|
||||
await card.Locator("footer button[title='Reply']").First.ClickAsync();
|
||||
var text = $"an e2e reply to {reference} ({Guid.NewGuid():N})";
|
||||
// after the @names the composer prefills, as a reader types
|
||||
var box = card.Locator("textarea").Last;
|
||||
await box.FillAsync(((await box.InputValueAsync()).TrimEnd() + " " + text).Trim());
|
||||
var published = session.Page.WaitForResponseAsync(r => r.Url.EndsWith("/api/v1/statuses") && r.Request.Method == "POST");
|
||||
await card.Locator("button[type=submit]").Last.ClickAsync();
|
||||
var response = await published;
|
||||
Assert.True(response.Ok, $"publishing the reply answered {response.Status}");
|
||||
var replyUri = (await response.JsonAsync())?.GetProperty("uri").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(replyUri));
|
||||
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
||||
return row["exists"]!.GetValue<bool>() && row["parent_uri"]?.GetValue<string>() == uri;
|
||||
}, 45), $"the reply never landed under the post on {origin}");
|
||||
|
||||
var reply = session.Page.Locator($"article[data-uri='{replyUri}']").First;
|
||||
await reply.WaitForAsync(new() { Timeout = 15000 });
|
||||
await reply.Locator("footer [title='Other']").First.ClickAsync();
|
||||
await reply.GetByRole(AriaRole.Button).Filter(new() { HasText = "Delete" }).First.ClickAsync();
|
||||
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var row = (await Pasture.Stored(origin, replyUri))[replyUri]!;
|
||||
return !row["exists"]!.GetValue<bool>() || row["deleted"]!.GetValue<bool>();
|
||||
}, 45), $"the deleted reply is still on {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Administration as the pasture's administrator (pastureroot, whom the scenarios make): a server blocked for everyone
|
||||
// and unblocked, the federation statistics, and the reports moderators handle; each change checked on /clientapi
|
||||
public sealed class AdministrationTests(Browser browser)
|
||||
{
|
||||
const string Root = PastureAdmin.Root;
|
||||
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
||||
|
||||
static Task<JsonNode> AsAdmin(string path) => PastureAdmin.Get(path);
|
||||
|
||||
static async Task<ILocator> Open(IPage page, string section)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/administration");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
||||
await header.ClickAsync();
|
||||
return header.Locator("xpath=..");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_is_blocked_for_everyone_and_unblocked()
|
||||
{
|
||||
const string server = "instance-block-by-e2e.example";
|
||||
await using var session = await SignIn.As(browser, Admin);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.admin.server-block", "privapub", Root, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Blocked servers");
|
||||
var blocks = panel.Locator("[data-server-blocks]");
|
||||
await blocks.WaitForAsync(new() { Timeout = 30000 });
|
||||
await blocks.Locator("[data-server-block-domain]").FillAsync(server);
|
||||
await blocks.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
||||
var row = blocks.Locator($"[data-server-block='{server}']");
|
||||
await Assertions.Expect(row).ToContainTextAsync("Suspended", new() { Timeout = 15000 });
|
||||
Assert.Contains((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
||||
|
||||
await row.GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.DoesNotContain((await AsAdmin("/clientapi/admin/domainblocks/list"))!.AsArray(), b => b!["domain"]!.GetValue<string>() == server);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_federation_statistics_and_the_reports_are_shown()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Admin);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.admin.statistics", "privapub", Root, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Statistics");
|
||||
await Assertions.Expect(panel.Locator("[data-statistics-overview]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||
var expected = (await AsAdmin("/clientapi/admin/statistics/hosts?days=30&sort=traffic&page=1&limit=50"))!["hosts"]!.AsArray()
|
||||
.Select(h => h!["host"]!.GetValue<string>()).Take(5).ToList();
|
||||
Assert.NotEmpty(expected);
|
||||
foreach (var host in expected)
|
||||
await Assertions.Expect(panel.Locator($"[data-statistics-host='{host}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
||||
|
||||
var reports = await Open(page, "Reports");
|
||||
await Assertions.Expect(reports.Locator("[data-reports]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||
var open = (await AsAdmin("/clientapi/moderator/reports?resolved=false"))!.AsArray().Count;
|
||||
await Assertions.Expect(reports.Locator("[data-report-id]")).ToHaveCountAsync(open, new() { Timeout = 15000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// An article in a feed shows its title, its excerpt and a Read button, and its whole text once asked: a WriteFreely
|
||||
// blog the persona follows publishes one (SocialPub's peers/writefreely.sh keeps the blog's token).
|
||||
public sealed class ArticleTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task An_article_is_folded_in_the_feed_and_read_on_demand()
|
||||
{
|
||||
var tokenFile = Path.Combine(Pasture.Root, ".state", "writefreely", "wfuser.token");
|
||||
Assert.SkipUnless(File.Exists(tokenFile), "no WriteFreely blog: run tools/pasture/run.sh add writefreely");
|
||||
var persona = Pasture.Personas().First();
|
||||
var blog = (await PersonaApi.Call(persona, "/api/v2/search?q=wfuser%40writefreely.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
||||
Assert.SkipWhen(blog is null, "PrivaPub cannot resolve the WriteFreely blog");
|
||||
var blogId = blog!["id"]!.GetValue<string>();
|
||||
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/follow", HttpMethod.Post);
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={blogId}"))![0]!["following"]!.GetValue<bool>(), 45), "the blog never accepted");
|
||||
|
||||
var title = $"An article to fold {Guid.NewGuid():N}"[..30];
|
||||
var ending = $"the last words {Guid.NewGuid():N}"[..24];
|
||||
var body = string.Join(" ", Enumerable.Repeat("A long paragraph that a feed should not show whole.", 12)) + " " + ending;
|
||||
using var http = Pasture.Http();
|
||||
using var publish = new HttpRequestMessage(HttpMethod.Post, "https://writefreely.test/api/collections/wfuser/posts")
|
||||
{
|
||||
Content = new StringContent(new JsonObject { ["title"] = title, ["body"] = body }.ToJsonString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
publish.Headers.Authorization = new AuthenticationHeaderValue("Token", File.ReadAllText(tokenFile).Trim());
|
||||
(await http.SendAsync(publish)).EnsureSuccessStatusCode();
|
||||
JsonNode article = null;
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(article = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
|
||||
.FirstOrDefault(s => s!["privapub"]?["title"]?.GetValue<string>() == title)) is not null, 60), "the article never reached the persona");
|
||||
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.article", "writefreely", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var card = page.Locator($"article[data-uri='{article!["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await Assertions.Expect(card).ToContainTextAsync(title);
|
||||
await Assertions.Expect(card.Locator("[data-article]")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(card).Not.ToContainTextAsync(ending);
|
||||
await card.Locator("[data-article-open]").ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync(ending, new() { Timeout = 5000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
await PersonaApi.Call(persona, $"/api/v1/accounts/{blogId}/unfollow", HttpMethod.Post);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
using System.Formats.Tar;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now,
|
||||
// downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in
|
||||
// pieces; each step checked on /clientapi
|
||||
public sealed class BackupsTests(Browser browser)
|
||||
{
|
||||
const string Root = PastureAdmin.Root;
|
||||
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
||||
|
||||
internal static async Task<ILocator> Open(IPage page)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/administration");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First;
|
||||
await header.ClickAsync();
|
||||
var panel = header.Locator("xpath=..").Locator("[data-backups]");
|
||||
await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
internal static async Task<string> BackUp(ILocator panel)
|
||||
{
|
||||
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).ToHashSet();
|
||||
await panel.Locator("[data-backup-now]").ClickAsync();
|
||||
var made = default(string);
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
{
|
||||
var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!;
|
||||
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
|
||||
return made is not null && list["running"] is null;
|
||||
}, 300), "the backup was never made");
|
||||
await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||
return made;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_is_made_downloaded_and_uploaded_again()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Admin);
|
||||
var page = session.Page;
|
||||
var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar");
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.admin.backup", "privapub", Root, async () =>
|
||||
{
|
||||
var panel = await Open(page);
|
||||
var id = await BackUp(panel);
|
||||
var row = panel.Locator($"[data-backup='{id}']");
|
||||
await Assertions.Expect(row).ToContainTextAsync("Made by hand");
|
||||
|
||||
// the link, for the password
|
||||
await row.Locator("[data-backup-download]").ClickAsync();
|
||||
var dialog = page.Locator("[data-backup-download-dialog]");
|
||||
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
||||
await dialog.Locator("[data-backup-confirm]").ClickAsync();
|
||||
var link = dialog.Locator("[data-backup-link]");
|
||||
await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
||||
await download.SaveAsAsync(file);
|
||||
var names = new List<string>();
|
||||
await using (var reader = new TarReader(File.OpenRead(file)))
|
||||
while (await reader.GetNextEntryAsync() is { } entry)
|
||||
names.Add(entry.Name);
|
||||
Assert.Contains($"{id}/manifest.json", names);
|
||||
Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal));
|
||||
await dialog.Locator("[data-backup-dialog-close]").ClickAsync();
|
||||
|
||||
// deleted (asked first), then uploaded again from the file
|
||||
await row.Locator("[data-backup-delete]").ClickAsync();
|
||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue<string>() == id);
|
||||
|
||||
await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file);
|
||||
await panel.Locator("[data-backup-upload-start]").ClickAsync();
|
||||
await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 });
|
||||
var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!;
|
||||
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session
|
||||
// ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and
|
||||
// ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in
|
||||
// again (town.sh renew privapub).
|
||||
[Trait("Category", "Restore")]
|
||||
public sealed class RestoreTests(Browser browser)
|
||||
{
|
||||
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
|
||||
|
||||
[Fact]
|
||||
public async Task A_backup_is_restored_and_everyone_signs_in_again()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Admin);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () =>
|
||||
{
|
||||
var panel = await BackupsTests.Open(page);
|
||||
var id = await BackupsTests.BackUp(panel);
|
||||
var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue<string>();
|
||||
|
||||
await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync();
|
||||
var dialog = page.Locator("[data-backup-restore-dialog]");
|
||||
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
||||
var confirm = dialog.Locator("[data-backup-confirm]");
|
||||
await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test");
|
||||
await Assertions.Expect(confirm).ToBeDisabledAsync();
|
||||
await dialog.Locator("[data-backup-host]").FillAsync(host);
|
||||
await confirm.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
|
||||
// once PrivaPub is back, the session it ended sends the page to sign in again
|
||||
await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 });
|
||||
await Pasture.RenewTown();
|
||||
}, page);
|
||||
|
||||
await using var again = await SignIn.As(browser, Admin);
|
||||
var panelAgain = await BackupsTests.Open(again.Page);
|
||||
await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
||||
Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]);
|
||||
await again.AssertGuards();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The composer beyond words: a poll in a language picked, the persona's own post edited and its history shown, and a
|
||||
// quote, each checked on PrivaPub
|
||||
public sealed class ComposerTests(Browser browser)
|
||||
{
|
||||
static async Task OpenHome(IPage page)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
}
|
||||
|
||||
static async Task<JsonNode> Latest(Pasture.Persona persona) =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public async Task A_poll_is_published_from_the_composer()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"which one {Guid.NewGuid():N}"[..18];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.poll", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await OpenHome(page);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||
await page.Locator("button[data-poll-toggle]").ClickAsync();
|
||||
var choices = page.Locator("[data-poll-editor] input[type=text]");
|
||||
await choices.Nth(0).FillAsync("tea");
|
||||
await choices.Nth(1).FillAsync("coffee");
|
||||
// and in the language picked
|
||||
await page.Locator("[data-compose-language]").First.SelectOptionAsync("it");
|
||||
// (the composer's own submit: a card's time is a "Post" button too, its nerd stats)
|
||||
await page.Locator("form:has(textarea[data-compose]) button[type=submit]").First.ClickAsync();
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains(text) == true, 15),
|
||||
"the poll was never published");
|
||||
made = latest!["id"]!.GetValue<string>();
|
||||
Assert.Equal(new[] { "tea", "coffee" }, latest["poll"]!["options"]!.AsArray().Select(o => o!["title"]!.GetValue<string>()));
|
||||
Assert.Equal("it", latest["language"]?.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
// a mention being written is offered the accounts PrivaPub knows, and a click writes the whole handle out
|
||||
[Fact]
|
||||
public async Task A_mention_being_written_is_completed_from_the_accounts_the_server_knows()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
||||
var (author, other) = (personas[0], personas[1]);
|
||||
var acct = (await PersonaApi.Call(other, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.mention-suggestion", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await OpenHome(page);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
var box = page.Locator("textarea[data-compose]").First;
|
||||
await box.FillAsync("hello @" + acct[..Math.Min(4, acct.Length)]);
|
||||
await page.Locator($"[data-suggestions] [data-suggestion='@{acct}']").ClickAsync(new() { Timeout = 15000 });
|
||||
await Assertions.Expect(box).ToHaveValueAsync($"hello @{acct} ");
|
||||
await Assertions.Expect(page.Locator("[data-suggestions]")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personas_own_post_is_edited_and_quoted_from_its_card()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"first words {Guid.NewGuid():N}"[..20];
|
||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
string quote = null;
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.edit-quote", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await OpenHome(page);
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-edit]").ClickAsync();
|
||||
var box = card.Locator("textarea[data-compose]");
|
||||
await Assertions.Expect(box).ToHaveValueAsync(text, new() { Timeout = 10000 });
|
||||
await box.FillAsync(text + " (edited)");
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync(text + " (edited)", new() { Timeout = 10000 });
|
||||
Assert.Contains("(edited)", (await PersonaApi.Call(persona, $"/api/v1/statuses/{id}"))!["content"]!.GetValue<string>());
|
||||
await card.Locator("button[data-history]").ClickAsync();
|
||||
var versions = card.Locator("[data-history-list] [data-version]");
|
||||
await Assertions.Expect(versions).ToHaveCountAsync(2, new() { Timeout = 10000 });
|
||||
await Assertions.Expect(versions.First).ToContainTextAsync(text + " (edited)");
|
||||
await Assertions.Expect(versions.Last).ToContainTextAsync(text);
|
||||
await Assertions.Expect(versions.Last).Not.ToContainTextAsync("(edited)");
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await Assertions.Expect(card.Locator("[data-history-list]")).ToHaveCountAsync(0);
|
||||
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-quote]").ClickAsync();
|
||||
await card.Locator("textarea[data-compose]").FillAsync("quoting myself");
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Quote" }).Last.ClickAsync();
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (latest = await Latest(persona))?["content"]?.GetValue<string>().Contains("quoting myself") == true, 15),
|
||||
"the quote was never published");
|
||||
quote = latest!["id"]!.GetValue<string>();
|
||||
Assert.Equal(id, (latest["quote"]?["quoted_status"]?["id"] ?? latest["quote"]?["quoted_status_id"])?.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (quote is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{quote}", HttpMethod.Delete);
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Direct conversations: a message from another persona badges the Direct tab and its card says unread; marking it
|
||||
// read and taking it off the list are checked on PrivaPub
|
||||
public sealed class ConversationTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_new_message_is_unread_until_marked_and_leaves_the_list_when_removed()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
|
||||
var (reader, writer) = (personas[0], personas[1]);
|
||||
var message = (await PersonaApi.Call(writer, "/api/v1/statuses", HttpMethod.Post, new JsonObject
|
||||
{
|
||||
["status"] = $"@{reader.UserName} just between us {Guid.NewGuid():N}"[..40], ["visibility"] = "direct"
|
||||
}))!;
|
||||
var uri = message["uri"]!.GetValue<string>();
|
||||
async Task<JsonNode> Conversation() => (await PersonaApi.Call(reader, "/api/v1/conversations?limit=40"))!.AsArray()
|
||||
.FirstOrDefault(c => c!["last_status"]?["uri"]?.GetValue<string>() == uri);
|
||||
await using var session = await SignIn.As(browser, reader);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.conversation", "privapub", reader.UserName, async () =>
|
||||
{
|
||||
Assert.True(await Pasture.Eventually(async () => (await Conversation())?["unread"]?.GetValue<bool>() == true, 15),
|
||||
"the message never made an unread conversation");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("[data-unread-conversations]").WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-mail") }).ClickAsync();
|
||||
var card = page.Locator($"article[data-uri='{uri}']");
|
||||
await card.Locator("[data-conversation-unread]").WaitForAsync(new() { Timeout = 30000 });
|
||||
await card.Locator("[data-conversation-read]").ClickAsync();
|
||||
await Assertions.Expect(card.Locator("[data-conversation-unread]")).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||
Assert.False((await Conversation())!["unread"]!.GetValue<bool>());
|
||||
|
||||
await card.Locator("[data-conversation-remove]").ClickAsync();
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||
Assert.Null(await Conversation());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(writer, $"/api/v1/statuses/{message["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Posts written for later, hashtags followed and what Explore shows, each checked against PrivaPub's own answer
|
||||
public sealed class DiscoveryTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_post_scheduled_in_the_composer_waits_is_moved_and_is_dropped()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"later {Guid.NewGuid():N}"[..20];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.schedule", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
await page.Locator("textarea").First.FillAsync(text);
|
||||
await page.Locator("button[data-schedule]").ClickAsync();
|
||||
await page.Locator("input[data-schedule-at]").FillAsync(DateTime.Now.AddHours(2).ToString("yyyy-MM-ddTHH:mm"));
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Schedule" }).ClickAsync();
|
||||
JsonNode waiting = null;
|
||||
Assert.True(await Pasture.Eventually(async () => (waiting = (await PersonaApi.Call(persona, "/api/v1/scheduled_statuses"))!.AsArray()
|
||||
.FirstOrDefault(s => s!["params"]!["text"]?.GetValue<string>() == text)) is not null, 15), "PrivaPub never kept the scheduled post");
|
||||
var id = waiting!["id"]!.GetValue<string>();
|
||||
Assert.DoesNotContain((await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=10"))!.AsArray(),
|
||||
s => s!["content"]!.GetValue<string>().Contains(text));
|
||||
|
||||
await page.GotoAsync($"{Pasture.Site}/scheduled");
|
||||
var item = page.Locator($"article[data-scheduled-id='{id}']");
|
||||
await Assertions.Expect(item).ToContainTextAsync(text, new() { Timeout = 15000 });
|
||||
var later = DateTime.Now.AddHours(5);
|
||||
await item.Locator("input[type=datetime-local]").FillAsync(later.ToString("yyyy-MM-ddTHH:mm"));
|
||||
await item.GetByRole(AriaRole.Button, new() { Name = "Move" }).ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"))?["scheduled_at"]?.GetValue<DateTime>().ToUniversalTime()
|
||||
is { } at && Math.Abs((at - later.ToUniversalTime()).TotalMinutes) < 2, 15), "PrivaPub never moved the post");
|
||||
await item.GetByRole(AriaRole.Button, new() { Name = "Never publish it" }).ClickAsync();
|
||||
await page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
await Assertions.Expect(item).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Null(await PersonaApi.Call(persona, $"/api/v1/scheduled_statuses/{id}"));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hashtag_is_followed_from_its_feed_tab_and_listed_in_explore()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var tag = $"e2etag{Guid.NewGuid():N}"[..14];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.follow-tag", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
// a reader finds the hashtag through search, which opens it as a tab of the feed
|
||||
await page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("#" + tag)}");
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "#" + tag }).ClickAsync(new() { Timeout = 30000 });
|
||||
var follow = page.Locator("button[data-tag-follow]");
|
||||
await Assertions.Expect(follow).ToContainTextAsync($"Follow #{tag}", new() { Timeout = 30000 });
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).ToContainTextAsync($"Stop following #{tag}", new() { Timeout = 15000 });
|
||||
Assert.True((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue<bool>());
|
||||
|
||||
await page.GotoAsync($"{Pasture.Site}/explore");
|
||||
await page.GetByRole(AriaRole.Tab, new() { Name = "Followed hashtags" }).ClickAsync();
|
||||
var row = page.Locator($"[data-tag='{tag}']");
|
||||
await row.WaitForAsync(new() { Timeout = 15000 });
|
||||
await row.GetByRole(AriaRole.Button, new() { Name = "Unfollow" }).ClickAsync();
|
||||
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.False((await PersonaApi.Call(persona, $"/api/v1/tags/{tag}"))!["following"]!.GetValue<bool>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/tags/{tag}/unfollow", HttpMethod.Post);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Explore_shows_what_privapub_says_is_trending_and_its_directory()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var trending = (await PersonaApi.Call(persona, "/api/v1/trends/tags?limit=20"))!.AsArray().Select(t => t!["name"]!.GetValue<string>()).ToList();
|
||||
var directory = (await PersonaApi.Call(persona, "/api/v1/directory?order=active&local=false&limit=40"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()).ToList();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.explore", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/explore");
|
||||
await page.Locator("[data-tag], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var shownTags = await page.Locator("[data-tag]").EvaluateAllAsync<string[]>("rows => rows.map(r => r.dataset.tag)");
|
||||
Assert.Equal(trending, shownTags);
|
||||
|
||||
await page.GetByRole(AriaRole.Tab, new() { Name = "People" }).ClickAsync();
|
||||
await page.Locator("[data-account], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var shownPeople = await page.Locator("[data-account]").EvaluateAllAsync<string[]>("rows => rows.map(r => r.dataset.account)");
|
||||
Assert.Equal(directory, shownPeople);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Joining another server's event from its card (an owner decision): the persona is asked once that taking part is
|
||||
// public, Mobilizon makes it a participant and accepts, and leaving takes it off there. The event is the one
|
||||
// SocialPub's tools/pasture/scenarios/mobilizon.sh keeps for these tests.
|
||||
public sealed class EventTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_persona_joins_a_mobilizon_event_from_its_card_and_leaves_it()
|
||||
{
|
||||
var kept = Path.Combine(Pasture.Root, ".state", "mobilizon-kept-event.json");
|
||||
Assert.SkipUnless(File.Exists(kept), "no Mobilizon event kept for the client: run tools/pasture/interop.sh mobilizon");
|
||||
var url = JsonNode.Parse(File.ReadAllText(kept))!["url"]!.GetValue<string>();
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count == 0, "the town has no personas");
|
||||
var reader = personas[0];
|
||||
var status = (await PersonaApi.Call(reader, $"/api/v2/search?q={Uri.EscapeDataString(url)}&resolve=true&type=statuses"))?["statuses"]?.AsArray().FirstOrDefault();
|
||||
Assert.SkipWhen(status is null, "PrivaPub cannot resolve the kept Mobilizon event");
|
||||
var id = status!["id"]!.GetValue<string>();
|
||||
async Task<string> Participation() =>
|
||||
(await PersonaApi.Call(reader, $"/api/v1/statuses/{id}"))?["privapub"]?["event"]?["participation"]?.GetValue<string>();
|
||||
// a run cut short may have left the persona in: start from outside
|
||||
if (await Participation() is not null)
|
||||
await PersonaApi.Call(reader, $"/api/privapub/v1/statuses/{id}/leave", HttpMethod.Post);
|
||||
await using var session = await SignIn.As(browser, reader);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.join", "mobilizon", "kept-event", async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{id}");
|
||||
var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue<string>()}']").First;
|
||||
await card.Locator("[data-event-join]").ClickAsync(new() { Timeout = 30000 });
|
||||
await VoteAndReactTests.AcceptNoticeIfAsked(page);
|
||||
await card.Locator("[data-event-leave]").WaitForAsync(new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await Participation() == "accepted", 45), "Mobilizon never accepted the Join");
|
||||
Assert.Equal("participant", await MobilizonRole(reader.ActorUri, url));
|
||||
|
||||
await page.ReloadAsync();
|
||||
await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "accepted", new() { Timeout = 30000 });
|
||||
await card.Locator("[data-event-leave]").ClickAsync();
|
||||
await Assertions.Expect(card.Locator("[data-participation]")).ToHaveAttributeAsync("data-participation", "none", new() { Timeout = 15000 });
|
||||
Assert.Null(await Participation());
|
||||
Assert.True(await Pasture.Eventually(async () => await MobilizonRole(reader.ActorUri, url) == "", 45),
|
||||
"the persona is still a participant on Mobilizon");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
// the persona's role in the event, from Mobilizon's database; empty when it takes no part
|
||||
static async Task<string> MobilizonRole(string actorUri, string eventUrl)
|
||||
{
|
||||
static string Quoted(string value) => "'" + value.Replace("'", "''") + "'";
|
||||
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (var arg in new[] { "exec", "pasture-postgis", "psql", "-U", "pasture", "-d", "mobilizon", "-tAc",
|
||||
"select p.role from participants p join actors a on a.id = p.actor_id join events e on e.id = p.event_id " +
|
||||
$"where a.url = {Quoted(actorUri)} and e.url = {Quoted(eventUrl)}" })
|
||||
start.ArgumentList.Add(arg);
|
||||
using var process = Process.Start(start)!;
|
||||
var output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return output.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Coming back to the feed from a thread shows it as it was left, with its older pages, without reading it again
|
||||
// (Services/FeedMemory.cs).
|
||||
public sealed class FeedMemoryTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task The_feed_comes_back_from_a_thread_as_it_was_left()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
// a deletion the stream tells (late federated Deletes from the tests before) takes its post out at once
|
||||
var deletions = 0;
|
||||
page.WebSocket += (_, socket) => socket.FrameReceived += (_, frame) =>
|
||||
{
|
||||
if (frame.Text?.Contains("\"event\":\"delete\"") == true)
|
||||
Interlocked.Increment(ref deletions);
|
||||
};
|
||||
await Results.Cell("client.action.feed-memory", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var feed = page.Locator("[data-feed-count]");
|
||||
await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
// an older page too when there is one, so there is more to keep than the first
|
||||
await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = root.scrollHeight");
|
||||
await Pasture.Eventually(async () => int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0") > 20, 10);
|
||||
var count = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
|
||||
var deletedBefore = Volatile.Read(ref deletions);
|
||||
|
||||
var homeReads = 0;
|
||||
page.Request += (_, request) =>
|
||||
{
|
||||
if (request.Url.Contains("/api/v1/timelines/home") && !request.Url.Contains("min_id"))
|
||||
homeReads++;
|
||||
};
|
||||
await page.EvalOnSelectorAsync("#feed-scroll", "root => root.scrollTop = 0");
|
||||
await page.Locator("article[data-uri] footer a[title='Expand']").First.ClickAsync();
|
||||
await page.WaitForURLAsync("**/expand/**");
|
||||
await page.GoBackAsync();
|
||||
await page.Locator("article[data-uri]").First.WaitForAsync(new() { Timeout = 10000 });
|
||||
|
||||
var back = int.Parse(await feed.GetAttributeAsync("data-feed-count") ?? "0");
|
||||
var deleted = Volatile.Read(ref deletions) - deletedBefore;
|
||||
Assert.True(back >= count - deleted, $"the feed came back with {back} posts, left with {count} ({deleted} deleted meanwhile)");
|
||||
Assert.Equal(0, homeReads);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A filter in decePub: made on /filters with a word only one post has, which the home feed then folds behind the
|
||||
// filter's title (warn) and leaves out once the filter hides; PrivaPub keeps what the page sent.
|
||||
public sealed class FilterTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_filters_word_folds_then_hides_a_post_in_the_home_feed()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var word = $"zq{Guid.NewGuid():N}"[..10];
|
||||
var title = $"e2e {word}";
|
||||
foreach (var left in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
||||
await PersonaApi.Call(persona, $"/api/v2/filters/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = $"a post about {word}", ["visibility"] = "unlisted" }))!;
|
||||
var uri = post["uri"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.filter", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/filters");
|
||||
await page.Locator("input[data-filter-new]").FillAsync(title);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Make a filter" }).ClickAsync();
|
||||
var article = page.Locator("article[data-filter-id]").Filter(new() { HasText = title });
|
||||
await article.Locator("input[data-filter-keyword-new]").FillAsync(word);
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
var filterId = await article.GetAttributeAsync("data-filter-id");
|
||||
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["keywords"]?.AsArray()
|
||||
.Any(k => k!["keyword"]!.GetValue<string>() == word) == true, 15), "PrivaPub never got the filter's word");
|
||||
|
||||
// warn: the card is folded behind the filter's title, and opens on request
|
||||
// the feed opens on its default tab, Home, when no tab is remembered
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var card = page.Locator($"article[data-uri='{uri}']");
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(card.Locator("[data-filtered]")).ToContainTextAsync(title);
|
||||
await Assertions.Expect(card).Not.ToContainTextAsync($"a post about {word}");
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = "Show anyway" }).ClickAsync();
|
||||
await Assertions.Expect(card).ToContainTextAsync($"a post about {word}");
|
||||
|
||||
// hide: the card is left out
|
||||
await page.GotoAsync($"{Pasture.Site}/filters");
|
||||
article = page.Locator($"article[data-filter-id='{filterId}']");
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
||||
await article.Locator("select").First.SelectOptionAsync("Hide");
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => (await PersonaApi.Call(persona, $"/api/v2/filters/{filterId}"))?["filter_action"]?
|
||||
.GetValue<string>() == "hide", 15), "PrivaPub never made the filter hide");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(page.Locator($"article[data-uri='{uri}']")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
foreach (var made in (await PersonaApi.Call(persona, "/api/v2/filters"))!.AsArray().Where(f => f!["title"]!.GetValue<string>() == title))
|
||||
await PersonaApi.Call(persona, $"/api/v2/filters/{made!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A circle made in decePub, joined by another persona with its invitation code, and that member taken out by the
|
||||
// owner, who alone sees who is in it
|
||||
public sealed class GroupTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_circle_is_made_joined_by_invitation_and_its_member_taken_out()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has fewer than two personas");
|
||||
var (owner, joiner) = (personas[0], personas[1]);
|
||||
var userName = $"e2ecircle{DateTime.UtcNow:MMddHHmmss}";
|
||||
await using var ownerSession = await SignIn.As(browser, owner);
|
||||
await using var joinerSession = await SignIn.As(browser, joiner);
|
||||
var page = ownerSession.Page;
|
||||
await Results.Cell("client.action.group", "privapub", owner.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/groups");
|
||||
var form = page.Locator("[data-group-new]");
|
||||
await form.Locator("input[data-group-username]").FillAsync(userName);
|
||||
await form.Locator("input[data-group-name]").FillAsync(userName);
|
||||
await form.Locator("[data-group-create]").ClickAsync();
|
||||
var invitation = page.Locator("[data-group-invitation]");
|
||||
await invitation.WaitForAsync(new() { Timeout = 30000 });
|
||||
var code = await invitation.InputValueAsync();
|
||||
Assert.Equal(64, code.Length);
|
||||
|
||||
var other = joinerSession.Page;
|
||||
await other.GotoAsync($"{Pasture.Site}/groups");
|
||||
var join = other.Locator("[data-group-join]");
|
||||
await join.Locator("input[data-group-code]").FillAsync(code);
|
||||
await join.Locator("[data-group-join-submit]").ClickAsync();
|
||||
await other.Locator("article[data-group-id] [data-group-leave]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
// closed and opened again, so its members are read after the join
|
||||
var card = page.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName });
|
||||
await card.Locator("[data-group-manage]").ClickAsync();
|
||||
await card.Locator("[data-group-manage]").ClickAsync();
|
||||
var member = card.Locator($"[data-member='{joiner.ActorUri}']");
|
||||
await member.WaitForAsync(new() { Timeout = 30000 });
|
||||
Assert.Equal("false", await member.GetAttributeAsync("data-pending"));
|
||||
await member.Locator("[data-member-remove]").ClickAsync();
|
||||
await Assertions.Expect(member).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
|
||||
await other.ReloadAsync();
|
||||
await other.Locator("[data-group-new]").WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(other.Locator("article[data-group-id]").Filter(new() { HasText = "@" + userName })).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
await ownerSession.AssertGuards();
|
||||
await joinerSession.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A list in decePub: made on /lists, filled with an account the persona follows (found by its name among the accounts
|
||||
// it follows), read as the feed's Lists tab, made exclusive and deleted. Each step is checked against PrivaPub's own
|
||||
// answer to the persona's token, so the page and the server agree on the list, its members and its posts.
|
||||
public sealed class ListTests(Browser browser)
|
||||
{
|
||||
static Task<JsonNode> Api(Pasture.Persona persona, string path, HttpMethod method = null, JsonObject body = null) =>
|
||||
PersonaApi.Call(persona, path, method, body);
|
||||
|
||||
// an account the persona follows that has written something
|
||||
static async Task<JsonNode> Followed(Pasture.Persona persona) =>
|
||||
(await Api(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray()
|
||||
.FirstOrDefault(a => a!["statuses_count"]!.GetValue<int>() > 0 && !a["acct"]!.GetValue<string>().StartsWith("st_"));
|
||||
|
||||
[Fact]
|
||||
public async Task An_account_joins_and_leaves_a_list_from_its_profile()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var member = await Followed(persona);
|
||||
Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
|
||||
var memberId = member!["id"]!.GetValue<string>();
|
||||
var listId = (await Api(persona, "/api/v1/lists", HttpMethod.Post, new JsonObject { ["title"] = $"e2e {Guid.NewGuid():N}"[..12] }))!["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.list-member", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
async Task<bool> InList() =>
|
||||
(await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Any(a => a!["id"]!.GetValue<string>() == memberId);
|
||||
|
||||
await page.GotoAsync($"{Pasture.Site}/account/{memberId}");
|
||||
var profile = page.Locator("article[data-account-id]").First;
|
||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
||||
await profile.GetByRole(AriaRole.Button, new() { Name = "Lists" }).ClickAsync();
|
||||
var box = profile.Locator($"[data-list-id='{listId}'] input");
|
||||
await box.CheckAsync();
|
||||
Assert.True(await Pasture.Eventually(InList, 15), "the profile's checkbox never put the account in the list");
|
||||
await box.UncheckAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => !await InList(), 15), "the profile's checkbox never took the account out");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Api(persona, $"/api/v1/lists/{listId}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_list_is_made_filled_read_as_a_feed_and_deleted()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var member = await Followed(persona);
|
||||
Assert.SkipWhen(member is null, $"{persona.UserName} follows nobody who has posted");
|
||||
var memberId = member!["id"]!.GetValue<string>();
|
||||
var title = $"e2e {Guid.NewGuid():N}"[..12];
|
||||
// lists an earlier failed run left behind
|
||||
foreach (var left in (await Api(persona, "/api/v1/lists"))!.AsArray().Where(l => l!["title"]!.GetValue<string>().StartsWith("e2e ")))
|
||||
await Api(persona, $"/api/v1/lists/{left!["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.list", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/lists");
|
||||
await page.Locator("[data-list-new] input, input[data-list-new]").First.FillAsync(title);
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Make a list" }).ClickAsync();
|
||||
var article = page.Locator("article[data-list-id]").Filter(new() { HasText = title });
|
||||
await article.WaitForAsync(new() { Timeout = 15000 });
|
||||
var listId = await article.GetAttributeAsync("data-list-id");
|
||||
Assert.Contains((await Api(persona, "/api/v1/lists"))!.AsArray(), l => l!["id"]!.GetValue<string>() == listId);
|
||||
|
||||
// the list opens once made: find the member among the accounts followed, and add it
|
||||
await article.Locator("[data-list-find] input, input[data-list-find]").First.FillAsync(member["username"]!.GetValue<string>());
|
||||
await article.Locator("[data-list-find] input, input[data-list-find]").First.PressAsync("Enter");
|
||||
var candidate = article.Locator($"[data-candidate-id='{memberId}']");
|
||||
await candidate.WaitForAsync(new() { Timeout = 15000 });
|
||||
await candidate.GetByRole(AriaRole.Button, new() { Name = "Add" }).ClickAsync();
|
||||
await article.Locator($"[data-member-id='{memberId}']").WaitForAsync(new() { Timeout = 15000 });
|
||||
Assert.Equal([memberId], (await Api(persona, $"/api/v1/lists/{listId}/accounts"))!.AsArray().Select(a => a!["id"]!.GetValue<string>()));
|
||||
|
||||
// read as a feed: the cards are the list timeline's first page, as PrivaPub answers it
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Read the list" }).ClickAsync();
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var expected = (await Api(persona, $"/api/v1/timelines/list/{listId}?limit=20"))!.AsArray()
|
||||
.Select(s => (s!["reblog"] ?? s)["uri"]!.GetValue<string>()).ToHashSet();
|
||||
var shown = await page.Locator("article[data-uri]").EvaluateAllAsync<string[]>("cards => cards.map(c => c.dataset.uri)");
|
||||
Assert.NotEmpty(expected);
|
||||
Assert.Subset(expected, shown.ToHashSet());
|
||||
|
||||
// exclusive, then deleted
|
||||
await page.GotoAsync($"{Pasture.Site}/lists");
|
||||
article = page.Locator($"article[data-list-id='{listId}']");
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
||||
await article.GetByLabel("Keep its members out of the home feed").CheckAsync();
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => (await Api(persona, $"/api/v1/lists/{listId}"))?["exclusive"]?.GetValue<bool>() == true, 15),
|
||||
"PrivaPub never made the list exclusive");
|
||||
// the icon's glyph (CSS content) is part of a button's name, so names are matched as substrings
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Delete" }).First.ClickAsync();
|
||||
await article.GetByRole(AriaRole.Button, new() { Name = "Delete it for good" }).ClickAsync();
|
||||
await Assertions.Expect(article).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Null(await Api(persona, $"/api/v1/lists/{listId}"));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Media from the composer and the profile, checked on PrivaPub: a picture uploaded as soon as it is picked and posted
|
||||
// with its description, the reminder before a picture without one, a failed upload retried without the rest being sent
|
||||
// again, and an avatar set and removed
|
||||
public sealed class MediaTests(Browser browser)
|
||||
{
|
||||
// a 1×1 PNG
|
||||
static readonly byte[] Png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==");
|
||||
|
||||
static async Task OpenComposer(IPage page)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await page.GetByRole(AriaRole.Button, new() { Name = "Post" }).First.ClickAsync();
|
||||
}
|
||||
|
||||
static Task Attach(IPage page, string name) =>
|
||||
page.Locator("input[data-attach]").SetInputFilesAsync(new FilePayload { Name = name, MimeType = "image/png", Buffer = Png });
|
||||
|
||||
static ILocator Submit(IPage page) => page.Locator("form:has(textarea[data-compose]) button[type=submit]").First;
|
||||
|
||||
static async Task<JsonNode> Published(Pasture.Persona persona, string text)
|
||||
{
|
||||
JsonNode latest = null;
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(latest = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault())?["content"]
|
||||
?.GetValue<string>().Contains(text) == true, 15), "the post was never published");
|
||||
return latest;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_picture_is_uploaded_when_picked_and_posted_with_its_description()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"a picture {Guid.NewGuid():N}"[..20];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.media", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await OpenComposer(page);
|
||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||
await Attach(page, "dot.png");
|
||||
// uploaded on its own, before anything is published
|
||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||
await page.Locator("[data-alt-text]").First.FillAsync("a single dot");
|
||||
await Submit(page).ClickAsync();
|
||||
|
||||
var post = await Published(persona, text);
|
||||
made = post["id"]!.GetValue<string>();
|
||||
Assert.Equal("a single dot", post["media_attachments"]![0]!["description"]!.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_picture_without_a_description_is_asked_about_once()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"undescribed {Guid.NewGuid():N}"[..22];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.media-reminder", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await OpenComposer(page);
|
||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||
await Attach(page, "dot.png");
|
||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||
await Submit(page).ClickAsync();
|
||||
|
||||
await Assertions.Expect(page.Locator("[data-alt-reminder]")).ToBeVisibleAsync();
|
||||
await page.Locator("[data-alt-publish-anyway]").ClickAsync();
|
||||
|
||||
var post = await Published(persona, text);
|
||||
made = post["id"]!.GetValue<string>();
|
||||
Assert.Null(post["media_attachments"]![0]!["description"]?.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_upload_is_retried_alone()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"retried {Guid.NewGuid():N}"[..18];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
string made = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.media-retry", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
// the second upload is refused once, as a server would refuse it
|
||||
var uploads = 0;
|
||||
await page.RouteAsync("**/api/v2/media", async route =>
|
||||
{
|
||||
if (Interlocked.Increment(ref uploads) == 2)
|
||||
await route.FulfillAsync(new() { Status = 422, ContentType = "application/json", Body = "{\"error\":\"Validation failed: try again\"}" });
|
||||
else
|
||||
await route.ContinueAsync();
|
||||
});
|
||||
await OpenComposer(page);
|
||||
await page.Locator("textarea[data-compose]").First.FillAsync(text);
|
||||
await Attach(page, "first.png");
|
||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||
await Attach(page, "second.png");
|
||||
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToHaveCountAsync(1, new() { Timeout = 20000 });
|
||||
await Assertions.Expect(page.Locator("[data-upload-state=failed]")).ToContainTextAsync("try again");
|
||||
|
||||
await page.Locator("[data-upload-retry]").ClickAsync();
|
||||
await Assertions.Expect(page.Locator("[data-upload-state=ready]")).ToHaveCountAsync(2, new() { Timeout = 20000 });
|
||||
foreach (var alt in await page.Locator("[data-alt-text]").AllAsync())
|
||||
await alt.FillAsync("a dot");
|
||||
await Submit(page).ClickAsync();
|
||||
|
||||
var post = await Published(persona, text);
|
||||
made = post["id"]!.GetValue<string>();
|
||||
Assert.Equal(2, post["media_attachments"]!.AsArray().Count);
|
||||
// the first file was sent once: one upload for it, two for the second (refused, then retried)
|
||||
Assert.Equal(3, uploads);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (made is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{made}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_avatar_is_set_and_removed_from_settings()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.avatar", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Profile" }).First;
|
||||
await header.ClickAsync();
|
||||
var editor = header.Locator("xpath=..").Locator("[data-profile-editor]");
|
||||
await editor.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await editor.Locator("input[data-profile-avatar]").SetInputFilesAsync(new FilePayload { Name = "me.png", MimeType = "image/png", Buffer = Png });
|
||||
await editor.Locator("[data-profile-save]").ClickAsync();
|
||||
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
var set = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>();
|
||||
Assert.DoesNotContain("missing", set);
|
||||
|
||||
await editor.Locator("[data-profile-avatar-remove]").ClickAsync();
|
||||
await Assertions.Expect(page.GetByText("Avatar removed.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
Assert.Contains("missing", (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["avatar"]!.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The persona's notification policy from Settings: people it does not follow are filtered, a like from one waits in a
|
||||
// request the Notifications tab leads to, and letting the request in brings the notification back; the policy is put back to accepting everything, and
|
||||
// the stranger's permission is taken away again
|
||||
public sealed class NotificationPolicyTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_strangers_like_waits_in_a_request_the_persona_lets_in()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
var author = personas[0];
|
||||
// (a persona of the town the author does not follow)
|
||||
var stranger = default(Pasture.Persona);
|
||||
foreach (var candidate in personas.Skip(1))
|
||||
if ((await PersonaApi.Call(author, $"/api/v1/accounts/relationships?id[]={candidate.LocalId}"))![0]!["following"]!.GetValue<bool>() == false)
|
||||
{
|
||||
stranger = candidate;
|
||||
break;
|
||||
}
|
||||
Assert.SkipWhen(stranger is null, "the town's first persona follows every other");
|
||||
var strangerAcct = (await PersonaApi.Call(stranger!, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like me, stranger {Guid.NewGuid():N}"[..30], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.notification-policy", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Notifications" }).First;
|
||||
await header.ClickAsync();
|
||||
var panel = page.Locator("[data-notification-policy]");
|
||||
await panel.WaitForAsync(new() { Timeout = 30000 });
|
||||
await panel.Locator("[data-policy='for_not_following']").SelectOptionAsync("filter");
|
||||
await panel.Locator("[data-policy-save]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(author, "/api/v2/notifications/policy"))!["for_not_following"]!.GetValue<string>() == "filter", 15), "the policy was never saved");
|
||||
|
||||
await PersonaApi.Call(stranger!, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
Assert.DoesNotContain((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
||||
|
||||
// the Notifications tab says requests are waiting, and leads to them
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
||||
await page.Locator("[data-filtered-requests]").ClickAsync(new() { Timeout = 30000 });
|
||||
var request = page.Locator($"[data-notification-requests] [data-request='{strangerAcct}']");
|
||||
await request.WaitForAsync(new() { Timeout = 30000 });
|
||||
await request.Locator("[data-request-accept]").ClickAsync();
|
||||
await Assertions.Expect(request).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.Contains((await PersonaApi.Call(author, "/api/v1/notifications?types[]=favourite"))!.AsArray(),
|
||||
n => n!["account"]!["acct"]!.GetValue<string>() == strangerAcct && n["status"]?["id"]?.GetValue<string>() == id);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, "/api/v2/notifications/policy", HttpMethod.Patch, new JsonObject { ["for_not_following"] = "accept" });
|
||||
// (letting a request in lets its account in for good, which no API takes back)
|
||||
await Pasture.Mongo($"db.NotificationPermission.deleteMany({{AvatarId: '{author.LocalId}', FromActorURI: '{stranger!.ActorUri}'}})");
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Notifications as groups: two likes of one post are one card naming both, the tab's badge counts what is unread, and
|
||||
// reading the tab moves the marker so PrivaPub counts nothing unread
|
||||
public sealed class NotificationTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Two_likes_of_one_post_are_one_card_and_reading_them_clears_the_badge()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 3, "the town has fewer than three personas");
|
||||
var (author, first, second) = (personas[0], personas[1], personas[2]);
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like me {Guid.NewGuid():N}"[..16], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await PersonaApi.Call(first, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
await PersonaApi.Call(second, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
await Results.Cell("client.render.notification-group", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("[data-unread]").WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await page.GetByRole(AriaRole.Tab).Filter(new() { Has = page.Locator(".ion-md-notifications") }).ClickAsync();
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").Filter(new() { Has = page.Locator("[data-notification='favourite']") });
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(card.Locator("[data-notification='favourite']")).ToHaveAttributeAsync("data-count", "2");
|
||||
await Assertions.Expect(card).ToContainTextAsync($"{second.Name} and {first.Name} favourited your post");
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(author, "/api/v2/notifications/unread_count"))!["count"]!.GetValue<int>() == 0, 15), "reading the tab left notifications unread");
|
||||
await Assertions.Expect(page.Locator("[data-unread]")).ToHaveCountAsync(0);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A persona's archive from Settings (owner decision 2026-10-07): one town persona's archive made and downloaded through
|
||||
// its link, then imported into another, whose profile shows the posts while nobody was told; the copies are taken away
|
||||
// again after, so the town stays as seeded
|
||||
public sealed class PersonaArchiveTests(Browser browser)
|
||||
{
|
||||
static async Task<ILocator> Open(IPage page)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
||||
await header.ClickAsync();
|
||||
var panel = header.Locator("xpath=..").Locator("[data-archive]");
|
||||
await panel.WaitForAsync(new() { Timeout = 30000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_archive_is_downloaded_and_imported_into_another_persona()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
var (source, target) = (personas[0], personas[1]);
|
||||
var file = Path.Combine(Path.GetTempPath(), $"decepub-archive-{Guid.NewGuid():N}.zip");
|
||||
try
|
||||
{
|
||||
await using (var session = await SignIn.As(browser, source))
|
||||
{
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.archive-export", "privapub", source.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page);
|
||||
await panel.Locator("[data-archive-make]").ClickAsync();
|
||||
var get = panel.Locator("[data-archive-get-link]");
|
||||
await get.WaitForAsync(new() { Timeout = 120000 });
|
||||
await get.ClickAsync();
|
||||
var link = panel.Locator("[data-archive-link]");
|
||||
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
||||
await download.SaveAsAsync(file);
|
||||
using var archive = ZipFile.OpenRead(file);
|
||||
Assert.Contains(archive.Entries, e => e.FullName == "outbox.json");
|
||||
Assert.Contains(archive.Entries, e => e.FullName == "actor.json");
|
||||
Assert.DoesNotContain(archive.Entries, e => e.FullName.Contains(source.Root, StringComparison.Ordinal));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
await using (var session = await SignIn.As(browser, target))
|
||||
{
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.archive-import", "privapub", target.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page);
|
||||
await panel.Locator("input[data-archive-file]").SetInputFilesAsync(file);
|
||||
foreach (var part in new[] { "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins" })
|
||||
await panel.Locator($"[data-archive-part='{part}'] input, input[data-archive-part='{part}']").First.UncheckAsync();
|
||||
await panel.Locator("[data-archive-import-start]").ClickAsync();
|
||||
var report = panel.Locator("[data-archive-report][data-archive-state='done']");
|
||||
await report.WaitForAsync(new() { Timeout = 300000 });
|
||||
await Assertions.Expect(report.Locator("[data-archive-count='posts']")).ToBeVisibleAsync();
|
||||
|
||||
var copies = (await PersonaApi.Call(target, $"/api/v1/accounts/{target.LocalId}/statuses?limit=40"))!.AsArray();
|
||||
Assert.NotEmpty(copies);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(file);
|
||||
// the copies go, as if never imported: the town stays as seeded
|
||||
await Pasture.Mongo($"const copies = db.Post.find({{GroupUserId:'{target.LocalId}', ImportedFromURI:{{$exists:true, $ne:null}}}}, {{_id:1}}).toArray().map(p => p._id); "
|
||||
+ "db.MediaAttachment.deleteMany({PostId:{$in:copies.map(id => id.toString())}}); db.Post.deleteMany({_id:{$in:copies}})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A persona pins its own post from the card, finds it above its posts on its profile, and unpins it. PrivaPub tells
|
||||
// the persona's followers with Add and Remove; SocialPub's tools/pasture/scenarios/pins.sh checks that side.
|
||||
public sealed class PinTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_persona_pins_its_post_from_the_card_and_its_profile_shows_it_first()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"pin me {Guid.NewGuid():N}"[..20];
|
||||
var post = (await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
var me = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!["id"]!.GetValue<string>();
|
||||
async Task<bool> Pinned() =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/{me}/statuses?pinned=true"))!.AsArray().Any(s => s!["id"]!.GetValue<string>() == id);
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.pin", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/account/{me}");
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-pin]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(Pinned, 15), "the pin never reached PrivaPub");
|
||||
|
||||
await page.ReloadAsync();
|
||||
var pinnedCard = page.Locator($"[data-pinned] article[data-uri='{post["uri"]!.GetValue<string>()}']");
|
||||
await pinnedCard.WaitForAsync(new() { Timeout = 30000 });
|
||||
Assert.Equal(1, await page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").CountAsync());
|
||||
|
||||
await pinnedCard.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await Assertions.Expect(pinnedCard.Locator("button[data-pin]")).ToContainTextAsync("Unpin", new() { Timeout = 5000 });
|
||||
await pinnedCard.Locator("button[data-pin]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => !await Pinned(), 15), "the unpin never reached PrivaPub");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Accounts in decePub: found by searching their handle (PrivaPub resolves it), opened as a profile, followed and
|
||||
// unfollowed there; the follow is checked on the account's own server, where the persona must appear as a follower.
|
||||
public sealed class ProfileTests(Browser browser)
|
||||
{
|
||||
static readonly string[] Platforms = ["gts", "mastodon", "akkoma", "misskey", "sharkey"];
|
||||
|
||||
public static TheoryData<string> Origins() => new(Platforms);
|
||||
|
||||
// an account of the platform that the persona does not follow yet, from the town's own sessions
|
||||
static (string Platform, string UserName, string Token) Stranger(string platform, Pasture.Persona persona)
|
||||
{
|
||||
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Pasture.Root, ".state", "town", "sessions.json")))!.AsObject();
|
||||
var candidates = sessions.Where(kv => kv.Key.StartsWith(platform + "/") && !kv.Key.Contains("/st_"))
|
||||
.Select(kv => (platform, kv.Value!["account"]!["username"]!.GetValue<string>(), kv.Value!["token"]!.GetValue<string>()))
|
||||
.ToList();
|
||||
return candidates.Last();
|
||||
}
|
||||
|
||||
// the newest GoToSocial account that SocialPub's moves scenario moved (scenarios/moves.sh), as PrivaPub knows it
|
||||
static async Task<(string Old, string New)> Moved()
|
||||
{
|
||||
var start = new System.Diagnostics.ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval",
|
||||
"const a = db.ForeignAvatar.find({ActorURI: {$regex: 'gts.test.users.mvold'}, MovedToURL: {$ne: null}}).sort({_id: -1}).limit(1).toArray()[0];" +
|
||||
"print(a ? a.ActorURI + ' ' + a.MovedToURL : '')" })
|
||||
start.ArgumentList.Add(arg);
|
||||
using var process = System.Diagnostics.Process.Start(start)!;
|
||||
var output = (await process.StandardOutput.ReadToEndAsync()).Trim();
|
||||
await process.WaitForExitAsync();
|
||||
var parts = output.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length == 2 ? (parts[0], parts[1]) : default;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_moved_accounts_profile_says_where_it_went()
|
||||
{
|
||||
var (old, moved) = await Moved();
|
||||
Assert.SkipWhen(old is null, "no moved account: run SocialPub's tools/pasture/interop.sh moves");
|
||||
var persona = Pasture.Personas().First();
|
||||
var handle = old.Split("/users/")[1] + "@gts.test";
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
await Results.Cell("client.render.moved", "gts", handle, async () =>
|
||||
{
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
|
||||
var banner = session.Page.Locator("[data-moved]");
|
||||
await banner.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(banner).ToContainTextAsync("@" + moved.Split("/users/")[1] + "@gts.test");
|
||||
await session.AssertGuards();
|
||||
}, session.Page);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Origins))]
|
||||
public async Task A_remote_account_found_by_its_handle_is_followed_and_unfollowed_from_its_profile(string platform)
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var (_, userName, _) = Stranger(platform, persona);
|
||||
var handle = $"{userName}@{platform}.test";
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
await Results.Cell("client.action.follow", platform, handle, async () =>
|
||||
{
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/search?q={Uri.EscapeDataString("@" + handle)}");
|
||||
var profile = session.Page.Locator("article[data-account-id]").First;
|
||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(profile).ToContainTextAsync("@" + handle);
|
||||
|
||||
var follow = profile.GetByRole(AriaRole.Button).Filter(new() { HasTextRegex = new System.Text.RegularExpressions.Regex("^(Follow|Unfollow|Cancel the request)$") }).First;
|
||||
if ((await follow.InnerTextAsync()).Trim() != "Follow")
|
||||
{
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
||||
}
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).Not.ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => await FollowedOn(platform, userName, persona.UserName), 45),
|
||||
$"{handle} never learnt that {persona.UserName} follows it");
|
||||
|
||||
await follow.ClickAsync();
|
||||
await Assertions.Expect(follow).ToHaveTextAsync("Follow", new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => !await FollowedOn(platform, userName, persona.UserName), 45),
|
||||
$"{handle} still has {persona.UserName} as a follower after the unfollow");
|
||||
await session.AssertGuards();
|
||||
}, session.Page);
|
||||
}
|
||||
|
||||
// whether the peer itself lists the persona as following (or asking to follow) its account
|
||||
static async Task<bool> FollowedOn(string platform, string userName, string persona)
|
||||
{
|
||||
var result = await Pasture.Town("drive", platform, userName, "relationship", $"{{\"acct\": \"{persona}@privapub.test\"}}");
|
||||
return result?["followed_by"]?.GetValue<bool>() == true || (await Pasture.Town("drive", platform, userName, "pending", "{}"))
|
||||
.AsArray().Any(a => a?.GetValue<string>()?.StartsWith(persona + "@") == true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personas_own_profile_lists_its_posts()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
await Results.Cell("client.render.profile", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/account/{persona.LocalId}");
|
||||
var profile = session.Page.Locator("article[data-account-id]").First;
|
||||
await profile.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(profile).ToContainTextAsync("@" + persona.UserName);
|
||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await session.AssertGuards();
|
||||
}, session.Page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A persona reports a remote account from its profile, with a category, its words and a copy to the account's server:
|
||||
// PrivaPub's moderators see it, forwarded (/clientapi/moderator/reports). And who liked or boosted a post is listed
|
||||
// from its card
|
||||
public sealed class ReportTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_remote_account_is_reported_from_its_profile()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var target = (await PersonaApi.Call(persona, "/api/v2/search?q=mastouser%40mastodon.test&resolve=true&type=accounts"))?["accounts"]?.AsArray().FirstOrDefault();
|
||||
Assert.SkipWhen(target is null, "no Mastodon: run tools/pasture/run.sh add mastodon");
|
||||
var targetId = target!["id"]!.GetValue<string>();
|
||||
var words = $"reported by the e2e tests {Guid.NewGuid():N}"[..40];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.report", "mastodon", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/account/{Uri.EscapeDataString(targetId)}");
|
||||
// (the profile's own button: each of its posts' cards has one too)
|
||||
await page.Locator($"article[data-account-id='{targetId}'] button[data-report]").ClickAsync(new() { Timeout = 30000 });
|
||||
var dialog = page.Locator("[data-report-dialog]");
|
||||
await dialog.Locator("[data-report-category]").SelectOptionAsync("Spam");
|
||||
await dialog.Locator("[data-report-comment]").FillAsync(words);
|
||||
await Assertions.Expect(dialog.Locator("[data-report-forward]")).ToBeCheckedAsync();
|
||||
await dialog.Locator("[data-report-send]").ClickAsync();
|
||||
await Assertions.Expect(dialog).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
|
||||
var report = (await PastureAdmin.Get("/clientapi/moderator/reports?resolved=false"))!.AsArray()
|
||||
.FirstOrDefault(r => r!["comment"]?.GetValue<string>() == words);
|
||||
Assert.NotNull(report);
|
||||
Assert.Equal((targetId, "spam", true), (report!["targetAccountId"]!.GetValue<string>(), report["category"]!.GetValue<string>(),
|
||||
report["forwarded"]!.GetValue<bool>()));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Who_liked_and_boosted_a_post_is_listed_from_its_card()
|
||||
{
|
||||
var personas = Pasture.Personas();
|
||||
Assert.SkipWhen(personas.Count < 2, "the town has one persona");
|
||||
var (author, fan) = (personas[0], personas[1]);
|
||||
var post = (await PersonaApi.Call(author, "/api/v1/statuses", HttpMethod.Post,
|
||||
new JsonObject { ["status"] = $"like and boost me {Guid.NewGuid():N}"[..28], ["visibility"] = "public" }))!;
|
||||
var id = post["id"]!.GetValue<string>();
|
||||
var fanAcct = (await PersonaApi.Call(fan, "/api/v1/accounts/verify_credentials"))!["acct"]!.GetValue<string>();
|
||||
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/favourite", HttpMethod.Post);
|
||||
await PersonaApi.Call(fan, $"/api/v1/statuses/{id}/reblog", HttpMethod.Post);
|
||||
await using var session = await SignIn.As(browser, author);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.render.interacted-by", "privapub", author.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{Uri.EscapeDataString(id)}");
|
||||
var card = page.Locator($"article[data-uri='{post["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
foreach (var (button, list) in new[] { ("data-liked-by", "likes"), ("data-boosted-by", "boosts") })
|
||||
{
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator($"button[{button}]").ClickAsync();
|
||||
var dialog = card.Locator($"[data-interacted-by='{list}']");
|
||||
await Assertions.Expect(dialog.Locator($"[data-account='{fanAcct}']")).ToHaveCountAsync(1, new() { Timeout = 15000 });
|
||||
await page.Keyboard.PressAsync("Escape");
|
||||
await Assertions.Expect(dialog).ToHaveCountAsync(0);
|
||||
}
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(author, $"/api/v1/statuses/{id}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// Settings as a persona: its profile saved (update_credentials) and read back from PrivaPub, a server blocked and
|
||||
// unblocked, the follows exported and servers imported in Mastodon's files, and a new persona made; what a test changes
|
||||
// is put back through the API afterwards, but for the persona (PrivaPub keeps every persona, so the test makes it under
|
||||
// the scenarios' own root, never a town one)
|
||||
public sealed class SettingsTests(Browser browser)
|
||||
{
|
||||
static async Task<ILocator> Open(IPage page, string section)
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = section }).First;
|
||||
await header.ClickAsync();
|
||||
return header.Locator("xpath=..");
|
||||
}
|
||||
|
||||
static JsonObject Restore(JsonNode account)
|
||||
{
|
||||
var fields = new JsonObject();
|
||||
var source = account["source"]!;
|
||||
var kept = source["fields"]!.AsArray();
|
||||
for (var i = 0; i < 4; i++)
|
||||
fields[i.ToString()] = new JsonObject
|
||||
{
|
||||
["name"] = i < kept.Count ? kept[i]!["name"]!.GetValue<string>() : "",
|
||||
["value"] = i < kept.Count ? kept[i]!["value"]!.GetValue<string>() : ""
|
||||
};
|
||||
return new JsonObject
|
||||
{
|
||||
["display_name"] = account["display_name"]!.GetValue<string>(),
|
||||
["note"] = source["note"]!.GetValue<string>(),
|
||||
["locked"] = account["locked"]!.GetValue<bool>(),
|
||||
["source"] = new JsonObject { ["privacy"] = source["privacy"]!.GetValue<string>() },
|
||||
["fields_attributes"] = fields
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_personas_profile_is_saved_from_settings()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
var original = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
||||
var mark = Guid.NewGuid().ToString("N")[..6];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.profile-edit", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Profile");
|
||||
var editor = panel.Locator("[data-profile-editor]");
|
||||
await editor.WaitForAsync(new() { Timeout = 30000 });
|
||||
await Assertions.Expect(editor.Locator("[data-profile-name]")).ToHaveValueAsync(original["display_name"]!.GetValue<string>(),
|
||||
new() { Timeout = 10000 });
|
||||
|
||||
await editor.Locator("[data-profile-name]").FillAsync($"Edited {mark}");
|
||||
await editor.Locator("[data-profile-note]").FillAsync($"A biography written in the e2e tests {mark}");
|
||||
var row = editor.Locator("[data-profile-field='3'] input");
|
||||
await row.Nth(0).FillAsync("e2e");
|
||||
await row.Nth(1).FillAsync(mark);
|
||||
await editor.Locator("[data-profile-privacy]").SelectOptionAsync("Unlisted");
|
||||
await editor.Locator("[data-profile-save]").ClickAsync();
|
||||
await Assertions.Expect(page.GetByText("Profile saved.")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
|
||||
var saved = (await PersonaApi.Call(persona, "/api/v1/accounts/verify_credentials"))!;
|
||||
Assert.Equal($"Edited {mark}", saved["display_name"]!.GetValue<string>());
|
||||
Assert.Equal($"A biography written in the e2e tests {mark}", saved["source"]!["note"]!.GetValue<string>());
|
||||
Assert.Equal("unlisted", saved["source"]!["privacy"]!.GetValue<string>());
|
||||
Assert.Contains(saved["source"]!["fields"]!.AsArray(), f => f!["name"]!.GetValue<string>() == "e2e" && f["value"]!.GetValue<string>() == mark);
|
||||
// the persona chooser shows the new name at once
|
||||
await Assertions.Expect(page.GetByText($"Edited {mark}", new() { Exact = true }).First).ToBeVisibleAsync(new() { Timeout = 5000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, Restore(original));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_server_is_blocked_and_unblocked_from_settings()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
const string server = "blocked-by-e2e.example";
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.domain-block", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Mutes/Blocks");
|
||||
var servers = panel.Locator("[data-domain-blocks]");
|
||||
await servers.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await servers.Locator("[data-domain-new]").FillAsync($"https://{server.ToUpperInvariant()}/about");
|
||||
await servers.GetByRole(AriaRole.Button, new() { Name = "Block server" }).ClickAsync();
|
||||
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
||||
Assert.Contains(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
||||
|
||||
await servers.Locator($"[data-domain='{server}']").GetByRole(AriaRole.Button, new() { Name = "Unblock" }).ClickAsync();
|
||||
await Assertions.Expect(servers.Locator($"[data-domain='{server}']")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.DoesNotContain(server, (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
|
||||
// the follows export is Mastodon's following_accounts.csv, naming every account in full
|
||||
[Fact]
|
||||
public async Task The_follows_are_exported_in_mastodons_format()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var following = (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/following?limit=80"))!.AsArray();
|
||||
Assert.SkipWhen(following.Count == 0, $"{persona.UserName} follows nobody in this town");
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.export", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Data import/export");
|
||||
var button = panel.Locator("[data-export-kind='Follows']");
|
||||
await button.WaitForAsync(new() { Timeout = 30000 });
|
||||
var download = await page.RunAndWaitForDownloadAsync(() => button.ClickAsync(), new() { Timeout = 60000 });
|
||||
Assert.Equal("following_accounts.csv", download.SuggestedFilename);
|
||||
var lines = (await File.ReadAllLinesAsync((await download.PathAsync())!)).ToList();
|
||||
Assert.Equal("Account address,Show boosts,Notify on new posts,Languages", lines[0]);
|
||||
foreach (var account in following)
|
||||
{
|
||||
var acct = account!["acct"]!.GetValue<string>();
|
||||
var full = acct.Contains('@') ? acct : $"{acct}@privapub.test";
|
||||
Assert.Contains(lines, l => l.StartsWith(full + ",", StringComparison.Ordinal));
|
||||
}
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blocked_servers_are_imported_from_a_mastodon_file()
|
||||
{
|
||||
var persona = Pasture.Personas().Last();
|
||||
string[] servers = ["imported-one.example", "imported-two.example"];
|
||||
var csv = Path.Combine(Path.GetTempPath(), $"blocked_domains-{Guid.NewGuid():N}.csv");
|
||||
await File.WriteAllTextAsync(csv, string.Join("\n", servers) + "\n");
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.action.import", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
var panel = await Open(page, "Data import/export");
|
||||
var import = panel.Locator("[data-import]");
|
||||
await import.WaitForAsync(new() { Timeout = 30000 });
|
||||
await import.Locator("[data-import-kind]").SelectOptionAsync("ServerBlocks");
|
||||
await import.Locator("[data-import-file]").SetInputFilesAsync(csv);
|
||||
await import.Locator("[data-import-start]").ClickAsync();
|
||||
await Assertions.Expect(import.Locator("[data-import-report]")).ToContainTextAsync("2 of 2 done, 0 could not be.", new() { Timeout = 30000 });
|
||||
var blocked = (await PersonaApi.Call(persona, "/api/v1/domain_blocks"))!.AsArray().Select(d => d!.GetValue<string>()).ToList();
|
||||
Assert.All(servers, s => Assert.Contains(s, blocked));
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var server in servers)
|
||||
await PersonaApi.Call(persona, $"/api/v1/domain_blocks?domain={server}", HttpMethod.Delete);
|
||||
File.Delete(csv);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_new_persona_is_made_and_used()
|
||||
{
|
||||
var name = $"e2e_{Guid.NewGuid():N}"[..12];
|
||||
await using var session = await SignIn.As(browser, new Pasture.Persona("alice", "alice", "pastureroot", "Pasture-Pass-1!", null, null, null));
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.persona-create", "privapub", "pastureroot", async () =>
|
||||
{
|
||||
var panel = await Open(page, "Personas");
|
||||
var form = panel.Locator("[data-persona-new]");
|
||||
await form.WaitForAsync(new() { Timeout = 30000 });
|
||||
await form.Locator("[data-persona-username]").FillAsync(name);
|
||||
await form.Locator("[data-persona-name]").FillAsync($"Made by e2e {name}");
|
||||
await form.Locator("[data-persona-biography]").FillAsync("A persona the end-to-end tests made.");
|
||||
await form.Locator("[data-persona-create]").ClickAsync();
|
||||
var made = panel.Locator($"[data-persona='{name}']");
|
||||
await made.WaitForAsync(new() { Timeout = 15000 });
|
||||
|
||||
await made.GetByRole(AriaRole.Button, new() { Name = "Use" }).ClickAsync();
|
||||
await Assertions.Expect(made).ToContainTextAsync("in use", new() { Timeout = 15000 });
|
||||
await Assertions.Expect(page.GetByText($"Made by e2e {name}", new() { Exact = true }).First).ToBeVisibleAsync();
|
||||
using var http = Pasture.Http();
|
||||
var webfinger = await http.GetAsync($"{Pasture.Api}/.well-known/webfinger?resource=acct:{name}@privapub.test");
|
||||
Assert.True(webfinger.IsSuccessStatusCode, $"{name} is not found by WebFinger");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// The home feed hears of a new post and of its deletion through PrivaPub's streaming API, in seconds, where polling
|
||||
// would take up to 45
|
||||
public sealed class StreamingTests(Browser browser)
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_post_made_elsewhere_waits_behind_the_new_button_and_its_deletion_takes_it_away()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var text = $"streamed {Guid.NewGuid():N}"[..21];
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
// the stream counts once the app has subscribed to the home feed on it (it opens after the first page is read,
|
||||
// later on a busy page)
|
||||
var subscribed = new TaskCompletionSource();
|
||||
page.WebSocket += (_, socket) =>
|
||||
{
|
||||
if (socket.Url.Contains("/api/v1/streaming"))
|
||||
socket.FrameSent += (_, frame) =>
|
||||
{
|
||||
if (frame.Text?.Contains("\"subscribe\"") == true && frame.Text.Contains("\"user\""))
|
||||
subscribed.TrySetResult();
|
||||
};
|
||||
};
|
||||
JsonNode post = null;
|
||||
try
|
||||
{
|
||||
await Results.Cell("client.render.stream", "privapub", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
subscribed = new();
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await subscribed.Task.WaitAsync(TimeSpan.FromSeconds(30));
|
||||
// (and the server has taken the subscription in)
|
||||
await Task.Delay(500);
|
||||
|
||||
post = await PersonaApi.Call(persona, "/api/v1/statuses", HttpMethod.Post, new JsonObject { ["status"] = text, ["visibility"] = "unlisted" });
|
||||
var fresh = page.GetByRole(AriaRole.Button, new() { Name = "1 new" });
|
||||
await fresh.WaitForAsync(new() { Timeout = 10000 });
|
||||
await fresh.ClickAsync();
|
||||
var card = page.Locator($"article[data-uri='{post!["uri"]!.GetValue<string>()}']");
|
||||
await Assertions.Expect(card).ToContainTextAsync(text, new() { Timeout = 5000 });
|
||||
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
post = null;
|
||||
await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (post is not null)
|
||||
await PersonaApi.Call(persona, $"/api/v1/statuses/{post["id"]!.GetValue<string>()}", HttpMethod.Delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A remote thread opened in decePub fills with the replies PrivaPub never received: opening it has PrivaPub read the
|
||||
// thread from its server (Mastodon's FEP-7888 context), and the page reads the thread again a few seconds later
|
||||
public sealed class ThreadTests(Browser browser)
|
||||
{
|
||||
const string Platform = "mastodon";
|
||||
const string Author = "elif_ms06";
|
||||
// followed by no persona, so Mastodon never delivers her reply to PrivaPub
|
||||
const string Outsider = "gigi_ms28";
|
||||
|
||||
static async Task<JsonNode> Drive(string user, string verb, JsonObject parameters) =>
|
||||
await Pasture.Town("drive", Platform, user, verb, parameters.ToJsonString());
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_thread_brings_the_replies_that_never_reached_privapub()
|
||||
{
|
||||
var persona = Pasture.Personas().First();
|
||||
var mark = Guid.NewGuid().ToString("N")[..8];
|
||||
var root = await Drive(Author, "post", new JsonObject { ["text"] = $"a thread to open {mark}", ["visibility"] = "public" });
|
||||
var rootUri = root["uri"]!.GetValue<string>();
|
||||
string replyUri = null;
|
||||
try
|
||||
{
|
||||
var reply = await Drive(Outsider, "post", new JsonObject
|
||||
{
|
||||
["text"] = $"said where nobody listens {mark}", ["visibility"] = "public", ["reply_to_uri"] = rootUri
|
||||
});
|
||||
replyUri = reply["uri"]!.GetValue<string>();
|
||||
var found = await PersonaApi.Call(persona, $"/api/v2/search?q={Uri.EscapeDataString(rootUri)}&resolve=true&type=statuses");
|
||||
var rootId = found!["statuses"]!.AsArray().Single()!["id"]!.GetValue<string>();
|
||||
await Task.Delay(3000);
|
||||
Assert.SkipWhen((await Pasture.Stored("privapub", replyUri))[replyUri]?["exists"]?.GetValue<bool>() == true,
|
||||
$"{Outsider}'s reply reached PrivaPub by delivery, so the thread proves nothing");
|
||||
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.render.thread-backfill", Platform, persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/expand/{rootId}");
|
||||
await page.Locator($"article[data-uri='{rootUri}']").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var answer = page.Locator($"article[data-uri='{replyUri}']");
|
||||
await Assertions.Expect(answer).ToContainTextAsync($"said where nobody listens {mark}", new() { Timeout = 25000 });
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (replyUri is not null)
|
||||
await Drive(Outsider, "delete", new JsonObject { ["uri"] = replyUri });
|
||||
await Drive(Author, "delete", new JsonObject { ["uri"] = rootUri });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A vote in another server's poll and an emoji reaction to another server's post, done in decePub and counted on that
|
||||
// server. The first of either asks the persona, once, to accept that both are public (an owner decision).
|
||||
public sealed class VoteAndReactTests(Browser browser)
|
||||
{
|
||||
static readonly Lazy<JsonArray> Expected = new(() => Pasture.Town("expect").GetAwaiter().GetResult().AsArray());
|
||||
|
||||
static TheoryData<string, string> Pick(Func<JsonNode, bool> wanted, params string[] origins)
|
||||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
var personas = Pasture.Personas().Select(p => p.UserName).ToHashSet();
|
||||
foreach (var origin in origins)
|
||||
{
|
||||
var item = Expected.Value.FirstOrDefault(i => i!["origin"]!.GetValue<string>() == origin && wanted(i)
|
||||
&& i["readers"]!.AsArray().Any(r => personas.Contains(r!.GetValue<string>())));
|
||||
if (item is not null)
|
||||
data.Add(origin, item["ref"]!.GetValue<string>());
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string, string> Polls() =>
|
||||
Pick(i => i["poll"] is JsonArray, "gts", "mastodon", "misskey", "sharkey", "akkoma", "hollo");
|
||||
|
||||
public static TheoryData<string, string> Reactable() =>
|
||||
Pick(i => i["visibility"]!.GetValue<string>() is "public" or "unlisted", "misskey", "sharkey", "akkoma");
|
||||
|
||||
|
||||
async Task<(JsonNode Item, Session Session, ILocator Card)> Open(string reference)
|
||||
{
|
||||
var item = Expected.Value.First(i => i!["ref"]!.GetValue<string>() == reference)!;
|
||||
var personas = Pasture.Personas();
|
||||
var reader = item["readers"]!.AsArray().Select(r => personas.FirstOrDefault(p => p.UserName == r!.GetValue<string>()))
|
||||
.Last(p => p is not null);
|
||||
var session = await SignIn.As(browser, reader);
|
||||
await session.Page.GotoAsync($"{Pasture.Site}/expand/{item["id"]!.GetValue<string>()}");
|
||||
var card = session.Page.Locator($"article[data-uri='{item["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
return (item, session, card);
|
||||
}
|
||||
|
||||
// the one-time notice, when this persona has not seen it yet
|
||||
internal static async Task AcceptNoticeIfAsked(IPage page)
|
||||
{
|
||||
var proceed = page.GetByRole(AriaRole.Button, new() { Name = "Continue" });
|
||||
try
|
||||
{
|
||||
await proceed.WaitForAsync(new() { Timeout = 3000 });
|
||||
await proceed.ClickAsync();
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<JsonObject> Row(string origin, string uri) => (await Pasture.Stored(origin, uri))[uri]!.AsObject();
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Polls))]
|
||||
public async Task A_vote_is_counted_on_the_polls_own_server(string origin, string reference)
|
||||
{
|
||||
var (item, session, card) = await Open(reference);
|
||||
await using var _ = session;
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
var choices = card.Locator("[data-poll] input");
|
||||
Assert.SkipWhen(await choices.CountAsync() == 0, "this persona already voted, or the poll is closed");
|
||||
await Results.Cell("client.action.vote", origin, reference, async () =>
|
||||
{
|
||||
var before = (await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0;
|
||||
await choices.First.CheckAsync();
|
||||
await card.Locator("[data-poll]").GetByRole(AriaRole.Button, new() { Name = "Vote" }).ClickAsync();
|
||||
await AcceptNoticeIfAsked(session.Page);
|
||||
await Assertions.Expect(card.Locator("[data-poll] input")).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
((await Row(origin, uri))["votes"]?.AsArray().Sum(v => v?.GetValue<int>() ?? 0) ?? 0) > before, 45),
|
||||
$"the vote never counted on {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Reactable))]
|
||||
public async Task A_reaction_reaches_the_posts_server_and_its_withdrawal_too(string origin, string reference)
|
||||
{
|
||||
var (item, session, card) = await Open(reference);
|
||||
await using var _ = session;
|
||||
var uri = item["uri"]!.GetValue<string>();
|
||||
const string emoji = "🔥";
|
||||
await Results.Cell("client.action.react", origin, reference, async () =>
|
||||
{
|
||||
static int Count(JsonObject row) => row["reactions"]?[emoji]?.GetValue<int>() ?? 0;
|
||||
var chip = card.Locator("[data-reactions] button").Filter(new() { HasText = emoji }).First;
|
||||
// a run cut short may have left the persona's reaction: start from the withdrawn state
|
||||
if (await chip.CountAsync() > 0 && ((await chip.GetAttributeAsync("class")) ?? "").Split(' ').Contains("btn-active"))
|
||||
{
|
||||
var left = Count(await Row(origin, uri));
|
||||
await chip.ClickAsync();
|
||||
await Pasture.Eventually(async () => Count(await Row(origin, uri)) < left, 60);
|
||||
}
|
||||
var before = Count(await Row(origin, uri));
|
||||
await card.Locator("footer [title='React']").First.ClickAsync();
|
||||
await card.GetByRole(AriaRole.Button, new() { Name = emoji, Exact = true }).First.ClickAsync();
|
||||
await AcceptNoticeIfAsked(session.Page);
|
||||
await Assertions.Expect(chip).ToHaveClassAsync(new System.Text.RegularExpressions.Regex("btn-active"), new() { Timeout = 15000 });
|
||||
Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) > before, 45),
|
||||
$"the reaction never reached {origin}");
|
||||
|
||||
await chip.ScrollIntoViewIfNeededAsync();
|
||||
await chip.ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => Count(await Row(origin, uri)) == before, 60),
|
||||
$"withdrawing the reaction never reached {origin}");
|
||||
await session.AssertGuards();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Actions
|
||||
{
|
||||
// A Smithereen account that follows the persona writes on its wall (FEP-400e): the card says so, and the persona takes
|
||||
// it off from the card's menu, which deletes it on Smithereen too. SocialPub's tools/pasture/scenarios/smithereen.sh
|
||||
// checks the rest of the wire.
|
||||
public sealed class WallTests(Browser browser)
|
||||
{
|
||||
// Smithereen's VKontakte-like API as smuser (the token SocialPub's peers/smithereen.sh keeps)
|
||||
static async Task<JsonNode> Smithereen(string token, string method, params (string Name, string Value)[] args)
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"https://smithereen.test/api/method/{method}")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(args.Select(a => KeyValuePair.Create(a.Name, a.Value)).Append(KeyValuePair.Create("v", "1.0")))
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await http.SendAsync(request);
|
||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_post_on_the_personas_wall_says_so_and_the_persona_takes_it_off()
|
||||
{
|
||||
var tokenFile = Path.Combine(Pasture.Root, ".state", "smithereen", "smuser.token");
|
||||
Assert.SkipUnless(File.Exists(tokenFile), "no Smithereen: run tools/pasture/run.sh add smithereen");
|
||||
var token = File.ReadAllText(tokenFile).Trim();
|
||||
// (one whose follows need no approval)
|
||||
var persona = default(Pasture.Persona);
|
||||
var me = default(JsonNode);
|
||||
foreach (var candidate in Pasture.Personas())
|
||||
{
|
||||
me = await PersonaApi.Call(candidate, "/api/v1/accounts/verify_credentials");
|
||||
if (me?["locked"]?.GetValue<bool>() == false)
|
||||
{
|
||||
persona = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.SkipWhen(persona is null, "every persona of the town is locked");
|
||||
var actorUri = me["url"]!.GetValue<string>().Replace("/@", "/peasants/");
|
||||
// (its profile told again, so Smithereen reads its wall; smuser follows it, as its followers may write there)
|
||||
await PersonaApi.Call(persona, "/api/v1/accounts/update_credentials", HttpMethod.Patch, new JsonObject());
|
||||
var personaOnSm = (await Smithereen(token, "utils.loadRemoteObject", ("q", actorUri)))?["response"]?["id"]?.ToString();
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(personaOnSm), "Smithereen cannot resolve the persona");
|
||||
await Smithereen(token, "friends.add", ("user_id", personaOnSm));
|
||||
try
|
||||
{
|
||||
var smuser = (await PersonaApi.Call(persona, "/api/v2/search?q=smuser%40smithereen.test&resolve=true&type=accounts"))!["accounts"]![0]!["id"]!.GetValue<string>();
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await PersonaApi.Call(persona, $"/api/v1/accounts/relationships?id[]={smuser}"))![0]!["followed_by"]!.GetValue<bool>(), 45), "smuser never followed the persona");
|
||||
|
||||
var text = $"a word on the wall {Guid.NewGuid():N}"[..32];
|
||||
// (Smithereen lets its followers write there once the persona's Accept has reached it, which may be after
|
||||
// PrivaPub counts the follow)
|
||||
var written = default(string);
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
!string.IsNullOrEmpty(written = (await Smithereen(token, "wall.post", ("owner_id", personaOnSm), ("message", text)))?["response"]?.ToString()), 30),
|
||||
"Smithereen refused smuser's post on the persona's wall");
|
||||
JsonNode status = null;
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(status = (await PersonaApi.Call(persona, "/api/v1/timelines/home?limit=40"))!.AsArray()
|
||||
.FirstOrDefault(s => s!["content"]!.GetValue<string>().Contains(text))) is not null, 60), "the post on the wall never reached the persona");
|
||||
Assert.Equal(me["id"]!.GetValue<string>(), status!["privapub"]?["wall"]?["id"]?.GetValue<string>());
|
||||
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.wall", "smithereen", persona.UserName, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
await page.EvaluateAsync("() => localStorage.removeItem('FeedFilter')");
|
||||
await page.GotoAsync($"{Pasture.Site}/");
|
||||
var card = page.Locator($"article[data-uri='{status["uri"]!.GetValue<string>()}']").First;
|
||||
await card.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
await Assertions.Expect(card.Locator("[data-wall]")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(card.Locator("[data-wall]")).ToContainTextAsync(me["display_name"]!.GetValue<string>() is { Length: > 0 } name ? name : me["username"]!.GetValue<string>());
|
||||
await card.Locator("footer [title='Other'] > [role=button]").ClickAsync();
|
||||
await card.Locator("button[data-take-off-wall]").ClickAsync();
|
||||
await session.Page.Locator("[data-confirm-yes]").ClickAsync();
|
||||
Assert.True(await Pasture.Eventually(async () => await PersonaApi.Call(persona, $"/api/v1/statuses/{status["id"]!.GetValue<string>()}") is null, 15),
|
||||
"the post is still on PrivaPub");
|
||||
Assert.True(await Pasture.Eventually(async () =>
|
||||
(await Smithereen(token, "wall.getById", ("posts", written)))?["response"] is not JsonArray { Count: > 0 }, 30), "the post is still on Smithereen");
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// (the town as it was: smuser no longer follows the persona)
|
||||
await Smithereen(token, "friends.delete", ("user_id", personaOnSm));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Login
|
||||
{
|
||||
public sealed class LoginTests(Browser browser)
|
||||
{
|
||||
static Pasture.Persona WithSiblings() =>
|
||||
Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
||||
|
||||
[Fact]
|
||||
public async Task A_root_signs_in_chooses_a_persona_and_reads_its_home()
|
||||
{
|
||||
var persona = WithSiblings();
|
||||
await using var session = await SignIn.As(browser, persona, fresh: true);
|
||||
|
||||
await session.Page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var home = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home").ToList();
|
||||
Assert.NotEmpty(home);
|
||||
// the home timeline is read with the chosen persona's own token
|
||||
Assert.Contains(home, c => c.Authorization == "Bearer " + persona.Token || c.Authorization is not null);
|
||||
await session.AssertGuards();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_persona_reads_the_other_personas_home_with_its_own_token()
|
||||
{
|
||||
var persona = WithSiblings();
|
||||
var sibling = Pasture.Personas().First(p => p.Root == persona.Root && p.UserName != persona.UserName);
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var before = session.ApiCalls.Count;
|
||||
|
||||
await session.Page.Locator("nav [title='Switch persona']").First.ClickAsync();
|
||||
await session.Page.Locator("nav").GetByRole(AriaRole.Button).Filter(new() { HasText = sibling.Name ?? sibling.UserName }).First.ClickAsync();
|
||||
|
||||
// the sibling's home, read with another token than the first persona's (whose own home may still be loading when the
|
||||
// switch is asked for)
|
||||
string FirstToken() => session.ApiCalls.FirstOrDefault(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null).Authorization;
|
||||
Assert.True(await Pasture.Eventually(() => Task.FromResult(session.ApiCalls.Skip(before)
|
||||
.Any(c => new Uri(c.Url).AbsolutePath == "/api/v1/timelines/home" && c.Authorization is not null && c.Authorization != FirstToken())), 20),
|
||||
"the sibling's home was never read with its own token");
|
||||
await session.AssertGuards();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logging_out_revokes_every_persona_token()
|
||||
{
|
||||
var persona = WithSiblings();
|
||||
await using var session = await SignIn.As(browser, persona, fresh: true);
|
||||
var tokens = session.ApiCalls.Where(c => new Uri(c.Url).AbsolutePath.StartsWith("/api/") && c.Authorization is not null)
|
||||
.Select(c => c.Authorization).Distinct().ToList();
|
||||
Assert.NotEmpty(tokens);
|
||||
|
||||
await session.Page.GotoAsync(Pasture.Site + "/logout");
|
||||
await session.Page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/login", new() { Timeout = 30000 });
|
||||
|
||||
using var http = Pasture.Http();
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, Pasture.Api + "/api/v1/accounts/verify_credentials");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", token);
|
||||
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.SendAsync(request)).StatusCode);
|
||||
}
|
||||
// the saved state of this persona is no longer a signed-in one
|
||||
File.Delete(Path.Combine(AppContext.BaseDirectory, ".state", persona.UserName + ".json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Login
|
||||
{
|
||||
// The two ways in now that sign-up is closed: the server's first login, made from the client while it has no
|
||||
// administrator, and a login made through a group's invitation
|
||||
public sealed class SignUpTests(Browser browser)
|
||||
{
|
||||
const string Password = "Pasture-Pass-1!";
|
||||
|
||||
static async Task<bool> SetupPending()
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
return JsonNode.Parse(await http.GetStringAsync(Pasture.Api + "/clientapi/user/setup"))!["isPending"]!.GetValue<bool>();
|
||||
}
|
||||
|
||||
static async Task<string> Jwt(string root, string password)
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
using var login = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/login", new { userName = root, password });
|
||||
login.EnsureSuccessStatusCode();
|
||||
return (await login.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
||||
}
|
||||
|
||||
static async Task<JsonNode> Send(HttpMethod method, string path, string jwt, object body = null)
|
||||
{
|
||||
using var http = Pasture.Http();
|
||||
using var request = new HttpRequestMessage(method, Pasture.Api + path) { Content = body is null ? null : JsonContent.Create(body) };
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
using var response = await http.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return JsonNode.Parse(await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_first_login_sets_the_server_up_and_becomes_its_administrator()
|
||||
{
|
||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
||||
var root = $"e2eadmin{stamp}";
|
||||
var persona = $"e2efounder{stamp}";
|
||||
// the pasture's administrator closed setup: it is opened for this test, and closed again whatever happens
|
||||
await Pasture.Mongo("db.ServerSetup.deleteOne({_id: 'setup'})");
|
||||
try
|
||||
{
|
||||
Assert.True(await SetupPending());
|
||||
await using var session = await browser.Open();
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.setup", "privapub", root, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/login");
|
||||
await page.Locator("[data-setup-offer]").ClickAsync();
|
||||
await page.Locator("input[data-setup-username]").FillAsync(root);
|
||||
await page.Locator("input[data-setup-password]").FillAsync(Password);
|
||||
await page.Locator("input[data-setup-repeat]").FillAsync(Password);
|
||||
await page.Locator("input[data-setup-persona-username]").FillAsync(persona);
|
||||
await page.Locator("input[data-setup-persona-name]").FillAsync("The e2e founder");
|
||||
await page.Locator("[data-setup-submit]").ClickAsync();
|
||||
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
await page.Locator("nav a[href='administration']").First.WaitForAsync(new() { Timeout = 15000 });
|
||||
Assert.False(await SetupPending());
|
||||
var jwt = await Jwt(root, Password);
|
||||
var personas = (await Send(HttpMethod.Get, "/clientapi/avatar/private/list", jwt))!.AsArray();
|
||||
Assert.Equal(persona, Assert.Single(personas)!["userName"]!.GetValue<string>());
|
||||
await session.AssertGuards();
|
||||
|
||||
// set up: a reader without a login is offered sign-in and invitations only
|
||||
await using var stranger = await browser.Open();
|
||||
await stranger.Page.GotoAsync($"{Pasture.Site}/setup");
|
||||
await stranger.Page.Locator("[data-setup-done]").WaitForAsync(new() { Timeout = 15000 });
|
||||
await stranger.Page.GotoAsync($"{Pasture.Site}/login");
|
||||
await stranger.Page.Locator("[data-join-offer]").WaitForAsync(new() { Timeout = 15000 });
|
||||
Assert.Equal(0, await stranger.Page.Locator("[data-setup-offer]").CountAsync());
|
||||
}, page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// the pasture's administrator stays pastureroot: the test's is demoted, and setup stays closed
|
||||
await Pasture.Mongo($"db.RootUser.updateOne({{UserName: '{root}'}}, {{$set: {{Policies: ['isUser']}}}}); " +
|
||||
"if (!db.ServerSetup.findOne({_id: 'setup'})) db.ServerSetup.insertOne({_id: 'setup', AdminId: 'e2e', SealedAt: new Date()})");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_invitation_link_makes_a_login_whose_persona_joins_the_group()
|
||||
{
|
||||
var stamp = DateTime.UtcNow.ToString("MMddHHmmss");
|
||||
// the circle's owner is a login of the test's own (the pasture's sign-up is open), so the town's sessions don't matter
|
||||
var ownerJwt = default(string);
|
||||
using (var http = Pasture.Http())
|
||||
{
|
||||
using var signup = await http.PostAsJsonAsync(Pasture.Api + "/clientapi/user/signup", new { userName = $"e2eowner{stamp}", password = Password });
|
||||
Assert.SkipWhen(!signup.IsSuccessStatusCode, $"PrivaPub refuses sign-ups ({(int)signup.StatusCode})");
|
||||
ownerJwt = (await signup.Content.ReadFromJsonAsync<JsonObject>())!["token"]!.GetValue<string>();
|
||||
}
|
||||
var ownerId = (await Send(HttpMethod.Post, "/clientapi/avatar/private/insert", ownerJwt,
|
||||
new { userName = $"e2ehost{stamp}", name = "An e2e host", biography = "for the invitation test" }))!["id"]!.GetValue<string>();
|
||||
var group = await Send(HttpMethod.Post, "/clientapi/group/insert", ownerJwt,
|
||||
new { avatarId = ownerId, userName = $"e2ecircle{stamp}", name = "An e2e circle", isCommunity = false });
|
||||
var code = group!["invitationCode"]!.GetValue<string>();
|
||||
var root = $"e2ejoin{stamp}";
|
||||
var persona = $"e2eguest{stamp}";
|
||||
await using var session = await browser.Open();
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.action.invitation", "privapub", root, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/join?code={Uri.EscapeDataString(code)}");
|
||||
Assert.Equal(code, await page.Locator("input[data-join-code]").InputValueAsync());
|
||||
await page.Locator("input[data-join-username]").FillAsync(root);
|
||||
await page.Locator("input[data-join-password]").FillAsync(Password);
|
||||
await page.Locator("input[data-join-repeat]").FillAsync(Password);
|
||||
await page.Locator("input[data-join-persona-username]").FillAsync(persona);
|
||||
await page.Locator("[data-join-submit]").ClickAsync();
|
||||
|
||||
await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/", new() { Timeout = 30000 });
|
||||
await page.Locator("article[data-uri], [data-feed-empty]").First.WaitForAsync(new() { Timeout = 30000 });
|
||||
var members = (await Send(HttpMethod.Get,
|
||||
$"/clientapi/group/members?avatarId={Uri.EscapeDataString(ownerId)}&groupId={Uri.EscapeDataString(group["id"]!.GetValue<string>())}", ownerJwt))!.AsArray();
|
||||
Assert.Contains(members, m => (m!["handle"]?.GetValue<string>() ?? "").Contains(persona) || m["name"]?.GetValue<string>() == persona);
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// Controls the sweep doesn't reach, since it measures pages with their sections closed: Settings' Data import/export
|
||||
// opened on the narrowest phone, its file inputs and checkboxes thumb-sized and drawn like the rest of the app (a
|
||||
// ticked box filled, not a tick on nothing), in both themes. Photos go to tests/e2e/out/mobile-controls/.
|
||||
[Trait("Category", "Mobile")]
|
||||
public sealed class ControlsTests(Browser browser)
|
||||
{
|
||||
static readonly Phone Phone = Phone.Narrow360;
|
||||
static readonly string OutDir = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "out", "mobile-controls");
|
||||
|
||||
[Fact]
|
||||
public async Task Data_transfer_controls_are_thumb_sized_and_drawn_like_the_rest()
|
||||
{
|
||||
await using var session = await SignIn.As(browser, Pasture.Personas().First(), Phone);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.mobile.controls.data-transfer", "privapub", Phone.Key, async () =>
|
||||
{
|
||||
await page.GotoAsync($"{Pasture.Site}/settings");
|
||||
var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
|
||||
await header.TapAsync();
|
||||
var archive = page.Locator("[data-archive]");
|
||||
await archive.WaitForAsync(new() { Timeout = 30000 });
|
||||
|
||||
Fit.AssertTouchable(await Fit.Now(page, "[data-archive]"));
|
||||
Fit.AssertTouchable(await Fit.Now(page, "[data-import]"));
|
||||
|
||||
// a ticked box is filled: daisyUI leaves it transparent unless a colour asks otherwise
|
||||
var ticked = archive.Locator("input[type=checkbox]:checked").First;
|
||||
var fill = await ticked.EvaluateAsync<string>("e => getComputedStyle(e).backgroundColor");
|
||||
Assert.False(fill is "transparent" or "rgba(0, 0, 0, 0)", $"a ticked checkbox is drawn on nothing ({fill})");
|
||||
// a file input is as tall as the other fields on a touch screen
|
||||
foreach (var input in await page.Locator("[data-archive] input[type=file], [data-import] input[type=file]").AllAsync())
|
||||
Assert.True((await input.BoundingBoxAsync())!.Height >= 44, $"a file input is {(await input.BoundingBoxAsync())!.Height}px tall: "
|
||||
+ await input.EvaluateAsync<string>("e => { const s = getComputedStyle(e); return `${e.className} --size=${s.getPropertyValue('--size')} height=${s.height} coarse=${matchMedia('(pointer: coarse)').matches}`; }"));
|
||||
// photos the size of the screen: one of an element taller than the screen makes Playwright resize the page,
|
||||
// which drops the touch emulation for what comes after
|
||||
Directory.CreateDirectory(OutDir);
|
||||
foreach (var theme in new[] { "neo-dark", "neo-light" })
|
||||
{
|
||||
await page.EvaluateAsync("t => document.documentElement.setAttribute('data-theme', t)", theme);
|
||||
foreach (var (part, what) in new[] { ("archive", "[data-archive-make]"), ("restore", "input[data-archive-file]"), ("csv", "[data-import]") })
|
||||
{
|
||||
await page.Locator(what).First.EvaluateAsync("e => e.scrollIntoView({ block: 'start' })");
|
||||
await page.ScreenshotAsync(new() { Path = Path.Combine(OutDir, $"{part}-{theme}.png"), Animations = ScreenshotAnimations.Disabled });
|
||||
}
|
||||
}
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using decePubClient.E2E.Support;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// What phones get stays on phones: at 1440x900 with a mouse the controls keep daisyUI's sizes, measured on probes made
|
||||
// with the app's own classes, and the nav menu stays open beside the feed.
|
||||
[Trait("Category", "Mobile")]
|
||||
public sealed class DesktopUnchangedTests(Browser browser)
|
||||
{
|
||||
const string Probe = """
|
||||
() => ["btn", "btn btn-sm", "btn btn-xs", "input", "input input-sm"].map(classes => {
|
||||
const el = document.createElement(classes.startsWith("input") ? "input" : "button")
|
||||
el.className = classes
|
||||
document.body.append(el)
|
||||
const height = el.getBoundingClientRect().height
|
||||
el.remove()
|
||||
return height
|
||||
})
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Desktop_keeps_its_sizes()
|
||||
{
|
||||
var persona = Pasture.Personas().GroupBy(p => p.Root).OrderByDescending(g => g.Count()).First().First();
|
||||
await using var session = await SignIn.As(browser, persona);
|
||||
var page = session.Page;
|
||||
await Results.Cell("client.mobile.desktop", "privapub", "desktop", async () =>
|
||||
{
|
||||
Assert.False(await page.EvaluateAsync<bool>("() => matchMedia('(pointer: coarse)').matches"), "the desktop session has a coarse pointer");
|
||||
// btn, btn btn-sm, btn btn-xs, input, input input-sm
|
||||
var heights = await page.EvaluateAsync<double[]>(Probe);
|
||||
Assert.Equal([40, 32, 24, 40, 32], heights.Select(h => Math.Round(h)).ToArray());
|
||||
await Assertions.Expect(page.Locator("nav a.btn").First).ToBeVisibleAsync();
|
||||
await session.AssertGuards();
|
||||
}, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace decePubClient.E2E.Mobile
|
||||
{
|
||||
// The phone sweep: a page measured and photographed one screen at a time down its main scroller (fit.js measures),
|
||||
// what doesn't fit kept as violations.json next to the screenshots, and a contact sheet of every route at every phone
|
||||
// in tests/e2e/out/mobile/<run>/index.html, for the eye.
|
||||
public static class Fit
|
||||
{
|
||||
public static readonly string Script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Mobile", "fit.js"));
|
||||
|
||||
static readonly string RunDir = Path.Combine(TestsRoot(), "out", "mobile", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"));
|
||||
static readonly object SheetGate = new();
|
||||
|
||||
static string TestsRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.E2E.csproj")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
return dir ?? throw new InvalidOperationException("decePubClient.E2E.csproj not found above the test output");
|
||||
}
|
||||
|
||||
// what a sweep found, each finding once however many screens showed it
|
||||
public sealed class Report
|
||||
{
|
||||
readonly HashSet<string> seen = [];
|
||||
public List<JsonObject> Overflow { get; } = [];
|
||||
public List<JsonObject> Beyond { get; } = [];
|
||||
public List<JsonObject> Popovers { get; } = [];
|
||||
public List<JsonObject> Small { get; } = [];
|
||||
public List<JsonObject> Tiny { get; } = [];
|
||||
public int Screens { get; set; }
|
||||
|
||||
public void Take(JsonNode measured, int screen)
|
||||
{
|
||||
Add(Overflow, measured["overflow"], "overflow", screen);
|
||||
Add(Beyond, measured["beyond"], "beyond", screen);
|
||||
Add(Popovers, measured["popovers"], "popover", screen);
|
||||
Add(Small, measured["small"], "small", screen);
|
||||
Add(Tiny, measured["tiny"], "tiny", screen);
|
||||
}
|
||||
|
||||
void Add(List<JsonObject> into, JsonNode found, string kind, int screen)
|
||||
{
|
||||
foreach (var item in found!.AsArray().Select(i => i!.AsObject()))
|
||||
{
|
||||
var key = $"{kind}|{item["element"]}|{item["text"]}|{item["card"]}";
|
||||
if (!seen.Add(key))
|
||||
continue;
|
||||
var copy = Clean(item.DeepClone()).AsObject();
|
||||
copy["screen"] = screen;
|
||||
into.Add(copy);
|
||||
}
|
||||
}
|
||||
|
||||
// Playwright marks the objects it hands back with $id; the findings don't need them
|
||||
static JsonNode Clean(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
obj.Remove("$id");
|
||||
foreach (var (_, value) in obj)
|
||||
Clean(value);
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
foreach (var value in array)
|
||||
Clean(value);
|
||||
return node;
|
||||
}
|
||||
|
||||
public string Json() => new JsonObject
|
||||
{
|
||||
["screens"] = Screens,
|
||||
["overflow"] = new JsonArray(Overflow.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["beyond"] = new JsonArray(Beyond.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["popovers"] = new JsonArray(Popovers.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["small"] = new JsonArray(Small.Select(o => (JsonNode)o.DeepClone()).ToArray()),
|
||||
["tiny"] = new JsonArray(Tiny.Select(o => (JsonNode)o.DeepClone()).ToArray())
|
||||
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
public static Task Settle(IPage page) => page.EvaluateAsync("() => window.__fit.settle()");
|
||||
|
||||
public static async Task<Report> Sweep(IPage page, Support.Phone phone, string slug, int screens)
|
||||
{
|
||||
var dir = Path.Combine(RunDir, phone.Key, slug);
|
||||
Directory.CreateDirectory(dir);
|
||||
var report = new Report();
|
||||
await Settle(page);
|
||||
await page.EvaluateAsync("() => window.__fit.primary()");
|
||||
for (var screen = 0; screen < screens; screen++)
|
||||
{
|
||||
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("() => window.__fit.measure()")).GetRawText())!;
|
||||
report.Take(measured, screen);
|
||||
await page.ScreenshotAsync(new()
|
||||
{
|
||||
Path = Path.Combine(dir, $"{screen:00}.png"), Animations = ScreenshotAnimations.Disabled, Caret = ScreenshotCaret.Hide
|
||||
});
|
||||
report.Screens = screen + 1;
|
||||
if (!await page.EvaluateAsync<bool>("() => window.__fit.step(0.8)"))
|
||||
break;
|
||||
await Settle(page);
|
||||
}
|
||||
await File.WriteAllTextAsync(Path.Combine(dir, "violations.json"), report.Json());
|
||||
ContactSheet();
|
||||
return report;
|
||||
}
|
||||
|
||||
// one measurement of the page as it is now; with a scope (an open menu, sheet or dialog), its targets only
|
||||
public static async Task<Report> Now(IPage page, string scope = null)
|
||||
{
|
||||
await Settle(page);
|
||||
var report = new Report();
|
||||
var measured = JsonNode.Parse((await page.EvaluateAsync<JsonElement>("scope => window.__fit.measure(scope)", scope)).GetRawText())!;
|
||||
report.Take(measured, 0);
|
||||
report.Screens = 1;
|
||||
return report;
|
||||
}
|
||||
|
||||
// nothing scrolls sideways, nothing lies past the screen's sides, and every open menu, dialog or tip is whole
|
||||
public static void AssertFits(Report report)
|
||||
{
|
||||
var problems = report.Overflow.Select(o => $"sideways scroll in {o["element"]} (+{o["extra"]}px), furthest: {o["culprit"]?.ToJsonString()}")
|
||||
.Concat(report.Beyond.Select(b => $"past the screen: {b.ToJsonString()}"))
|
||||
.Concat(report.Popovers.Select(p => $"cut or outside: {p.ToJsonString()}"))
|
||||
.ToList();
|
||||
Assert.True(problems.Count == 0, $"{problems.Count} fit problems:\n" + string.Join("\n", problems.Take(12)));
|
||||
}
|
||||
|
||||
// every target a thumb can reach has a 44px hit area, and no control's text is under 12px
|
||||
public static void AssertTouchable(Report report)
|
||||
{
|
||||
var problems = report.Small.Select(s => $"small target: {s.ToJsonString()}")
|
||||
.Concat(report.Tiny.Select(t => $"small text: {t.ToJsonString()}"))
|
||||
.ToList();
|
||||
Assert.True(problems.Count == 0, $"{problems.Count} touch problems:\n" + string.Join("\n", problems.Take(12)));
|
||||
}
|
||||
|
||||
// every route as a row, every phone as a column: the first screen and how many findings
|
||||
static void ContactSheet()
|
||||
{
|
||||
lock (SheetGate)
|
||||
{
|
||||
var phones = Directory.GetDirectories(RunDir).Select(Path.GetFileName).Order().ToList();
|
||||
var routes = phones.SelectMany(p => Directory.GetDirectories(Path.Combine(RunDir, p)).Select(Path.GetFileName)).Distinct().Order().ToList();
|
||||
var html = new StringBuilder("<!doctype html><meta charset=utf-8><title>decePub on phones</title>")
|
||||
.Append("<style>body{font:13px system-ui;background:#222;color:#ddd}td{vertical-align:top;padding:6px}img{width:180px;display:block}")
|
||||
.Append("a{color:#9cf}.bad{color:#f88}</style><table><tr><th></th>");
|
||||
foreach (var phone in phones)
|
||||
html.Append("<th>").Append(WebUtility.HtmlEncode(phone)).Append("</th>");
|
||||
html.Append("</tr>");
|
||||
foreach (var route in routes)
|
||||
{
|
||||
html.Append("<tr><th>").Append(WebUtility.HtmlEncode(route)).Append("</th>");
|
||||
foreach (var phone in phones)
|
||||
{
|
||||
var dir = Path.Combine(RunDir, phone, route);
|
||||
html.Append("<td>");
|
||||
if (File.Exists(Path.Combine(dir, "violations.json")))
|
||||
{
|
||||
var found = JsonNode.Parse(File.ReadAllText(Path.Combine(dir, "violations.json")))!;
|
||||
var fit = found["overflow"]!.AsArray().Count + found["beyond"]!.AsArray().Count + found["popovers"]!.AsArray().Count;
|
||||
var touch = found["small"]!.AsArray().Count + found["tiny"]!.AsArray().Count;
|
||||
var link = $"{phone}/{route}";
|
||||
html.Append($"<a href=\"{link}/00.png\"><img src=\"{link}/00.png\"></a>")
|
||||
.Append($"<span class=\"{(fit + touch > 0 ? "bad" : "")}\">fit {fit} · touch {touch} · {found["screens"]} screens</span> ")
|
||||
.Append($"<a href=\"{link}/violations.json\">findings</a>");
|
||||
}
|
||||
html.Append("</td>");
|
||||
}
|
||||
html.Append("</tr>");
|
||||
}
|
||||
File.WriteAllText(Path.Combine(RunDir, "index.html"), html.Append("</table>").ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user