4 Commits
Author SHA1 Message Date
thepraandClaude Opus 5.5 b731d8c8c0 R3 two-factor in decePub: an authenticator app set up in Settings, its code asked for at sign-in
Build / Build (push) Successful in 1m3s
Completion program R3, with PrivaPub's TOTP routes of the same release.

- Settings, the login: TotpEditor turns two-factor sign-in on (the password, then the QR code, the secret and the
  otpauth:// link, then the app's first code; the ten recovery codes shown once with Copy), shows how many recovery
  codes are left (warned at three), and turns it off with the password and a code, asked first.
- Turning it on, like a password change, ends every other session: AuthService.ContinueSession takes the new JWT and
  exchanges the persona's token again.
- Sign-in: PrivaPub's 428 keeps the name and password and asks for the six digits (DOtp), or a recovery code instead;
  a wrong code says so; Back returns to the password.
- window.copyText (main.js) through AppStatusService.CopyText.
- 29 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:33:46 +02:00
thepraandClaude Opus 5.5 7d4b35f0d7 R2 accounts in decePub: forgotten password, email by code, sessions, the theme kept with the login
Completion program R2, with PrivaPub's routes of the same release.

- Pages/PasswordRecovery.razor (/password-recovery): asks for a login or an address and shows the server's one
  sentence; with ?rc= it takes the code out of the address bar at once, checks it and sets a new password. Login links
  there ("Forgot your password?").
- Settings, the login: its email address with a verified badge, changed with the password and the code emailed to it
  (DOtp), removed with the password; "Sign out everywhere" (every device and persona, asked first).
- A password change takes the new JWT PrivaPub answers and exchanges the persona's token again, since every persona
  token ended with it.
- The theme kept with the login (PageSettings.SyncWithLogin, Settings > General): taken at sign-in and start-up once a
  client saved it, saved 1.5 s after a change here, never posted back when nothing changed, never the language.
- Login, Setup and Join start the session through CascadingState.SignedIn, which takes the login's theme.
- 38 keys in both resx files; CLAUDE.md follows.

Verified: dotnet build decePubClient.csproj -c Release (0 errors, no new warnings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 03:21:59 +02:00
thepraandClaude Opus 5.5 fd2f2e7c65 R1 cleanup: collAnon and dead code gone, no IndexedDB, honest PWA files
Build / Build (push) Successful in 51s
Starts the completion program (SocialPub docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- The localizer's namespace is decePubClient.Services; collAnon is gone from the repo.
- Dead code: PagesBase, VConstants, SettingsOptions, ViewLanguage, unused APIs routes, InstanceService.Self,
  RequestStats.LastOf/AverageMs, PostAnon/DeleteTotallyAnon, unused AppStatusService methods and their main.js helpers
  (the app badge stays for Web Push), ExtensionMethods/SUtility leftovers (Markdig and BlazorZXingJs packages go),
  unused PublicCacheData/PageSettings/AppConfiguration fields, commented-out code.
- The client log in IndexedDB, which nothing ever read, goes with the whole IndexedDB layer (DnetIndexedDb, IStorage):
  logs go to the browser console; main.js deletes the old "data" database once.
- The solution names the real ClientModels project. Unused vendor assets (fontawesome, solid, open-iconic, rxjs) go.
- robots.txt disallows everything; manifest.json and index.html say decePub; the service worker's cache name follows
  each publish's assets version, so old caches are purged.
- MessageType is Visibility (member names kept: they are resx keys); ExpandMessage.razor is Thread.razor (same route).
- i18n: dead keys removed, Italian "camera" is "punto di vista", DInputBase/DCalendar validation messages come from
  ErrorMessages, DataTransfer's spinner has its label.
- CLAUDE.md follows: client storage, JS interop, PWA, the D fields' messages.

Verified: dotnet build decePubClient.csproj and decePubClient.sln -c Release (0 errors, no new warnings); a Pasture
publish served by the pasture's decepub peer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 02:56:57 +02:00
thepraandClaude Opus 5.5 e4414f1ac9 No test project: tests/e2e goes
Build / Build (push) Successful in 42s
Owner decision 2026-10-17, with SocialPub's PrivaPub.Tests. The e2e project leaves the solution, the csproj's
exclusions, Tailwind's sources and .gitignore; comments no longer point at it, and CLAUDE.md's e2e section becomes
Verifying: a publish with the Pasture environment, which SocialPub's tools/pasture/peers/decepub.sh serves as
https://decepub.test. appsettings.Pasture.json stays for that, and the deploy still fails if it ever ships. The cards'
data-* hooks stay in the markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:28:34 +02:00
136 changed files with 1914 additions and 11424 deletions

No files matched your search

-3
View File
@@ -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/
+69 -79
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+8 -8
View File
@@ -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"]
};
+3 -1
View File
@@ -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;
}
}
+1 -1
View File
@@ -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;
}
+242 -3
View File
@@ -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;
+1 -1
View File
@@ -14,7 +14,7 @@
data-export-kind="@data">
@if (exporting == data)
{
<DLoading Size="DSize.Xs" />
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
else
{
+2 -2
View File
@@ -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();
+306
View File
@@ -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."]);
}
}
-704
View File
@@ -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"
}
]
}
-68
View File
@@ -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")
+7 -34
View File
@@ -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;
-54
View File
@@ -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;
}
}
}
}
+101 -2
View File
@@ -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;
-6
View File
@@ -1,6 +0,0 @@
namespace decePubClient.LayerComponents
{
public class PagesBase : LocalizableComponentBase
{
}
}
+13 -4
View File
@@ -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)}";
-1
View File
@@ -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";
-37
View File
@@ -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; }
}
}
+6 -6
View File
@@ -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
View File
@@ -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;
}
}
}
+5 -5
View File
@@ -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 -11
View File
@@ -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;
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace decePubClient.Models
{
public class SettingsOptions
{
public bool IsOpen { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace decePubClient.Models.Types
{
public enum MessageType
{
Direct = 1,
FollowersOnly,
Unlisted,
Public
}
}
+11
View File
@@ -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
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace decePubClient.Models
{
public static class VConstants
{
public const string HideClass = "hidden";
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace decePubClient.Models
{
public class ViewLanguage
{
public string Name { get; set; }
public string International2Code { get; set; }
}
}
+1 -2
View File
@@ -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
View File
@@ -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("");
+92 -17
View File
@@ -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,20 +27,48 @@
<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>
<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>
@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">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<Prefix><i class="ion-md-key"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Username"]</span>
<DInput TValue="string" class="w-full" @bind-Value="form.UserName" autocomplete="username" autocapitalize="none" spellcheck="false">
<Prefix><i class="ion-md-person"></i></Prefix>
</DInput>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">@Localizer["Password"]</span>
<DInput TValue="string" Type="password" class="w-full" @bind-Value="form.Password" autocomplete="current-password">
<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>
+216
View File
@@ -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");
}
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+18
View File
@@ -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>
+6
View File
@@ -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>
+6
View File
@@ -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>
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
+5 -26
View File
@@ -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);
}
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
+249 -5
View File
@@ -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&lt;string&gt;), 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)
+1 -1
View File
@@ -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
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
-16
View File
@@ -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 -35
View File
@@ -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)
public ValueTask ProcessError(Exception exception, string where, CancellationToken cancellationToken = default)
{
try
{
logger.LogError(exception, "[{Where}]", where);
await storage.AddLog(exception, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
}
logger.LogError(exception, "[{Where}]", where);
return ValueTask.CompletedTask;
}
public async ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
public ValueTask ProcessError(string message, string where, CancellationToken cancellationToken = default)
{
try
{
logger.LogError("[{Where}] {Message}", where, message);
await storage.AddLog(message, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessError)}");
}
logger.LogError("[{Where}] {Message}", where, message);
return ValueTask.CompletedTask;
}
public async ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
public ValueTask ProcessWarning(string message, string where, CancellationToken cancellationToken = default)
{
try
{
logger.LogWarning("[{Where}] {Message}", where, message);
await storage.AddLog(message, where, cancellationToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"{nameof(LoggingService)}.{nameof(ProcessWarning)}");
}
logger.LogWarning("[{Where}] {Message}", where, message);
return ValueTask.CompletedTask;
}
}
-123
View File
@@ -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);
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
using DnetIndexedDb;
using Microsoft.JSInterop;
namespace decePubClient.Services
{
public class IndexedDb(IJSRuntime jsRuntime, IndexedDbOptions<IndexedDb> options) : IndexedDbInterop(jsRuntime, options)
{
}
}
-6
View File
@@ -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)
{
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
-2
View File
@@ -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
View File
@@ -1,4 +1,3 @@
using collAnon.Client.Services;
using System.Text;
using decePubClient.Extensions;
-10
View File
@@ -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)
{
+4 -6
View File
@@ -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,
-2
View File
@@ -1,7 +1,5 @@
using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
+12 -4
View File
@@ -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());
}
-18
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
-67
View File
@@ -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);
}
}
}
-161
View File
@@ -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();
});
}
}
}
-73
View File
@@ -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);
}
}
}
-63
View File
@@ -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);
}
}
}
-138
View File
@@ -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();
}
}
}
-141
View File
@@ -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);
}
}
}
}
-58
View File
@@ -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);
}
}
}
}
-111
View File
@@ -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);
}
}
}
-70
View File
@@ -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();
}
}
}
-57
View File
@@ -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);
}
}
}
-72
View File
@@ -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);
}
}
}
}
-58
View File
@@ -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);
}
}
}
-115
View File
@@ -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);
}
}
}
-184
View File
@@ -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);
}
}
}
}
-52
View File
@@ -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);
}
}
}
}
-82
View File
@@ -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}})");
}
}
}
}
-55
View File
@@ -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);
}
}
}
}
-118
View File
@@ -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);
}
}
}
-84
View File
@@ -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);
}
}
}
}
-208
View File
@@ -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);
}
}
}
-66
View File
@@ -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);
}
}
}
}
-61
View File
@@ -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 });
}
}
}
}
-123
View File
@@ -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();
});
}
}
}
-101
View File
@@ -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));
}
}
}
}
-69
View File
@@ -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"));
}
}
}
-131
View File
@@ -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);
}
}
}
-56
View File
@@ -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);
}
}
}
-40
View File
@@ -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);
}
}
}
-182
View File
@@ -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