Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b5fe10a9 | ||
|
|
125414df2c | ||
|
|
5020c031a3 | ||
|
|
048eb15bb7 | ||
|
|
9b792042e6 | ||
|
|
87529bca49 | ||
|
|
d08fbabaa5 | ||
|
|
9f68ca3ed0 | ||
|
|
70d1ffe9a2 | ||
|
|
f67e2ae35e | ||
|
|
1f3bb27e17 | ||
|
|
cff75e3a47 | ||
|
|
ff5e635dda | ||
|
|
6727e1817a | ||
|
|
c42229f78c | ||
|
|
8156c1927c | ||
|
|
088c54ddf3 | ||
|
|
6fccb6fe35 | ||
|
|
3b3f1f6b93 | ||
|
|
62189bf11d | ||
|
|
fb4949b511 | ||
|
|
d4b1788a4f | ||
|
|
587997dd67 | ||
|
|
6fc2d001b2 | ||
|
|
4eda3da75d | ||
|
|
163f7e4e2b | ||
|
|
44a5895b0c | ||
|
|
3d7d406834 | ||
|
|
bdc8be4508 | ||
|
|
9ad96f560d | ||
|
|
bc5f2beaf7 | ||
|
|
fba57318fa | ||
|
|
12bb75809f | ||
|
|
37b12c5fee | ||
|
|
830385ea2b | ||
|
|
dc63fa57b8 | ||
|
|
5252b8f320 | ||
|
|
a4ced664cf | ||
|
|
8fecf7de2d | ||
|
|
3ca29603ed | ||
|
|
8e59145826 | ||
|
|
e4f9d0b61e | ||
|
|
65938bb2a0 | ||
|
|
bb680e8cb8 | ||
|
|
52d201eee9 | ||
|
|
402f9e0d75 | ||
|
|
8492f24064 | ||
|
|
6f240828cf | ||
|
|
5ecf2d9629 | ||
|
|
f105d1f7ea | ||
|
|
f66c280b0b | ||
|
|
10ff4b3d2a | ||
|
|
bebb4d6370 | ||
|
|
cb34ab88f7 | ||
|
|
af2c61292b | ||
|
|
a3a66b6db2 | ||
|
|
5d5f1d6909 | ||
|
|
9872b78677 | ||
|
|
b478049303 | ||
|
|
b66f0acf4e | ||
|
|
679463eb3f | ||
|
|
02928caac1 | ||
|
|
e708f1ad2e | ||
|
|
0310bbe0f9 | ||
|
|
69ab8e50eb | ||
|
|
3df3c333bf | ||
|
|
99b5e2dbf3 | ||
|
|
d06f684f0d | ||
|
|
b39eadffeb | ||
|
|
9d0b5fac3e | ||
|
|
9ab87b2779 | ||
|
|
c47b6e5533 | ||
|
|
e0682fa868 | ||
|
|
bd18de35a6 | ||
|
|
bf37223849 | ||
|
|
93e13e5563 | ||
|
|
b45321f28d | ||
|
|
b218768904 | ||
|
|
53158b4cf4 | ||
|
|
12b51a211a | ||
|
|
a584134858 | ||
|
|
0aea550c6c | ||
|
|
5721bfd796 | ||
|
|
1c0dfeb9a4 | ||
|
|
f6fc0c535c | ||
|
|
1c7d1ece9c | ||
|
|
5f150b7a59 | ||
|
|
7b0377c85f | ||
|
|
f3e8cce2ed | ||
|
|
eeb873b816 | ||
|
|
f1e743c331 | ||
|
|
1265611f9e | ||
|
|
f38ac73615 | ||
|
|
a85ebd30c0 | ||
|
|
bd5901d15e | ||
|
|
6b08da458a | ||
|
|
26346cde30 | ||
|
|
47d6e22988 | ||
|
|
0646de22bd | ||
|
|
2b42377b3f | ||
|
|
8ae265c8f8 | ||
|
|
9f0b25e92b | ||
|
|
3866558d1d | ||
|
|
fb90558dec | ||
|
|
5860b71223 | ||
|
|
c65a44ba88 | ||
|
|
761cfc2cce | ||
|
|
2d293a6148 | ||
|
|
7f6837ccb1 | ||
|
|
206506f5e9 | ||
|
|
a5d493a8c9 | ||
|
|
e485f7bd47 | ||
|
|
7eb7c017a5 | ||
|
|
01808fa644 | ||
|
|
7e37f56db6 | ||
|
|
8686275b69 | ||
|
|
01e5aa4ddd | ||
|
|
c5a69d240a | ||
|
|
26dac40720 | ||
|
|
34c0f696af | ||
|
|
4a9a3d3235 | ||
|
|
17922f4dfd | ||
|
|
ea5e607779 | ||
|
|
b146e182c6 | ||
|
|
0695c08ac3 | ||
|
|
bbeeda7268 | ||
|
|
325f7605f2 | ||
|
|
1d12dff033 | ||
|
|
aecbf620a8 | ||
|
|
9268968fd4 | ||
|
|
b274ce1268 | ||
|
|
40217f3261 | ||
|
|
d61eac0a01 | ||
|
|
b2570eab38 | ||
|
|
71ca134599 | ||
|
|
d4226a289c | ||
|
|
83d4148de1 | ||
|
|
5f72435bfb | ||
|
|
7bfd7ce285 | ||
|
|
864dae4469 | ||
|
|
cafd2e6db3 | ||
|
|
73c362de96 | ||
|
|
5b3e456e09 | ||
|
|
b710493701 | ||
|
|
ce473f2741 | ||
|
|
02a05d7a9a | ||
|
|
0614bdcf18 | ||
|
|
4b6d261739 | ||
|
|
f70ec59359 | ||
|
|
ed08f80f05 | ||
|
|
2873344690 | ||
|
|
d6131af289 | ||
|
|
6b55b5c35c | ||
|
|
d405269526 | ||
|
|
08acdc0503 | ||
|
|
b9286c2c6e | ||
|
|
128ff89426 | ||
|
|
436f7da464 | ||
|
|
5d7edc4a4c | ||
|
|
eb7552e8f3 | ||
|
|
31d1d21d1e | ||
|
|
d9fb5c582f | ||
|
|
2cd75176a4 | ||
|
|
c7e8760ffe | ||
|
|
ad8d353f9a | ||
|
|
e2f61ede56 | ||
|
|
8f25bf056d | ||
|
|
123ba39520 | ||
|
|
f6dbf71964 | ||
|
|
8c2eba6cbb | ||
|
|
fcd35f5043 | ||
|
|
5f56681c01 | ||
|
|
cd5eb25948 | ||
|
|
ab526ed291 | ||
|
|
aa7e43a61e | ||
|
|
e29da1b47a | ||
|
|
d4e9acdb79 | ||
|
|
5f31dbd521 | ||
|
|
e0f2eb7da7 | ||
|
|
d0a13d9cc2 | ||
|
|
4f5df743ae | ||
|
|
c8a305ae92 | ||
|
|
fc5bb9511f | ||
|
|
31d614efd4 | ||
|
|
2645dea26f | ||
|
|
2cfea7b60c | ||
|
|
c5e4934ba6 | ||
|
|
c301f0c498 | ||
|
|
6ef7e2891a | ||
|
|
cee85309b8 | ||
|
|
fc15f6356d | ||
|
|
bc8ce63707 | ||
|
|
90a7e38ce9 | ||
|
|
d37999970c | ||
|
|
e247001bdb | ||
|
|
ac7cbd136b | ||
|
|
4fa53f63bd | ||
|
|
15cd034b29 | ||
|
|
125a49a1a0 | ||
|
|
5e34517e73 | ||
|
|
85fdff606d | ||
|
|
00b2685cf4 | ||
|
|
7c6fe80f1b | ||
|
|
e6729c77e7 | ||
|
|
f9658a8e7a | ||
|
|
6f1ab0073c | ||
|
|
001fdac3be | ||
|
|
fdcf5176bc | ||
|
|
7be6749a23 | ||
|
|
81de470f64 | ||
|
|
b691c6766d | ||
|
|
fc111d8c46 | ||
|
|
dcd024100a | ||
|
|
bb9bd71391 | ||
|
|
4c94254502 | ||
|
|
829eae3ccf | ||
|
|
8f4d6cbdf9 | ||
|
|
31542a181e | ||
|
|
51f2c10c6f | ||
|
|
a5d9a89445 | ||
|
|
0ccbcd558f | ||
|
|
c00865d805 | ||
|
|
28b581b6b1 | ||
|
|
4a713f3fb6 | ||
|
|
8f75317050 | ||
|
|
525b5368a1 | ||
|
|
d8f163b5ce | ||
|
|
70b4c45eb0 | ||
|
|
0028e96e76 | ||
|
|
b54cdf78b2 | ||
|
|
4d9be37c1c | ||
|
|
e6a6fe46d7 | ||
|
|
7ff3a61e75 | ||
|
|
afd2e727d0 | ||
|
|
b34c71775c | ||
|
|
e99b76dbd7 | ||
|
|
a76cc34557 | ||
|
|
61f6ca0676 | ||
|
|
8af50dad9c | ||
|
|
60bf21b019 | ||
|
|
1c78da34a9 | ||
|
|
5a13597c00 | ||
|
|
e6a362c0b8 | ||
|
|
9ec1f9930b | ||
|
|
e54e17708f | ||
|
|
e9529310fe | ||
|
|
796f06acf4 | ||
|
|
d1a91c40c4 | ||
|
|
934b6fe687 | ||
|
|
1a62e8a567 | ||
|
|
d80cd42a0c | ||
|
|
d2f0c7a14e | ||
|
|
0f85030744 | ||
|
|
2eb2a63f1e | ||
|
|
bf7c88ce71 | ||
|
|
611abb5857 | ||
|
|
a060204dd6 | ||
|
|
ccc3597699 | ||
|
|
034b792801 | ||
|
|
31f6015591 |
No files matched your search
@@ -12,6 +12,7 @@ env:
|
||||
BACKUPS: /var/backups/privapub.thepra.dev
|
||||
LOCAL_URL: http://127.0.0.1:6970
|
||||
PUBLIC_URL: https://privapub.thepra.dev
|
||||
SERVER_BACKUPS: /var/lib/privapub/backups
|
||||
|
||||
jobs:
|
||||
site:
|
||||
@@ -52,6 +53,25 @@ jobs:
|
||||
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
||||
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
|
||||
|
||||
# The server's own backup (PrivaPub admin backup, owner decision 2026-10-07) of the database as the live build left it:
|
||||
# the new build's command runs before its migrations. The media are listed, not linked (the runner may not link
|
||||
# www-data's files; the nightly backups hold them). Never the statistics salt, which must not outlive its day (owner
|
||||
# rule), nor the configuration's copy with its secrets. No deploy while a restore waits for its boot.
|
||||
- name: Back up the database
|
||||
run: |
|
||||
[ ! -e "$SERVER_BACKUPS/restore.json" ] || { echo "::error::a restore is pending or running ($SERVER_BACKUPS/restore.json): deploy after it"; exit 1; }
|
||||
out=$(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup --kind pre-deploy --db-only)
|
||||
echo "$out"
|
||||
id=$(echo "$out" | grep -oE '^[0-9]{8}-[0-9]{6}-pre-deploy' | tail -1)
|
||||
[ -d "$SERVER_BACKUPS/$id" ] || { echo "::error::the backup was not made"; exit 1; }
|
||||
(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup verify "$id")
|
||||
if [ -e "$SERVER_BACKUPS/$id/db/InteractionSalt.jsonl.gz" ]; then
|
||||
echo "::error::the backup holds the statistics salt"
|
||||
exit 1
|
||||
fi
|
||||
echo "BACKUP_ID=$id" >> "$GITHUB_ENV"
|
||||
echo "::notice::database backed up as $id"
|
||||
|
||||
- name: Stop, sync, start
|
||||
run: |
|
||||
sudo systemctl stop "$UNIT"
|
||||
@@ -83,6 +103,46 @@ jobs:
|
||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
|
||||
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
|
||||
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 before any lookup, so a name no persona can have
|
||||
# (a hyphen is outside UserNameRegex) checks it without naming anyone; a browser asking for a persona gets its page
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/no-such-persona")
|
||||
[ "$code" = "401" ] || { echo "::error::an unsigned GET of a persona answered $code, not 401: SecureMode is off"; exit 1; }
|
||||
directory=$(curl -fsS "$PUBLIC_URL/api/v1/directory?local=true&limit=1") || { echo "::error::the directory did not answer"; exit 1; }
|
||||
persona=$(echo "$directory" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['username'] if d else '')")
|
||||
if [ -z "$persona" ]; then
|
||||
echo "::notice::no persona in the directory: the browser redirect is not checked"
|
||||
else
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/$persona")
|
||||
[ "$code" = "302" ] || { echo "::error::a browser asking for @$persona got $code, not a redirect"; exit 1; }
|
||||
fi
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
||||
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
|
||||
[ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede")
|
||||
[ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; }
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
|
||||
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
||||
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
||||
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
|
||||
open=$(curl -fsS "$PUBLIC_URL/nodeinfo/2.1" | python3 -c "import json,sys; print(json.load(sys.stdin)['openRegistrations'])")
|
||||
enabled=$(curl -fsS "$PUBLIC_URL/api/v2/instance" | python3 -c "import json,sys; print(json.load(sys.stdin)['registrations']['enabled'])")
|
||||
[ "$open" = "$enabled" ] || { echo "::error::NodeInfo says registrations are $open, the instance API $enabled"; exit 1; }
|
||||
curl -fsS "$PUBLIC_URL/stargazing" | grep -q 'The crawler is <strong>on</strong>' \
|
||||
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
|
||||
echo "::notice::serving $served"
|
||||
|
||||
# The deploy no longer signs in (owner decision 2026-10-17): it kept a login of its own, deploy-smoke, whose @thepra
|
||||
# is the administrator's now. Its checks are the anonymous ones above.
|
||||
|
||||
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
|
||||
- name: Geolocation databases are current
|
||||
run: |
|
||||
for kind in city asn; do
|
||||
db="/var/lib/privapub/geo/dbip-$kind-lite.mmdb"
|
||||
for i in $(seq 1 60); do [ -s "$db" ] && break; sleep 10; done
|
||||
[ -s "$db" ] || { echo "::error::$db is missing: the server has not fetched DB-IP Lite"; exit 1; }
|
||||
days=$(( ($(date +%s) - $(stat -c %Y "$db")) / 86400 ))
|
||||
[ "$days" -le 40 ] || { echo "::error::$db was installed $days days ago"; exit 1; }
|
||||
done
|
||||
curl -fsS "$PUBLIC_URL/stargazing" | grep -o 'DB-IP Lite [0-9-]*' | head -1 | sed 's/^/::notice::locating with /'
|
||||
+14
@@ -257,6 +257,7 @@ Generated_Code/
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
!PrivaPub/Infrastructure/Backup/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
@@ -398,3 +399,16 @@ FodyWeavers.xsd
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
|
||||
|
||||
PrivaPub/media-store/
|
||||
PrivaPub/media-store-proxy/
|
||||
PrivaPub/media-store-trash/
|
||||
PrivaPub/media-store-incoming/
|
||||
PrivaPub/media-store-backups/
|
||||
tools/pasture/.publish/
|
||||
tools/pasture/.flood/
|
||||
tools/pasture/.pushsink/
|
||||
.claude/worktrees/
|
||||
tools/pasture/.ca/
|
||||
tools/pasture/.state/
|
||||
tools/pasture/out/
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
# Federation
|
||||
|
||||
PrivaPub is an ActivityPub server written in C#. This document follows
|
||||
[FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) and describes how it federates.
|
||||
|
||||
## Supported federation protocols and standards
|
||||
|
||||
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
|
||||
- [WebFinger](https://webfinger.net/)
|
||||
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
|
||||
(`hs2019` hashed with SHA-256, or SHA-512 as some sign it; a `SHA-256=` or `SHA-512=` digest)
|
||||
- [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (RFC 9421) and Content-Digest (RFC 9530), verified on
|
||||
deliveries and signed fetches: `rsa-v1_5-sha256` and `rsa-pss-sha512` with RSA keys
|
||||
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
|
||||
- Object integrity proofs ([FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md),
|
||||
`eddsa-jcs-2022`, JSON canonicalised by [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) by Ed25519 keys published
|
||||
as Multikeys ([FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)), on what personas
|
||||
send to relays and verified on what is forwarded
|
||||
|
||||
## Tested against
|
||||
|
||||
End to end, in a private network on the workstation (`tools/pasture/`), each peer driven through its own client API
|
||||
and every run starting clean, with signed fetches required (as privapub.thepra.dev runs):
|
||||
- **GoToSocial 0.22.1**
|
||||
- **Mastodon 4.7.3**
|
||||
- **Misskey 2026.10**
|
||||
- **Sharkey 2025.4.7**
|
||||
- **Akkoma 3.20.1**
|
||||
- **Lemmy 1.0.0-beta.2**, and **Lemmy 0.19.20** (built with the platform's roots, so it trusts the pasture's CA)
|
||||
- **PeerTube 8.3.1**
|
||||
- **Pixelfed 0.14.4**
|
||||
- **WordPress 7.1.2 with ActivityPub 9.3.1**
|
||||
- **Friendica 2026.05**
|
||||
- **Mobilizon 5.2.4**
|
||||
- **Gancio 1.28.2**
|
||||
- **Funkwhale 2.0.11**
|
||||
- **PieFed 1.7.17**
|
||||
- **Mbin 1.10.1**
|
||||
- **NodeBB 4.16.1**
|
||||
- **Smithereen 1.0.3**
|
||||
- **Mitra 5.9.1**
|
||||
- **snac2 2.95**
|
||||
- **WriteFreely 0.17.2**
|
||||
- **Owncast 0.3.0**
|
||||
- **Ghost 6.67** with its ActivityPub service 1.2.14
|
||||
- **BookWyrm 0.9.3**
|
||||
- **Vernissage 1.43.0**
|
||||
- **Hubzilla 11.4.1** with its pubcrawl addon
|
||||
- **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`)
|
||||
- **Castopod 1.15.5** (podcasts as actors, their episodes with the sound)
|
||||
- **Ktistec 3.13.0**
|
||||
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
|
||||
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
|
||||
**Pleroma 2.10.2**
|
||||
|
||||
Checked both ways, where the peer has the feature:
|
||||
- follows, locked accounts included;
|
||||
- every visibility, content warnings, replies and their notifications;
|
||||
- likes, boosts and emoji reactions with their undos;
|
||||
- direct messages;
|
||||
- polls;
|
||||
- quotes;
|
||||
- images with alt text;
|
||||
- edits with history and deletes;
|
||||
- communities and circles;
|
||||
- blocks and unfollows.
|
||||
|
||||
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. The platforms not in the pasture are
|
||||
read from documents in their published shape (`docs/INTEROP.md` records each one); there is no test project.
|
||||
|
||||
## Supported FEPs
|
||||
|
||||
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md):
|
||||
every actor names its handle, and a peer's `webfinger` on another domain than its actor's host (Mastodon's
|
||||
`LOCAL_DOMAIN`) is the handle shown once that domain's WebFinger points back to the actor; asked again on a rename
|
||||
- [FEP-d556: Server-Level Actor Discovery Using WebFinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md)
|
||||
(WebFinger for the server's origin links its instance actor as `…#Service`) and
|
||||
[FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2677/fep-2677.md)
|
||||
(`/.well-known/nodeinfo` links it as `…#Application`)
|
||||
- [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md): the instance
|
||||
actor's `implements`, and every other actor's `generator`, name RFC 9421 (verified with RSA keys)
|
||||
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
|
||||
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md)
|
||||
(`QuoteAuthorization` at `/parrot-licences/{id}`; checked with Mastodon both ways)
|
||||
- [FEP-9967: Polls](https://codeberg.org/fediverse/fep/src/branch/main/fep/9967/fep-9967.md)
|
||||
- [FEP-c0e0: Emoji reactions](https://codeberg.org/fediverse/fep/src/branch/main/fep/c0e0/fep-c0e0.md) (`EmojiReact`, and Misskey's
|
||||
`Like` with content)
|
||||
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
||||
- [FEP-521a: Representing actor's public keys](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md) and
|
||||
[FEP-8b32: Object Integrity Proofs](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) (see
|
||||
"Integrity proofs")
|
||||
- [FEP-171b: Conversation Containers](https://codeberg.org/fediverse/fep/src/branch/main/fep/171b/fep-171b.md), received:
|
||||
a thread owner's `Add` of someone's `Create`, `Update` or `Delete` to the thread's context is taken as that activity
|
||||
passed on by the owner (on its FEP-8b32 proof, or as its origin has it)
|
||||
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
||||
(sent and honoured; see "Followers synchronisation")
|
||||
- [FEP-5624: Per-object reply control policies](https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md):
|
||||
read (see "Replies its author approves")
|
||||
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
|
||||
and [FEP-f228: Backfilling conversations](https://codeberg.org/fediverse/fep/src/branch/main/fep/f228/fep-f228.md):
|
||||
a root post serves its `context` collection, and a remote thread is read from its `context` (or `contextHistory`)
|
||||
before its `replies` (see "Activities")
|
||||
- [FEP-7628: Move actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/7628/fep-7628.md): received; a persona's
|
||||
follows move with the account (see `Move` under "Activities")
|
||||
- [FEP-ae0c: Fediverse Relay Protocols](https://codeberg.org/fediverse/fep/src/branch/main/fep/ae0c/fep-ae0c.md): the
|
||||
instance actor follows the relays configured, and their `Announce`s are taken as their origin vouches (see "Relays")
|
||||
- [FEP-8967: Generic attachments, link previews](https://codeberg.org/fediverse/fep/src/branch/main/fep/8967/fep-8967.md):
|
||||
received; a `preview` builds the card
|
||||
- [FEP-9098: Custom emojis](https://codeberg.org/fediverse/fep/src/branch/main/fep/9098/fep-9098.md): received, and this
|
||||
server's own sent on posts, polls, actors and reactions, each served at its id (see "Custom emoji")
|
||||
|
||||
Planned (see `docs/ROADMAP.md`, the completion program, and the per-platform notes in `docs/INTEROP.md`): FEP-8967 link
|
||||
attachments sent (R10).
|
||||
|
||||
## Actors
|
||||
|
||||
Every account is an *avatar*: one private login can own several, and they are deliberately unlinkable. Nothing in an
|
||||
actor document, a collection, NodeInfo or a delivery relates two avatars of the same login.
|
||||
|
||||
| Thing | Address |
|
||||
|---|---|
|
||||
| Actor (Person, Group, Application) | `/peasants/{name}` (`/users/{name}` redirects) |
|
||||
| Inbox | `/peasants/{name}/mouth` |
|
||||
| Outbox | `/peasants/{name}/anus` |
|
||||
| Shared inbox | `/human-centipede` |
|
||||
| Followers / following | `/peasants/{name}/groupies`, `/peasants/{name}/stalking` |
|
||||
| Objects | `/peasants/{name}/scribbles/{id}` |
|
||||
| Activities | `/peasants/{name}/grunts/{id}` |
|
||||
| Quote permissions (`QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
|
||||
| Direct-message context | `/peasants/{name}/whispers/{id}` |
|
||||
| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` |
|
||||
| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
|
||||
| Featured (pinned) posts, featured hashtags | `/peasants/{name}/trophies`, `/peasants/{name}/tattoos` |
|
||||
| A custom emoji of this server (`Emoji`) | `/grimaces/{shortcode}` |
|
||||
| Profile and post pages | `/@{name}`, `/@{name}/{id}` |
|
||||
|
||||
The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.
|
||||
|
||||
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
|
||||
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
|
||||
personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and
|
||||
`discoverable` is `true` unless it turns that off.
|
||||
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
|
||||
is used to read another server's content. Its `name` is the server's name and its `summary` the server's short
|
||||
description, as the administrator sets them (the server's settings; until then "PrivaPub" and a sentence saying what it
|
||||
does); NodeInfo's `metadata.nodeName` and `nodeDescription` come from the same settings. It also answers at the server's root (`/`) for a request that asks for
|
||||
ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a
|
||||
peer's root, and to `/inbox` when there is none.
|
||||
- The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from <host>"), one for the whole server with
|
||||
its own key. It sends the reports Lemmy, PieFed and Mbin take in Lemmy's shape (see **Reports** below) and does
|
||||
nothing else: nobody follows or mentions it, the Mastodon API has no account for it, and it has no page.
|
||||
- The reader of feeds is `/peasants/privapub_feeds` (type `Service`, "Feeds read on <host>"), one for the whole server
|
||||
with its own key. When a persona here follows a feed, the reader follows each community in it, and stops following one
|
||||
no feed read here holds any more; no community and no feed learns which persona reads it. It does nothing else.
|
||||
|
||||
## Groups
|
||||
|
||||
A group is either a **community** or a **circle**.
|
||||
|
||||
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
|
||||
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
|
||||
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers and to the
|
||||
author's own server, whether anyone there follows it or not (Lemmy keeps its user's post pending until then). A new post is
|
||||
also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A
|
||||
top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which
|
||||
the actor's `attributedTo` points to, with `postingRestrictedToMods` as Lemmy expects. A mention of a community posts into it.
|
||||
- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the
|
||||
circle and its members collection, delivered to each member's own inbox and never announced. Each member's copy also
|
||||
names that member in `cc`, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep
|
||||
it; it names no other member. The posts are served only to a signed request from a member, or from the instance actor
|
||||
of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A
|
||||
Mastodon member's replies reach only the people they mention. Its owner lets a request in with an `Accept` of the
|
||||
Follow, and declines one or takes a member from elsewhere out with a `Reject` of it, as Mastodon removes a follower.
|
||||
- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched
|
||||
from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when
|
||||
they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post
|
||||
keeps the group its `audience` names, however it arrived.
|
||||
- A post in a remote group followed here that the group's own server delivers itself, as its author's `Create` with the
|
||||
group as its `audience` (Mbin sends a magazine's threads that way, with no announce), is kept as if announced: the
|
||||
group's server speaks for posts in it. The same from another server is not.
|
||||
- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed at once
|
||||
from a community on the post's own server, which speaks for it (PieFed keeps serving a thread its moderator removed),
|
||||
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
|
||||
from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies,
|
||||
ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as
|
||||
`blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to
|
||||
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
|
||||
there with its thread; a move into a community this server hosts is not taken.
|
||||
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
|
||||
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
|
||||
turn. A persona following a feed is followed through the server (owner decision 2026-10-07): the reader of feeds
|
||||
follows its communities, the feed itself is sent nothing, and the persona reads the feed's threads in a timeline of
|
||||
its own (`GET /api/v1/timelines/feed/:id`; the feeds it reads are `GET /api/v1/feeds`), not in its home.
|
||||
|
||||
## Activities
|
||||
|
||||
Received:
|
||||
|
||||
| Activity | Effect |
|
||||
|---|---|
|
||||
| `Follow` | follows an avatar or community; `Accept` is sent unless the community approves members by hand |
|
||||
| `Accept{Follow}`, `Reject{Follow}` | completes or ends a follow an avatar requested; once a follow holds, the account's latest public posts are read from its outbox's first page (twenty at most, once a day), so its profile is not empty; homes get only what arrives from then on |
|
||||
| `Accept{Join}`, `Reject{Join}` | from the event's server: a persona's participation in the event is accepted or refused |
|
||||
| `Undo{Follow, Like, Announce}` | reverses it |
|
||||
| `Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …}` | stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows (a persona is addressed by its actor id or by its profile page, as Mbin addresses its private messages); a public parent is fetched to complete the thread |
|
||||
| `Update{Note}` | replaces the content; the previous version is kept |
|
||||
| `Update{Person}` | refetches the actor |
|
||||
| `Like` | counted and notified, on posts the liker could see |
|
||||
| `Dislike` | counted as a downvote (Lemmy, PieFed, Mbin, Friendica); `Undo` takes it back |
|
||||
| `EmojiReact`, `Like` with an emoji `content` | an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its `tag`; a `Like` whose content is ❤ stays a favourite; `Undo` takes it back |
|
||||
| `Warn` (Lemmy 1.0) | a community moderator's warning about a persona's post there becomes the persona's `moderation_warning` notification, believed from the community's own server or an account its moderators collection lists |
|
||||
| `Resolve{Flag}` (Lemmy 1.0) | a report we sent is marked resolved by the server holding what was reported (kept beside our own moderators' resolution) |
|
||||
| `Join` | answered with `Ignore`: PrivaPub hosts no events of its own yet (FEP-8a8e) |
|
||||
| `Announce` | counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin |
|
||||
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
|
||||
| `Flag` | becomes a report for this server's moderators |
|
||||
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
|
||||
| `Add`/`Remove` of a `Hashtag` on the actor's `featured` or `featuredTags` | the account features a hashtag on its profile, or stops (Mastodon sends them to `featured`); shown as its featured tags (`GET /api/v1/accounts/:id/featured_tags`). Its `featuredTags` collection itself is read with its counts, at most once a day (20 tags at most) |
|
||||
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
|
||||
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
|
||||
|
||||
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
|
||||
`Reject{Follow}`, `Like`, `Dislike`, `Announce` and their `Undo`, `Flag`, `Join` and `Leave` of a remote event,
|
||||
`Add`/`Remove` of a pinned post or a featured `Hashtag`, and the replies to a persona's posts passed on to its followers. A deleted post answers 410 with a `Tombstone`.
|
||||
|
||||
- **Votes.** A favourite is a `Like`, a downvote a `Dislike`; a persona has one vote a post, and a changed vote is sent
|
||||
as the new vote alone, as Lemmy sends one (an `Undo` of the old one beside it could arrive after it and take the new
|
||||
one away). `Undo` is sent only when the vote is taken back. A vote on a post in a remote community goes to the
|
||||
community, which counts it and passes it on, as Lemmy sends one, and to the post's author only when it lives on
|
||||
another server: one copy a server, since PieFed drops a second copy of an activity it has just seen.
|
||||
|
||||
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
|
||||
Uploaded files have all metadata removed.
|
||||
- **Threads** (owner decision 2026-10-06). A persona's public or unlisted post outside any group names its `replies`
|
||||
(`…/scribbles/{id}/replies`) and its conversation, `context` (FEP-7888): the root post's `…/scribbles/{id}/context`,
|
||||
which a reply inherits from its parent, ours or another server's. Both list only the public and unlisted posts
|
||||
PrivaPub holds, the context every one under the root (at most 500); followers-only, circle, direct and local-only posts
|
||||
name neither, and their collections answer 404. Mastodon completes a thread from them.
|
||||
- **Followers synchronisation** (FEP-8fcf, owner decision 2026-10-06). A persona's delivery addressed to its followers
|
||||
carries a signed `Collection-Synchronization` header: its `followers`, its roll-call (`…/groupies/roll-call`) and the
|
||||
digest (XOR of each id's SHA-256) of its accepted followers on the receiving server only. The roll-call answers a
|
||||
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
|
||||
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
|
||||
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
|
||||
Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas
|
||||
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
|
||||
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
|
||||
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
|
||||
- **Walls** (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its `wall`
|
||||
(`…/graffiti`, `sm:wall`) and Smithereen's `privacySettings`: its followers may write on it (`wallPosting`), everyone
|
||||
may read it (`wallPostVisibility`). A public post that is not a reply, sent with the wall as its `target` by an account
|
||||
following the persona, is hosted: the persona is notified (as a mention), it reaches the persona's home and its
|
||||
followers' here, and the persona's followers' servers and the author's are told with `Add{Note}` (the wall as the
|
||||
target). Anything else written there is dropped. The persona may delete it (`DELETE /api/v1/statuses/:id`), which
|
||||
sends `Remove{Note}` with the wall as a plain id; Smithereen deletes the post then, as it does when an owner deletes a
|
||||
post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest
|
||||
first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there
|
||||
has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away.
|
||||
- **Pinned posts** are the actor's `featured` collection (`/trophies`). A pin or an unpin is told to the post's audience
|
||||
as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
||||
- **Featured hashtags** are the actor's `featuredTags` collection (`/tattoos`): an `OrderedCollection` of `Hashtag`s, in
|
||||
the order the persona featured them (ten at most), each `{"type": "Hashtag", "href": "<this server>/tags/<tag>", "name":
|
||||
"#Tag"}`. Featuring one sends its followers
|
||||
```json
|
||||
{"id": "…/peasants/alice/grunts/feature-tag-…", "type": "Add", "actor": "…/peasants/alice",
|
||||
"object": {"type": "Hashtag", "href": "https://privapub.thepra.dev/tags/gardening", "name": "#Gardening"},
|
||||
"target": "…/peasants/alice/trophies", "to": ["…#Public"], "cc": ["…/peasants/alice/groupies"]}
|
||||
```
|
||||
and unfeaturing it the same `Remove`. The target is the `featured` collection, not `featuredTags`: that is what Mastodon
|
||||
sends and the only target it takes a Hashtag from (it tells a pin from a tag by the object's type).
|
||||
- **What is never sent:** a persona's endorsements of other accounts (Mastodon's account pins and 4.4's endorsements) stay
|
||||
private, never published as featured accounts or a collection; its private notes on accounts and its dismissed follow
|
||||
suggestions are its own. No collection lists who follows whom (familiar followers are never shown either).
|
||||
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
|
||||
followed); an unblock sends `Undo{Block}`. A server's block of a whole server sends that server nothing; the
|
||||
administrator may list it at `/api/v1/instance/domain_blocks` (off for each block by default, its name partly hidden
|
||||
when asked, with its SHA-256 digest), as Mastodon lists its blocks.
|
||||
- **Reports** are sent as `Flag`, never by the reporting account and never naming it.
|
||||
- To the reported account's server: from the instance actor, with the account and the posts as `object` and the
|
||||
persona's words in `content`.
|
||||
- To a community on a server whose NodeInfo names Lemmy, PieFed or Mbin (owner decision 2026-10-06, the second place
|
||||
PrivaPub decides by a server's software; PieFed and Mbin once the pasture showed each keeps such a report with its
|
||||
reason): one `Flag` per reported post or comment that was made in that community (its own
|
||||
`audience`, else its thread's), from the reporter, `to` the community, the post alone as `object`, the words (or,
|
||||
with none, the category) in `summary` and `content`, sent to the community's inbox. Lemmy refuses a Flag from an
|
||||
`Application`, with no `to` or with no reason; PieFed drops one from an `Application`; Mbin reads the reason from
|
||||
`summary` alone. Such a server gets no instance actor's Flag: an account alone, or a post outside its communities,
|
||||
is not reported there, since Lemmy takes neither (PieFed would take an account alone from the reporter; that is not
|
||||
sent yet).
|
||||
- **Direct messages** go out as a `Note` addressed to their recipients, except a message to one account elsewhere that
|
||||
writes to us as `ChatMessage`s (Pleroma's type), or whose server takes nothing else: Lemmy before 1.0 and Mbin, as
|
||||
their NodeInfo names them (owner decision 2026-10-05, the first place PrivaPub decides by a server's software). That
|
||||
message goes out as a `ChatMessage`, to the account alone, without a mention in its text.
|
||||
- **Replies are passed on** (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server
|
||||
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
|
||||
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
|
||||
`Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public
|
||||
or unlisted. A server that receives it checks it as it checks any forwarded activity: Mastodon, Misskey and Sharkey
|
||||
keep it only when its author LD-signed it (Mastodon does; Misskey 2026.10 and Akkoma do not), others read the reply
|
||||
from its origin.
|
||||
- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a
|
||||
`Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's
|
||||
server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as
|
||||
`privapub.event.participation`: `pending`, `accepted` or `rejected`. An event that takes participants by invitation
|
||||
or on another site (Mobilizon's `joinMode` `invite` or `external`) is refused before anything is sent (422). Who
|
||||
takes part is public on the event's server.
|
||||
|
||||
A `Create`'s `Note` carries Mastodon's `content`, `contentMap`, `summary` and `sensitive`, plus `Mention` and `Hashtag`
|
||||
tags.
|
||||
|
||||
**Polls** (FEP-9967) go out as a `Question` with `oneOf` or `anyOf`, each option's count in `replies.totalItems`,
|
||||
`endTime`, `votersCount`, and `closed` once it has ended. Incoming votes are `Create{Note{name, inReplyTo}}` with no
|
||||
content, one per choice, counted once per voter. Counts are refreshed with an `Update{Question}` at most every three
|
||||
minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without `published`.
|
||||
An incoming `Update` without a newer `updated` only refreshes counts and details; it is never recorded as an edit.
|
||||
A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is:
|
||||
a post first met after its edits has them, and an edit that carries them brings the versions missed in between.
|
||||
|
||||
**Quotes** (FEP-044f, with the older keys):
|
||||
- **Received.** A quote is read from `quote`, `quoteUrl`, `quoteUri`, `_misskey_quote` or a FEP-e232 `Link` tag.
|
||||
- With a `quoteAuthorization`, the stamp is fetched and must be a `QuoteAuthorization` on the quoted author's origin,
|
||||
attributed to them, naming exactly the two posts.
|
||||
- A FEP-044f `quote` without a stamp is pending until an `Update` brings one.
|
||||
- A quote made with the older keys only, by software that asks nobody, is shown when the quoted post is public.
|
||||
- A `Delete` of a stamp revokes the quote.
|
||||
- **Sent.** Our accounts quote with every key, plus the `RE:` fallback.
|
||||
- When the quoted post states an `interactionPolicy.canQuote` that allows us, `quote` is included and a `QuoteRequest`
|
||||
(with the quoting post as `instrument`) goes to the quoted author. The quote stays pending until their `Accept` brings a
|
||||
stamp we can verify; the post is then updated with `quoteAuthorization`.
|
||||
- Posts that state no policy are quoted the older way, without `quote`.
|
||||
- Quoting posts are also delivered to the quoted author.
|
||||
- **Quoting our accounts.** Our public and unlisted posts state `interactionPolicy.canQuote`. By default anyone may quote,
|
||||
automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
|
||||
- A `QuoteRequest` is answered with `Accept{result}` naming a stamp at `/peasants/{name}/parrot-licences/{id}`, or
|
||||
with `Reject`.
|
||||
- A stamp is a `QuoteAuthorization` naming both posts. A revoked stamp answers 410 and is announced with
|
||||
`Delete{stamp}`.
|
||||
- Followers-only posts and DMs can never be quoted.
|
||||
|
||||
**Interaction policies** (GoToSocial's `canReply`, `canLike`, `canAnnounce`, with the older `always` and
|
||||
`approvalRequired`):
|
||||
- **Read** on every remote post; one left out means anyone, automatically. A persona is let in at once when the rule
|
||||
names the public, the persona itself, the author's followers while it follows the author, or the accounts the author
|
||||
follows while the author follows it.
|
||||
- **Shut out:** the client API answers 422 to a reply, favourite or boost the rule does not allow at all.
|
||||
- **Asked first:** a `ReplyRequest`, `LikeRequest` or `AnnounceRequest` (the interaction as its `instrument`) goes to the
|
||||
author alone, and the interaction waits (`privapub.approval: pending`). The author's `Accept` brings an authorization
|
||||
(`result`), which must be on the author's origin, attributed to them and naming the interaction and the post; only then
|
||||
does the reply go out with `replyAuthorization`, the boost with `announceAuthorization` and the like with
|
||||
`likeAuthorization`. A `Reject` leaves it ours alone (`rejected`), and takes a like or a boost back.
|
||||
- **As a third party,** a reply that a post's policy does not let in at once is kept only with an authorization that
|
||||
verifies the same way. A rule naming a collection we cannot list (followers, following) lets the reply in.
|
||||
- **Shown** to clients as GoToSocial's `interaction_policy` (`can_favourite`, `can_reply`, `can_reblog`).
|
||||
- Our own posts state only `canQuote`: anyone may reply to, like and boost them.
|
||||
|
||||
**Replies its author approves** (FEP-5624's `canReply`, as PeerTube sets it on a video whose comments are moderated; a
|
||||
`null` one says nothing): a persona it names, or that the post mentions, may reply, and the reply waits
|
||||
(`privapub.approval: pending`), its `Create` going to the author alone. The author's `ApproveReply`, signed by the author
|
||||
and naming the post answered, lets it out to its audience with `replyApproval`; a `RejectReply` leaves it ours alone
|
||||
(`rejected`). An empty `canReply` refuses every reply (422).
|
||||
|
||||
**Custom emoji** (`Emoji` tags) are read on posts, display names, bios and profile fields, at most 64 per object.
|
||||
This server's own (its administrator adds them; shortcodes of letters, digits and underscores) are sent the same way, as
|
||||
Mastodon sends them, on a post or poll whose words, title, warning or options name them as `:shortcode:`, on an actor
|
||||
whose name, bio or fields do, and on an `EmojiReact`:
|
||||
|
||||
```json
|
||||
{"id": "https://privapub.thepra.dev/grimaces/blobcat", "type": "Emoji", "name": ":blobcat:",
|
||||
"updated": "2026-10-17T12:00:00.000Z",
|
||||
"icon": {"type": "Image", "mediaType": "image/png", "url": "https://privapub.thepra.dev/media/files/2026/10/….png"}}
|
||||
```
|
||||
|
||||
The picture is a PNG of at most 256 px a side, or an animated GIF, and at most 100 KiB (GoToSocial's limit for another
|
||||
server's emoji). Its `id` serves the same object with its `@context` (`application/activity+json`, signed or not, since
|
||||
an emoji tells nothing about anyone); a deleted emoji answers 410 with a `Tombstone`, and one added again under the same
|
||||
shortcode comes back at the same id with a newer `updated`. A post or an actor rendered again carries the emoji as they
|
||||
are then: one deleted or disabled since is left out, and a new picture has a newer `updated`. No activity is sent when
|
||||
an emoji changes. A reaction with one of them has `content` `:shortcode:` and that tag.
|
||||
|
||||
**Profiles** keep their header, profile fields, `manuallyApprovesFollowers`, `published`, `movedTo`, `indexable`,
|
||||
`memorial` and avatar and header descriptions. A post's title becomes `name` and is also the first, bold line of `content`, because Mastodon does not show
|
||||
`name`. A content warning without its own text uses the title, or "Content warning".
|
||||
|
||||
Received `summary` is a content warning only on a `Note` or `Question`, or when `sensitive` is `true`. On an `Article`,
|
||||
`Page`, `Event`, `Video` or `Audio` it is an excerpt or description, kept as such and never hidden.
|
||||
|
||||
Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound
|
||||
followers-only posts are recognised by the author's own `followers` collection.
|
||||
|
||||
## Link previews
|
||||
|
||||
For a public post that links to a page, PrivaPub builds a preview card. It uses, in order:
|
||||
1. a Link attachment's own `preview` (FEP-8967) or the post's own image, title and summary;
|
||||
2. otherwise, the linked page, read once by the server between 0 and 60 seconds after the post arrives (never when
|
||||
someone reads it), with an `Accept: text/html` request that reads at most 512 KB, through the same address checks as
|
||||
every other fetch.
|
||||
|
||||
The page's OpenGraph and Twitter tags give the title, description and image; the result is cached per address for 7
|
||||
days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy.
|
||||
`Federation:FetchLinkPreviews=false` turns page fetching off.
|
||||
|
||||
## Relays
|
||||
|
||||
PrivaPub reads from the relays `Federation:Relays` names, and those its administrator adds in the server's settings (by
|
||||
their actor's address, or their inbox's), none by default. Its instance actor follows `Public` at each, as Mastodon
|
||||
subscribes (`Federation/Relays/Relays.cs`, a minute after start, every six hours and at once when the administrator adds
|
||||
or removes one: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets the
|
||||
`Undo`, unless the same relay is still named under another address). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it
|
||||
forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it
|
||||
announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A
|
||||
persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision
|
||||
2026-10-06), as Mastodon sends them; nothing less public, and no boost. Mastodon takes a post Activity-Relay forwards
|
||||
only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of
|
||||
relay (checked live).
|
||||
|
||||
## Moderation
|
||||
|
||||
What this server's staff does to an account tells other servers nothing new (owner decision 2026-10-17): no `Flag`,
|
||||
`Block`, `Update` or `Delete` is sent for a silence or a suspension, and none is undone when it is lifted.
|
||||
|
||||
- **A persona silenced here** stays out of this server's public and hashtag timelines, trends, directory and search,
|
||||
and its posts no longer go to the relays. Everything else is as before: its actor, its collections, and every
|
||||
delivery to its followers and to the accounts it addresses.
|
||||
- **A persona suspended here** (by the staff, or with its login banned) delivers nothing, takes no new follower (a
|
||||
`Follow` is left unanswered, as one waiting for a locked account's approval), and its outbox, followers, following,
|
||||
featured collections, posts, activities, replies, contexts, wall and quote permissions answer 404, signed or not. Its
|
||||
actor document, its inbox (202, as ever) and WebFinger stay exactly as they were, so lifting the suspension serves
|
||||
everything again without telling anyone it happened. A post of it removed meanwhile is deleted here only. The one thing
|
||||
it may still send is the `Delete` of itself, if it is deleted.
|
||||
- **An account elsewhere suspended here:** what it sends is dropped after the 202, but a `Delete` or an `Undo`; nothing
|
||||
is addressed to it, and nothing goes to its own inbox (its server's shared inbox still serves the others there).
|
||||
Silenced, its posts stay out of the public timelines here. Its server hears of neither.
|
||||
- **A server silenced** (a domain block of `silence`) has its accounts' posts kept out of the public timelines here, as a
|
||||
silenced account's are; a suspended one is refused at the inbox, as before.
|
||||
- **A post removed** by the staff: one of ours is deleted as its author would delete it (a `Delete` to its audience); a
|
||||
copy of one from elsewhere is hidden here and never taken in again, and its server is not told.
|
||||
- **A persona or a group deleted** (by its login, its owner or an administrator): a `Delete` of the actor to its
|
||||
followers, the accounts it follows and a group's members elsewhere; its documents answer 410 from then on, and its name
|
||||
is never given again.
|
||||
|
||||
## Server descriptions and the crawler
|
||||
|
||||
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
|
||||
- **Describing a server:** a server it exchanges activities with is described at most once a week, from:
|
||||
- `/.well-known/nodeinfo` and the NodeInfo document it links;
|
||||
- `/api/v2/instance`, falling back to `/api/v1/instance`.
|
||||
|
||||
These requests are unsigned, because they are not ActivityPub documents.
|
||||
- **The crawler** is on at privapub.thepra.dev. It identifies as
|
||||
`PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing)`.
|
||||
- **What it reads:** `/robots.txt`, then the documents above and `/api/v1/instance/peers`, and nothing else: no
|
||||
accounts, posts or directories.
|
||||
- **How often:** one server a minute, each at most weekly.
|
||||
- **Opting out:** in robots.txt, the crawler obeys the group `PrivaPub-Stargazer`, then `PrivaPub`, then `*`. A
|
||||
robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks.
|
||||
|
||||
## Web Push
|
||||
|
||||
Not federation, but traffic an operator may see: when a client signed in as a persona subscribes to Web Push (Mastodon's
|
||||
`/api/v1/push/subscription`), PrivaPub POSTs its notifications to the push service the client named, encrypted to the
|
||||
client (RFC 8291 `aes128gcm`, or the older `aesgcm` draft Mastodon still sends) and carrying a VAPID JWT (RFC 8292) signed
|
||||
by the server's own P-256 key, the one `/api/v2/instance` gives as `configuration.vapid.public_key`. No HTTP signature
|
||||
and no ActivityPub; https endpoints on public addresses only, no redirects. The push service learns when, never what.
|
||||
|
||||
## Local-only posts
|
||||
|
||||
Posts with a location (shown to nearby users of this server) never leave the server, in any form.
|
||||
|
||||
## Security rules a peer will notice
|
||||
|
||||
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
|
||||
date may be at most one hour old and fifteen minutes ahead. A bad signature gets 401, malformed input 400, an accepted
|
||||
activity 202, too many requests 429. Activities are processed after the 202.
|
||||
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
|
||||
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
|
||||
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
|
||||
- **Another account of the same server.** An object attributed to another account of the actor's own server (Mobilizon's
|
||||
organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is
|
||||
taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is
|
||||
refused (400).
|
||||
- **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with
|
||||
its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post
|
||||
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
|
||||
404 or 410, and anything else is dropped. LD signatures are not verified. A reply forwarded this way is kept when
|
||||
someone here follows the author of the post it answers, as Mastodon does.
|
||||
- **Follow requests** still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days: a
|
||||
server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started
|
||||
sending there), and one that holds the follow answers it again. Each is the same follow under a new id
|
||||
(`…/follow-<id>-again-<n>`), since Lemmy ignores an id it has seen; an answer naming any of them counts.
|
||||
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||
redirects and read at most 1 MB.
|
||||
- **Threads.** A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread,
|
||||
its replies are read from their servers, at most once an hour: the thread's conversation container (FEP-171b's
|
||||
`contextHistory`: the posts its owner's `Add`s brought in) or its `context` collection (FEP-7888) if it has one, else
|
||||
its `replies` (PeerTube's `comments`) and theirs, two levels down; 5 pages and 100 posts at most. Only public and
|
||||
unlisted replies are kept, each fetched from its own origin. A thread collection read whole, which counts its posts or
|
||||
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
|
||||
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
|
||||
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
|
||||
`/peasants/privapub_reports` and the reader of feeds `/peasants/privapub_feeds`) are the exception, since their keys
|
||||
are needed first, and so are its custom emoji at `/grimaces/{shortcode}`.
|
||||
A browser asking for HTML is redirected to the public page instead.
|
||||
- **Posts that are not public** (followers-only, direct, circle) are served to a signed request from someone they were
|
||||
for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted
|
||||
they answer those same readers 410. A direct message's `context`, `/peasants/{name}/whispers/{id}`, is an
|
||||
`OrderedCollection` of the conversation's posts for its participants.
|
||||
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
|
||||
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
|
||||
- **Activity ids.** `Create` and `Announce` ids dereference, a community's announces included, until the post they
|
||||
carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. `Follow`, `Like`, `Block` and
|
||||
the `Accept`, `Reject` and `Undo` that answer them do not, because serving them would reveal who follows, likes and
|
||||
blocks whom. Neither do `Update`, `Delete`, `EmojiReact`, `QuoteRequest` and its answers, `Flag`, `Ignore` or poll
|
||||
votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its
|
||||
own (`announce-{boost}`, `like-{favourite}`), so one given again after its `Undo` is new to the server it reaches.
|
||||
- **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag.
|
||||
- **Delivery.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it
|
||||
follows, names or answers (a circle post excepted: each member's copy names that member). Failed deliveries are
|
||||
retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||
starting at an hour and growing to a week: unreachable, timing out, or a gateway answering 502, 503 or 504. A 500 is
|
||||
the server failing on that one activity, retried on its own while its other deliveries go on. A server can also take a Follow (202) and drop it afterwards, as Pleroma
|
||||
does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the
|
||||
persona follows once more, at most once an hour.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed
|
||||
through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
|
||||
- Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's
|
||||
follower and following counts, and the outbox's `totalItems` is the persona's post count, though only public posts
|
||||
are listed. `/api/v1/instance/peers` is empty: which servers this one talks to is not published.
|
||||
- HTTP signatures are verified with RSA keys only, under draft-cavage or RFC 9421. What PrivaPub sends is signed with
|
||||
draft-cavage only, which every server reads.
|
||||
- **Integrity proofs** (FEP-8b32, FEP-521a). Every persona has an Ed25519 key of its own (never shared between personas),
|
||||
named in its actor's `assertionMethod` as a `Multikey` (`…#ed25519-key`, `publicKeyMultibase`); the terms are defined
|
||||
in the actor's own context, so no context document has to be fetched. A persona's activity going to a relay carries a
|
||||
`DataIntegrityProof` (`eddsa-jcs-2022`, `proofPurpose` `assertionMethod`) over the activity as delivered; Mastodon
|
||||
4.7 verifies it (checked against its own verifier), so what the relay forwards reaches it. Nothing else carries one:
|
||||
Mitra takes a proof over the HTTP signature, and refuses one by a key it has not read yet without reading the actor
|
||||
again, which every server that knew a persona before it had its key would do. What a persona passes on of others'
|
||||
carries theirs or none. Received: an actor's own Multikeys (at most five, under its id, controlled by it) are kept;
|
||||
a forwarded activity whose proof one of them verifies is taken as it came, instead of being read again from its origin
|
||||
(an unknown key of the actor's has the actor read again first).
|
||||
@@ -0,0 +1,38 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
public class DomainBlockForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(253, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Domain { get; set; }
|
||||
|
||||
public bool Suspend { get; set; } = true;
|
||||
public bool RejectMedia { get; set; }
|
||||
// listed at /api/v1/instance/domain_blocks (off by default: it tells the blocked server, and everyone, of the block)
|
||||
public bool Published { get; set; } = false;
|
||||
public bool Obfuscate { get; set; } = false;//listed with part of its name hidden
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PublicComment { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PrivateComment { get; set; }
|
||||
}
|
||||
|
||||
public class ViewDomainBlock
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Domain { get; set; }
|
||||
public string Severity { get; set; }
|
||||
public bool RejectMedia { get; set; }
|
||||
public string PublicComment { get; set; }
|
||||
public string PrivateComment { get; set; }
|
||||
public bool Published { get; set; } = false;
|
||||
public bool Obfuscate { get; set; } = false;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// The staff's acts on accounts and posts (completion program R6). Moderators silence and suspend personas here and
|
||||
// accounts elsewhere, remove posts and resolve reports with one of those acts; administrators also change logins
|
||||
// (roles, bans, deletions, two-factor sign-in). Every act is kept in the staff's log.
|
||||
public static class ModerationActions
|
||||
{
|
||||
public const string None = "none";
|
||||
public const string Silence = "silence";
|
||||
public const string Unsilence = "unsilence";
|
||||
public const string Suspend = "suspend";
|
||||
public const string Unsuspend = "unsuspend";
|
||||
public const string RemovePosts = "remove_posts";//a report's resolution only: the posts it names
|
||||
|
||||
public static bool IsAccountAction(string action) => action is Silence or Unsilence or Suspend or Unsuspend;
|
||||
|
||||
public static bool IsResolution(string action) => action is None or Silence or Suspend or RemovePosts;
|
||||
}
|
||||
|
||||
// POST /clientapi/moderator/accounts/moderate: a persona here (its id) or an account elsewhere (its id here)
|
||||
public class ModerateAccountForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AccountId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Action { get; set; }//ModerationActions: silence, unsilence, suspend, unsuspend
|
||||
|
||||
// the staff's own words: kept in the log and on the account, never shown to it
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
|
||||
// what a persona here is told with its warning (Mastodon's moderation_warning); nothing reaches an account elsewhere
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/moderator/posts/remove: a post here is deleted as its author would (the deletion federates), a copy of
|
||||
// a post elsewhere is taken down here only
|
||||
public class RemovePostForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PostId { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/moderator/reports/{id}/resolve: the body is optional; without one, or with action none, the report is
|
||||
// only marked resolved
|
||||
public class ResolveReportForm
|
||||
{
|
||||
[StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Action { get; set; } = ModerationActions.None;//none, silence, suspend, remove_posts
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Warning { get; set; }
|
||||
}
|
||||
|
||||
// an account as moderators see it: a persona here (never its login) or an account elsewhere, and what the staff did to it
|
||||
public class ViewModeratedAccount
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool IsLocal { get; set; } = false;
|
||||
public string UserName { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string Handle { get; set; }//user@host
|
||||
public string Url { get; set; }//its actor's id
|
||||
public string Domain { get; set; }
|
||||
public DateTime? SilencedAt { get; set; }
|
||||
public DateTime? SuspendedAt { get; set; }
|
||||
public bool DomainSilenced { get; set; } = false;//an account elsewhere whose server is silenced: limited without its own silence
|
||||
public bool DomainSuspended { get; set; } = false;
|
||||
public string Note { get; set; }//the staff's last note on it
|
||||
}
|
||||
|
||||
// GET /clientapi/moderator/accounts?q= and /clientapi/moderator/accounts/moderated?offset=&limit=: Next is the offset of
|
||||
// the next page, null on the last
|
||||
public class ViewModeratedAccountsPage
|
||||
{
|
||||
public List<ViewModeratedAccount> Items { get; set; } = [];
|
||||
public int? Next { get; set; }
|
||||
}
|
||||
|
||||
// a login as administrators see it (GET /clientapi/admin/roots), with its personas: only administrators ever read a login
|
||||
// next to its personas
|
||||
public class ViewAdminRoot
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Role { get; set; }//user, moderator, admin
|
||||
public bool IsBanned { get; set; } = false;
|
||||
public bool HasEmail { get; set; } = false;
|
||||
public bool IsEmailValidated { get; set; } = false;
|
||||
public bool HasTotp { get; set; } = false;
|
||||
public bool IsYou { get; set; } = false;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public List<ViewAdminPersona> Personas { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ViewAdminPersona
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public DateTime? SilencedAt { get; set; }
|
||||
public DateTime? SuspendedAt { get; set; }
|
||||
public List<ViewAdminGroup> Groups { get; set; } = [];//the groups it owns
|
||||
}
|
||||
|
||||
public class ViewAdminGroup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool IsCommunity { get; set; } = false;
|
||||
public int MembersCount { get; set; } = 0;
|
||||
}
|
||||
|
||||
// newest first; Next is the `before` of the next page, null on the last
|
||||
public class ViewAdminRootsPage
|
||||
{
|
||||
public List<ViewAdminRoot> Items { get; set; } = [];
|
||||
public string Next { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/admin/roots/{id}/role
|
||||
public class RootRoleForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Role { get; set; }//user, moderator, admin
|
||||
}
|
||||
|
||||
// what can't be undone asks for the administrator's own password: deleting a persona or a group
|
||||
public class AdminPasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Note { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// the staff's log, newest first (GET /clientapi/moderator/log?before=&limit=): Next is the `before` of the next page,
|
||||
// null on the last
|
||||
public class ViewAdminActionsPage
|
||||
{
|
||||
public List<ViewAdminAction> Items { get; set; } = [];
|
||||
public string Next { get; set; }
|
||||
}
|
||||
|
||||
public class ViewAdminAction
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public DateTime At { get; set; }
|
||||
// who acted: a login name (to administrators; to a moderator only its own), "cli" for the command line, null when not
|
||||
// told (an act on a persona of the acting login keeps nobody's name)
|
||||
public string By { get; set; }
|
||||
public bool ByYou { get; set; } = false;
|
||||
public string Action { get; set; }//e.g. domain_block.insert, settings.update, report.resolve, root.promote
|
||||
public string TargetKind { get; set; }//root, persona, account, domain, report, settings, rule, announcement, emoji, relay, backup
|
||||
public string TargetId { get; set; }
|
||||
public string TargetLabel { get; set; }
|
||||
public string ReportId { get; set; }
|
||||
public string Note { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// an announcement as the administrator's page shows it (/clientapi/admin/announcements/list): its Markdown and what it
|
||||
// renders to, whether it is published and shown now, how many personas dismissed it and its reactions
|
||||
public class ViewAnnouncement
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string Html { get; set; }
|
||||
public DateTime? StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public bool AllDay { get; set; } = false;
|
||||
public bool IsPublished { get; set; } = false;
|
||||
public bool IsShown { get; set; } = false;//published and not ended
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public long Reads { get; set; } = 0;
|
||||
public List<ViewAnnouncementReaction> Reactions { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ViewAnnouncementReaction
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public long Count { get; set; } = 0;
|
||||
}
|
||||
|
||||
// insert (no Id) and update (its Id)
|
||||
public class AnnouncementForm
|
||||
{
|
||||
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Id { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(5000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Text { get; set; }//Markdown
|
||||
|
||||
public DateTime? StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }//once past, it is no longer shown
|
||||
public bool AllDay { get; set; } = false;
|
||||
public bool IsPublished { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// the server's backups as the administrator's page shows them, with what is running and the last restore
|
||||
public class ViewBackups
|
||||
{
|
||||
public List<ViewBackup> Backups { get; set; } = [];
|
||||
public string Running { get; set; }//"backup" or "restore" while one runs
|
||||
public ViewRestoreWaiting Waiting { get; set; }
|
||||
public ViewRestore LastRestore { get; set; }
|
||||
public string Host { get; set; }//what a restore asks to be typed
|
||||
public long FreeBytes { get; set; }
|
||||
}
|
||||
|
||||
public class ViewBackup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Kind { get; set; }//nightly, manual, pre-deploy, pre-restore, uploaded
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public long Bytes { get; set; }//the database's files
|
||||
public long MediaBytes { get; set; }
|
||||
public int MediaFiles { get; set; }
|
||||
public int MediaMissing { get; set; }
|
||||
public bool DbOnly { get; set; }
|
||||
public bool Consistent { get; set; }
|
||||
public int Collections { get; set; }
|
||||
public long Documents { get; set; }
|
||||
public string AppCommit { get; set; }
|
||||
public int MigrationNumber { get; set; }
|
||||
public int RootsDeletedSince { get; set; }//logins deleted after it, which a restore deletes again
|
||||
public string NotRestorable { get; set; }//why not, from its manifest; hashes are checked when asked
|
||||
}
|
||||
|
||||
public class ViewRestoreWaiting
|
||||
{
|
||||
public string Backup { get; set; }
|
||||
public string State { get; set; }
|
||||
public int Attempts { get; set; }
|
||||
public string RequestedBy { get; set; }
|
||||
public DateTime RequestedAt { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
public class ViewRestore
|
||||
{
|
||||
public string Backup { get; set; }
|
||||
public DateTime BackupCreatedAt { get; set; }
|
||||
public string PreRestore { get; set; }
|
||||
public string RequestedBy { get; set; }
|
||||
public DateTime RestoredAt { get; set; }
|
||||
public bool Abandoned { get; set; }
|
||||
public string Error { get; set; }
|
||||
public long Documents { get; set; }
|
||||
public int Collections { get; set; }
|
||||
public int MediaRestored { get; set; }
|
||||
public int MediaMissing { get; set; }
|
||||
public int RootsDeleted { get; set; }
|
||||
public int PersonasDeleted { get; set; }
|
||||
public int GroupsDeleted { get; set; }
|
||||
public int PostsDeleted { get; set; }
|
||||
public List<string> RootsTombstoned { get; set; } = [];
|
||||
public List<string> PersonasTombstoned { get; set; } = [];
|
||||
public List<string> GroupsTombstoned { get; set; } = [];
|
||||
public int PostsGone { get; set; }
|
||||
public int MediaTrashed { get; set; }
|
||||
public int ProtectiveKept { get; set; }
|
||||
public int SessionsEnded { get; set; }
|
||||
}
|
||||
|
||||
// a backup downloaded or restored: the administrator's password again, and for a restore the server's host typed out
|
||||
public class BackupPasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
||||
public class RestoreBackupForm : BackupPasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(253, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Host { get; set; }
|
||||
}
|
||||
|
||||
// a download link good for a while, for one backup
|
||||
public class ViewBackupTicket
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public long Bytes { get; set; }
|
||||
}
|
||||
|
||||
// a backup being uploaded in pieces: how much has arrived
|
||||
public class ViewBackupUpload
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public long Received { get; set; }
|
||||
public long ChunkBytes { get; set; }//the largest piece accepted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// one of the server's custom emoji as the administrator's page shows it (/clientapi/admin/emoji/list, completion program
|
||||
// R7): disabled ones too, deleted ones never
|
||||
public class ViewLocalEmoji
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Shortcode { get; set; }
|
||||
public string Category { get; set; }
|
||||
public bool VisibleInPicker { get; set; } = true;
|
||||
public bool Disabled { get; set; } = false;
|
||||
public string Url { get; set; }//its picture: a PNG, or an animated GIF
|
||||
public string StaticUrl { get; set; }//a still PNG of it
|
||||
public string ContentType { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public long Size { get; set; } = 0;
|
||||
public string Uri { get; set; }//its id on other servers (/grimaces/{shortcode}): why a shortcode never changes
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public DateTime ImageUpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
// POST /clientapi/admin/emoji/insert and /emoji/update, as multipart/form-data with the picture as `image` (PNG, GIF,
|
||||
// WebP or JPEG, 256 KB at most; required to insert, optional to update). The shortcode names the emoji: an update
|
||||
// changes the emoji with that shortcode, and an emoji is never renamed (its shortcode is its id elsewhere); an update
|
||||
// sends the emoji as it should be, its category (none when empty), whether it is in the picker and whether it is
|
||||
// disabled.
|
||||
public class LocalEmojiForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.ShortcodeRegex, ErrorMessageResourceName = "InvalidShortcode", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Shortcode { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Category { get; set; }
|
||||
|
||||
public bool VisibleInPicker { get; set; } = true;
|
||||
public bool Disabled { get; set; } = false;//kept, but offered nowhere and taken into no new post
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// a report as moderators read it: who sent it ("local" for a persona here, never which one; or the remote actor), whom
|
||||
// and which posts it is about, and whether it was resolved
|
||||
public class ViewReport
|
||||
{
|
||||
public string ID { get; set; }
|
||||
public string Reporter { get; set; }
|
||||
public string TargetAccountId { get; set; }
|
||||
public string TargetActorURI { get; set; }
|
||||
public List<string> PostIds { get; set; } = new();
|
||||
public List<string> ObjectURIs { get; set; } = new();
|
||||
public string Category { get; set; }
|
||||
public List<string> RuleIds { get; set; } = [];//the server's rules it says were broken
|
||||
public List<string> Rules { get; set; } = [];//their texts, in the same order (a deleted rule's too)
|
||||
public string Comment { get; set; }
|
||||
public bool Forwarded { get; set; }
|
||||
public bool IsResolved { get; set; }
|
||||
public string Resolution { get; set; }//what resolving it did: none, silence, suspend, remove_posts (null before R6)
|
||||
public string ResolutionNote { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it
|
||||
public string RemoteResolvedBy { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// The server's own settings as the administrator's page shows them (GET /clientapi/admin/settings): what the instance
|
||||
// API, NodeInfo, the instance actor, the web pages and the emails say of the server, its rules, its relays, its media
|
||||
// limits next to the configured ones, and whether its weekly activity is published. Registrations are read-only: they
|
||||
// stay in the configuration (Registrations:Mode).
|
||||
public class ViewServerSettings
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string ShortDescription { get; set; }
|
||||
public string Description { get; set; }//Markdown
|
||||
public DateTime? DescriptionUpdatedAt { get; set; }
|
||||
public string ContactEmail { get; set; }
|
||||
public string ContactAccountId { get; set; }
|
||||
public ViewContactAccount ContactAccount { get; set; }//null when none is set, or it is gone
|
||||
public string ThumbnailUrl { get; set; }//what the instance API shows (the default picture when none was uploaded)
|
||||
public bool ThumbnailIsDefault { get; set; } = true;
|
||||
public string PrivacyPolicy { get; set; }//Markdown
|
||||
public DateTime? PrivacyPolicyUpdatedAt { get; set; }
|
||||
public string TermsOfService { get; set; }//Markdown; null: none
|
||||
public DateTime? TermsEffectiveAt { get; set; }
|
||||
public List<ViewServerRule> Rules { get; set; } = [];//deleted ones too (IsDeleted), as old reports name them
|
||||
public List<ViewRelay> Relays { get; set; } = [];
|
||||
public List<ViewStaffPick> StaffPicks { get; set; } = [];//the accounts suggested first to every persona (R8), their own routes
|
||||
public ViewMediaLimits Media { get; set; } = new();
|
||||
public string RegistrationMode { get; set; }//"Invitations" or "Open", from the configuration
|
||||
public bool PublishActivity { get; set; } = false;
|
||||
public DateTime? UpdatedAt { get; set; }//null: never saved, every value is the default
|
||||
public string UpdatedBy { get; set; }//the administrator's login, or "cli"
|
||||
}
|
||||
|
||||
public class ViewContactAccount
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string UserName { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Handle { get; set; }//user@host
|
||||
}
|
||||
|
||||
public class ViewServerRule
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string Hint { get; set; }
|
||||
public int Position { get; set; } = 0;
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
// the administrator's overrides (null: the configured value holds), what the configuration says, and the hard caps
|
||||
public class ViewMediaLimits
|
||||
{
|
||||
public long? QuotaBytesPerRoot { get; set; }
|
||||
public long? MaxImageBytes { get; set; }
|
||||
public long? MaxVideoBytes { get; set; }
|
||||
public int? MaxSeconds { get; set; }
|
||||
public long ConfiguredQuotaBytesPerRoot { get; set; } = 0;//0: no quota
|
||||
public long ConfiguredMaxImageBytes { get; set; } = 0;
|
||||
public long ConfiguredMaxVideoBytes { get; set; } = 0;
|
||||
public int ConfiguredMaxSeconds { get; set; } = 0;
|
||||
public long EffectiveQuotaBytesPerRoot { get; set; } = 0;
|
||||
public long EffectiveMaxImageBytes { get; set; } = 0;
|
||||
public long EffectiveMaxVideoBytes { get; set; } = 0;
|
||||
public int EffectiveMaxSeconds { get; set; } = 0;
|
||||
public long HardMaxImageBytes { get; set; } = ServerMediaLimitsForm.HardMaxImageBytes;
|
||||
public long HardMaxVideoBytes { get; set; } = ServerMediaLimitsForm.HardMaxVideoBytes;
|
||||
public int HardMaxSeconds { get; set; } = ServerMediaLimitsForm.HardMaxSeconds;
|
||||
}
|
||||
|
||||
// POST /clientapi/admin/settings: everything the page edits at once (relays and the thumbnail have their own routes).
|
||||
// A rule keeps its id when the form names it; one without an id is new; one left out is deleted (kept, marked).
|
||||
public class ServerSettingsForm
|
||||
{
|
||||
public const int MaxRules = 50;
|
||||
public const int MaxText = 20000;
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Title", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Title { get; set; }
|
||||
|
||||
[StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string ShortDescription { get; set; }
|
||||
|
||||
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Description", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Description { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(254, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
|
||||
public string ContactEmail { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string ContactAccountId { get; set; }//a local persona's account id (its Mastodon id), or none
|
||||
|
||||
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PrivacyPolicy { get; set; }
|
||||
|
||||
[StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string TermsOfService { get; set; }//empty: none
|
||||
|
||||
public DateTime? TermsEffectiveAt { get; set; }//a date; today when terms are given without one
|
||||
|
||||
[MaxLength(MaxRules, ErrorMessageResourceName = "MaxLengthArray", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public List<ServerRuleForm> Rules { get; set; } = [];
|
||||
|
||||
public ServerMediaLimitsForm Media { get; set; } = new();
|
||||
|
||||
public bool PublishActivity { get; set; } = false;
|
||||
}
|
||||
|
||||
public class ServerRuleForm
|
||||
{
|
||||
[StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Id { get; set; }//an existing rule's id, or none for a new rule
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Text { get; set; }
|
||||
|
||||
[StringLength(1000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Hint { get; set; }
|
||||
}
|
||||
|
||||
// null keeps the configured value
|
||||
public class ServerMediaLimitsForm
|
||||
{
|
||||
public const long HardMaxImageBytes = 40L * 1024 * 1024;
|
||||
public const long HardMaxVideoBytes = 99L * 1024 * 1024;
|
||||
public const int HardMaxSeconds = 4 * 3600;
|
||||
|
||||
[Range(0L, long.MaxValue, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public long? QuotaBytesPerRoot { get; set; }//0: no quota
|
||||
|
||||
[Range(1L, HardMaxImageBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public long? MaxImageBytes { get; set; }
|
||||
|
||||
[Range(1L, HardMaxVideoBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public long? MaxVideoBytes { get; set; }
|
||||
|
||||
[Range(1, HardMaxSeconds, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public int? MaxSeconds { get; set; }
|
||||
}
|
||||
|
||||
// a relay the instance actor subscribes to: named by the configuration (Federation:Relays, removed only there) or by
|
||||
// the settings (added and removed from the page)
|
||||
public class ViewRelay
|
||||
{
|
||||
public string Address { get; set; }
|
||||
public string Source { get; set; }//"configuration" or "settings"
|
||||
public string State { get; set; }//"pending", "accepted", "rejected", or "none" before its first request
|
||||
public string ActorURI { get; set; }
|
||||
public DateTime? RequestedAt { get; set; }
|
||||
public DateTime? AnsweredAt { get; set; }
|
||||
}
|
||||
|
||||
// an account the administrators suggest to every persona here, first (R8: Mastodon's staff picks, `featured` among a
|
||||
// suggestion's sources): a persona of this server or an account elsewhere, by its Mastodon account id. Added and removed
|
||||
// on their own routes (/clientapi/admin/staff-picks), never through the settings form, so an older page saving the form
|
||||
// never drops them.
|
||||
public class ViewStaffPick
|
||||
{
|
||||
public string AccountId { get; set; }
|
||||
public bool IsLocal { get; set; } = false;
|
||||
public string Handle { get; set; }//user@host
|
||||
public string Name { get; set; }
|
||||
public bool IsShown { get; set; } = true;//false: deleted, suspended or silenced since, so suggested to nobody
|
||||
}
|
||||
|
||||
public class StaffPickForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AccountId { get; set; }
|
||||
}
|
||||
|
||||
public class RelayForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(2048, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Address { get; set; }//the relay's actor (https://relay.example/actor) or its inbox
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
namespace PrivaPub.ClientModels.Admin
|
||||
{
|
||||
// /clientapi/admin/statistics/overview: the last days' federation at a glance; counters keyed as the ledger keys them
|
||||
public class ViewStatisticsOverview
|
||||
{
|
||||
public int Days { get; set; }
|
||||
public Dictionary<string, long> Channels { get; set; } = new();
|
||||
public Dictionary<string, long> Inbound { get; set; } = new();
|
||||
public Dictionary<string, long> Outbound { get; set; } = new();
|
||||
public double? DeliverySuccess { get; set; }
|
||||
public ViewLatency DeliveryLatencyMs { get; set; } = new();
|
||||
public ViewHostCounts Hosts { get; set; } = new();
|
||||
public int Accounts { get; set; }
|
||||
public ViewLedgerCounts Ledger { get; set; } = new();
|
||||
public ViewMediaTotals Media { get; set; } = new();
|
||||
}
|
||||
|
||||
// what media take on the server: the uploads and pictures kept, the remote media cache, the trash waiting its grace
|
||||
public class ViewMediaTotals
|
||||
{
|
||||
public long MediaBytes { get; set; }
|
||||
public long MediaFiles { get; set; }
|
||||
public long ProxyCacheBytes { get; set; }
|
||||
public long TrashBytes { get; set; }
|
||||
}
|
||||
|
||||
public class ViewLatency
|
||||
{
|
||||
public int? P50 { get; set; }
|
||||
public int? P95 { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostCounts
|
||||
{
|
||||
public int Active { get; set; }
|
||||
public long Known { get; set; }
|
||||
}
|
||||
|
||||
public class ViewLedgerCounts
|
||||
{
|
||||
public long Written { get; set; }
|
||||
public long Dropped { get; set; }
|
||||
public long Failed { get; set; }
|
||||
}
|
||||
|
||||
// /clientapi/admin/statistics/hosts: one page of the servers we exchanged with
|
||||
public class ViewHostsPage
|
||||
{
|
||||
public int Total { get; set; }
|
||||
public int Page { get; set; }
|
||||
public List<ViewHostRow> Hosts { get; set; } = new();
|
||||
public string Attribution { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostRow
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Software { get; set; }
|
||||
public string Version { get; set; }
|
||||
public long In { get; set; }
|
||||
public long Out { get; set; }
|
||||
public long Refused { get; set; }
|
||||
public long Failures { get; set; }
|
||||
public int? LatencyP50 { get; set; }
|
||||
public int Accounts { get; set; }
|
||||
public DateTime? DescribedAt { get; set; }
|
||||
public ViewHostDelivery Delivery { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostDelivery
|
||||
{
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public DateTime? UnavailableUntil { get; set; }
|
||||
public DateTime? LastSuccessAt { get; set; }
|
||||
public DateTime? LastFailureAt { get; set; }
|
||||
}
|
||||
|
||||
// /clientapi/admin/statistics/crawler: how far the crawler got
|
||||
public class ViewCrawler
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public long Touched { get; set; }
|
||||
public long Crawled { get; set; }
|
||||
public long Due { get; set; }
|
||||
public long RobotsDisallowed { get; set; }
|
||||
public List<ViewCrawledHost> Recent { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ViewCrawledHost
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Seen { get; set; }
|
||||
public DateTime? LastCrawledAt { get; set; }
|
||||
public int? PeersCount { get; set; }
|
||||
public bool? RobotsDisallowed { get; set; }
|
||||
public string CrawlError { get; set; }
|
||||
public string Software { get; set; }
|
||||
}
|
||||
|
||||
// /clientapi/admin/statistics/hosts/{host}: one server, what it says of itself, its days and its latest events
|
||||
public class ViewHostDetail
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public ViewHostInstance Instance { get; set; }
|
||||
public List<ViewHostDay> Days { get; set; } = [];
|
||||
public List<ViewInteractionEvent> Events { get; set; } = [];
|
||||
public string Attribution { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostInstance
|
||||
{
|
||||
public string Software { get; set; }
|
||||
public string SoftwareVersion { get; set; }
|
||||
public string NodeName { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string NodeDescription { get; set; }
|
||||
public bool? OpenRegistrations { get; set; }
|
||||
public string RegistrationMode { get; set; }
|
||||
public long? UsersTotal { get; set; }
|
||||
public long? UsersActiveMonth { get; set; }
|
||||
public long? LocalPosts { get; set; }
|
||||
public List<string> Languages { get; set; } = [];
|
||||
public string Seen { get; set; }
|
||||
public DateTime? FirstSeenAt { get; set; }
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
public DateTime? DescribedAt { get; set; }
|
||||
public string DescriptionError { get; set; }
|
||||
public DateTime? LastSuccessAt { get; set; }
|
||||
public DateTime? LastFailureAt { get; set; }
|
||||
public string LastError { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public DateTime? UnavailableUntil { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string City { get; set; }
|
||||
public int? Asn { get; set; }
|
||||
public string AsnOrg { get; set; }
|
||||
public string Cdn { get; set; }
|
||||
public string InstanceApi { get; set; }
|
||||
public string SourceUrl { get; set; }
|
||||
}
|
||||
|
||||
public class ViewHostDay
|
||||
{
|
||||
public DateTime Day { get; set; }
|
||||
public Dictionary<string, long> Counters { get; set; } = new();
|
||||
public Dictionary<string, long> Latency { get; set; } = new();
|
||||
public Dictionary<string, long> Bytes { get; set; } = new();
|
||||
public Dictionary<string, long> Reads { get; set; } = new();
|
||||
public int Accounts { get; set; }
|
||||
public bool Live { get; set; }//today's or a day not rolled up yet, folded from its events
|
||||
}
|
||||
|
||||
// one ledger entry: what was received, delivered or fetched, and how it ended; never who
|
||||
public class ViewInteractionEvent
|
||||
{
|
||||
public DateTime At { get; set; }
|
||||
public string Host { get; set; }
|
||||
public string Channel { get; set; }
|
||||
public string Activity { get; set; }
|
||||
public string Object { get; set; }
|
||||
public string Purpose { get; set; }
|
||||
public string Trigger { get; set; }
|
||||
public string Outcome { get; set; }
|
||||
public string Reason { get; set; }
|
||||
public int? Status { get; set; }
|
||||
public int? LatencyMs { get; set; }
|
||||
public int? WaitMs { get; set; }
|
||||
public long? Bytes { get; set; }
|
||||
public int? Attempt { get; set; }
|
||||
public string Audience { get; set; }
|
||||
public string LocalKind { get; set; }
|
||||
public string Inbox { get; set; }
|
||||
public string Signature { get; set; }
|
||||
public List<string> Features { get; set; } = [];
|
||||
public long? AgeSeconds { get; set; }
|
||||
}
|
||||
|
||||
// /clientapi/admin/statistics/server: the server's own day, its client API and the documents it served
|
||||
public class ViewServerDay
|
||||
{
|
||||
public DateTime Day { get; set; }
|
||||
public Dictionary<string, long> Client { get; set; } = new();
|
||||
public Dictionary<string, long> ClientLatency { get; set; } = new();
|
||||
public Dictionary<string, long> Served { get; set; } = new();
|
||||
public long LedgerWritten { get; set; }
|
||||
public long LedgerDropped { get; set; }
|
||||
public long LedgerFailed { get; set; }
|
||||
public int Accounts { get; set; }
|
||||
public int Hosts { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,8 @@
|
||||
public const int GroupInvitationLength = 64;
|
||||
|
||||
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
|
||||
public const string UserNameRegex = "^[a-z0-9_]+$";
|
||||
// a custom emoji's shortcode: Mastodon's rule (letters, digits and underscores, two at least), 64 at most
|
||||
public const string ShortcodeRegex = "^[a-zA-Z0-9_]{2,64}$";
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression("^[a-z0-9_]+$", ErrorMessageResourceName = "EmptySpacesNotAllowed", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
NoWhiteSpaces]
|
||||
public string UserName { get; set; }
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string Description { get; set; }
|
||||
|
||||
public string InvitationPassword { get; set; }
|
||||
public bool IsCommunity { get; set; }
|
||||
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
|
||||
public bool IsDiscoverable { get; set; } = true;
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,19 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string InvitationPassword { get; set; }
|
||||
}
|
||||
|
||||
// the group's owner deletes it (POST /clientapi/group/delete), with the login's password: its members are told, and its
|
||||
// name stays taken
|
||||
public class DeleteGroupForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string GroupId { get; set; }
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
||||
public class GroupMembershipForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
|
||||
public bool RemoveInvitationPassword { get; set; }
|
||||
public bool RegenerateInvitationCode { get; set; }
|
||||
public bool? IsDiscoverable { get; set; }
|
||||
public string PostingPolicy { get; set; }
|
||||
public bool? ManuallyApprovesMembers { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ namespace PrivaPub.ClientModels.Group
|
||||
public string Description { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public bool IsCommunity { get; set; }
|
||||
public string PostingPolicy { get; set; }
|
||||
public bool IsDiscoverable { get; set; }
|
||||
public bool ManuallyApprovesMembers { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PrivaPub.ClientModels.Group
|
||||
{
|
||||
// someone in a group, or asking to join it, as its owner and moderators see them
|
||||
public class ViewGroupMember
|
||||
{
|
||||
public string ActorUri { get; set; }
|
||||
public string Handle { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string PictureUrl { get; set; }
|
||||
public bool IsLocal { get; set; }
|
||||
public bool IsPending { get; set; }
|
||||
public string Role { get; set; }//owner, moderator, member
|
||||
public DateTime Since { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,12 @@ namespace PrivaPub.ClientModels
|
||||
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
|
||||
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string InvitationCode { get; set; }
|
||||
|
||||
[StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarUserName { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,10 @@ namespace PrivaPub.ClientModels
|
||||
public bool ThemeIsDarkMode { get; set; } = false;
|
||||
|
||||
public string InvitationPassword { get; set; }
|
||||
|
||||
// the authenticator app's code, or one of the recovery codes, for a login with two-factor sign-in; asked for with a
|
||||
// 428 once the password was right
|
||||
[StringLength(32, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string OneTimeCode { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,41 @@ namespace PrivaPub.ClientModels.Post
|
||||
public string GroupId { get; set; }
|
||||
public string AnsweringToPostId { get; set; }
|
||||
public bool HasContentWarning { get; set; }
|
||||
|
||||
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string SpoilerText { get; set; }
|
||||
|
||||
public string Visibility { get; set; }//public (default), unlisted, followersonly
|
||||
|
||||
[Range(-90, 90, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public double? Latitude { get; set; }
|
||||
|
||||
[Range(-180, 180, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public double? Longitude { get; set; }
|
||||
|
||||
[Range(1, 50, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public double? RangeKm { get; set; }
|
||||
}
|
||||
|
||||
public class UpdatePostForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string PostId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(5000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Text { get; set; }
|
||||
|
||||
[StringLength(200, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Title { get; set; }
|
||||
|
||||
public bool HasContentWarning { get; set; }
|
||||
|
||||
[StringLength(500, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string SpoilerText { get; set; }
|
||||
}
|
||||
|
||||
public class InsertDmForm
|
||||
|
||||
@@ -8,12 +8,17 @@ namespace PrivaPub.ClientModels.Post
|
||||
public string AuthorActorURI { get; set; }
|
||||
public string GroupId { get; set; }
|
||||
public string DmGroupId { get; set; }
|
||||
public string Visibility { get; set; }
|
||||
public string AnsweringToPostId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string ContentHtml { get; set; }
|
||||
public bool HasContentWarning { get; set; }
|
||||
public bool IsFederatedCopy { get; set; }
|
||||
public DateTime CreationDate { get; set; }
|
||||
public ViewPost ReblogOf { get; set; }
|
||||
public double? RangeKm { get; set; }
|
||||
public double? DistanceKm { get; set; }
|
||||
}
|
||||
|
||||
public class ViewDmGroup
|
||||
|
||||
@@ -105,6 +105,24 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use only lower-case letters, digits and underscores..
|
||||
/// </summary>
|
||||
public static string InvalidUserName {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidUserName", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use only letters, digits and underscores, from 2 to 64 of them..
|
||||
/// </summary>
|
||||
public static string InvalidShortcode {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidShortcode", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
|
||||
/// </summary>
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
<data name="InvalidEmail" xml:space="preserve">
|
||||
<value>{0} è invalida.</value>
|
||||
</data>
|
||||
<data name="InvalidUserName" xml:space="preserve">
|
||||
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
|
||||
</data>
|
||||
<data name="InvalidShortcode" xml:space="preserve">
|
||||
<value>Usa solo lettere, cifre e trattini bassi, da 2 a 64.</value>
|
||||
</data>
|
||||
<data name="InvalidPassword" xml:space="preserve">
|
||||
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
|
||||
</data>
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
<data name="InvalidEmail" xml:space="preserve">
|
||||
<value>{0} is invalid.</value>
|
||||
</data>
|
||||
<data name="InvalidUserName" xml:space="preserve">
|
||||
<value>Use only lower-case letters, digits and underscores.</value>
|
||||
</data>
|
||||
<data name="InvalidShortcode" xml:space="preserve">
|
||||
<value>Use only letters, digits and underscores, from 2 to 64 of them.</value>
|
||||
</data>
|
||||
<data name="InvalidPassword" xml:space="preserve">
|
||||
<value>Invalid password, upper-case, lower-case and number characters required.</value>
|
||||
</data>
|
||||
|
||||
@@ -60,132 +60,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to (necessary in case of password recovery).
|
||||
/// </summary>
|
||||
public static string _necessary_in_case_of_password_recovery_ {
|
||||
get {
|
||||
return ResourceManager.GetString("(necessary in case of password recovery)", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to (<a class="is-underlined has-text-info" tabindex="-1" target="_blank" href="https://keepassxc.org/">password manager</a> suggested).
|
||||
/// </summary>
|
||||
public static string _password_manager_suggested_ {
|
||||
get {
|
||||
return ResourceManager.GetString("(password manager suggested)", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Amount.
|
||||
/// </summary>
|
||||
public static string Amount {
|
||||
get {
|
||||
return ResourceManager.GetString("Amount", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Answer id.
|
||||
/// </summary>
|
||||
public static string AnswerId {
|
||||
get {
|
||||
return ResourceManager.GetString("AnswerId", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Answer text.
|
||||
/// </summary>
|
||||
public static string AnswerText {
|
||||
get {
|
||||
return ResourceManager.GetString("AnswerText", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Custom character(emoji).
|
||||
/// </summary>
|
||||
public static string Character {
|
||||
get {
|
||||
return ResourceManager.GetString("Character", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Colour.
|
||||
/// </summary>
|
||||
public static string ColourIndex {
|
||||
get {
|
||||
return ResourceManager.GetString("ColourIndex", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Comment id.
|
||||
/// </summary>
|
||||
public static string CommentId {
|
||||
get {
|
||||
return ResourceManager.GetString("CommentId", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Comment text.
|
||||
/// </summary>
|
||||
public static string CommentText {
|
||||
get {
|
||||
return ResourceManager.GetString("CommentText", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Confrontation.
|
||||
/// </summary>
|
||||
public static string Confrontation {
|
||||
get {
|
||||
return ResourceManager.GetString("Confrontation", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Confrontation id.
|
||||
/// </summary>
|
||||
public static string ConfrontationId {
|
||||
get {
|
||||
return ResourceManager.GetString("ConfrontationId", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Context.
|
||||
/// </summary>
|
||||
public static string Context {
|
||||
get {
|
||||
return ResourceManager.GetString("Context", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to End date.
|
||||
/// </summary>
|
||||
public static string DateEnd {
|
||||
get {
|
||||
return ResourceManager.GetString("DateEnd", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Start date.
|
||||
/// </summary>
|
||||
public static string DateStart {
|
||||
get {
|
||||
return ResourceManager.GetString("DateStart", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Description.
|
||||
/// </summary>
|
||||
@@ -195,60 +69,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Discussion.
|
||||
/// </summary>
|
||||
public static string Discussion {
|
||||
get {
|
||||
return ResourceManager.GetString("Discussion", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to End date.
|
||||
/// </summary>
|
||||
public static string DiscussionEndDate {
|
||||
get {
|
||||
return ResourceManager.GetString("DiscussionEndDate", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to End time.
|
||||
/// </summary>
|
||||
public static string DiscussionEndTime {
|
||||
get {
|
||||
return ResourceManager.GetString("DiscussionEndTime", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Discussion id.
|
||||
/// </summary>
|
||||
public static string DiscussionId {
|
||||
get {
|
||||
return ResourceManager.GetString("DiscussionId", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Discussion text.
|
||||
/// </summary>
|
||||
public static string DiscussionText {
|
||||
get {
|
||||
return ResourceManager.GetString("DiscussionText", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Discussion title.
|
||||
/// </summary>
|
||||
public static string DiscussionTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("DiscussionTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Email.
|
||||
/// </summary>
|
||||
@@ -267,42 +87,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Max amount of confrontations.
|
||||
/// </summary>
|
||||
public static string ExpectedMaxConfrontationsBeforeClosingAndShowing {
|
||||
get {
|
||||
return ResourceManager.GetString("ExpectedMaxConfrontationsBeforeClosingAndShowing", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Fact.
|
||||
/// </summary>
|
||||
public static string Fact {
|
||||
get {
|
||||
return ResourceManager.GetString("Fact", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Facts.
|
||||
/// </summary>
|
||||
public static string Facts {
|
||||
get {
|
||||
return ResourceManager.GetString("Facts", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to File id.
|
||||
/// </summary>
|
||||
public static string FileId {
|
||||
get {
|
||||
return ResourceManager.GetString("FileId", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Groups.
|
||||
/// </summary>
|
||||
@@ -312,15 +96,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Include the first answer in the invitation preview? (making it more engaging to join the discussion).
|
||||
/// </summary>
|
||||
public static string IncludeFirstAnswerInInvitation {
|
||||
get {
|
||||
return ResourceManager.GetString("IncludeFirstAnswerInInvitation", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Invitation code.
|
||||
/// </summary>
|
||||
@@ -330,15 +105,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Invitation code or link.
|
||||
/// </summary>
|
||||
public static string InvitationCodeLink {
|
||||
get {
|
||||
return ResourceManager.GetString("InvitationCodeLink", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Invitation password.
|
||||
/// </summary>
|
||||
@@ -348,42 +114,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Require password for preview.
|
||||
/// </summary>
|
||||
public static string IsPasswordProtectedForPreview {
|
||||
get {
|
||||
return ResourceManager.GetString("IsPasswordProtectedForPreview", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Require password to show the preview.
|
||||
/// </summary>
|
||||
public static string IsPasswordProtectedForPreviewText {
|
||||
get {
|
||||
return ResourceManager.GetString("IsPasswordProtectedForPreviewText", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Add password protection.
|
||||
/// </summary>
|
||||
public static string IsPasswordProtectedText {
|
||||
get {
|
||||
return ResourceManager.GetString("IsPasswordProtectedText", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to IsStructured.
|
||||
/// </summary>
|
||||
public static string IsStructured {
|
||||
get {
|
||||
return ResourceManager.GetString("IsStructured", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Language id.
|
||||
/// </summary>
|
||||
@@ -426,15 +156,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Enable notifications?(useful to receive app updates).
|
||||
/// </summary>
|
||||
public static string NativeNotificationsEnabled {
|
||||
get {
|
||||
return ResourceManager.GetString("NativeNotificationsEnabled", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to New password.
|
||||
/// </summary>
|
||||
@@ -462,15 +183,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to One time only.
|
||||
/// </summary>
|
||||
public static string OneTime {
|
||||
get {
|
||||
return ResourceManager.GetString("OneTime", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Password.
|
||||
/// </summary>
|
||||
@@ -480,24 +192,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Perception.
|
||||
/// </summary>
|
||||
public static string Perception {
|
||||
get {
|
||||
return ResourceManager.GetString("Perception", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Perceptions.
|
||||
/// </summary>
|
||||
public static string Perceptions {
|
||||
get {
|
||||
return ResourceManager.GetString("Perceptions", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Prefer system theme.
|
||||
/// </summary>
|
||||
@@ -516,96 +210,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Recurring.
|
||||
/// </summary>
|
||||
public static string Recurring {
|
||||
get {
|
||||
return ResourceManager.GetString("Recurring", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Scan for:.
|
||||
/// </summary>
|
||||
public static string Scan_for_ {
|
||||
get {
|
||||
return ResourceManager.GetString("Scan for:", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Search.
|
||||
/// </summary>
|
||||
public static string SearchText {
|
||||
get {
|
||||
return ResourceManager.GetString("SearchText", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Sharing Secret Code.
|
||||
/// </summary>
|
||||
public static string ShareCode {
|
||||
get {
|
||||
return ResourceManager.GetString("ShareCode", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Show donator badge.
|
||||
/// </summary>
|
||||
public static string ShowDonatorBadge {
|
||||
get {
|
||||
return ResourceManager.GetString("ShowDonatorBadge", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Show the logs tab to support troubleshooting..
|
||||
/// </summary>
|
||||
public static string ShowLogs {
|
||||
get {
|
||||
return ResourceManager.GetString("ShowLogs", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Take type.
|
||||
/// </summary>
|
||||
public static string TakeType {
|
||||
get {
|
||||
return ResourceManager.GetString("TakeType", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Simple.
|
||||
/// </summary>
|
||||
public static string TakeTypeSimple {
|
||||
get {
|
||||
return ResourceManager.GetString("TakeTypeSimple", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Structured.
|
||||
/// </summary>
|
||||
public static string TakeTypeStructured {
|
||||
get {
|
||||
return ResourceManager.GetString("TakeTypeStructured", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Theme image.
|
||||
/// </summary>
|
||||
public static string Theme_image {
|
||||
get {
|
||||
return ResourceManager.GetString("Theme image", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Dark mode.
|
||||
/// </summary>
|
||||
@@ -615,15 +219,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Ticks.
|
||||
/// </summary>
|
||||
public static string Ticks {
|
||||
get {
|
||||
return ResourceManager.GetString("Ticks", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Title.
|
||||
/// </summary>
|
||||
@@ -633,24 +228,6 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Update reason.
|
||||
/// </summary>
|
||||
public static string UpdateReason {
|
||||
get {
|
||||
return ResourceManager.GetString("UpdateReason", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Submit anonymously.
|
||||
/// </summary>
|
||||
public static string UpsertUserTakeIsAnonymous {
|
||||
get {
|
||||
return ResourceManager.GetString("UpsertUserTakeIsAnonymous", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Username.
|
||||
/// </summary>
|
||||
@@ -659,23 +236,5 @@ namespace PrivaPub.ClientModels.Resources {
|
||||
return ResourceManager.GetString("Username", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Username to search and add.
|
||||
/// </summary>
|
||||
public static string Username_to_search_and_add {
|
||||
get {
|
||||
return ResourceManager.GetString("Username to search and add", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Usernames to search and add.
|
||||
/// </summary>
|
||||
public static string Usernames_to_search_and_add {
|
||||
get {
|
||||
return ResourceManager.GetString("Usernames to search and add", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(необходимо в случай на възстановяване на парола)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">предложен мениджър на пароли</a>)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Сума</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Отговор id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Текст на отговора</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Персонализиран символ (емотикон)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Цвят</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Коментар id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Текст на коментара</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Конфронтация</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Конфронтация id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Контекст</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Крайна дата</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Начална дата</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Описание</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Дискусия</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Крайна дата</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Краен час</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Дискусия id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Текст за обсъждане</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Заглавие на дискусията</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Имейл</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Емотикони</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Максимален брой конфронтации</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Факт</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Факти</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Идентификатор на файла</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Групи</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Да включите първия отговор в предварителния преглед на поканата? (за да се включите в дискусията по-ангажиращо)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Код на поканата</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Код или връзка за покана</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Парола за покана</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Изискване на парола за предварителен преглед</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Изискване на парола за показване на предварителния преглед</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Добавяне на защита с парола</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Идентификатор на езика</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Име</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Активиране на известията?(полезно за получаване на актуализации на приложението)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Нова парола</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Стара парола</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Само веднъж</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Парола</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Възприемане</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Възприятия</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Предпочитайте темата на системата</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Код за възстановяване</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Повтарящи се</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Сканиране за:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Търсене</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Споделяне на тайния код</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Покажи значката на дарителя</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Покажете раздела с дневници, за да подпомогнете отстраняването на неизправности.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Вид на вземането</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Прост</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Структуриран</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Изображение на темата</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Тъмен режим</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Кърлежи</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Заглавие</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Причина за актуализиране</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Подайте анонимно</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Потребителско име</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Потребителско име за търсене и добавяне</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Потребителски имена за търсене и добавяне</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(nutné v případě obnovení hesla)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">správce hesel</a> navrženo)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Částka</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Odpověď id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Text odpovědi</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Vlastní znak(emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Barva</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Komentář id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Text komentáře</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontace</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Id konfrontace</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontext</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Datum ukončení</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Datum zahájení</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Popis</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskuse</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Datum ukončení</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Čas ukončení</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskuze id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Text k diskusi</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Název diskuse</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maximální počet konfrontací</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Identifikátor souboru</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Skupiny</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Zahrnout první odpověď do náhledu pozvánky? (abyste se mohli zapojit do diskuse)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kód pozvánky</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Pozvánkový kód nebo odkaz</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Heslo pozvánky</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Vyžadovat heslo pro náhled</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Vyžadování hesla pro zobrazení náhledu</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Přidání ochrany heslem</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Identifikace jazyka</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Název</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Povolit oznámení?(užitečné pro příjem aktualizací aplikace)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nové heslo</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Staré heslo</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Pouze jednou</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Heslo</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Vnímání</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Vnímání</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Preferovat systémové téma</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Kód obnovy</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Opakující se</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Vyhledat:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Vyhledávání</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Sdílení tajného kódu</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Zobrazit odznak dárce</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Zobrazit kartu protokolů pro podporu řešení problémů.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Vezměte si typ</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Jednoduché</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturovaný</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Tématický obrázek</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tmavý režim</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Klíšťata</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Název</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Důvod aktualizace</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Anonymní odeslání</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Uživatelské jméno</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Uživatelské jméno pro vyhledávání a přidávání</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Uživatelská jména pro vyhledávání a přidávání</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(nødvendig i tilfælde af inddrivelse af adgangskode)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">password manager</a> foreslået)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Beløb</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Svar id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Svar tekst</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Brugerdefineret tegn (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Farve</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Kommentar id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Kommentar tekst</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontation</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontation id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontekst</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Slutdato</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Startdato</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Beskrivelse</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskussion</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Slutdato</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Sluttidspunkt</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskussion id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Tekst til drøftelse</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Diskussionstitel</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maksimalt antal konfrontationer</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Fil id</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupper</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Medtage det første svar i forhåndsvisningen af invitationen? (gør det mere indbydende at deltage i diskussionen)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kode for indbydelse</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Indbydelseskode eller link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Adgangskode til invitationen</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Kræver adgangskode til visning af preview</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Kræver adgangskode for at vise forhåndsvisningen</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Tilføj adgangskodebeskyttelse</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Sprog-id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Navn</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Aktiver notifikationer? (nyttigt for at modtage opdateringer til appen)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Ny adgangskode</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Gammel adgangskode</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Kun én gang</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Adgangskode</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Opfattelse</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Opfattelser</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Foretrækker systemtema</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Kode for inddrivelse</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Tilbagevendende</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scan efter:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Søg på</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Deling af den hemmelige kode</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Vis donator-badge</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Vis fanen Logfiler for at støtte fejlfinding.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Tag type</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Enkel</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Struktureret</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Temabillede</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Mørk tilstand</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Flåter</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titel</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Årsag til opdatering</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Indsend anonymt</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Brugernavn</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Brugernavn til at søge og tilføje</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Brugernavne til at søge og tilføje</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(notwendig für die Wiederherstellung des Passworts)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">Passwortmanager</a> vorgeschlagen)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Betrag</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Antwort id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Antwort-Text</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Benutzerdefiniertes Zeichen (Emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Farbe</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Kommentar</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Text kommentieren</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontation</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontation id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontext</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Datum des Endes</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Datum des Beginns</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Beschreibung</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskussion</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Datum des Endes</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Endzeit</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskussion id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Text zur Diskussion</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Titel der Diskussion</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-Mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maximale Anzahl von Konfrontationen</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Tatsache</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakten</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Datei-ID</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Gruppen</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Die erste Antwort in die Vorschau der Einladung aufnehmen? (um den Anreiz zur Teilnahme an der Diskussion zu erhöhen)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Einladungs-Code</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Einladungscode oder Link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Passwort für die Einladung</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Passwort für Vorschau erforderlich</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Passwort erforderlich, um die Vorschau anzuzeigen</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Passwortschutz hinzufügen</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Sprache id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Benachrichtigungen aktivieren?(nützlich, um App-Updates zu erhalten)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Neues Passwort</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Altes Passwort</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Nur einmalig</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Passwort</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Wahrnehmung</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Wahrnehmungen</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Systemthema bevorzugen</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Wiederherstellungscode</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Wiederkehrende</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scannen Sie nach:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Suche</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Gemeinsamer Geheimcode</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Spenderausweis anzeigen</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Zeigen Sie die Registerkarte Protokolle an, um die Fehlersuche zu unterstützen.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Typ nehmen</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Einfach</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturiert</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Thema Bild</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Dunkler Modus</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Zecken</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titel</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Grund für die Aktualisierung</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Anonym einreichen</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Benutzername</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Benutzername zum Suchen und Hinzufügen</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Nutzernamen zum Suchen und Hinzufügen</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(απαραίτητο σε περίπτωση ανάκτησης κωδικού πρόσβασης)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">διαχειριστής κωδικών πρόσβασης</a> προτείνεται)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Ποσό</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Απάντηση id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Κείμενο απάντησης</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Προσαρμοσμένος χαρακτήρας (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Χρώμα</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Σχόλιο id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Κείμενο σχολιασμού</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Αντιπαράθεση</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>id αντιπαράθεσης</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Πλαίσιο</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Ημερομηνία λήξης</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Ημερομηνία έναρξης</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Περιγραφή</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Συζήτηση</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Ημερομηνία λήξης</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Ώρα λήξης</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Συζήτηση id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Κείμενο συζήτησης</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Τίτλος συζήτησης</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Ηλεκτρονικό ταχυδρομείο</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Μέγιστος αριθμός αντιπαραθέσεων</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Γεγονός</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Γεγονότα</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>id αρχείου</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Ομάδες</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Να συμπεριλάβετε την πρώτη απάντηση στην προεπισκόπηση της πρόσκλησης; (καθιστώντας την πιο ελκυστική για να συμμετάσχετε στη συζήτηση)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Κωδικός πρόσκλησης</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Κωδικός πρόσκλησης ή σύνδεσμος</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Κωδικός πρόσκλησης</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Απαίτηση κωδικού πρόσβασης για προεπισκόπηση</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Απαιτείται κωδικός πρόσβασης για την εμφάνιση της προεπισκόπησης</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Προσθήκη προστασίας με κωδικό πρόσβασης</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Id γλώσσας</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Όνομα</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Ενεργοποίηση ειδοποιήσεων; (χρήσιμο για να λαμβάνετε ενημερώσεις εφαρμογών)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Νέος κωδικός πρόσβασης</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Παλαιός κωδικός πρόσβασης</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Μόνο μία φορά</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Κωδικός πρόσβασης</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Αντίληψη</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Αντιλήψεις</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Προτιμήστε το θέμα του συστήματος</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Κωδικός ανάκτησης</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Επαναλαμβανόμενο</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Σάρωση για:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Αναζήτηση</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Κοινή χρήση μυστικού κώδικα</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Εμφάνιση σήματος δωρητή</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Εμφάνιση της καρτέλας αρχείων καταγραφής για την υποστήριξη της αντιμετώπισης προβλημάτων.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Πάρτε τον τύπο</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Απλό</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Δομημένο</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Εικόνα θέματος</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Σκοτεινή λειτουργία</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Κρότωνες</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Τίτλος</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Αιτία ενημέρωσης</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Υποβάλετε ανώνυμα</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Όνομα χρήστη</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Όνομα χρήστη για αναζήτηση και προσθήκη</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Ονόματα χρηστών για αναζήτηση και προσθήκη</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(necesario en caso de recuperación de la contraseña)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">gestor de contraseñas</a> sugerido)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Importe</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Respuesta</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Texto de respuesta</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Carácter personalizado (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Color</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Comentario id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Texto de los comentarios</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confrontación</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Identificación de la confrontación</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Contexto</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Fecha de finalización</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Fecha de inicio</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Descripción</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discusión</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Fecha de finalización</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Hora de finalización</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Discusión</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Texto de debate</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Título del debate</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Envíe un correo electrónico a</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Máxima cantidad de enfrentamientos</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Datos</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Hechos</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>ID de archivo</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupos</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>¿Incluir la primera respuesta en la vista previa de la invitación? (para que sea más atractivo participar en el debate)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Código de invitación</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Código o enlace de invitación</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Contraseña de la invitación</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Requerir contraseña para la vista previa</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Requiere contraseña para mostrar la vista previa</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Añadir protección por contraseña</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Id. de idioma</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nombre</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Activar las notificaciones (útiles para recibir las actualizaciones de la aplicación)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nueva contraseña</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Contraseña antigua</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Una sola vez</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Contraseña</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Percepción</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Percepciones</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Prefiero el tema del sistema</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Código de recuperación</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Recurrente</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Busca:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Buscar en</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Compartir el código secreto</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Mostrar el distintivo de donante</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Mostrar la pestaña de registros para apoyar la resolución de problemas.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Toma de tipo</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Simple</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Estructurado</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Imagen del tema</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Modo oscuro</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Garrapatas</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Título</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Motivo de actualización</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Presentar de forma anónima</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Nombre de usuario</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Nombre de usuario para buscar y añadir</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Nombres de usuario para buscar y añadir</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(vajalik parooli taastamise korral)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">Password manager</a> soovitatav)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Summa</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Vastus id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Vastuse tekst</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Kohandatud märk (emotikoni)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Värv</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Kommentaar id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Kommentaaride tekst</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Vastasseis</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Vastasseisu id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontekst</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Lõppkuupäev</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Alguskuupäev</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Kirjeldus</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Arutelu</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Lõppkuupäev</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Lõpuaeg</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Arutelu id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Arutelu tekst</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Arutelu pealkiri</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-post</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Vastasseisude maksimaalne arv</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakt</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Faktid</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Faili id</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Rühmad</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Lisage esimene vastus kutse eelvaates? (muudab aruteluga liitumise atraktiivsemaks)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kutse kood</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Kutse kood või link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Kutse parool</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Nõuab eelvaate jaoks parooli</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Eelvaate kuvamiseks nõutakse parooli</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Lisage paroolikaitse</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Keele id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nimi</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Teavituste lubamine?(kasulik, et saada rakenduse uuendusi)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Uus parool</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Vana parool</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Ainult üks kord</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Parool</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Tajumine</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Arvamused</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Eelistage süsteemi teemat</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Taastamiskood</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Korduvad</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Skaneeri:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Otsi</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Salajase koodi jagamine</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Näita doonori märki</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Näidake veaotsingu toetamiseks logide vahekaarti.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Võtke tüüp</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Lihtne</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Struktureeritud</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Teema pilt</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tume režiim</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Puugid</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Pealkiri</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Uuendamise põhjus</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Esitage anonüümselt</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Kasutajanimi</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Kasutajanimi otsimiseks ja lisamiseks</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Kasutajanimed otsimiseks ja lisamiseks</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(tarvitaan salasanan palautuksen yhteydessä)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">salasananhallinta</a> ehdotettu)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Määrä</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Vastaa id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Vastaus teksti</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Mukautettu merkki (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Väri</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Kommentti id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Kommenttiteksti</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Vastakkainasettelu</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Vastakkainasettelu id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Konteksti</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Loppupäivä</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Aloituspäivä</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Kuvaus</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Keskustelu</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Loppupäivä</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Loppuaika</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Keskustelu id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Keskusteluteksti</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Keskustelun otsikko</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Sähköposti</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Vastakkainasettelujen enimmäismäärä</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Faktat</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Tiedoston id</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Ryhmät</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Sisällytä ensimmäinen vastaus kutsun esikatseluun? (mikä tekee keskusteluun osallistumisesta houkuttelevampaa)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kutsun koodi</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Kutsukoodi tai linkki</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Kutsun salasana</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Vaadi salasana esikatselua varten</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Vaadi salasana esikatselun näyttämiseksi</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Lisää salasanasuojaus</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Kielitunnus</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nimi</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Ota ilmoitukset käyttöön?(hyödyllinen sovelluksen päivitysten saamiseksi)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Uusi salasana</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Vanha salasana</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Vain kerran</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Salasana</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Havainto</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Käsitykset</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Suositeltava järjestelmäteema</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Palautuskoodi</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Toistuvat</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Etsi:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Etsi</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Salaisen koodin jakaminen</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Näytä lahjoittajan merkki</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Näytä lokit-välilehti vianmäärityksen tueksi.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Ota tyyppi</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Yksinkertainen</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturoitu</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Aiheen kuva</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tumma tila</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Punkit</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Otsikko</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Päivityksen syy</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Lähetä nimettömänä</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Käyttäjätunnus</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Käyttäjätunnus hakuun ja lisäämiseen</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Käyttäjätunnukset, joita voit etsiä ja lisätä</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(nécessaire en cas de récupération du mot de passe)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">gestionnaire de mots de passe</a> suggéré)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Montant</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Réponse id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Texte de réponse</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Caractère personnalisé (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Couleur</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Commentaire</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Texte du commentaire</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confrontation</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Confrontation id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Contexte</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Date de fin</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Date de début</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Description</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discussion</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Date de fin</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Heure de fin</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>ID de discussion</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Texte de discussion</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Titre de la discussion</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Courriel</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Nombre maximal de confrontations</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fait</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Faits et chiffres</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Identifiant du fichier</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Groupes</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Inclure la première réponse dans l'aperçu de l'invitation ? (ce qui rend plus attrayant le fait de rejoindre la discussion)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Code d'invitation</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Code ou lien d'invitation</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Mot de passe de l'invitation</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Exiger un mot de passe pour la prévisualisation</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Exiger un mot de passe pour afficher l'aperçu</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Ajouter une protection par mot de passe</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>EstStructuré</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Identifiant de langue</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nom</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Activer les notifications ? (utile pour recevoir les mises à jour de l'application)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nouveau mot de passe</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Ancien mot de passe</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>En une seule fois</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Mot de passe</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Perception</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Perceptions</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Préférer le thème du système</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Code de récupération</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Récurrent</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scanner pour :</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Recherche</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Partage du code secret</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Afficher le badge de donateur</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Affichez l'onglet des journaux pour faciliter le dépannage.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Type de prise</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Simple</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Structuré</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Image du thème</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Mode foncé</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Tiques</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titre</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Raison de la mise à jour</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Soumettre anonymement</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Nom d'utilisateur</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Nom d'utilisateur pour rechercher et ajouter</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Noms d'utilisateur à rechercher et à ajouter</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(jelszó helyreállítása esetén szükséges)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">jelszókezelő</a> javasolt)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Összeg</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Válasz id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Válasz szöveg</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Egyéni karakter (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Színes</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Megjegyzés id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Megjegyzés szövege</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontáció</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontációs azonosító</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontextus</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Végdátum</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Kezdeti időpont</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Leírás</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Megbeszélés</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Végdátum</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Végidő</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Megbeszélés id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Megbeszélés szövege</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Vita címe</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>A konfrontációk maximális száma</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Tény</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Tények</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Fájl azonosító</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Csoportok</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Az első válasz szerepeljen a meghívó előnézetében? (így vonzóbbá teszi a vitához való csatlakozást)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Meghívó kód</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Meghívó kód vagy link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Meghívó jelszó</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Jelszó megkövetelése az előnézethez</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Jelszó megkövetelése az előnézet megjelenítéséhez</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Jelszóvédelem hozzáadása</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Nyelvi azonosító</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Név</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Értesítések engedélyezése?(hasznos az alkalmazás frissítéseinek fogadásához)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Új jelszó</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Régi jelszó</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Csak egyszeri alkalom</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Jelszó</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Percepció</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Perceptions</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>A rendszer témájának előnyben részesítése</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Helyreállítási kód</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Visszatérő</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Keresés:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Keresés</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Titkos kód megosztása</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Adományozói jelvény megjelenítése</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>A naplók lap megjelenítése a hibaelhárítás támogatására.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Vegye a típust</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Egyszerű</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturált</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Témakép</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Sötét üzemmód</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Kullancsok</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Cím</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Frissítés oka</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Névtelenül beküldeni</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Felhasználónév</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Felhasználónév kereséshez és hozzáadáshoz</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Felhasználónevek keresése és hozzáadása</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(necessaria in caso di recupero password)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(è suggerito l'uso del <a class="is-underlined has-text-info" tabindex="-1" target="_blank" href="https://keepassxc.org/">gestore password</a>)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Importo</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>ID risposta</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Testo risposta</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Carattere personalizzato (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Colore</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>ID commento</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Testo commento</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confronto</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Id confronto</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Contesto</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Data di fine</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Data di inizio</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Descrizione</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discussione</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Data di fine</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Tempo di fine</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>ID discussione</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Testo discussione</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Titolo discussione</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Email</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Quantità massima di scontri</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fatto</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>I fatti</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>ID file</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Gruppi</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Includere la prima risposta nell'anteprima dell'invito? (rendendo più coinvolgente l'adesione alla discussione)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Codice d'invito</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Collegamento o codice di invito</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Password dell'invito</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Richiedi la password per l'anteprima</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Richiedi la password per l'anteprima della discussione</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Aggiungere la protezione con password</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>È strutturato</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>ID lingua</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nome</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Abilitare le notifiche?(utili per percepire aggiornamenti)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nuova password</value>
|
||||
</data>
|
||||
@@ -243,78 +150,24 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Attuale password</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Paga una volta</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Password</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>La percezione</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Percezioni</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Preferire il tema di sistema</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Codice di recupero</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Ricorrente</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scansione per:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Ricerca</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Condivisione del codice segreto</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Mostra il badge del donatore</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Mostra il tab dei log a supporto della risoluzione dei problemi.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Prendi il tipo</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Semplice</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strutturato</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Immagine a tema</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Modalità scura</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Zecche</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titolo</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Motivo dell'aggiornamento</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Invia in forma anonima</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Soprannome</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Soprannome da ricercare e aggiungere</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Soprannomi da ricercare e aggiungere</value>
|
||||
</data>
|
||||
<data name="AvatarId" xml:space="preserve">
|
||||
<value>Avatar</value>
|
||||
</data>
|
||||
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(パスワード復旧時に必要)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">パスワードマネージャー</a>の提案)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>金額</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>回答ID</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>回答テキスト</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>カスタムキャラクター(絵文字)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>カラー</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>コメントID</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>コメント文</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>対決</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>コンフロンティアID</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>コンテキスト</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>終了日</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>開始日</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>商品説明</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>ディスカッション</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>終了日</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>終了時間</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>ディスカッションID</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>ディスカッションテキスト</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>議論のタイトル</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>電子メール</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>絵文字</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>対決の最大量</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>事実</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>事実</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>ファイルID</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>グループ</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>招待状のプレビューに最初の回答を含める?(議論への参加をより魅力的にするために)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>招待状コード</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>招待コードまたはリンク</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>招待状のパスワード</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>プレビューにパスワードが必要</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>プレビューを表示するにはパスワードが必要です</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>パスワード保護機能の追加</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>言語ID</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>名前</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>通知を有効にしますか?(アプリの更新情報を受け取るのに便利です)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>新しいパスワード</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>古いパスワード</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>1回限り</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>パスワード</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>パーセプション</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>認知度</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>システムテーマを優先する</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>リカバリーコード</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>リカーリング</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>スキャンしてください:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>検索</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>秘密の暗号を共有する</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>寄付者バッジを表示する</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>トラブルシューティングをサポートするために、ログタブを表示します。</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>テイクタイプ</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>シンプル</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>構造化</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>テーマイメージ</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>ダークモード</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>ダニ</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>タイトル</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>更新理由</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>匿名で投稿する</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>ユーザー名</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>検索して追加するユーザー名</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>検索して追加するユーザー名</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(būtina slaptažodžio atkūrimo atveju)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">paslapčių tvarkyklė</a> siūloma)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Suma</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Atsakymas id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Atsakymo tekstas</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Pasirinktinis simbolis (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Spalva</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Komentaras id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Komentaro tekstas</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontacija</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontacijos id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontekstas</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Galutinė data</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Pradžios data</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Aprašymas</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskusija</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Galutinė data</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Pabaigos laikas</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskusijos id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Diskusijos tekstas</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Diskusijos pavadinimas</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>El. paštas</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Didžiausias konfrontacijų skaičius</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Faktas</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Faktai</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Failo ID</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupės</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Įtraukti pirmąjį atsakymą į kvietimo peržiūrą? (kad būtų patraukliau prisijungti prie diskusijos)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kvietimo kodas</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Kvietimo kodas arba nuoroda</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Kvietimo slaptažodis</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Reikalauti slaptažodžio peržiūrai</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Reikalauti slaptažodžio, kad būtų rodoma peržiūra</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Pridėti apsaugą slaptažodžiu</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Kalbos ID</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Pavadinimas</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Įjungti pranešimus? (naudinga norint gauti programėlės atnaujinimus)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Naujas slaptažodis</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Senasis slaptažodis</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Tik vieną kartą</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Slaptažodis</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Suvokimas</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Suvokimas</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Pirmenybė sistemos temai</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Atkūrimo kodas</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Pasikartojantis</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Ieškoti:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Paieška</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Dalijimasis slaptuoju kodu</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Rodyti rėmėjo ženklelį</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Parodykite skirtuką "Žurnalai", kad galėtumėte padėti šalinti trikdžius.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Paimkite tipą</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Paprastas</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Struktūrinis</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Temos vaizdas</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tamsus režimas</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Erkės</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Pavadinimas</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Atnaujinimo priežastis</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Pateikti anonimiškai</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Vartotojo vardas</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Vartotojo vardas paieškai ir pridėjimui</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Vartotojų vardų paieška ir pridėjimas</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(nepieciešams paroles atjaunošanas gadījumā)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">vārdu pārvaldnieks</a> ierosināts)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Summa</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Atbilde id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Atbildes teksts</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Pielāgots raksturs (emodži)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Krāsa</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Komentārs id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Komentāru teksts</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontācija</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontācijas id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Konteksts</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Beigu datums</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Sākuma datums</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Apraksts</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskusija</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Beigu datums</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Beigu laiks</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskusijas id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Diskusijas teksts</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Diskusijas nosaukums</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-pasts</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emocijas</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maksimālais konfrontāciju skaits</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakti</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakti</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Faila id</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupas</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Iekļaut pirmo atbildi uzaicinājuma priekšskatījumā? (lai iesaistīšanās diskusijā būtu saistošāka)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Uzaicinājuma kods</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Uzaicinājuma kods vai saite</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Uzaicinājuma parole</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Pieprasīt paroli priekšskatīšanai</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Pieprasīt paroli, lai parādītu priekšskatījumu</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Pievienojiet paroles aizsardzību</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Valodas identifikators</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nosaukums</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Ieslēgt paziņojumus?(noderīgi, lai saņemtu lietotnes atjauninājumus)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Jauna parole</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Vecā parole</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Tikai vienu reizi</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Parole</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Uztvere</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Uzskati</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Dodiet priekšroku sistēmas tēmai</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Atgūšanas kods</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Atkārtoti</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Skenēt, lai atrastu:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Meklēšana</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Koplietošanas slepenais kods</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Rādīt ziedotāja nozīmīti</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Parādiet cilni žurnāli, lai atbalstītu problēmu novēršanu.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Veikt veidu</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Vienkāršs</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturēts</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Tēmas attēls</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tumšais režīms</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Ērces</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Nosaukums</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Atjaunināšanas iemesls</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Iesniedziet anonīmi</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Lietotājvārds</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Lietotājvārds, lai meklētu un pievienotu</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Meklēšanas un pievienošanas lietotājvārdi</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(nodig in geval van wachtwoordherstel)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">wachtwoord manager</a> voorgesteld)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Bedrag</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Antwoord id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Antwoord tekst</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Aangepast karakter (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Kleur</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Commentaar id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Commentaar tekst</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confrontatie</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Confrontatie id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Context</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Einddatum</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Startdatum</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Beschrijving</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discussie</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Einddatum</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Eindtijd</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Discussie id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Besprekingstekst</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Titel van de discussie</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maximum aantal confrontaties</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Feit</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Feiten</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Bestand id</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Groepen</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Het eerste antwoord opnemen in de preview van de uitnodiging? (maakt het aantrekkelijker om deel te nemen aan de discussie)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Uitnodiging code</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Uitnodigingscode of link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Uitnodiging wachtwoord</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Wachtwoord nodig voor preview</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Wachtwoord vereist om de preview te tonen</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Wachtwoordbeveiliging toevoegen</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Taal id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Naam</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Meldingen inschakelen?(handig om app updates te ontvangen)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nieuw wachtwoord</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Oud wachtwoord</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Eenmalig</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Wachtwoord</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Perceptie</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Percepties</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Voorkeur systeemthema</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Herstelcode</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Terugkerend</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scan voor:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Zoeken op</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Het delen van de geheime code</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Toon donateur badge</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Toon de logs tab ter ondersteuning van troubleshooting.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Neem type</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Eenvoudig</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Gestructureerd</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Thema-afbeelding</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Donkere modus</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Teken</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titel</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Update reden</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Anoniem indienen</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Gebruikersnaam</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Gebruikersnaam om te zoeken en toe te voegen</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Gebruikersnamen om te zoeken en toe te voegen</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(konieczne w przypadku odzyskiwania hasła)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">password manager</a> sugerowane)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Kwota</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Odpowiedź</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Odpowiedź</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Znak niestandardowy (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Kolor</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Komentarz id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Tekst komentarza</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontacja</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontacja id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontekst</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Data końcowa</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Data rozpoczęcia</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Opis</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Dyskusja</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Data końcowa</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Czas zakończenia</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Dyskusja id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Tekst do dyskusji</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Tytuł dyskusji</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maksymalna ilość konfrontacji</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakt</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakty</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Id pliku</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupy</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Zamieścić pierwszą odpowiedź w podglądzie zaproszenia? (sprawia, że dołączenie do dyskusji jest bardziej zachęcające)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kod zaproszenia</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Kod lub link do zaproszenia</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Hasło do zaproszenia</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Wymagaj hasła do podglądu</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Wymagaj hasła, aby wyświetlić podgląd</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Dodaj ochronę hasłem</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Język id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nazwa</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Włączyć powiadomienia? (przydatne do otrzymywania aktualizacji aplikacji)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nowe hasło</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Stare hasło</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Tylko jeden raz</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Hasło</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Percepcja</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Spostrzeżenia</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Preferowany motyw systemowy</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Kod odzyskiwania</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Powtarzające się</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Skanuj dla:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Szukaj</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Dzielenie się tajnym kodem</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Pokaż odznakę darczyńcy</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Pokaż kartę logów, aby ułatwić rozwiązywanie problemów.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Rodzaj ujęcia</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Proste</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturalne</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Obrazek tematyczny</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tryb ciemny</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Kleszcze</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Tytuł</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Powód aktualizacji</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Zgłoś się anonimowo</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Nazwa użytkownika</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Nazwa użytkownika do wyszukiwania i dodawania</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Nazwy użytkowników do wyszukiwania i dodawania</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(necessário em caso de recuperação de senha)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">gerenciador de palavras-passe</a> sugerido)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Montante</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Responder id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Responder texto</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Personalidade(emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Cor</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Comentar id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Comentar texto</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confrontação</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Id de confronto</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Contexto</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Data final</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Data de início</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Descrição</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discussão</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Data final</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Tempo final</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Id de discussão</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Texto para discussão</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Título do debate</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Email</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Quantidade máxima de confrontos</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Facto</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fatos</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Identificação do arquivo</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupos</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Incluir a primeira resposta na pré-visualização do convite? (tornando-a mais envolvente para participar da discussão)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Código de convite</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Código do convite ou link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Senha do convite</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Exigir senha para visualização</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Exigir senha para mostrar a visualização</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Adicionar proteção por senha</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Identificação do idioma</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nome</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Habilitar notificações?(útil para receber atualizações de aplicativos)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nova senha</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Senha antiga</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Só uma vez</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Senha</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Percepção</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Percepções</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Tema do sistema de preferências</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Código de recuperação</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Recurrente</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Procura por:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Pesquisa</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Partilha do Código Secreto</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Mostrar crachá do doador</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Mostrar o separador de registos para suportar a resolução de problemas.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Tirar tipo</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Simples</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Estruturado</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Imagem temática</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Modo escuro</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Carraças</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Título</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Motivo da actualização</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Submeter de forma anónima</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Nome de usuário</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Nome de usuário para pesquisar e adicionar</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Nomes de usuário para pesquisar e adicionar</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(necessary in case of password recovery)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="_blank" href="https://keepassxc.org/">password manager</a> suggested)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Amount</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Answer id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Answer text</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Custom character(emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Colour</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Comment id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Comment text</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confrontation</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Confrontation id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Context</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>End date</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Start date</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Description</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discussion</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>End date</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>End time</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Discussion id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Discussion text</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Discussion title</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Email</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Max amount of confrontations</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fact</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Facts</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>File id</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Groups</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Include the first answer in the invitation preview? (making it more engaging to join the discussion)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Invitation code</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Invitation code or link</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Invitation password</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Require password for preview</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Require password to show the preview</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Add password protection</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Language id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Enable notifications?(useful to receive app updates)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>New password</value>
|
||||
</data>
|
||||
@@ -243,78 +150,24 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Old password</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>One time only</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Password</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Perception</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Perceptions</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Prefer system theme</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Recovery code</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Recurring</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scan for:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Search</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Sharing Secret Code</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Show donator badge</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Show the logs tab to support troubleshooting.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Take type</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Simple</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Structured</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Theme image</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Dark mode</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Ticks</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Title</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Update reason</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Submit anonymously</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Username</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Username to search and add</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Usernames to search and add</value>
|
||||
</data>
|
||||
<data name="AvatarId" xml:space="preserve">
|
||||
<value>Avatar</value>
|
||||
</data>
|
||||
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(necesar în cazul recuperării parolei)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">manager de parole</a> sugerat)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Suma</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Răspuns id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Text de răspuns</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Caracter personalizat (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Culoare</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Comentariu id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Comentariu text</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Confruntare</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Id de confruntare</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Context</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Data de încheiere</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Data de începere</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Descriere</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Discuție</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Data de încheiere</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Ora de sfârșit</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Discuție id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Text de discuție</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Titlul discuției</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Email</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Numărul maxim de confruntări</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fapt</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fapte</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>ID fișier</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupuri</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Includeți primul răspuns în previzualizarea invitației? (pentru a face mai atractivă participarea la discuție)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Codul de invitație</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Cod sau link de invitație</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Parola de invitație</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Necesită o parolă pentru previzualizare</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Necesită o parolă pentru a afișa previzualizarea</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Adăugați protecție prin parolă</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>ID-ul limbii</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Nume</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Activați notificările?(util pentru a primi actualizări ale aplicației)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Parolă nouă</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Parolă veche</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>O singură dată</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Parola</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Percepție</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Percepții</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Preferați tema de sistem</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Cod de recuperare</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Recurent</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Scanează pentru:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Căutare</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Partajarea codului secret</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Arată insigna de donator</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Afișați fila jurnale pentru a sprijini depanarea.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Tipul de luare</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Simplu</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Structurat</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Imagine tematică</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Mod întunecat</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Căpușe</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titlu</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Motivul actualizării</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Trimiteți în mod anonim</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Nume utilizator</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Nume de utilizator pentru a căuta și adăuga</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Nume de utilizator pentru căutare și adăugare</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(необходимо в случае восстановления пароля)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(Предлагается <a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">password manager</a>)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Сумма</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Идентификатор ответа</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Текст ответа</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Пользовательский символ (эмодзи)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Цвет</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Идентификатор комментария</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Текст комментария</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Противостояние</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Идентификатор противостояния</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Контекст</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Дата окончания</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Дата начала</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Описание</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Обсуждение</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Дата окончания</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Время окончания</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Обсуждаемый иди</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Текст для обсуждения</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Название дискуссии</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>Электронная почта</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Максимальное количество противостояний</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Факт</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Факты</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Идентификатор файла</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Группы</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Включить первый ответ в предварительный просмотр приглашения? (что делает его более привлекательным для участия в обсуждении)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Код приглашения</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Код приглашения или ссылка</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Пароль приглашения</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Требуется пароль для предварительного просмотра</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Требуется пароль для показа предварительного просмотра</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Добавить защиту паролем</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Языковой идентификатор</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Имя</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Включить уведомления? (полезно для получения обновлений приложения)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Новый пароль</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Старый пароль</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Только один раз</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Пароль</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Восприятие</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Восприятие</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Предпочитайте системную тему</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Код восстановления</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Повторяющийся</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Сканирование для:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Поиск</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Обмен секретным кодом</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Показать значок жертвователя</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Покажите вкладку журналов для поддержки поиска и устранения неисправностей.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Возьмите тип</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Простой</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Структурированный</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Тематическое изображение</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Темный режим</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Клещи</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Название</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Причина обновления</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Подавать анонимно</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Имя пользователя</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Имя пользователя для поиска и добавления</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Имена пользователей для поиска и добавления</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(potrebné v prípade obnovenia hesla)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">správca hesiel</a> navrhnuté)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Suma</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Odpoveď id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Text odpovede</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Vlastný znak (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Farba</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Komentár id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Text komentára</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontácia</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Id konfrontácie</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontext</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Dátum ukončenia</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Dátum začiatku</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Popis</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskusia</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Dátum ukončenia</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Čas ukončenia</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskusia id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Text diskusie</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Názov diskusie</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-mail</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Maximálny počet konfrontácií</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Skutočnosť</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakty</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Identifikátor súboru</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Skupiny</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Zahrnúť prvú odpoveď do náhľadu pozvánky? (aby bolo zapojenie do diskusie pútavejšie)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Pozývací kód</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Pozývací kód alebo odkaz</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Heslo pozvánky</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Vyžadovať heslo pre náhľad</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Vyžadovať heslo na zobrazenie náhľadu</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Pridanie ochrany heslom</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Identifikátor jazyka</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Názov</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Povolenie oznámení?(užitočné na prijímanie aktualizácií aplikácie)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nové heslo</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Staré heslo</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Iba jedenkrát</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Heslo</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Vnímanie</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Vnímanie</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Uprednostniť tému systému</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Kód obnovy</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Opakujúce sa</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Vyhľadávanie:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Vyhľadávanie</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Zdieľanie tajného kódu</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Zobraziť odznak darcu</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Zobrazenie karty protokolov na podporu riešenia problémov.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Vezmite si typ</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Jednoduché</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Štruktúrované</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Obrázok témy</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Tmavý režim</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Kliešte</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Názov</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Dôvod aktualizácie</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Odoslať anonymne</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Používateľské meno</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Používateľské meno na vyhľadávanie a pridávanie</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Používateľské mená na vyhľadávanie a pridávanie</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(potrebno v primeru obnovitve gesla)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">predlagani upravitelj gesel</a>)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Znesek</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Odgovor id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Besedilo odgovora</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Znak po meri (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Barva</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Id komentarja</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Besedilo komentarja</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontacija</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Id konfrontacije</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontekst</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Končni datum</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Datum začetka</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Opis</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Razprava</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Končni datum</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Končni čas</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Razprava id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Besedilo za razpravo</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Naslov razprave</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-pošta</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Največje število soočenj</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Dejstva</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Dejstva</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Id datoteke</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Skupine</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Vključiti prvi odgovor v predogled vabila? (da bo vključitev v razpravo bolj privlačna)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Koda vabila</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Koda vabila ali povezava</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Geslo za povabilo</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Zahtevajte geslo za predogled</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Zahtevajte geslo za prikaz predogleda</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Dodajanje zaščite z geslom</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Id jezika</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Ime</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Omogočite obvestila? (koristno za prejemanje posodobitev aplikacije)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Novo geslo</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Staro geslo</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Samo enkrat</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Geslo</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Zaznavanje</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Dojemanje</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Prednost sistemske teme</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Koda za obnovitev</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Ponavljajoče se</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Iskanje za:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Iskanje</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Deljenje skrivne kode</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Prikaži značko donatorja</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Prikažite zavihek dnevniki za podporo pri odpravljanju težav.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Vrsta posnetka</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Enostavno</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturiran</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Slika teme</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Temni način</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Klopi</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Naslov</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Razlog za posodobitev</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Pošlji anonimno</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Uporabniško ime</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Uporabniško ime za iskanje in dodajanje</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Uporabniška imena za iskanje in dodajanje</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(nödvändig vid återställning av lösenord)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">password manager</a> föreslås)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>Belopp</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>Svar id</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>Svarstext</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>Anpassat tecken (emoji)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>Färg</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>Kommentar id</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>Kommentar</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>Konfrontation</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>Konfrontation id</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>Kontext</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>Slutdatum</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>Startdatum</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>Beskrivning</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>Diskussion</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>Slutdatum</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>Sluttid</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>Diskussion id</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>Diskussionstext</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>Titel på diskussionen</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>E-post</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>Emoji</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>Max antal konfrontationer</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>Fakta</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>Filid</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>Grupper</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>Inkludera det första svaret i förhandsgranskningen av inbjudan? (för att göra det mer lockande att delta i diskussionen)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>Kod för inbjudan</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>Inbjudningskod eller länk</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>Lösenord för inbjudan</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>Kräver lösenord för förhandsgranskning</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>Kräver lösenord för att visa förhandsgranskningen</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>Lägg till lösenordsskydd</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>IsStructured</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>Språk-id</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Namn</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>Aktivera meddelanden (användbart för att få uppdateringar av appen)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>Nytt lösenord</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>Gammalt lösenord</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>Endast en gång</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Lösenord</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>Uppfattning</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>Uppfattningar</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>Föredrar systemtema</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>Kod för återvinning</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>Återkommande</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>Skanna efter:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>Sök på</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>Att dela med sig av den hemliga koden</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>Visa donatormärket</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>Visa fliken Loggar för att underlätta felsökning.</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>Typ av tagning</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>Enkelt</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>Strukturerad</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>Tema bild</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>Mörkt läge</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>Fästingar</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Titel</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>Orsak till uppdatering</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>Skicka in anonymt</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>Användarnamn</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>Användarnamn för att söka och lägga till</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>Användarnamn att söka och lägga till</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -117,123 +117,30 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="(necessary in case of password recovery)" xml:space="preserve">
|
||||
<value>(在恢复密码的情况下是必要的)</value>
|
||||
</data>
|
||||
<data name="(password manager suggested)" xml:space="preserve">
|
||||
<value>(<a class="is-underlined has-text-info" tabindex="-1" target="blank" href="https://keepassxc.org/">密码管理器</a>建议)</value>
|
||||
</data>
|
||||
<data name="Amount" xml:space="preserve">
|
||||
<value>数量</value>
|
||||
</data>
|
||||
<data name="AnswerId" xml:space="preserve">
|
||||
<value>答案是</value>
|
||||
</data>
|
||||
<data name="AnswerText" xml:space="preserve">
|
||||
<value>回答文本</value>
|
||||
</data>
|
||||
<data name="Character" xml:space="preserve">
|
||||
<value>自定义字符(表情符号)</value>
|
||||
</data>
|
||||
<data name="ColourIndex" xml:space="preserve">
|
||||
<value>颜色</value>
|
||||
</data>
|
||||
<data name="CommentId" xml:space="preserve">
|
||||
<value>评论编号</value>
|
||||
</data>
|
||||
<data name="CommentText" xml:space="preserve">
|
||||
<value>评论文本</value>
|
||||
</data>
|
||||
<data name="Confrontation" xml:space="preserve">
|
||||
<value>对抗</value>
|
||||
</data>
|
||||
<data name="ConfrontationId" xml:space="preserve">
|
||||
<value>对抗的身份</value>
|
||||
</data>
|
||||
<data name="Context" xml:space="preserve">
|
||||
<value>背景介绍</value>
|
||||
</data>
|
||||
<data name="DateEnd" xml:space="preserve">
|
||||
<value>结束日期</value>
|
||||
</data>
|
||||
<data name="DateStart" xml:space="preserve">
|
||||
<value>开始日期</value>
|
||||
</data>
|
||||
<data name="Description" xml:space="preserve">
|
||||
<value>描述</value>
|
||||
</data>
|
||||
<data name="Discussion" xml:space="preserve">
|
||||
<value>讨论</value>
|
||||
</data>
|
||||
<data name="DiscussionEndDate" xml:space="preserve">
|
||||
<value>结束日期</value>
|
||||
</data>
|
||||
<data name="DiscussionEndTime" xml:space="preserve">
|
||||
<value>结束时间</value>
|
||||
</data>
|
||||
<data name="DiscussionId" xml:space="preserve">
|
||||
<value>讨论的内容</value>
|
||||
</data>
|
||||
<data name="DiscussionText" xml:space="preserve">
|
||||
<value>讨论文本</value>
|
||||
</data>
|
||||
<data name="DiscussionTitle" xml:space="preserve">
|
||||
<value>讨论题目</value>
|
||||
</data>
|
||||
<data name="Email" xml:space="preserve">
|
||||
<value>电子邮件</value>
|
||||
</data>
|
||||
<data name="Emoji" xml:space="preserve">
|
||||
<value>表情符号</value>
|
||||
</data>
|
||||
<data name="ExpectedMaxConfrontationsBeforeClosingAndShowing" xml:space="preserve">
|
||||
<value>对抗的最大数量</value>
|
||||
</data>
|
||||
<data name="Fact" xml:space="preserve">
|
||||
<value>事实</value>
|
||||
</data>
|
||||
<data name="Facts" xml:space="preserve">
|
||||
<value>事实</value>
|
||||
</data>
|
||||
<data name="FileId" xml:space="preserve">
|
||||
<value>文件编号</value>
|
||||
</data>
|
||||
<data name="Groups" xml:space="preserve">
|
||||
<value>群体</value>
|
||||
</data>
|
||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
||||
<value>在邀请预览中包括第一个答案?(使其更加吸引人加入讨论)</value>
|
||||
</data>
|
||||
<data name="InvitationCode" xml:space="preserve">
|
||||
<value>邀请函代码</value>
|
||||
</data>
|
||||
<data name="InvitationCodeLink" xml:space="preserve">
|
||||
<value>邀请函代码或链接</value>
|
||||
</data>
|
||||
<data name="InvitationPassword" xml:space="preserve">
|
||||
<value>邀请函密码</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreview" xml:space="preserve">
|
||||
<value>预览时需要密码</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedForPreviewText" xml:space="preserve">
|
||||
<value>需要密码才能显示预览</value>
|
||||
</data>
|
||||
<data name="IsPasswordProtectedText" xml:space="preserve">
|
||||
<value>添加密码保护</value>
|
||||
</data>
|
||||
<data name="IsStructured" xml:space="preserve">
|
||||
<value>是结构化的</value>
|
||||
</data>
|
||||
<data name="LanguageId" xml:space="preserve">
|
||||
<value>语言标识</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>命名</value>
|
||||
</data>
|
||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
||||
<value>启用通知?(对接收应用程序的更新很有用)</value>
|
||||
</data>
|
||||
<data name="NewPassword" xml:space="preserve">
|
||||
<value>新密码</value>
|
||||
</data>
|
||||
@@ -243,76 +150,22 @@
|
||||
<data name="OldPassword" xml:space="preserve">
|
||||
<value>旧密码</value>
|
||||
</data>
|
||||
<data name="OneTime" xml:space="preserve">
|
||||
<value>仅限一次</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>密码</value>
|
||||
</data>
|
||||
<data name="Perception" xml:space="preserve">
|
||||
<value>感知</value>
|
||||
</data>
|
||||
<data name="Perceptions" xml:space="preserve">
|
||||
<value>观念</value>
|
||||
</data>
|
||||
<data name="PreferSystemTheming" xml:space="preserve">
|
||||
<value>倾向于系统主题</value>
|
||||
</data>
|
||||
<data name="RecoveryCode" xml:space="preserve">
|
||||
<value>恢复代码</value>
|
||||
</data>
|
||||
<data name="Recurring" xml:space="preserve">
|
||||
<value>经常性的</value>
|
||||
</data>
|
||||
<data name="Scan for:" xml:space="preserve">
|
||||
<value>进行扫描:</value>
|
||||
</data>
|
||||
<data name="SearchText" xml:space="preserve">
|
||||
<value>搜索</value>
|
||||
</data>
|
||||
<data name="ShareCode" xml:space="preserve">
|
||||
<value>分享秘密代码</value>
|
||||
</data>
|
||||
<data name="ShowDonatorBadge" xml:space="preserve">
|
||||
<value>显示捐赠者徽章</value>
|
||||
</data>
|
||||
<data name="ShowLogs" xml:space="preserve">
|
||||
<value>显示日志标签以支持故障排除。</value>
|
||||
</data>
|
||||
<data name="TakeType" xml:space="preserve">
|
||||
<value>采取类型</value>
|
||||
</data>
|
||||
<data name="TakeTypeSimple" xml:space="preserve">
|
||||
<value>简单</value>
|
||||
</data>
|
||||
<data name="TakeTypeStructured" xml:space="preserve">
|
||||
<value>结构化</value>
|
||||
</data>
|
||||
<data name="Theme image" xml:space="preserve">
|
||||
<value>主题图片</value>
|
||||
</data>
|
||||
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||
<value>黑暗模式</value>
|
||||
</data>
|
||||
<data name="Ticks" xml:space="preserve">
|
||||
<value>蜱虫</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>标题</value>
|
||||
</data>
|
||||
<data name="UpdateReason" xml:space="preserve">
|
||||
<value>更新原因</value>
|
||||
</data>
|
||||
<data name="UpsertUserTakeIsAnonymous" xml:space="preserve">
|
||||
<value>匿名提交</value>
|
||||
</data>
|
||||
<data name="Username" xml:space="preserve">
|
||||
<value>帐号</value>
|
||||
</data>
|
||||
<data name="Username to search and add" xml:space="preserve">
|
||||
<value>搜索和添加的用户名</value>
|
||||
</data>
|
||||
<data name="Usernames to search and add" xml:space="preserve">
|
||||
<value>搜索和添加的用户名</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,18 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels
|
||||
{
|
||||
// The server's first login, made while it has no administrator (/clientapi/user/setup), with its first persona.
|
||||
public class SetupForm : LoginForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarUserName { get; set; }
|
||||
|
||||
[StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.Social
|
||||
{
|
||||
public class FollowForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(2048, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string Target { get; set; }//user@host, @user, or an actor URI
|
||||
}
|
||||
|
||||
public class ViewFollowing
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string TargetActorURI { get; set; }
|
||||
public string TargetAccountId { get; set; }
|
||||
public bool TargetIsLocal { get; set; }
|
||||
public string State { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ViewNotification
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string FromAccountId { get; set; }
|
||||
public string FromActorURI { get; set; }
|
||||
public string PostId { get; set; }
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// The login's email address, set only with its password and only once a code sent to it comes back
|
||||
// (/clientapi/user/email, /email/verify).
|
||||
public class EmailChangeForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(254, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Email", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Email { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
DataType(DataType.Password),
|
||||
PasswordPropertyText(true),
|
||||
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
||||
public class EmailCodeForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(16, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Code { get; set; }
|
||||
}
|
||||
|
||||
// what only the login's password may do (removing its email address)
|
||||
public class PasswordForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
DataType(DataType.Password),
|
||||
PasswordPropertyText(true),
|
||||
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
|
||||
public class ViewAccount
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string Email { get; set; }
|
||||
public bool IsEmailValidated { get; set; } = false;
|
||||
public string PendingEmail { get; set; }//waiting for its code
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool TwoFactorEnabled { get; set; } = false;
|
||||
public int RecoveryCodesLeft { get; set; } = 0;
|
||||
}
|
||||
|
||||
// a code from the authenticator app (six digits) or a recovery code
|
||||
public class TotpCodeForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Code { get; set; }
|
||||
}
|
||||
|
||||
public class TotpDisableForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
DataType(DataType.Password),
|
||||
PasswordPropertyText(true),
|
||||
StringLength(Constants.MaxPasswordLength, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(32, MinimumLength = 6, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Code { get; set; }
|
||||
}
|
||||
|
||||
// what an authenticator app needs: the secret, as text and as the otpauth:// URI its QR code holds
|
||||
public class ViewTotpSetup
|
||||
{
|
||||
public string Secret { get; set; }
|
||||
public string OtpAuthUri { get; set; }
|
||||
public string QrDataUri { get; set; }//an SVG image, as a data: URI
|
||||
}
|
||||
|
||||
// shown once: the recovery codes, and this device's new session (turning two-factor on ends every other one)
|
||||
public class ViewTotpEnabled
|
||||
{
|
||||
public List<string> RecoveryCodes { get; set; } = [];
|
||||
public JwtUser Session { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.User.Avatar
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
RegularExpression(Constants.UserNameRegex, ErrorMessageResourceName = "InvalidUserName", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
|
||||
public string UserName { get; set; }//preferredUsername
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User.Avatar
|
||||
{
|
||||
// How a persona's clients show what others post (R8): Mastodon's reading:expand:media ("default" hides media marked
|
||||
// sensitive, "show_all", "hide_all") and reading:expand:spoilers, which GET /api/v1/preferences answers. Mastodon's API
|
||||
// sets neither, so they are set here: GET and POST /clientapi/avatar/private/preferences, for the login's own persona.
|
||||
public class ViewPersonaPreferences
|
||||
{
|
||||
public string AvatarId { get; set; }
|
||||
public string ExpandMedia { get; set; }
|
||||
public bool ExpandSpoilers { get; set; } = false;
|
||||
}
|
||||
|
||||
public class PersonaPreferencesForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = nameof(AvatarId), ResourceType = typeof(FieldsNameResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
StringLength(16, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||
public string ExpandMedia { get; set; }//"default", "show_all" or "hide_all"
|
||||
|
||||
public bool ExpandSpoilers { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// A login deletes one of its own personas (POST /clientapi/avatar/private/delete), and with it the groups the persona
|
||||
// owns, with its password again, as it deletes itself.
|
||||
public class DeletePersonaForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "AvatarId", ResourceType = typeof(FieldsNameResource))]
|
||||
public string AvatarId { get; set; }
|
||||
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using PrivaPub.ClientModels.Resources;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// Deleting one's own login, and with it every persona and group it owns, asks for the password again.
|
||||
public class RemoveSelfForm
|
||||
{
|
||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
{
|
||||
public class UsersIds
|
||||
{
|
||||
public IList<string> UserIdList { get; set; } = Array.Empty<string>();
|
||||
public List<string> UserIdList { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// A persona's archive (owner decision 2026-10-07): made for it to take away, in Mastodon's account-archive layout, and
|
||||
// restored into it. States: none, queued, running, ready (export) or done (import), failed, stopped.
|
||||
public class ViewPersonaArchive
|
||||
{
|
||||
public string ExportState { get; set; }
|
||||
public DateTime? ExportAskedAt { get; set; }
|
||||
public DateTime? ExportReadyAt { get; set; }
|
||||
public DateTime? ExportExpiresAt { get; set; }
|
||||
public long ExportBytes { get; set; }
|
||||
public string ExportError { get; set; }
|
||||
|
||||
public string ImportState { get; set; }
|
||||
public DateTime? ImportAskedAt { get; set; }
|
||||
public DateTime? ImportEndedAt { get; set; }
|
||||
public List<string> ImportParts { get; set; } = [];
|
||||
public long ImportDone { get; set; }
|
||||
public long ImportTotal { get; set; }
|
||||
public Dictionary<string, int> ImportCounts { get; set; } = [];//what was imported and what skipped, by why
|
||||
public string ImportError { get; set; }
|
||||
public string ImportFrom { get; set; }//the archive's actor
|
||||
public bool ImportSameActor { get; set; }
|
||||
}
|
||||
|
||||
// the parts of an archive to import: posts (with their media), profile, follows, blocks, mutes, domainblocks, lists,
|
||||
// bookmarks, filters, tags, notificationpolicy, pins; and only when asked, located, scheduled, likes (which tell their
|
||||
// authors)
|
||||
public class ImportPersonaArchiveForm
|
||||
{
|
||||
public string UploadId { get; set; }
|
||||
public List<string> Parts { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ViewArchiveTicket
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public long Bytes { get; set; }
|
||||
}
|
||||
|
||||
public class ViewArchiveUpload
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public long Received { get; set; }
|
||||
public long ChunkBytes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
public class ViewServerSetup
|
||||
{
|
||||
// no administrator yet: the first login made through /clientapi/user/setup becomes it
|
||||
public bool IsPending { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PrivaPub.ClientModels.User
|
||||
{
|
||||
// /clientapi/user/storage: what the login's media take (every persona's uploads and pictures), and its quota when the
|
||||
// server sets one
|
||||
public class ViewStorage
|
||||
{
|
||||
public long MediaBytes { get; set; }
|
||||
public long MediaFiles { get; set; }
|
||||
public long? QuotaBytes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,8 @@ namespace PrivaPub.ClientModels.User
|
||||
public bool ThemeIsDarkMode { get; set; } = false;
|
||||
public bool ThemeIsLightGray { get; set; } = false;
|
||||
public bool ThemeIsDarkGray { get; set; } = false;
|
||||
|
||||
// when a client last saved these; null until one did, so a client signing in keeps its own until then
|
||||
public DateTime? SavedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,37 @@ EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public static class MastodonScopes
|
||||
{
|
||||
public static readonly string[] All =
|
||||
{
|
||||
"read", "write", "follow", "push", "profile",
|
||||
"read:accounts", "read:blocks", "read:bookmarks", "read:favourites", "read:filters", "read:follows", "read:lists",
|
||||
"read:mutes", "read:notifications", "read:search", "read:statuses",
|
||||
"write:accounts", "write:blocks", "write:bookmarks", "write:conversations", "write:favourites", "write:filters",
|
||||
"write:follows", "write:lists", "write:media", "write:mutes", "write:notifications", "write:reports", "write:statuses"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> Parse(string scopes)
|
||||
{
|
||||
var parsed = (scopes ?? string.Empty).Split(new[] { ' ', '+', ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(s => All.Contains(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return parsed.Count == 0 ? new[] { "read" } : parsed;
|
||||
}
|
||||
|
||||
public static bool Grants(IEnumerable<string> granted, string required)
|
||||
{
|
||||
var scopes = granted as ICollection<string> ?? granted.ToList();
|
||||
if (scopes.Contains(required))
|
||||
return true;
|
||||
var colon = required.IndexOf(':');
|
||||
if (colon > 0 && scopes.Contains(required[..colon]))
|
||||
return true;
|
||||
return required.StartsWith("read:follows") || required.StartsWith("write:follows") || required.StartsWith("write:blocks")
|
||||
|| required.StartsWith("read:blocks") || required.StartsWith("write:mutes") || required.StartsWith("read:mutes")
|
||||
? scopes.Contains("follow")
|
||||
: false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public class OAuthPruner : BackgroundService
|
||||
{
|
||||
static readonly TimeSpan Interval = TimeSpan.FromHours(6);
|
||||
static readonly TimeSpan Threshold = TimeSpan.FromDays(14);
|
||||
|
||||
readonly IServiceProvider _services;
|
||||
readonly ILogger<OAuthPruner> _logger;
|
||||
|
||||
public OAuthPruner(IServiceProvider services, ILogger<OAuthPruner> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Interval, stoppingToken);
|
||||
var (tokens, authorizations) = await Prune(DateTimeOffset.UtcNow - Threshold, stoppingToken);
|
||||
if (tokens + authorizations > 0)
|
||||
_logger.LogInformation("{Service} removed {Tokens} tokens and {Authorizations} authorizations", nameof(OAuthPruner), tokens, authorizations);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} pass failed", nameof(OAuthPruner));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//one pass: tokens and authorizations created before the threshold that are no longer valid
|
||||
public async Task<(long Tokens, long Authorizations)> Prune(DateTimeOffset threshold, CancellationToken token)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var tokens = await scope.ServiceProvider.GetRequiredService<IOpenIddictTokenManager>().PruneAsync(threshold, token);
|
||||
var authorizations = await scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>().PruneAsync(threshold, token);
|
||||
return (tokens, authorizations);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
using PrivaPub.Models;
|
||||
|
||||
using System.Net;
|
||||
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public static class OAuthSetup
|
||||
{
|
||||
public const string LoginScheme = "PrivaPub.OAuth";
|
||||
public const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob";
|
||||
|
||||
public static IServiceCollection PrivaPubOAuth(this IServiceCollection services, IWebHostEnvironment environment)
|
||||
{
|
||||
services.AddSingleton<IMongoDatabase>(_ => DB.Default.Database());
|
||||
services.AddHostedService<OAuthPruner>();
|
||||
|
||||
services.AddAuthentication()
|
||||
.AddCookie(LoginScheme, options =>
|
||||
{
|
||||
options.Cookie.Name = "privapub.oauth";
|
||||
options.Cookie.Path = "/oauth";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
|
||||
options.SlidingExpiration = false;
|
||||
options.LoginPath = "/oauth/login";
|
||||
});
|
||||
|
||||
services.AddOpenIddict()
|
||||
.AddCore(options => options.UseMongoDb())
|
||||
.AddServer(options =>
|
||||
{
|
||||
options.SetAuthorizationEndpointUris("/oauth/authorize")
|
||||
.SetTokenEndpointUris("/oauth/token")
|
||||
.SetRevocationEndpointUris("/oauth/revoke")
|
||||
.SetConfigurationEndpointUris("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server");
|
||||
options.AllowAuthorizationCodeFlow().AllowClientCredentialsFlow().AllowTokenExchangeFlow();
|
||||
//the only subject token accepted is the root JWT, for PersonaExchange; OpenIddict's own tokens are not exchanged
|
||||
options.Configure(server =>
|
||||
{
|
||||
server.SubjectTokenTypes.Clear();
|
||||
server.SubjectTokenTypes.Add(PersonaExchange.SubjectTokenType);
|
||||
});
|
||||
options.AddEventHandler(RootJwtSubjectToken.Descriptor);
|
||||
options.RegisterScopes(MastodonScopes.All);
|
||||
options.UseReferenceAccessTokens();
|
||||
options.SetAccessTokenLifetime(null);
|
||||
options.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(10));
|
||||
options.DisableAccessTokenEncryption();
|
||||
|
||||
var aspNetCore = options.UseAspNetCore()
|
||||
.EnableAuthorizationEndpointPassthrough()
|
||||
.EnableTokenEndpointPassthrough();
|
||||
if (!environment.IsProduction())
|
||||
aspNetCore.DisableTransportSecurityRequirement();
|
||||
|
||||
options.AddEventHandler<ApplyAuthorizationResponseContext>(builder => builder.UseInlineHandler(OutOfBandCode)
|
||||
.SetOrder(OpenIddictServerAspNetCoreHandlers.Authentication.ProcessFormPostResponse.Descriptor.Order - 1_000)
|
||||
.SetType(OpenIddictServerHandlerType.Custom));
|
||||
// a revoked token takes its Web Push subscription with it (R9), once OpenIddict has revoked it
|
||||
options.AddEventHandler<HandleRevocationRequestContext>(builder => builder.UseInlineHandler(async context =>
|
||||
{
|
||||
if (context.GenericTokenPrincipal?.GetTokenId() is { Length: > 0 } tokenId)
|
||||
await PrivaPub.Domain.Social.WebPush.ForgetToken(tokenId);
|
||||
})
|
||||
.SetOrder(OpenIddictServerHandlers.Revocation.RevokeToken.Descriptor.Order + 1_000)
|
||||
.SetType(OpenIddictServerHandlerType.Custom));
|
||||
// Mastodon 4.4's userinfo (Controllers/UserinfoController), named in the authorization server's metadata
|
||||
options.AddEventHandler<HandleConfigurationRequestContext>(builder => builder.UseInlineHandler(context =>
|
||||
{
|
||||
if (context.Issuer != default)
|
||||
context.UserInfoEndpoint = new Uri(context.Issuer, Controllers.UserinfoController.Path.TrimStart('/'));
|
||||
return default;
|
||||
})
|
||||
.SetOrder(OpenIddictServerHandlers.Discovery.AttachEndpoints.Descriptor.Order + 1_000)
|
||||
.SetType(OpenIddictServerHandlerType.Custom));
|
||||
options.AddEventHandler<ApplyTokenResponseContext>(builder => builder.UseInlineHandler(context =>
|
||||
{
|
||||
if (context.Response.AccessToken != default)
|
||||
context.Response["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return default;
|
||||
}));
|
||||
})
|
||||
.AddValidation(options =>
|
||||
{
|
||||
options.UseLocalServer();
|
||||
options.UseAspNetCore();
|
||||
});
|
||||
|
||||
services.AddOptions<OpenIddictServerOptions>().Configure<IOptionsMonitor<AppConfiguration>>((options, app) =>
|
||||
{
|
||||
options.Issuer = new Uri(app.CurrentValue.BackendBaseAddress.TrimEnd('/') + "/");
|
||||
options.SigningCredentials.Add(new SigningCredentials(OidcKey.Load(OidcKey.Signing), SecurityAlgorithms.RsaSha256));
|
||||
options.EncryptionCredentials.Add(new EncryptingCredentials(OidcKey.Load(OidcKey.Encryption),
|
||||
SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512));
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
static async ValueTask OutOfBandCode(ApplyAuthorizationResponseContext context)
|
||||
{
|
||||
if (context.RedirectUri != OutOfBand || context.Response.Code == default)
|
||||
return;
|
||||
var http = context.Transaction.GetHttpRequest()?.HttpContext;
|
||||
if (http == default)
|
||||
return;
|
||||
var code = WebUtility.HtmlEncode(context.Response.Code);
|
||||
http.Response.StatusCode = StatusCodes.Status200OK;
|
||||
http.Response.ContentType = "text/html; charset=utf-8";
|
||||
http.Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'";
|
||||
http.Response.Headers["Cache-Control"] = "no-store";
|
||||
await http.Response.WriteAsync($$"""
|
||||
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Authorization code</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:480px;margin:48px auto;padding:0 16px}
|
||||
code{display:block;font-size:1.1rem;padding:12px;border:1px solid #ccc;word-break:break-all}</style></head>
|
||||
<body><h1>Authorization code</h1><p>Copy this code into the application:</p><code>{{code}}</code></body></html>
|
||||
""");
|
||||
context.HandleRequest();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
public class OidcKey : Entity
|
||||
{
|
||||
public string Use { get; set; }
|
||||
public string PrivateKeyPem { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public const string Signing = "signing";
|
||||
public const string Encryption = "encryption";
|
||||
|
||||
public static RsaSecurityKey Load(string use)
|
||||
{
|
||||
var key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
if (key == default)
|
||||
{
|
||||
using var created = RSA.Create(2048);
|
||||
key = new OidcKey { Use = use, PrivateKeyPem = created.ExportRSAPrivateKeyPem() };
|
||||
DB.Default.SaveAsync(key).GetAwaiter().GetResult();
|
||||
key = DB.Default.Find<OidcKey>().Match(k => k.Use == use).Sort(k => k.CreatedAt, Order.Ascending).ExecuteFirstAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(key.PrivateKeyPem);
|
||||
return new RsaSecurityKey(rsa) { KeyId = key.ID };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using static OpenIddict.Server.OpenIddictServerEvents;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
// One sign-in for the first-party client (decePubClient): it signs in on /clientapi, then exchanges that root JWT for one
|
||||
// persona's Mastodon token (RFC 8693, grant_type urn:ietf:params:oauth:grant-type:token-exchange, subject_token_type
|
||||
// urn:ietf:params:oauth:token-type:jwt, avatar_id). Only the seeded first-party application holds the grant permission,
|
||||
// and the issued token is the same kind the authorization code flow issues: subject the avatar, never the root.
|
||||
public static class PersonaExchange
|
||||
{
|
||||
public const string SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt";
|
||||
public const string AvatarParameter = "avatar_id";
|
||||
//the root the subject token belongs to; it lives on the subject token's principal only, which is never stored or issued
|
||||
public const string RootClaim = "privapub_root";
|
||||
|
||||
public static string FirstPartyClientId(IConfiguration configuration) =>
|
||||
configuration["ClientApi:FirstPartyClientId"] is { Length: > 0 } clientId ? clientId : "decepub";
|
||||
|
||||
// what the first-party client's persona tokens may hold: push since R9, so decePub can subscribe to Web Push
|
||||
public static readonly string[] FirstPartyScopes = { "read", "write", "follow", "push" };
|
||||
|
||||
//the first-party application: public (a browser keeps no secret), token exchange only, no redirect
|
||||
public static async Task EnsureFirstPartyClient(IServiceProvider services, CancellationToken token)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
|
||||
var clientId = FirstPartyClientId(scope.ServiceProvider.GetRequiredService<IConfiguration>());
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = clientId,
|
||||
ClientType = ClientTypes.Public,
|
||||
ConsentType = ConsentTypes.Implicit,
|
||||
DisplayName = "decePubClient",
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.Endpoints.Revocation,
|
||||
Permissions.GrantTypes.TokenExchange
|
||||
}
|
||||
};
|
||||
foreach (var scopeName in FirstPartyScopes)
|
||||
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scopeName);
|
||||
|
||||
var existing = await applications.FindByClientIdAsync(clientId, token);
|
||||
if (existing == default)
|
||||
{
|
||||
await applications.CreateAsync(descriptor, token);
|
||||
return;
|
||||
}
|
||||
await applications.PopulateAsync(existing, descriptor, token);
|
||||
await applications.UpdateAsync(existing, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a root JWT given as a token exchange subject token, before OpenIddict tries it as one of its own tokens.
|
||||
// The principal it builds names the root under PersonaExchange.RootClaim for TokenController, which signs in a fresh
|
||||
// identity for the avatar.
|
||||
public sealed class RootJwtSubjectToken : IOpenIddictServerHandler<ValidateTokenContext>
|
||||
{
|
||||
public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder<ValidateTokenContext>()
|
||||
.UseScopedHandler<RootJwtSubjectToken>()
|
||||
.SetOrder(OpenIddictServerHandlers.Protection.ValidateIdentityModelToken.Descriptor.Order - 500)
|
||||
.SetType(OpenIddictServerHandlerType.Custom)
|
||||
.Build();
|
||||
|
||||
readonly IConfiguration _configuration;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public RootJwtSubjectToken(IConfiguration configuration, DbEntities dbEntities)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public async ValueTask HandleAsync(ValidateTokenContext context)
|
||||
{
|
||||
if (context.ValidTokenTypes.Count != 1 || !context.ValidTokenTypes.Contains(PersonaExchange.SubjectTokenType))
|
||||
return;
|
||||
|
||||
var root = await RootJwt.Root(context.Token, _configuration, _dbEntities, context.Transaction.CancellationToken);
|
||||
if (root == default)
|
||||
{
|
||||
context.Reject(Errors.InvalidGrant, "The session token is not valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(PersonaExchange.RootClaim, root.ID);
|
||||
var principal = new ClaimsPrincipal(identity)
|
||||
.SetTokenType(PersonaExchange.SubjectTokenType)
|
||||
.SetCreationDate(DateTimeOffset.UtcNow);
|
||||
if (context.ValidPresenters.Count > 0)
|
||||
principal.SetPresenters(context.ValidPresenters);
|
||||
context.Principal = principal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Security.Claims;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Auth
|
||||
{
|
||||
[ApiController]
|
||||
public class TokenController : ControllerBase
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public TokenController(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpPost("/oauth/token"), IgnoreAntiforgeryToken, Produces("application/json")]
|
||||
public async Task<IActionResult> Exchange(CancellationToken token)
|
||||
{
|
||||
var request = HttpContext.GetOpenIddictServerRequest();
|
||||
if (request == default)
|
||||
return BadRequest();
|
||||
|
||||
if (request.IsAuthorizationCodeGrantType())
|
||||
{
|
||||
var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
|
||||
if (principal == default || !await Usable(principal.GetClaim(Claims.Subject), token))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (request.IsClientCredentialsGrantType())
|
||||
{
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, "app:" + request.ClientId);
|
||||
identity.SetScopes(MastodonScopes.Parse(request.Scope));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
if (request.IsTokenExchangeGrantType())
|
||||
return await Persona(request, token);
|
||||
|
||||
return Refuse(Errors.UnsupportedGrantType, "The grant type is not supported.");
|
||||
}
|
||||
|
||||
//PersonaExchange: the root JWT, validated by RootJwtSubjectToken, for a token of one of the root's personas
|
||||
async Task<IActionResult> Persona(OpenIddictRequest request, CancellationToken token)
|
||||
{
|
||||
var subject = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
|
||||
var rootId = subject?.GetClaim(PersonaExchange.RootClaim);
|
||||
var avatarId = (string)request[PersonaExchange.AvatarParameter];
|
||||
if (string.IsNullOrEmpty(rootId) || string.IsNullOrEmpty(avatarId))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
var owns = await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
|
||||
var avatar = owns ? await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token) : default;
|
||||
if (avatar == default || !await Usable(avatarId, token))
|
||||
return Refuse(Errors.InvalidGrant, "The persona can no longer be used.");
|
||||
|
||||
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, avatar.ID);
|
||||
identity.SetClaim(Claims.Name, avatar.UserName);
|
||||
identity.SetScopes(MastodonScopes.Parse(request.Scope).Intersect(PersonaExchange.FirstPartyScopes));
|
||||
identity.SetDestinations(_ => new[] { Destinations.AccessToken });
|
||||
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
async Task<bool> Usable(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId))
|
||||
return false;
|
||||
var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
|
||||
var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
|
||||
var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
|
||||
// a suspended persona gets no token (R6), as its tokens were revoked when it was suspended
|
||||
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null, SuspendedAt: null };
|
||||
}
|
||||
|
||||
ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string>
|
||||
{
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
|
||||
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
|
||||
}), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
using PrivaPub.Infrastructure;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class AccountsController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IFollowService _follows;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly IRelationshipService _relationships;
|
||||
readonly IFeedFollows _feeds;
|
||||
|
||||
public AccountsController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
||||
IFollowService follows, IOutboxPublisher outbox, IRelationshipService relationships, IFeedFollows feeds)
|
||||
{
|
||||
_feeds = feeds;
|
||||
_relationships = relationships;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_follows = follows;
|
||||
_outbox = outbox;
|
||||
}
|
||||
|
||||
// Mastodon 4.4: a token with the profile scope alone may read it too
|
||||
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts|profile")]
|
||||
public async Task<IActionResult> VerifyCredentials(CancellationToken token) => Json(await _mapper.Local(Me, withSource: true, token));
|
||||
|
||||
[HttpPatch("/api/v1/accounts/update_credentials"), Scope("write:accounts"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(20 * 1024 * 1024),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = 20 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UpdateCredentials([FromServices] IMediaService media, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
||||
// a new picture is a row of its own; the one it replaces goes to the trash once the profile is saved
|
||||
var replaced = new List<Models.Media.MediaAttachment>();
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync(token);
|
||||
foreach (var (field, kind, width, height) in new[] { ("avatar", "avatar", 400, 400), ("header", "header", 1500, 500) })
|
||||
{
|
||||
if (form.Files[field] is not { } file)
|
||||
continue;
|
||||
var outcome = await media.ProfileImage(avatar.ID, kind, file, width, height, token);
|
||||
if (!outcome.Ok)
|
||||
{
|
||||
foreach (var made in replaced)
|
||||
await media.Trash(m => m.ID == made.ID, "profile not saved", token);
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
replaced.Add(outcome.Attachment);
|
||||
if (kind == "avatar")
|
||||
avatar.PictureURL = media.Url(outcome.Attachment.FilePath);
|
||||
else
|
||||
avatar.ThumbnailURL = media.Url(outcome.Attachment.FilePath);
|
||||
}
|
||||
}
|
||||
if (Params.Has("display_name"))
|
||||
avatar.Name = Params.Get("display_name")?.Trim();
|
||||
if (Params.Has("note"))
|
||||
avatar.Biography = Params.Get("note");
|
||||
if (Params.Bool("locked") is { } locked)
|
||||
avatar.Settings.IsLocked = locked;
|
||||
if (Params.Bool("bot") is { } bot)
|
||||
avatar.Settings.IsBot = bot;
|
||||
if (Params.Bool("discoverable") is { } discoverable)
|
||||
avatar.Settings.IsDiscoverable = discoverable;
|
||||
if (Params.Bool("indexable") is { } indexable)
|
||||
avatar.Settings.IsIndexable = indexable;
|
||||
// hide_collections stays true: who follows whom is never listed here, only counted (FEDERATION.md)
|
||||
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
|
||||
avatar.Settings.DefaultVisibility = privacy;
|
||||
if (Params.Bool("source[sensitive]") is { } sensitive)
|
||||
avatar.Settings.DefaultSensitive = sensitive;
|
||||
if (Params.Has("source[language]"))
|
||||
avatar.Settings.DefaultLanguage = Params.Get("source[language]");
|
||||
if (Params.Get("source[quote_policy]") is { } quotePolicy && QuotePolicies.IsKnown(quotePolicy))
|
||||
avatar.Settings.QuotePolicy = quotePolicy;
|
||||
var fields = new Dictionary<string, string>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
var name = Params.Get($"fields_attributes[{i}][name]") ?? Params.Get($"fields_attributes[][name]");
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
fields[name.Trim()] = Params.Get($"fields_attributes[{i}][value]")?.Trim() ?? string.Empty;
|
||||
}
|
||||
if (Params.List("fields_attributes[][name]").Count > 0)
|
||||
{
|
||||
var names = Params.List("fields_attributes[][name]");
|
||||
var values = Params.List("fields_attributes[][value]");
|
||||
fields = names.Select((n, i) => (n, v: i < values.Count ? values[i] : string.Empty)).Where(f => !string.IsNullOrWhiteSpace(f.n))
|
||||
.Take(4).ToDictionary(f => f.n.Trim(), f => f.v.Trim());
|
||||
}
|
||||
if (fields.Count > 0 || Params.Has("fields_attributes[0][name]"))
|
||||
avatar.Fields = fields;
|
||||
avatar.Emojis = PrivaPub.Domain.Content.LocalEmojis.OfProfile(avatar);//the server's custom emoji its name, note and fields use (R7)
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
foreach (var made in replaced)
|
||||
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == made.Kind && m.ID != made.ID, "replaced", token);
|
||||
|
||||
var actor = _localActors.FromAvatar(avatar);
|
||||
await _outbox.PublishProfile(actor, token);
|
||||
return Json(await _mapper.Local(actor, withSource: true, token));
|
||||
}
|
||||
|
||||
// Mastodon's: the picture goes (to the trash), the profile shows the default again, and followers are told
|
||||
[HttpDelete("/api/v1/profile/avatar"), Scope("write:accounts")]
|
||||
public Task<IActionResult> DeleteAvatar([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "avatar", token);
|
||||
|
||||
[HttpDelete("/api/v1/profile/header"), Scope("write:accounts")]
|
||||
public Task<IActionResult> DeleteHeader([FromServices] IMediaService media, CancellationToken token) => DeletePicture(media, "header", token);
|
||||
|
||||
async Task<IActionResult> DeletePicture(IMediaService media, string kind, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(Me.Id).ExecuteFirstAsync(token);
|
||||
if (kind == "avatar")
|
||||
avatar.PictureURL = default;
|
||||
else
|
||||
avatar.ThumbnailURL = default;
|
||||
avatar.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(avatar, token);
|
||||
await media.Trash(m => m.ProfileOfAvatarId == avatar.ID && m.Kind == kind, "removed", token);
|
||||
var actor = _localActors.FromAvatar(avatar);
|
||||
await _outbox.PublishProfile(actor, token);
|
||||
return Json(await _mapper.Local(actor, withSource: true, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/lookup"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Lookup(CancellationToken token)
|
||||
{
|
||||
var acct = Params.Get("acct")?.Trim().TrimStart('@');
|
||||
var parts = acct?.Split('@');
|
||||
if (parts is not { Length: 1 or 2 } || string.IsNullOrEmpty(parts[0]))
|
||||
return NotFoundError();
|
||||
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var local = await _localActors.FindByUserName(parts[0], token);
|
||||
return local is { IsFederated: true, IsCircle: false, IsServerActor: false, Suspended: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
||||
}
|
||||
var userName = parts[0];
|
||||
var domain = parts[1].ToLowerInvariant();
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/relationships"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Relationships(CancellationToken token)
|
||||
{
|
||||
var relationships = new List<Relationship>();
|
||||
foreach (var id in Params.List("id").Distinct().Take(40))
|
||||
relationships.Add(await Relationship(id, token));
|
||||
return Json(relationships);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) =>
|
||||
Json(Params.Bool("following") == true && MyId != default
|
||||
? await search.Followed(MyId, Params.Get("q"), Limit(), token)
|
||||
: await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token));
|
||||
|
||||
// Mastodon 4.3: several accounts at once (at most 40), those that exist, in no promised order
|
||||
[HttpGet("/api/v1/accounts"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Many(CancellationToken token)
|
||||
{
|
||||
var accounts = new List<object>();
|
||||
foreach (var id in Params.List("id").Distinct().Take(40))
|
||||
if (await _mapper.Account(id, token) is { } account)
|
||||
accounts.Add(account);
|
||||
return Json(accounts);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var account = await _mapper.Account(id, token);
|
||||
return account == default ? NotFoundError() : Json(account);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Statuses(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
// a suspended account's posts answer 404, as its account does (R6)
|
||||
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
||||
return NotFoundError();
|
||||
if (Params.Bool("pinned") == true)
|
||||
{
|
||||
if (local == default)
|
||||
{
|
||||
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
|
||||
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
|
||||
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
}
|
||||
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
|
||||
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
|
||||
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
|
||||
}
|
||||
|
||||
var query = local is { Kind: LocalActorKind.Group }
|
||||
? _dbEntities.Posts.Match(p => p.GroupId == local.Id).Match(VisibilityPolicy.IsShown)
|
||||
: local != default
|
||||
? _dbEntities.Posts.Match(p => p.GroupUserId == local.Id && !p.IsFederatedCopy).Match(VisibilityPolicy.IsShown)
|
||||
: _dbEntities.Posts.Match(p => p.ActorURI == remote.ActorURI && p.IsFederatedCopy).Match(VisibilityPolicy.IsShown);
|
||||
var viewerFollows = MyId != default && (local != default
|
||||
? await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetAccountId == local.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)
|
||||
: await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == remote.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
||||
if (MyId != id)
|
||||
query.Match(viewerFollows
|
||||
? p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted || p.Visibility == PostVisibility.FollowersOnly
|
||||
: p => p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
|
||||
else
|
||||
query.Match(p => p.Visibility != PostVisibility.LocalGeo);
|
||||
if (Params.Bool("exclude_replies") == true)
|
||||
{
|
||||
var self = local?.Id ?? remote.ID;
|
||||
query.Match(p => p.InReplyToURI == null || p.InReplyToAccountId == self);
|
||||
}
|
||||
if (Params.Bool("exclude_reblogs") == true)
|
||||
query.Match(p => p.ReblogOfPostId == null);
|
||||
if (Params.Bool("only_media") == true)
|
||||
query.Match(p => p.Media.Count > 0);
|
||||
if (Params.Get("tagged") is { } tag)
|
||||
query.Match(p => p.Tags.Contains(tag.ToLowerInvariant()));
|
||||
|
||||
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
||||
var statuses = await _mapper.Statuses(posts, MyId, token);
|
||||
Link($"/api/v1/accounts/{id}/statuses", posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
|
||||
return Json(statuses);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/followers"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Followers(string id, CancellationToken token)
|
||||
{
|
||||
var (local, _) = await Find(id, token);
|
||||
if (local == default || local.Id != MyId)
|
||||
return Json(Array.Empty<Account>());
|
||||
var followers = await Page.From(Params, Limit(40, 80))
|
||||
.Fetch(_dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted), f => f.ID, token);
|
||||
Link($"/api/v1/accounts/{id}/followers", followers.LastOrDefault()?.ID, followers.FirstOrDefault()?.ID);
|
||||
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Following(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
// a feed's following is what it is: its communities, as its server lists them
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token));
|
||||
if (local == default || local.Id != MyId)
|
||||
return Json(Array.Empty<Account>());
|
||||
var following = await Page.From(Params, Limit(40, 80))
|
||||
.Fetch(_dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted), f => f.ID, token);
|
||||
Link($"/api/v1/accounts/{id}/following", following.LastOrDefault()?.ID, following.FirstOrDefault()?.ID);
|
||||
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
|
||||
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
// PrivaPub's own: the feeds the persona reads (a feed's timeline is /api/v1/timelines/feed/:id)
|
||||
[HttpGet("/api/v1/feeds"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Feeds(CancellationToken token)
|
||||
{
|
||||
var read = await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId).Sort(s => s.ID, MongoDB.Entities.Order.Descending).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(read.Select(s => s.FeedAccountId), token);
|
||||
return Json(read.Select(s => accounts.GetValueOrDefault(s.FeedAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/follow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Follow(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
// nobody follows a suspended account (R6), here or elsewhere: it is not shown
|
||||
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
||||
return NotFoundError();
|
||||
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
{
|
||||
await _feeds.Subscribe(Me, remote, true, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
// notify: a `status` notification for each of its new posts; languages: only its posts in those reach the home (R8).
|
||||
// Either changes only when given, as on Mastodon
|
||||
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token, Params.Bool("notify"),
|
||||
Params.Has("languages") ? Params.List("languages") : default);
|
||||
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unfollow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Unfollow(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
await _feeds.Subscribe(Me, remote, false, token);
|
||||
else
|
||||
await _follows.UnfollowAs(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/remove_from_followers"), Scope("write:follows")]
|
||||
public async Task<IActionResult> RemoveFromFollowers(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
var uri = local?.Uri ?? remote?.ActorURI;
|
||||
if (uri == default)
|
||||
return NotFoundError();
|
||||
await _relationships.RemoveFollower(Me, uri, id, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/block"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> Block(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default || local?.Id == MyId)
|
||||
return NotFoundError();
|
||||
await _relationships.Block(Me, local?.Uri ?? remote.ActorURI, id, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unblock"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> Unblock(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
await _relationships.Unblock(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/mute"), Scope("write:mutes")]
|
||||
public async Task<IActionResult> Mute(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default || local?.Id == MyId)
|
||||
return NotFoundError();
|
||||
var duration = Params.Int("duration") is { } seconds and > 0 ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null;
|
||||
await _relationships.Mute(Me, local?.Uri ?? remote.ActorURI, id, Params.Bool("notifications") != false, duration, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unmute"), Scope("write:mutes")]
|
||||
public async Task<IActionResult> Unmute(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
await _relationships.Unmute(Me, local?.Uri ?? remote.ActorURI, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/blocks"), Scope("read:blocks")]
|
||||
public async Task<IActionResult> Blocks(CancellationToken token)
|
||||
{
|
||||
var blocks = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Block>().Match(b => b.AvatarId == MyId), b => b.ID, token);
|
||||
var accounts = await _mapper.Accounts(blocks.Select(b => b.TargetAccountId), token);
|
||||
Link("/api/v1/blocks", blocks.LastOrDefault()?.ID, blocks.FirstOrDefault()?.ID);
|
||||
return Json(blocks.Select(b => accounts.GetValueOrDefault(b.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/mutes"), Scope("read:mutes")]
|
||||
public async Task<IActionResult> Mutes(CancellationToken token)
|
||||
{
|
||||
var mutes = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId), m => m.ID, token);
|
||||
var accounts = await _mapper.Accounts(mutes.Select(m => m.TargetAccountId), token);
|
||||
Link("/api/v1/mutes", mutes.LastOrDefault()?.ID, mutes.FirstOrDefault()?.ID);
|
||||
return Json(mutes.Select(m => accounts.GetValueOrDefault(m.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/domain_blocks"), Scope("read:blocks")]
|
||||
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(b => b.Domain).ToList());
|
||||
|
||||
// Mastodon's preview of a server block: how many of the accounts the persona follows, and of those following it, the
|
||||
// block would take away, counted as BlockDomain takes them (an account on the server or a subdomain, or whose handle
|
||||
// is)
|
||||
[HttpGet("/api/v1/domain_blocks/preview"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> PreviewDomainBlock(CancellationToken token)
|
||||
{
|
||||
var domain = Params.Get("domain")?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(domain))
|
||||
return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: domain");
|
||||
if (Uri.CheckHostName(domain) != UriHostNameType.Dns)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Domain is invalid");
|
||||
var followings = await _dbEntities.Followings.Match(f => f.AvatarId == MyId && !f.TargetIsLocal && f.State == FollowState.Accepted)
|
||||
.ExecuteAsync(token);
|
||||
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.IsAccepted).ExecuteAsync(token);
|
||||
var handles = await Hidden.HandleDomains(followings.Select(f => f.TargetActorURI).Concat(followers.Select(f => f.ActorURI)).ToList(), token);
|
||||
return Json(new
|
||||
{
|
||||
following_count = followings.Count(f => Hidden.Blocks(domain, f.TargetActorURI, handles.GetValueOrDefault(f.TargetActorURI))),
|
||||
followers_count = followers.Count(f => Hidden.Blocks(domain, f.ActorURI, handles.GetValueOrDefault(f.ActorURI)))
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/domain_blocks"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> BlockDomain(CancellationToken token)
|
||||
{
|
||||
await _relationships.BlockDomain(Me, Params.Get("domain"), token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/domain_blocks"), Scope("write:blocks")]
|
||||
public async Task<IActionResult> UnblockDomain(CancellationToken token)
|
||||
{
|
||||
await _relationships.UnblockDomain(Me, Params.Get("domain"), token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/follow_requests"), Scope("read:follows")]
|
||||
public async Task<IActionResult> FollowRequests(CancellationToken token)
|
||||
{
|
||||
var requests = await _dbEntities.Followers.Match(f => f.LocalActorId == Me.Id && f.LocalActorKind == LocalActorKind.Person && !f.IsAccepted)
|
||||
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
return Json(await AccountsFor(requests.Select(r => r.ActorURI), token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/follow_requests/{id}/authorize"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Authorize(string id, CancellationToken token) =>
|
||||
await _follows.Decide(Me, id, accept: true, token) ? Json(await Relationship(id, token)) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v1/follow_requests/{id}/reject"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Reject(string id, CancellationToken token) =>
|
||||
await _follows.Decide(Me, id, accept: false, token) ? Json(await Relationship(id, token)) : NotFoundError();
|
||||
|
||||
// Familiar followers stay empty by design: which of the accounts the persona follows also follow another account would
|
||||
// tell who follows whom among the personas here (and their followers lists are never shown, hide_collections), so
|
||||
// every account answers none
|
||||
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
|
||||
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
|
||||
|
||||
// Endorsements (R8): Mastodon's account pins under their old names (pin, unpin) and 4.4's (endorse, unendorse). Only an
|
||||
// account the persona follows may be endorsed, as Mastodon requires; they are the persona's alone, never federated as
|
||||
// featured accounts and never listed to anyone else (the owner's default: endorsements stay private)
|
||||
[HttpPost("/api/v1/accounts/{id}/pin"), HttpPost("/api/v1/accounts/{id}/endorse"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Endorse(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default || local?.Id == MyId || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
||||
return NotFoundError();
|
||||
var uri = local?.Uri ?? remote.ActorURI;
|
||||
if (!await Follows(uri, token))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must be already following the person you want to endorse");
|
||||
if (!await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token))
|
||||
{
|
||||
if (await DB.Default.CountAsync<Endorsement>(e => e.AvatarId == MyId, token) >= MaxEndorsements)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can endorse at most {MaxEndorsements} accounts");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Endorsement { AvatarId = MyId, TargetAccountId = id, TargetActorURI = uri }, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unpin"), HttpPost("/api/v1/accounts/{id}/unendorse"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unendorse(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
var uri = local?.Uri ?? remote.ActorURI;
|
||||
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == MyId && (e.TargetActorURI == uri || e.TargetAccountId == id));
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
// the persona's own endorsements, newest first, those it still follows
|
||||
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
||||
public Task<IActionResult> Endorsements(CancellationToken token) => EndorsementsOf("/api/v1/endorsements", token);
|
||||
|
||||
// Mastodon 4.4: an account's endorsements. Only the persona's own are answered, to the persona itself: endorsements
|
||||
// stay private, so every other account answers none
|
||||
[HttpGet("/api/v1/accounts/{id}/endorsements"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> EndorsementsOfAccount(string id, CancellationToken token) =>
|
||||
MyId != default && id == MyId ? await EndorsementsOf($"/api/v1/accounts/{id}/endorsements", token) : Json(Array.Empty<Account>());
|
||||
|
||||
async Task<IActionResult> EndorsementsOf(string path, CancellationToken token)
|
||||
{
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && f.FollowerKind == null)
|
||||
.Project(f => f.Include(x => x.TargetActorURI)).ExecuteAsync(token)).Select(f => f.TargetActorURI).ToList();
|
||||
var page = await Page.From(Params, Limit(40, 80))
|
||||
.Fetch(DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && followed.Contains(e.TargetActorURI)), e => e.ID, token);
|
||||
Link(path, page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
||||
var accounts = await _mapper.Accounts(page.Select(e => e.TargetAccountId), token);
|
||||
return Json(page.Select(e => accounts.GetValueOrDefault(e.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
// A private note on an account (R8): the persona's own (PersonaNote, never its login's), shown only to it in the
|
||||
// relationship's `note`, never federated; an empty comment takes it away
|
||||
[HttpPost("/api/v1/accounts/{id}/note"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Note(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
var comment = Params.Get("comment")?.Trim();
|
||||
if (comment?.Length > PersonaNote.MaxLength)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Comment is too long (maximum is {PersonaNote.MaxLength} characters)");
|
||||
if (string.IsNullOrEmpty(comment))
|
||||
await DB.Default.DeleteAsync<PersonaNote>(n => n.AvatarId == MyId && n.TargetAccountId == id);
|
||||
else
|
||||
await DB.Default.Update<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id)
|
||||
.Modify(n => n.Text, comment)
|
||||
.Modify(n => n.UpdatedAt, DateTime.UtcNow)
|
||||
.Modify(b => b.SetOnInsert(n => n.CreatedAt, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
// Mastodon's preferences: posting defaults (update_credentials' source) and reading ones (R8: AvatarSettings, set
|
||||
// through /clientapi/avatar/private/preferences, since Mastodon's API sets none)
|
||||
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
|
||||
public IActionResult Preferences() => Json(new Dictionary<string, object>
|
||||
{
|
||||
["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public",
|
||||
["posting:default:sensitive"] = Me.Settings.DefaultSensitive,
|
||||
["posting:default:language"] = Me.Settings.DefaultLanguage,
|
||||
["reading:expand:media"] = ExpandMediaModes.IsKnown(Me.Settings.ExpandMedia) ? Me.Settings.ExpandMedia : ExpandMediaModes.Default,
|
||||
["reading:expand:spoilers"] = Me.Settings.ExpandSpoilers
|
||||
});
|
||||
|
||||
const int MaxEndorsements = 500;
|
||||
|
||||
async Task<bool> Follows(string uri, CancellationToken token) =>
|
||||
await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri && f.State == FollowState.Accepted && f.FollowerKind == null)
|
||||
.ExecuteAnyAsync(token);
|
||||
|
||||
async Task<List<Account>> AccountsFor(IEnumerable<string> actorUris, CancellationToken token)
|
||||
{
|
||||
var accounts = new List<Account>();
|
||||
foreach (var uri in actorUris)
|
||||
{
|
||||
var local = await _localActors.FindByUri(uri, token);
|
||||
if (local != default)
|
||||
{
|
||||
accounts.Add(await _mapper.Local(local, false, token));
|
||||
continue;
|
||||
}
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
if (foreign != default)
|
||||
accounts.Add(_mapper.Foreign(foreign));
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async Task<(LocalActor Local, ForeignAvatar Remote)> Find(string id, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(id).ExecuteFirstAsync(token);
|
||||
if (avatar is { DeletionAt: null })
|
||||
return (_localActors.FromAvatar(avatar), default);
|
||||
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
||||
if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community)
|
||||
return (community, default);
|
||||
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
|
||||
}
|
||||
|
||||
async Task<Relationship> Relationship(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
var uri = local?.Uri ?? remote?.ActorURI;
|
||||
var following = uri == default ? default : await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var reading = remote is { AvatarType: AvatarType.Feed } && await _feeds.IsSubscribed(MyId, remote.ActorURI, token);
|
||||
var followedBy = uri == default ? default : await _dbEntities.Followers.Match(f => f.LocalActorId == MyId && f.ActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blocking = uri != default && await DB.Default.Find<Block>().Match(b => b.AvatarId == MyId && b.TargetActorURI == uri).ExecuteAnyAsync(token);
|
||||
var mute = uri == default ? default : await DB.Default.Find<Mute>().Match(m => m.AvatarId == MyId && m.TargetActorURI == uri).ExecuteFirstAsync(token);
|
||||
var blockedBy = local != default
|
||||
? await DB.Default.Find<Block>().Match(b => b.AvatarId == local.Id && b.TargetActorURI == Me.Uri).ExecuteAnyAsync(token)
|
||||
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
|
||||
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
|
||||
// the persona's own endorsement and note (R8), never another persona's: a note made as a sibling stays its own
|
||||
var endorsed = uri != default && following?.State == FollowState.Accepted
|
||||
&& await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token);
|
||||
var note = await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id).ExecuteFirstAsync(token);
|
||||
return new Relationship
|
||||
{
|
||||
Id = id,
|
||||
Blocking = blocking,
|
||||
BlockedBy = blockedBy,
|
||||
Muting = mute != default && (mute.ExpiresAt == default || mute.ExpiresAt > DateTime.UtcNow),
|
||||
MutingNotifications = mute?.HideNotifications == true,
|
||||
DomainBlocking = domainBlocked,
|
||||
Following = following?.State == FollowState.Accepted || reading,
|
||||
Requested = following?.State == FollowState.Requested,
|
||||
ShowingReblogs = following?.ShowReblogs ?? false,
|
||||
Notifying = following?.Notify ?? false,
|
||||
Languages = following?.Languages,
|
||||
FollowedBy = followedBy?.IsAccepted == true,
|
||||
RequestedBy = followedBy is { IsAccepted: false },
|
||||
Endorsed = endorsed,
|
||||
Note = note?.Text ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's announcements: the administrators' (Domain/Social/Announcements.cs), each persona's dismissals and
|
||||
// reactions its own. Anonymous readers get the list without `read` or `me`.
|
||||
public class AnnouncementsController : MastodonController
|
||||
{
|
||||
readonly IAnnouncements _announcements;
|
||||
|
||||
public AnnouncementsController(IAnnouncements announcements)
|
||||
{
|
||||
_announcements = announcements;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/announcements"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> List(CancellationToken token) =>
|
||||
Json(await Map(await _announcements.Shown(token), MyId, token));
|
||||
|
||||
[HttpPost("/api/v1/announcements/{id}/dismiss"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Dismiss(string id, CancellationToken token) =>
|
||||
await _announcements.Dismiss(id, MyId, token) ? Json(new { }) : NotFoundError();
|
||||
|
||||
[HttpPut("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")]
|
||||
public Task<IActionResult> React(string id, string name, CancellationToken token) => Reaction(id, name, add: true, token);
|
||||
|
||||
[HttpDelete("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Unreact(string id, string name, CancellationToken token) => Reaction(id, name, add: false, token);
|
||||
|
||||
async Task<IActionResult> Reaction(string id, string name, bool add, CancellationToken token)
|
||||
{
|
||||
var (status, error) = await _announcements.React(id, MyId, name, add, token);
|
||||
return error == default ? Json(new { }) : Error(status, error);
|
||||
}
|
||||
|
||||
/// <summary>Mastodon's Announcement entities, as the viewer (a persona, or nobody) sees them.</summary>
|
||||
public static async Task<List<Dictionary<string, object>>> Map(IReadOnlyList<Announcement> announcements, string viewerId, CancellationToken token)
|
||||
{
|
||||
var ids = announcements.Select(a => a.ID).ToList();
|
||||
if (ids.Count == 0)
|
||||
return new List<Dictionary<string, object>>();
|
||||
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => ids.Contains(r.AnnouncementId)).ExecuteAsync(token);
|
||||
var read = viewerId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<AnnouncementRead>().Match(r => r.AvatarId == viewerId && ids.Contains(r.AnnouncementId)).ExecuteAsync(token))
|
||||
.Select(r => r.AnnouncementId).ToHashSet();
|
||||
return announcements.Select(a =>
|
||||
{
|
||||
var entity = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = a.ID,
|
||||
["content"] = a.Html ?? string.Empty,
|
||||
["starts_at"] = a.StartsAt == default ? default : MastodonJson.Time(a.StartsAt.Value),
|
||||
["ends_at"] = a.EndsAt == default ? default : MastodonJson.Time(a.EndsAt.Value),
|
||||
["all_day"] = a.AllDay,
|
||||
["published_at"] = MastodonJson.Time(a.PublishedAt ?? a.CreatedAt),
|
||||
["updated_at"] = MastodonJson.Time(a.UpdatedAt),
|
||||
["mentions"] = Array.Empty<object>(),
|
||||
["statuses"] = Array.Empty<object>(),
|
||||
["tags"] = Array.Empty<object>(),
|
||||
["emojis"] = Mappers.MastodonMapper.LocalEmojiEntities(LocalEmojis.In(a.Text)),//the server's custom emoji its text uses (R7)
|
||||
["reactions"] = reactions.Where(r => r.AnnouncementId == a.ID)
|
||||
.GroupBy(r => r.Name)
|
||||
.OrderBy(g => g.Min(r => r.CreatedAt))
|
||||
.Select(g => Reaction(g.Key, g.Count(), viewerId == default ? default : g.Any(r => r.AvatarId == viewerId)))
|
||||
.ToList()
|
||||
};
|
||||
if (viewerId != default)
|
||||
entity["read"] = read.Contains(a.ID);
|
||||
return entity;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
// a reaction: a unicode emoji's has no url, one of the server's custom emoji's has its pictures (R7); `me` only for a
|
||||
// persona
|
||||
static Dictionary<string, object> Reaction(string name, int count, bool? me)
|
||||
{
|
||||
var reaction = new Dictionary<string, object> { ["name"] = name, ["count"] = count };
|
||||
if (me is { } mine)
|
||||
reaction["me"] = mine;
|
||||
if (LocalEmojis.Find(name) is { } custom)
|
||||
{
|
||||
reaction["url"] = custom.Url;
|
||||
reaction["static_url"] = custom.StaticUrl;
|
||||
}
|
||||
return reaction;
|
||||
}
|
||||
|
||||
/// <summary>The `announcement.reaction` event's payload: the emoji, how many chose it now, and on which.</summary>
|
||||
public static async Task<Dictionary<string, object>> ReactionEvent(string announcementId, string name, CancellationToken token) => new()
|
||||
{
|
||||
["name"] = name,
|
||||
["count"] = await DB.Default.CountAsync<AnnouncementReaction>(r => r.AnnouncementId == announcementId && r.Name == name, token),
|
||||
["announcement_id"] = announcementId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Auth;
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class AppsController : MastodonController
|
||||
{
|
||||
readonly IOpenIddictApplicationManager _applications;
|
||||
readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
|
||||
|
||||
public AppsController(IOpenIddictApplicationManager applications, PrivaPub.Infrastructure.Push.IVapid vapid)
|
||||
{
|
||||
_applications = applications;
|
||||
_vapid = vapid;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/apps"), AllowAnonymous]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var name = Params.Get("client_name")?.Trim();
|
||||
if (string.IsNullOrEmpty(name) || name.Length > 200)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Application name can't be blank");
|
||||
|
||||
var redirects = Params.List("redirect_uris").SelectMany(r => r.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Distinct().ToList();
|
||||
if (redirects.Count == 0 || redirects.Any(r => !Uri.TryCreate(r, UriKind.Absolute, out var uri) || uri.Scheme is "javascript" or "data"))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Redirect URI must be an absolute URI.");
|
||||
|
||||
var scopes = MastodonScopes.Parse(string.Join(' ', Params.List("scopes")));
|
||||
var descriptor = new OpenIddictApplicationDescriptor
|
||||
{
|
||||
ClientId = Secret(32),
|
||||
ClientSecret = Secret(32),
|
||||
ClientType = ClientTypes.Confidential,
|
||||
ConsentType = ConsentTypes.Explicit,
|
||||
DisplayName = name,
|
||||
Permissions =
|
||||
{
|
||||
Permissions.Endpoints.Authorization,
|
||||
Permissions.Endpoints.Token,
|
||||
Permissions.Endpoints.Revocation,
|
||||
Permissions.GrantTypes.AuthorizationCode,
|
||||
Permissions.GrantTypes.ClientCredentials,
|
||||
Permissions.ResponseTypes.Code
|
||||
}
|
||||
};
|
||||
foreach (var scope in scopes)
|
||||
descriptor.Permissions.Add(Permissions.Prefixes.Scope + scope);
|
||||
foreach (var redirect in redirects)
|
||||
descriptor.RedirectUris.Add(new Uri(redirect));
|
||||
var website = Params.Get("website");
|
||||
if (Uri.TryCreate(website, UriKind.Absolute, out var site) && site.Scheme is "https" or "http")
|
||||
descriptor.Properties["website"] = System.Text.Json.JsonSerializer.SerializeToElement(website);
|
||||
|
||||
var application = await _applications.CreateAsync(descriptor, token);
|
||||
return Json(new Application
|
||||
{
|
||||
Id = await _applications.GetIdAsync(application, token),
|
||||
Name = name,
|
||||
Website = website,
|
||||
Scopes = scopes.ToList(),
|
||||
RedirectUris = redirects,
|
||||
RedirectUri = string.Join("\n", redirects),
|
||||
ClientId = descriptor.ClientId,
|
||||
ClientSecret = descriptor.ClientSecret,
|
||||
VapidKey = (await _vapid.Key(token)).ForApi//Web Push's server key, as Mastodon still gives it here
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/apps/verify_credentials")]
|
||||
public async Task<IActionResult> Verify(CancellationToken token)
|
||||
{
|
||||
var clientId = User.GetPresenters().FirstOrDefault() ?? User.GetClaim(Claims.ClientId);
|
||||
var application = clientId == default ? default : await _applications.FindByClientIdAsync(clientId, token);
|
||||
if (application == default)
|
||||
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
|
||||
var properties = await _applications.GetPropertiesAsync(application, token);
|
||||
return Json(new
|
||||
{
|
||||
id = await _applications.GetIdAsync(application, token),
|
||||
name = await _applications.GetDisplayNameAsync(application, token),
|
||||
website = properties.TryGetValue("website", out var website) ? website.GetString() : default,
|
||||
scopes = User.GetScopes().ToList(),
|
||||
redirect_uris = (await _applications.GetRedirectUrisAsync(application, token)).ToList(),
|
||||
vapid_key = (await _vapid.Key(token)).ForApi
|
||||
});
|
||||
}
|
||||
|
||||
static string Secret(int bytes) => Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Statistics;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// The CDNs in front of the servers this server knows, each named by its own domain (owner decision 2026-10-04): how many
|
||||
// servers are behind it, how it is recognised, and week by week who joined and who left.
|
||||
public class CdnsController : MastodonController
|
||||
{
|
||||
const int MaxWeeks = 260;
|
||||
|
||||
readonly IServerPlaces _places;
|
||||
|
||||
public CdnsController(IServerPlaces places) => _places = places;
|
||||
|
||||
[HttpGet("/api/privapub/v1/cdns"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> List(CancellationToken token) => Json((await _places.Cdns(token)).Select(Summary).ToList());
|
||||
|
||||
[HttpGet("/api/privapub/v1/cdns/{key}"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Detail(string key, CancellationToken token)
|
||||
{
|
||||
var history = await _places.Cdn(key, Math.Clamp(Params.Int("weeks") ?? 52, 1, MaxWeeks), token);
|
||||
if (history == default)
|
||||
return NotFoundError();
|
||||
var detail = new CdnDetail
|
||||
{
|
||||
Hosts = history.Hosts,
|
||||
HostsTruncated = history.Group.Servers > history.Hosts.Count,
|
||||
Weeks = history.Weeks.Select(w => new CdnWeekEntry { Week = w.Week, Servers = w.Servers, Joined = w.Joined, Left = w.Left }).ToList()
|
||||
};
|
||||
Fill(detail, history.Group);
|
||||
return Json(detail);
|
||||
}
|
||||
|
||||
static CdnSummary Summary(CdnGroup group)
|
||||
{
|
||||
var summary = new CdnSummary();
|
||||
Fill(summary, group);
|
||||
return summary;
|
||||
}
|
||||
|
||||
static void Fill(CdnSummary summary, CdnGroup group)
|
||||
{
|
||||
summary.Key = group.Key;
|
||||
summary.Name = group.Name;
|
||||
summary.Domain = group.Domain;
|
||||
summary.Servers = group.Servers;
|
||||
summary.Ranges = group.Ranges;
|
||||
summary.RangesUpdatedAt = group.RangesUpdatedAt is { } at ? MastodonJson.Time(at) : default;
|
||||
summary.DetectedBy = group.DetectedBy;
|
||||
}
|
||||
|
||||
public class CdnSummary
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Domain { get; set; }
|
||||
public int Servers { get; set; }
|
||||
public int Ranges { get; set; }
|
||||
public string RangesUpdatedAt { get; set; }
|
||||
public List<string> DetectedBy { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CdnDetail : CdnSummary
|
||||
{
|
||||
public List<string> Hosts { get; set; } = new();
|
||||
public bool HostsTruncated { get; set; }
|
||||
public List<CdnWeekEntry> Weeks { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CdnWeekEntry
|
||||
{
|
||||
public string Week { get; set; }
|
||||
public int Servers { get; set; }
|
||||
public List<string> Joined { get; set; } = new();
|
||||
public List<string> Left { get; set; } = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Content;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's list of the server's custom emoji (completion program R7), for clients' pickers: as Mastodon lists them,
|
||||
// those offered (not disabled) and shown in the picker, by shortcode. Read from LocalEmojis, the copy in memory that every
|
||||
// change reloads at once (and that is read again when five minutes old), so no request reads the database.
|
||||
public class CustomEmojisController : MastodonController
|
||||
{
|
||||
[HttpGet("/api/v1/custom_emojis"), AllowAnonymous]
|
||||
public IActionResult List() => Json(LocalEmojis.Current.Values
|
||||
.Where(e => e.VisibleInPicker)
|
||||
.OrderBy(e => e.Shortcode, StringComparer.Ordinal)
|
||||
.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = e.Url, StaticUrl = e.StaticUrl, VisibleInPicker = true, Category = e.Category })
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Featured hashtags as Mastodon's API has them (completion program R8, Domain/Social/FeaturedTags): the persona's own,
|
||||
// featured and unfeatured (its followers are told), suggested from the tags it uses, and any account's: a persona's own
|
||||
// here, or those an account elsewhere features as its server tells them. They are public, as on Mastodon.
|
||||
public class FeaturedTagsController : MastodonController
|
||||
{
|
||||
const int MaxSuggested = 10;//Mastodon's RECENT_TAGS_LIMIT
|
||||
const int SuggestionPosts = 200;
|
||||
|
||||
readonly IFeaturedTags _featured;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public FeaturedTagsController(IFeaturedTags featured, DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_featured = featured;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> Mine(CancellationToken token) => Json(await Entities(await _featured.Of(MyId, token), token));
|
||||
|
||||
[HttpPost("/api/v1/featured_tags"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Feature(CancellationToken token)
|
||||
{
|
||||
var (tag, error) = await _featured.Feature(Me, Params.Get("name"), token);
|
||||
return tag == default ? Error(StatusCodes.Status422UnprocessableEntity, error) : Json((await Entities(new List<FeaturedTag> { tag }, token))[0]);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/featured_tags/{id}"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unfeature(string id, CancellationToken token) =>
|
||||
await _featured.Unfeature(Me, id, default, token) == default ? NotFoundError() : Json(new { });
|
||||
|
||||
// the tags the persona used lately and does not feature yet, the most used first, as Mastodon's Tag
|
||||
[HttpGet("/api/v1/featured_tags/suggestions"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> Suggestions(CancellationToken token)
|
||||
{
|
||||
var featured = (await _featured.Of(MyId, token)).Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var recent = await _dbEntities.Posts.Match(p => p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null
|
||||
&& p.Tags.Count > 0)
|
||||
.Sort(p => p.ID, Order.Descending).Limit(SuggestionPosts).Project(p => p.Include(x => x.Tags)).ExecuteAsync(token);
|
||||
var used = recent.SelectMany((p, age) => p.Tags.Distinct().Select(tag => (tag, age)))
|
||||
.Where(t => !featured.Contains(t.tag) && TagsController.IsHashtag(t.tag))
|
||||
.GroupBy(t => t.tag)
|
||||
.OrderByDescending(g => g.Count()).ThenBy(g => g.Min(t => t.age))
|
||||
.Take(MaxSuggested)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
var views = new List<object>();
|
||||
foreach (var tag in used)
|
||||
views.Add(await TagsController.TagView(_dbEntities, _localActors.BaseAddress, tag, MyId, default, false, token));
|
||||
return Json(views);
|
||||
}
|
||||
|
||||
// what an account features: a persona's own hashtags, or what an account elsewhere tells (its featuredTags, its Add
|
||||
// and Remove); a community features none. A suspended account's answer 404, as its account does (R6).
|
||||
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> OfAccount(string id, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(id).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
return _localActors.FromAvatar(avatar) is { Suspended: false }
|
||||
? Json(await Entities(await _featured.Of(avatar.ID, token), token))
|
||||
: NotFoundError();
|
||||
if (await _dbEntities.Groups.MatchID(id).Match(g => !g.DeletionAt.HasValue).ExecuteAnyAsync(token))
|
||||
return Json(Array.Empty<FeaturedTagEntity>());
|
||||
var remote = await _dbEntities.ForeignAvatars.MatchID(id).Match(f => !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (remote == default || remote.SuspendedAt.HasValue)
|
||||
return NotFoundError();
|
||||
var tags = await DB.Default.Find<RemoteFeaturedTag>().Match(t => t.ActorURI == remote.ActorURI).Sort(t => t.Position, Order.Ascending)
|
||||
.Limit(FeaturedTags.MaxRemote).ExecuteAsync(token);
|
||||
var usage = await FeaturedTags.Usage(default, remote.ActorURI, tags.Select(t => t.Name).ToList(), _dbEntities, token);
|
||||
return Json(tags.Select(t => Entity(t.ID, t.Name, t.DisplayName, t.Href, usage.GetValueOrDefault(t.Name))).ToList());
|
||||
}
|
||||
|
||||
async Task<List<FeaturedTagEntity>> Entities(List<FeaturedTag> tags, CancellationToken token)
|
||||
{
|
||||
var byPersona = tags.GroupBy(t => t.AvatarId).ToList();
|
||||
var entities = new List<FeaturedTagEntity>();
|
||||
foreach (var persona in byPersona)
|
||||
{
|
||||
var usage = await FeaturedTags.Usage(persona.Key, default, persona.Select(t => t.Name).ToList(), _dbEntities, token);
|
||||
entities.AddRange(persona.Select(t => Entity(t.ID, t.Name, t.DisplayName, default, usage.GetValueOrDefault(t.Name))));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
FeaturedTagEntity Entity(string id, string name, string displayName, string href, (long Count, DateTime? Last) usage) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = string.IsNullOrEmpty(displayName) ? name : displayName,
|
||||
// (a link to the remote site is only a link, which the reader follows or not; ours is this server's tag page)
|
||||
Url = href ?? ActivityPubRenderer.TagUrl(_localActors.BaseAddress, name),
|
||||
StatusesCount = usage.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastStatusAt = usage.Last?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's filters (owner decision 2026-10-04, back from the cut list): v2 filters with their keywords and statuses,
|
||||
// and the v1 API, whose filter is one keyword of a v2 filter. Statuses carry what they match in `filtered`; clients
|
||||
// apply the context and the action. Nothing here leaves the server.
|
||||
public class FiltersController : MastodonController
|
||||
{
|
||||
const int MaxFilters = 100;
|
||||
const int MaxEntries = 100;
|
||||
const int MaxKeywordLength = 100;
|
||||
static readonly Regex Attribute = new(@"^keywords_attributes\[(\d*)\]\[(\w+)\]$", RegexOptions.Compiled);
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public FiltersController(DbEntities dbEntities)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
static string NewId() => ObjectId.GenerateNewId().ToString();
|
||||
|
||||
Task<PersonaFilter> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.ID == id && f.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
Task<PersonaFilter> WithKeyword(string keywordId, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Keywords.Any(k => k.Id == keywordId)).ExecuteFirstAsync(token);
|
||||
|
||||
Task<PersonaFilter> WithStatus(string statusFilterId, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId && f.Statuses.Any(s => s.Id == statusFilterId)).ExecuteFirstAsync(token);
|
||||
|
||||
static JsonResult Invalid(string message) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + message);
|
||||
|
||||
// title, context[], filter_action, expires_in: what a create or an update sets; an error message when one is wrong
|
||||
string Apply(PersonaFilter filter, bool creating)
|
||||
{
|
||||
var title = Params.Get("title")?.Trim();
|
||||
if (title != default || creating)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title) || title.Length > 200)
|
||||
return "Title can't be blank";
|
||||
filter.Title = title;
|
||||
}
|
||||
if (Params.Has("context") || creating)
|
||||
{
|
||||
var context = Params.List("context").Distinct().ToList();
|
||||
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
|
||||
return "Context can't be blank";
|
||||
filter.Context = context;
|
||||
}
|
||||
if (Params.Get("filter_action") is { } action)
|
||||
{
|
||||
if (!FilterActions.IsValid(action))
|
||||
return "Filter action is not included in the list";
|
||||
filter.Action = action;
|
||||
}
|
||||
if (Params.Has("expires_in"))
|
||||
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
|
||||
return default;
|
||||
}
|
||||
|
||||
// keywords_attributes, as Rails takes them: a JSON array of objects, or form fields [][keyword], [][whole_word], [][id],
|
||||
// [][_destroy], or numbered [0][keyword]
|
||||
List<Dictionary<string, string>> KeywordAttributes()
|
||||
{
|
||||
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } body
|
||||
&& body.TryGetProperty("keywords_attributes", out var array) && array.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
return array.EnumerateArray().Where(o => o.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
.Select(o => o.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.ValueKind switch
|
||||
{
|
||||
System.Text.Json.JsonValueKind.String => p.Value.GetString(),
|
||||
System.Text.Json.JsonValueKind.True => "true",
|
||||
System.Text.Json.JsonValueKind.False => "false",
|
||||
_ => p.Value.ToString()
|
||||
}))
|
||||
.ToList();
|
||||
var numbered = new SortedDictionary<int, Dictionary<string, string>>();
|
||||
var listed = new Dictionary<string, IReadOnlyList<string>>();
|
||||
foreach (var name in Params.Names)
|
||||
{
|
||||
var match = Attribute.Match(name);
|
||||
if (!match.Success)
|
||||
continue;
|
||||
if (match.Groups[1].Value.Length == 0)
|
||||
listed[match.Groups[2].Value] = Params.List(name);
|
||||
else
|
||||
{
|
||||
var index = int.Parse(match.Groups[1].Value);
|
||||
if (!numbered.TryGetValue(index, out var item))
|
||||
numbered[index] = item = new Dictionary<string, string>();
|
||||
item[match.Groups[2].Value] = Params.Get(name);
|
||||
}
|
||||
}
|
||||
var items = numbered.Values.ToList();
|
||||
var count = listed.Count == 0 ? 0 : listed.Values.Max(v => v.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
items.Add(listed.Where(kv => i < kv.Value.Count).ToDictionary(kv => kv.Key, kv => kv.Value[i]));
|
||||
return items;
|
||||
}
|
||||
|
||||
static bool IsTrue(string value) => value?.ToLowerInvariant() is "true" or "1" or "on" or "yes";
|
||||
|
||||
// adds, changes or removes keywords; an error message when one is wrong
|
||||
static string ApplyKeywords(PersonaFilter filter, List<Dictionary<string, string>> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var id = item.GetValueOrDefault("id");
|
||||
var existing = id == default ? default : filter.Keywords.FirstOrDefault(k => k.Id == id);
|
||||
if (existing != default && IsTrue(item.GetValueOrDefault("_destroy")))
|
||||
{
|
||||
filter.Keywords.Remove(existing);
|
||||
continue;
|
||||
}
|
||||
var keyword = item.GetValueOrDefault("keyword")?.Trim();
|
||||
if (existing == default && string.IsNullOrEmpty(keyword))
|
||||
return "Keyword can't be blank";
|
||||
if (keyword?.Length > MaxKeywordLength)
|
||||
return "Keyword is too long";
|
||||
var target = existing ?? new FilterKeyword { Id = NewId() };
|
||||
if (!string.IsNullOrEmpty(keyword))
|
||||
target.Keyword = keyword;
|
||||
if (item.ContainsKey("whole_word"))
|
||||
target.WholeWord = IsTrue(item["whole_word"]);
|
||||
else if (existing == default)
|
||||
target.WholeWord = true;
|
||||
if (existing == default)
|
||||
filter.Keywords.Add(target);
|
||||
}
|
||||
return filter.Keywords.Count > MaxEntries ? "Too many keywords" : default;
|
||||
}
|
||||
|
||||
// -- v2
|
||||
|
||||
[HttpGet("/api/v2/filters"), Scope("read:filters")]
|
||||
public async Task<IActionResult> All(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
|
||||
.Select(Filters.View).ToList());
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(Filters.View(filter)) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v2/filters"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
|
||||
return Invalid("Too many filters");
|
||||
var filter = new PersonaFilter { AvatarId = MyId };
|
||||
if ((Apply(filter, creating: true) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.View(filter));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v2/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
if ((Apply(filter, creating: false) ?? ApplyKeywords(filter, KeywordAttributes())) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.View(filter));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v2/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}/keywords"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Keywords(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(filter.Keywords.Select(Filters.Keyword).ToList()) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v2/filters/{id}/keywords"), Scope("write:filters")]
|
||||
public async Task<IActionResult> AddKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var item = new Dictionary<string, string> { ["keyword"] = Params.Get("keyword") };
|
||||
if (Params.Has("whole_word"))
|
||||
item["whole_word"] = Params.Get("whole_word");
|
||||
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Keyword(filter.Keywords[^1]));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/keywords/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Keyword(string id, CancellationToken token) =>
|
||||
await WithKeyword(id, token) is { } filter ? Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id))) : NotFoundError();
|
||||
|
||||
[HttpPut("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> UpdateKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var item = new Dictionary<string, string> { ["id"] = id, ["keyword"] = Params.Get("keyword") };
|
||||
if (Params.Has("whole_word"))
|
||||
item["whole_word"] = Params.Get("whole_word");
|
||||
if (ApplyKeywords(filter, new List<Dictionary<string, string>> { item }) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Keyword(filter.Keywords.First(k => k.Id == id)));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v2/filters/keywords/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteKeyword(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Keywords.RemoveAll(k => k.Id == id);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/{id}/statuses"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Statuses(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } filter ? Json(filter.Statuses.Select(Filters.Status).ToList()) : NotFoundError();
|
||||
|
||||
// a status the persona can see, once per filter
|
||||
[HttpPost("/api/v2/filters/{id}/statuses"), Scope("write:filters")]
|
||||
public async Task<IActionResult> AddStatus(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var statusId = Params.Get("status_id");
|
||||
var post = string.IsNullOrEmpty(statusId) ? default : await _dbEntities.Posts.Match(p => p.ID == statusId).ExecuteFirstAsync(token);
|
||||
if (post == default || !await Domain.Privacy.VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return NotFoundError();
|
||||
if (filter.Statuses.FirstOrDefault(s => s.PostId == statusId) is { } present)
|
||||
return Json(Filters.Status(present));
|
||||
if (filter.Statuses.Count >= MaxEntries)
|
||||
return Invalid("Too many statuses");
|
||||
var added = new FilterStatus { Id = NewId(), PostId = statusId };
|
||||
filter.Statuses.Add(added);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(Filters.Status(added));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/filters/statuses/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> Status(string id, CancellationToken token) =>
|
||||
await WithStatus(id, token) is { } filter ? Json(Filters.Status(filter.Statuses.First(s => s.Id == id))) : NotFoundError();
|
||||
|
||||
[HttpDelete("/api/v2/filters/statuses/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteStatus(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithStatus(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Statuses.RemoveAll(s => s.Id == id);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// -- v1: each keyword is a filter of its own, with its parent's context, action and expiry
|
||||
|
||||
static FilterV1Entity V1(PersonaFilter filter, FilterKeyword keyword) => new()
|
||||
{
|
||||
Id = keyword.Id,
|
||||
Phrase = keyword.Keyword,
|
||||
Context = filter.Context,
|
||||
WholeWord = keyword.WholeWord,
|
||||
ExpiresAt = filter.ExpiresAt.HasValue ? MastodonJson.Time(filter.ExpiresAt.Value) : default,
|
||||
Irreversible = filter.Action == FilterActions.Hide
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/filters"), Scope("read:filters")]
|
||||
public async Task<IActionResult> AllV1(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaFilter>().Match(f => f.AvatarId == MyId).Sort(f => f.ID, Order.Ascending).ExecuteAsync(token))
|
||||
.SelectMany(f => f.Keywords.Select(k => V1(f, k))).ToList());
|
||||
|
||||
[HttpGet("/api/v1/filters/{id}"), Scope("read:filters")]
|
||||
public async Task<IActionResult> OneV1(string id, CancellationToken token) =>
|
||||
await WithKeyword(id, token) is { } filter ? Json(V1(filter, filter.Keywords.First(k => k.Id == id))) : NotFoundError();
|
||||
|
||||
// phrase, context[], irreversible, whole_word, expires_in
|
||||
string ApplyV1(PersonaFilter filter, FilterKeyword keyword, bool creating)
|
||||
{
|
||||
var phrase = Params.Get("phrase")?.Trim();
|
||||
if (phrase != default || creating)
|
||||
{
|
||||
if (string.IsNullOrEmpty(phrase))
|
||||
return "Phrase can't be blank";
|
||||
if (phrase.Length > MaxKeywordLength)
|
||||
return "Phrase is too long";
|
||||
keyword.Keyword = phrase;
|
||||
if (creating)
|
||||
filter.Title = phrase;
|
||||
}
|
||||
if (Params.Has("context") || creating)
|
||||
{
|
||||
var context = Params.List("context").Distinct().ToList();
|
||||
if (context.Count == 0 || context.Any(c => !FilterContexts.IsValid(c)))
|
||||
return "Context can't be blank";
|
||||
filter.Context = context;
|
||||
}
|
||||
if (Params.Bool("whole_word") is { } wholeWord)
|
||||
keyword.WholeWord = wholeWord;
|
||||
if (Params.Bool("irreversible") is { } irreversible)
|
||||
filter.Action = irreversible ? FilterActions.Hide : FilterActions.Warn;
|
||||
if (Params.Has("expires_in"))
|
||||
filter.ExpiresAt = Params.Int("expires_in") is > 0 and var seconds ? DateTime.UtcNow.AddSeconds(seconds) : default;
|
||||
return default;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/filters"), Scope("write:filters")]
|
||||
public async Task<IActionResult> CreateV1(CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.CountAsync<PersonaFilter>(f => f.AvatarId == MyId, token) >= MaxFilters)
|
||||
return Invalid("Too many filters");
|
||||
var keyword = new FilterKeyword { Id = NewId(), WholeWord = true };
|
||||
var filter = new PersonaFilter { AvatarId = MyId, Keywords = new List<FilterKeyword> { keyword } };
|
||||
if (ApplyV1(filter, keyword, creating: true) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(V1(filter, keyword));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> UpdateV1(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
var keyword = filter.Keywords.First(k => k.Id == id);
|
||||
if (ApplyV1(filter, keyword, creating: false) is { } problem)
|
||||
return Invalid(problem);
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(V1(filter, keyword));
|
||||
}
|
||||
|
||||
// the keyword goes, and its filter with it when nothing else is left in it
|
||||
[HttpDelete("/api/v1/filters/{id}"), Scope("write:filters")]
|
||||
public async Task<IActionResult> DeleteV1(string id, CancellationToken token)
|
||||
{
|
||||
if (await WithKeyword(id, token) is not { } filter)
|
||||
return NotFoundError();
|
||||
filter.Keywords.RemoveAll(k => k.Id == id);
|
||||
if (filter.Keywords.Count == 0 && filter.Statuses.Count == 0)
|
||||
await DB.Default.DeleteAsync<PersonaFilter>(filter.ID);
|
||||
else
|
||||
await DB.Default.SaveAsync(filter, token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Content;
|
||||
using PrivaPub.Domain.Instance;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// The instance API: what the server says of itself comes from its settings (IInstanceSettings, edited on the
|
||||
// administrator's page; until then exactly what it said before), its numbers from Counted, its limits from where they
|
||||
// are enforced.
|
||||
public class InstanceController : MastodonController
|
||||
{
|
||||
// The Mastodon version this API answers as, then PrivaPub's own release (its tag, or the commit a build came from).
|
||||
// 4.5.0 since R9 (2026-10-18): every client route of Mastodon 4.3, 4.4 and 4.5 is served or answers as a documented
|
||||
// stub (docs/MASTODON-API.md), streaming and Web Push included. api_versions.mastodon is 4.5's own, 7.
|
||||
public const string MastodonVersion = "4.5.0";
|
||||
public static readonly string Version = Advertised(string.IsNullOrEmpty(BuildInfo.Ref) ? BuildInfo.Commit : BuildInfo.Ref);
|
||||
|
||||
static string Advertised(string release)
|
||||
{
|
||||
var own = release?.Trim();
|
||||
if (own is { Length: > 1 } && own[0] == 'v' && char.IsDigit(own[1]))
|
||||
own = own[1..];//a tag v1.27.0 is release 1.27.0
|
||||
return string.IsNullOrEmpty(own) ? $"{MastodonVersion} (compatible; PrivaPub)" : $"{MastodonVersion} (compatible; PrivaPub {own})";
|
||||
}
|
||||
const int ActivityWeeks = 12;
|
||||
static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(1);
|
||||
static volatile CachedActivity _activity;
|
||||
|
||||
sealed record CachedActivity(DateTime At, object Weeks);
|
||||
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
||||
readonly IInstanceSettings _settings;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly PrivaPub.Infrastructure.Push.IVapid _vapid;
|
||||
|
||||
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations, IInstanceSettings settings,
|
||||
MastodonMapper mapper, PrivaPub.Infrastructure.Push.IVapid vapid)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_registrations = registrations;
|
||||
_settings = settings;
|
||||
_mapper = mapper;
|
||||
_vapid = vapid;
|
||||
}
|
||||
|
||||
bool Open => _registrations.CurrentValue.IsOpen;
|
||||
|
||||
ServerSettings Settings => _settings.Current;
|
||||
|
||||
string Thumbnail => Settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}";
|
||||
|
||||
static object Rules(ServerSettings settings) =>
|
||||
settings.ListedRules().Select(r => new { id = r.Id, text = r.Text ?? string.Empty, hint = r.Hint ?? string.Empty }).ToList();
|
||||
|
||||
// the persona the administrator named as the server's contact, while it exists and its login may act
|
||||
async Task<object> ContactAccount(ServerSettings settings, CancellationToken token)
|
||||
{
|
||||
var id = settings.ContactAccountId;
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return default;
|
||||
var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar == default || (await Counted.HeldBackPersonas(token)).Contains(id))
|
||||
return default;
|
||||
return await _mapper.Local(_localActors.FromAvatar(avatar), false, token);
|
||||
}
|
||||
|
||||
// the languages this server is set up for (AppConfiguration:SupportedLanguages), "en" when none are named
|
||||
public static IReadOnlyList<string> LanguageCodes(IServiceProvider services)
|
||||
{
|
||||
var configured = services.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration>>().CurrentValue.SupportedLanguages;
|
||||
var codes = (configured ?? new List<string>()).Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Split('-')[0].ToLowerInvariant()).Distinct().ToList();
|
||||
return codes.Count == 0 ? new[] { "en" } : codes;
|
||||
}
|
||||
|
||||
string Domain => new Uri(_localActors.BaseAddress).Authority;
|
||||
|
||||
// where clients open their streams (StreamingController): this server, as ws or wss
|
||||
string Streaming => new UriBuilder(_localActors.BaseAddress) { Scheme = _localActors.BaseAddress.StartsWith("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws" }
|
||||
.Uri.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
// every limit is what the server enforces, read from where it is enforced
|
||||
static object Statuses => new { max_characters = PrivaPub.Domain.Statuses.StatusService.MaxCharacters, max_media_attachments = 4, characters_reserved_per_url = 23 };
|
||||
|
||||
object MediaAttachments
|
||||
{
|
||||
get
|
||||
{
|
||||
var media = HttpContext.RequestServices.GetRequiredService<IOptionsMonitor<PrivaPub.Domain.Media.MediaOptions>>().CurrentValue;
|
||||
return new
|
||||
{
|
||||
supported_mime_types = PrivaPub.Domain.Media.MediaService.SupportedTypes,
|
||||
image_size_limit = media.MaxImageBytes,
|
||||
image_matrix_limit = media.MaxPixels,
|
||||
video_size_limit = media.MaxVideoBytes,
|
||||
video_frame_rate_limit = (int)media.MaxFrameRate,
|
||||
video_matrix_limit = media.MaxVideoPixels
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static object Polls => new
|
||||
{
|
||||
max_options = PrivaPub.Domain.Statuses.PollService.MaxOptions,
|
||||
max_characters_per_option = PrivaPub.Domain.Statuses.PollService.MaxOptionLength,
|
||||
min_expiration = PrivaPub.Domain.Statuses.PollService.MinExpiration,
|
||||
max_expiration = PrivaPub.Domain.Statuses.PollService.MaxExpiration
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/instance"), AllowAnonymous]
|
||||
public async Task<IActionResult> V1(CancellationToken token)
|
||||
{
|
||||
var settings = Settings;
|
||||
var users = await Counted.Users(token);
|
||||
var statuses = await Counted.LocalPosts(token);
|
||||
var domains = await KnownServers(token);
|
||||
return Json(new
|
||||
{
|
||||
uri = Domain,
|
||||
title = settings.Title,
|
||||
short_description = settings.ShortDescription ?? string.Empty,
|
||||
description = settings.ShortDescription ?? string.Empty,
|
||||
email = settings.ContactEmail ?? string.Empty,
|
||||
version = Version,
|
||||
urls = new { streaming_api = Streaming },
|
||||
stats = new { user_count = users, status_count = statuses, domain_count = domains },
|
||||
thumbnail = Thumbnail,
|
||||
languages = LanguageCodes(HttpContext.RequestServices),
|
||||
registrations = Open,
|
||||
approval_required = false,
|
||||
invites_enabled = true,//a group invitation always creates an account
|
||||
configuration = new { accounts = new { max_featured_tags = PrivaPub.Domain.Social.FeaturedTags.MaxFeatured }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
|
||||
contact_account = await ContactAccount(settings, token),
|
||||
rules = Rules(settings)
|
||||
});
|
||||
}
|
||||
|
||||
// the servers we have exchanged activities with, as the statistics count them. Which ones is not published
|
||||
// (/instance/peers stays empty, as on a Mastodon with its peers API off): on a small server it would say who its
|
||||
// personas talk to.
|
||||
static async Task<long> KnownServers(CancellationToken token) =>
|
||||
await DB.Default.CountAsync<Models.Jobs.RemoteInstance>(i => i.Seen == "touched", token);
|
||||
|
||||
[HttpGet("/api/v2/instance"), AllowAnonymous]
|
||||
public async Task<IActionResult> V2(CancellationToken token) => Json(new
|
||||
{
|
||||
domain = Domain,
|
||||
title = Settings.Title,
|
||||
version = Version,
|
||||
api_versions = new { mastodon = 7 },
|
||||
source_url = "https://git.thepra.dev/thepra/SocialPub",
|
||||
description = Settings.ShortDescription ?? string.Empty,
|
||||
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
|
||||
thumbnail = new { url = Thumbnail },
|
||||
languages = LanguageCodes(HttpContext.RequestServices),
|
||||
configuration = new
|
||||
{
|
||||
urls = new { streaming = Streaming },
|
||||
accounts = new { max_featured_tags = PrivaPub.Domain.Social.FeaturedTags.MaxFeatured, max_pinned_statuses = StatusesController.MaxPins },
|
||||
statuses = Statuses,
|
||||
media_attachments = MediaAttachments,
|
||||
polls = Polls,
|
||||
translation = new { enabled = false },
|
||||
vapid = new { public_key = (await _vapid.Key(token)).ForApi }//Web Push (R9): the key every push is signed with
|
||||
},
|
||||
registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage },
|
||||
contact = new { email = Settings.ContactEmail ?? string.Empty, account = await ContactAccount(Settings, token) },
|
||||
rules = Rules(Settings)
|
||||
});
|
||||
|
||||
[HttpGet("/api/v1/instance/peers"), AllowAnonymous]
|
||||
public IActionResult Peers() => Json(Array.Empty<string>());
|
||||
|
||||
// Mastodon's weekly activity, only when the administrator publishes it (off by default): twelve weeks, this one first,
|
||||
// counted as Counted counts (local posts, logins made); sign-ins are not kept, so `logins` is 0. Worked out at most
|
||||
// once an hour.
|
||||
[HttpGet("/api/v1/instance/activity"), AllowAnonymous]
|
||||
public async Task<IActionResult> Activity(CancellationToken token)
|
||||
{
|
||||
if (!Settings.PublishActivity)
|
||||
return Json(Array.Empty<object>());
|
||||
if (_activity is { } cached && DateTime.UtcNow - cached.At < ActivityLifetime)
|
||||
return Json(cached.Weeks);
|
||||
var weeks = (await Counted.Weekly(ActivityWeeks, token)).Select(w => new
|
||||
{
|
||||
week = new DateTimeOffset(w.Week, TimeSpan.Zero).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture),
|
||||
statuses = w.Posts.ToString(CultureInfo.InvariantCulture),
|
||||
logins = "0",
|
||||
registrations = w.Registrations.ToString(CultureInfo.InvariantCulture)
|
||||
}).ToList();
|
||||
_activity = new CachedActivity(DateTime.UtcNow, weeks);
|
||||
return Json(weeks);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/instance/rules"), AllowAnonymous]
|
||||
public IActionResult InstanceRules() => Json(Rules(Settings));
|
||||
|
||||
[HttpGet("/api/v1/instance/extended_description"), AllowAnonymous]
|
||||
public IActionResult ExtendedDescription()
|
||||
{
|
||||
var settings = Settings;
|
||||
return Json(new
|
||||
{
|
||||
updated_at = settings.DescriptionUpdatedAt is { } updated ? MastodonJson.Time(updated) : MastodonJson.Day(DateTime.UtcNow),
|
||||
content = ContentRenderer.Document(settings.Description)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/instance/privacy_policy"), AllowAnonymous]
|
||||
public IActionResult PrivacyPolicy()
|
||||
{
|
||||
var settings = Settings;
|
||||
return Json(new
|
||||
{
|
||||
updated_at = MastodonJson.Time(settings.PrivacyPolicyUpdatedAt ?? ServerSettings.DefaultPrivacyPolicyUpdatedAt),
|
||||
content = ContentRenderer.Document(settings.PrivacyPolicy)
|
||||
});
|
||||
}
|
||||
|
||||
// Mastodon 4.4's terms of service: the one version the server keeps, 404 while it has none
|
||||
[HttpGet("/api/v1/instance/terms_of_service"), AllowAnonymous]
|
||||
public IActionResult TermsOfService() => Terms(default);
|
||||
|
||||
[HttpGet("/api/v1/instance/terms_of_service/{date}"), AllowAnonymous]
|
||||
public IActionResult TermsOfServiceOn(string date) =>
|
||||
DateTime.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var on)
|
||||
? Terms(on.Date)
|
||||
: NotFoundError();
|
||||
|
||||
IActionResult Terms(DateTime? on)
|
||||
{
|
||||
var settings = Settings;
|
||||
if (string.IsNullOrWhiteSpace(settings.TermsOfService))
|
||||
return NotFoundError();
|
||||
var effective = (settings.TermsEffectiveAt ?? settings.UpdatedAt ?? DateTime.UtcNow).Date;
|
||||
if (on != default && on.Value != effective)
|
||||
return NotFoundError();
|
||||
return Json(new
|
||||
{
|
||||
effective_date = effective.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
effective = effective <= DateTime.UtcNow.Date,
|
||||
content = ContentRenderer.Document(settings.TermsOfService),
|
||||
succeeded_by = default(string)
|
||||
});
|
||||
}
|
||||
|
||||
// the server's blocks the administrator chose to publish (off for each by default: publishing one tells the blocked
|
||||
// server, and everyone else, that it is blocked), its name partly hidden when asked, as Mastodon hides it
|
||||
[HttpGet("/api/v1/instance/domain_blocks"), AllowAnonymous]
|
||||
public async Task<IActionResult> DomainBlocks(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<DomainBlock>().Match(b => b.Published).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token))
|
||||
.Select(b => new
|
||||
{
|
||||
domain = b.Obfuscate ? Obfuscated(b.Domain) : b.Domain,
|
||||
digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(b.Domain ?? string.Empty))),
|
||||
severity = b.Severity switch { DomainBlockSeverity.Suspend => "suspend", DomainBlockSeverity.Silence => "silence", _ => "noop" },
|
||||
comment = b.PublicComment
|
||||
}));
|
||||
|
||||
// Mastodon's DomainBlock#public_domain: a quarter of the name kept at each end, every other character but the dots a *
|
||||
public static string Obfuscated(string domain)
|
||||
{
|
||||
if (string.IsNullOrEmpty(domain))
|
||||
return domain;
|
||||
var visible = domain.Length / 4;
|
||||
var hidden = domain.ToCharArray();
|
||||
for (var i = 0; i < hidden.Length; i++)
|
||||
if (i > visible && i < domain.Length - visible && hidden[i] != '.')
|
||||
hidden[i] = '*';
|
||||
return new string(hidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's lists (owner decision 2026-10-04, back from the cut list): a persona groups accounts it follows, reads them
|
||||
// as a timeline, and may keep an exclusive list's members out of its home. Nothing here leaves the server.
|
||||
public class ListsController : MastodonController
|
||||
{
|
||||
const int MaxLists = 50;
|
||||
const int MaxMembers = 500;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
public ListsController(DbEntities dbEntities, MastodonMapper mapper)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
static object View(PersonaList list) => new { id = list.ID, title = list.Title, replies_policy = list.RepliesPolicy, exclusive = list.Exclusive };
|
||||
|
||||
Task<PersonaList> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<PersonaList>().Match(l => l.ID == id && l.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
// a list holds only accounts the persona still follows: whatever ended the follow (an unfollow, a Reject, a Block, the
|
||||
// account's deletion), its membership goes with it, pruned here on the next read
|
||||
async Task<List<string>> Members(PersonaList list, CancellationToken token)
|
||||
{
|
||||
var members = (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID).ExecuteAsync(token)).Select(m => m.AccountId).ToList();
|
||||
if (members.Count == 0)
|
||||
return members;
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && members.Contains(f.TargetAccountId))
|
||||
.ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet();
|
||||
var gone = members.Where(m => !followed.Contains(m)).ToList();
|
||||
if (gone.Count > 0)
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID && gone.Contains(m.AccountId));
|
||||
return members.Where(followed.Contains).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/lists"), Scope("read:lists")]
|
||||
public async Task<IActionResult> All(CancellationToken token) =>
|
||||
Json((await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId).Sort(l => l.Title, MongoDB.Entities.Order.Ascending)
|
||||
.ExecuteAsync(token)).Select(View).ToList());
|
||||
|
||||
[HttpGet("/api/v1/lists/{id}"), Scope("read:lists")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } list ? Json(View(list)) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v1/lists"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var title = Params.Get("title")?.Trim();
|
||||
var policy = Params.Get("replies_policy") ?? ListRepliesPolicy.List;
|
||||
if (string.IsNullOrEmpty(title) || title.Length > 200)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank");
|
||||
if (!ListRepliesPolicy.IsValid(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list");
|
||||
if (await DB.Default.CountAsync<PersonaList>(l => l.AvatarId == MyId, token) >= MaxLists)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many lists");
|
||||
var list = new PersonaList { AvatarId = MyId, Title = title, RepliesPolicy = policy, Exclusive = Params.Bool("exclusive") == true };
|
||||
await DB.Default.SaveAsync(list, token);
|
||||
return Json(View(list));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/lists/{id}"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var title = Params.Get("title")?.Trim();
|
||||
var policy = Params.Get("replies_policy");
|
||||
if (title != default)
|
||||
{
|
||||
if (title.Length == 0 || title.Length > 200)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Title can't be blank");
|
||||
list.Title = title;
|
||||
}
|
||||
if (policy != default)
|
||||
{
|
||||
if (!ListRepliesPolicy.IsValid(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Replies policy is not included in the list");
|
||||
list.RepliesPolicy = policy;
|
||||
}
|
||||
if (Params.Bool("exclusive") is { } exclusive)
|
||||
list.Exclusive = exclusive;
|
||||
await DB.Default.SaveAsync(list, token);
|
||||
return Json(View(list));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/lists/{id}"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID);
|
||||
await DB.Default.DeleteAsync<PersonaList>(list.ID);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/lists/{id}/accounts"), Scope("read:lists")]
|
||||
public async Task<IActionResult> Accounts(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
await Members(list, token);
|
||||
var members = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID), m => m.ID, token);
|
||||
var accounts = await _mapper.Accounts(members.Select(m => m.AccountId), token);
|
||||
Link($"/api/v1/lists/{id}/accounts", members.LastOrDefault()?.ID, members.FirstOrDefault()?.ID);
|
||||
return Json(members.Where(m => accounts.ContainsKey(m.AccountId)).Select(m => accounts[m.AccountId]).ToList());
|
||||
}
|
||||
|
||||
// only accounts the persona follows may join its list, as on Mastodon
|
||||
[HttpPost("/api/v1/lists/{id}/accounts"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Add(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var ids = Params.List("account_ids").Distinct().ToList();
|
||||
if (ids.Count == 0)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Account ids can't be blank");
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && ids.Contains(f.TargetAccountId))
|
||||
.ExecuteAsync(token)).Select(f => f.TargetAccountId).ToHashSet();
|
||||
if (ids.Any(a => !followed.Contains(a)))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must follow this account to add it to a list");
|
||||
var present = (await DB.Default.Find<PersonaListMember>().Match(m => m.ListId == list.ID && ids.Contains(m.AccountId)).ExecuteAsync(token))
|
||||
.Select(m => m.AccountId).ToHashSet();
|
||||
if (await DB.Default.CountAsync<PersonaListMember>(m => m.ListId == list.ID, token) + ids.Count(a => !present.Contains(a)) > MaxMembers)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many accounts in the list");
|
||||
foreach (var account in ids.Where(a => !present.Contains(a)))
|
||||
await DB.Default.SaveAsync(new PersonaListMember { ListId = list.ID, AvatarId = MyId, AccountId = account }, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/lists/{id}/accounts"), Scope("write:lists")]
|
||||
public async Task<IActionResult> Remove(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var ids = Params.List("account_ids");
|
||||
await DB.Default.DeleteAsync<PersonaListMember>(m => m.ListId == list.ID && ids.Contains(m.AccountId));
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/lists"), Scope("read:lists")]
|
||||
public async Task<IActionResult> Containing(string id, CancellationToken token)
|
||||
{
|
||||
var listIds = (await DB.Default.Find<PersonaListMember>().Match(m => m.AvatarId == MyId && m.AccountId == id).ExecuteAsync(token))
|
||||
.Select(m => m.ListId).ToList();
|
||||
return Json((await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && listIds.Contains(l.ID)).ExecuteAsync(token))
|
||||
.Select(View).ToList());
|
||||
}
|
||||
|
||||
// the list's timeline: the persona's home entries by its members. Replies are kept as Mastodon keeps them: a member's
|
||||
// replies to itself and to the persona always, to others as the policy says (anyone followed, the list's members, no one)
|
||||
[HttpGet("/api/v1/timelines/list/{id}"), Scope("read:lists")]
|
||||
public async Task<IActionResult> Timeline(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } list)
|
||||
return NotFoundError();
|
||||
var members = await Members(list, token);
|
||||
var entries = await Page.From(Params, Limit()).Fetch(
|
||||
_dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId && members.Contains(e.AuthorAccountId)), e => e.PostId, token);
|
||||
var ids = entries.Select(e => e.PostId).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var answered = list.RepliesPolicy switch
|
||||
{
|
||||
ListRepliesPolicy.Followed => (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||
.Select(f => f.TargetAccountId).Append(MyId).ToHashSet(),
|
||||
ListRepliesPolicy.List => members.Append(MyId).ToHashSet(),
|
||||
_ => new HashSet<string> { MyId }
|
||||
};
|
||||
var shown = ids.Where(posts.ContainsKey).Select(i => posts[i])
|
||||
.Where(p => p.ReblogOfPostId != default || string.IsNullOrEmpty(p.AnsweringToPostId) && string.IsNullOrEmpty(p.InReplyToURI)
|
||||
|| p.InReplyToAccountId != default && (p.InReplyToAccountId == p.AuthorAccountId || answered.Contains(p.InReplyToAccountId)))
|
||||
.ToList();
|
||||
Link($"/api/v1/timelines/list/{id}", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
||||
return Json(await _mapper.Statuses(shown, MyId, token));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class MediaController : MastodonController
|
||||
{
|
||||
const long UploadLimit = 100L * 1024 * 1024;
|
||||
|
||||
readonly IMediaService _media;
|
||||
readonly IMediaProxy _proxy;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
readonly IInteractionLedger _ledger;
|
||||
|
||||
public MediaController(IMediaService media, IMediaProxy proxy, IJobQueue jobs, IInteractionLedger ledger = default)
|
||||
{
|
||||
_media = media;
|
||||
_proxy = proxy;
|
||||
_jobs = jobs;
|
||||
_ledger = ledger;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/media"), HttpPost("/api/v2/media"), Scope("write:media"), EnableRateLimiting(RateLimiting.Uploads), RequestSizeLimit(UploadLimit),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = UploadLimit)]
|
||||
public async Task<IActionResult> Upload(CancellationToken token)
|
||||
{
|
||||
if (!Request.HasFormContentType)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank");
|
||||
var form = await Request.ReadFormAsync(token);
|
||||
// v2 may answer before audio or video is processed (202, its url null until then), as Mastodon does; v1 waits
|
||||
var later = Request.Path.StartsWithSegments("/api/v2/media");
|
||||
var outcome = await _media.Upload(Me, form.Files["file"], form["description"], form["focus"], later, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
if (outcome.Attachment.ProcessingState != "pending")
|
||||
return Json(View(outcome.Attachment));
|
||||
await _jobs.EnqueueMany(new[] { ProcessMediaJob.JobFor(outcome.Attachment, new Uri(Me.BaseAddress).Host) }, token);
|
||||
return new JsonResult(View(outcome.Attachment)) { StatusCode = StatusCodes.Status202Accepted };
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
|
||||
return attachment?.ProcessingState switch
|
||||
{
|
||||
null when attachment == default => NotFoundError(),
|
||||
"pending" => new JsonResult(View(attachment)) { StatusCode = StatusCodes.Status206PartialContent },
|
||||
"failed" => Error(StatusCodes.Status422UnprocessableEntity, attachment.ProcessingError ?? "Validation failed: The file could not be processed"),
|
||||
_ => Json(View(attachment))
|
||||
};
|
||||
}
|
||||
|
||||
// Mastodon 4.4: a persona's own upload, while nothing holds it (not posted, not scheduled, not a profile picture), goes to
|
||||
// the trash as the janitor would take it
|
||||
[HttpDelete("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null).ExecuteFirstAsync(token);
|
||||
if (attachment == default)
|
||||
return NotFoundError();
|
||||
if (attachment.PostId != null || attachment.ScheduledStatusId != null)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Media attachment is currently used by a status");
|
||||
await _media.Trash(m => m.ID == attachment.ID && m.PostId == null && m.ScheduledStatusId == null, "deleted by its owner", token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// as on Mastodon, only media not posted yet: a posted one's description and focus change by editing its post (media_attributes)
|
||||
[HttpPut("/api/v1/media/{id}"), Scope("write:media")]
|
||||
public async Task<IActionResult> Update(string id, CancellationToken token)
|
||||
{
|
||||
var attachment = await DB.Default.Find<MediaAttachment>().Match(m => m.ID == id && m.OwnerAvatarId == MyId && m.TrashedAt == null && m.ProfileOfAvatarId == null && m.PostId == null).ExecuteFirstAsync(token);
|
||||
if (attachment == default)
|
||||
return NotFoundError();
|
||||
if (Params.Has("description"))
|
||||
attachment.Description = Params.Get("description")?.Trim() is { Length: > 0 } description ? description[..Math.Min(description.Length, 1500)] : default;
|
||||
if (FocalPoint.Parse(Params.Get("focus")) is { } focus)
|
||||
attachment.Focus = focus;
|
||||
await DB.Default.SaveAsync(attachment, token);
|
||||
return Json(View(attachment));
|
||||
}
|
||||
|
||||
[HttpGet("/media/proxy/{signature}/{encoded}"), AllowAnonymous, EnableRateLimiting(RateLimiting.Proxy), ApiExplorerSettings(IgnoreApi = true)]
|
||||
public async Task<IActionResult> Proxy(string signature, string encoded, CancellationToken token)
|
||||
{
|
||||
var url = _proxy.Verified(signature, encoded);
|
||||
if (url == default || _proxy.Refuses(url))
|
||||
return NotFound();
|
||||
Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
Response.Headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
|
||||
if (_proxy.Cached(url) is { Path: not null } cached && Serve(cached.Path, cached.ContentType) is { } hit)
|
||||
{
|
||||
_ledger?.Count(Interactions.HostOf(url), "media:hit");
|
||||
return hit;
|
||||
}
|
||||
if (Request.Headers.Range.Count == 0)
|
||||
{
|
||||
var (outcome, path, contentType) = await _proxy.Download(url, token);
|
||||
if (outcome == ProxyOutcome.Cached && Serve(path, contentType) is { } fetched)
|
||||
return fetched;
|
||||
if (outcome == ProxyOutcome.Failed)
|
||||
return NotFound();
|
||||
}
|
||||
return await Stream(url, token);
|
||||
}
|
||||
|
||||
// a cached file, opened before it is answered: trimmed meanwhile, what is open still reads; cached by browsers only
|
||||
// once there is something to cache
|
||||
IActionResult Serve(string path, string contentType)
|
||||
{
|
||||
FileStream file;
|
||||
try
|
||||
{
|
||||
file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 64 * 1024, useAsync: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
||||
return File(file, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Stream(string url, CancellationToken token)
|
||||
{
|
||||
var range = System.Net.Http.Headers.RangeHeaderValue.TryParse(Request.Headers.Range.ToString(), out var asked) ? asked : default;
|
||||
using var upstream = await _proxy.Open(url, range, token);
|
||||
if (upstream == default)
|
||||
return NotFound();
|
||||
if (upstream.IsSuccessStatusCode)
|
||||
Response.Headers["Cache-Control"] = "public, max-age=604800";
|
||||
Response.StatusCode = (int)upstream.StatusCode;
|
||||
Response.ContentType = upstream.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||
if (upstream.Content.Headers.ContentLength is { } length)
|
||||
Response.ContentLength = length;
|
||||
if (upstream.Content.Headers.ContentRange is { } contentRange)
|
||||
Response.Headers.ContentRange = contentRange.ToString();
|
||||
Response.Headers.AcceptRanges = "bytes";
|
||||
await upstream.Content.CopyToAsync(Response.Body, token);
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
object View(MediaAttachment attachment) => View(_media, attachment);
|
||||
|
||||
internal static object View(IMediaService media, MediaAttachment attachment) => new
|
||||
{
|
||||
id = attachment.ID,
|
||||
type = attachment.Kind,
|
||||
url = media.Url(attachment.FilePath),
|
||||
preview_url = media.Url(attachment.PreviewPath ?? attachment.FilePath),
|
||||
remote_url = default(string),
|
||||
text_url = default(string),
|
||||
meta = new
|
||||
{
|
||||
original = attachment.Width.HasValue && attachment.Height > 0
|
||||
? new { width = attachment.Width, height = attachment.Height, size = $"{attachment.Width}x{attachment.Height}", aspect = (double)attachment.Width / attachment.Height.Value }
|
||||
: default,
|
||||
focus = attachment.Focus is { Length: 2 } ? new { x = attachment.Focus[0], y = attachment.Focus[1] } : default
|
||||
},
|
||||
description = attachment.Description,
|
||||
blurhash = attachment.Blurhash
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's notification policy and requests (4.3): what the persona's notifications from strangers, new accounts,
|
||||
// unasked private mentions and silenced accounts become, and the requests that gather the filtered ones, one per
|
||||
// account, let in or dismissed
|
||||
public class NotificationRequestsController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public NotificationRequestsController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
static string Name(NotificationAction action) => action switch
|
||||
{
|
||||
NotificationAction.Filter => "filter",
|
||||
NotificationAction.Drop => "drop",
|
||||
_ => "accept"
|
||||
};
|
||||
|
||||
static NotificationAction? Parse(string value) => value?.ToLowerInvariant() switch
|
||||
{
|
||||
"accept" => NotificationAction.Accept,
|
||||
"filter" => NotificationAction.Filter,
|
||||
"drop" => NotificationAction.Drop,
|
||||
_ => null
|
||||
};
|
||||
|
||||
async Task<object> Summary(CancellationToken token) => new
|
||||
{
|
||||
pending_requests_count = await DB.Default.CountAsync<NotificationRequest>(r => r.AvatarId == MyId, token),
|
||||
pending_notifications_count = await DB.Default.CountAsync<Notification>(n => n.AvatarId == MyId && n.Filtered, token)
|
||||
};
|
||||
|
||||
async Task<object> V2(NotificationPolicy policy, CancellationToken token) => new
|
||||
{
|
||||
for_not_following = Name(policy.ForNotFollowing),
|
||||
for_not_followers = Name(policy.ForNotFollowers),
|
||||
for_new_accounts = Name(policy.ForNewAccounts),
|
||||
for_private_mentions = Name(policy.ForPrivateMentions),
|
||||
for_limited_accounts = Name(policy.ForLimitedAccounts),
|
||||
summary = await Summary(token)
|
||||
};
|
||||
|
||||
// the first version spoke of filtering only: dropped is filtered for it
|
||||
async Task<object> V1(NotificationPolicy policy, CancellationToken token) => new
|
||||
{
|
||||
filter_not_following = policy.ForNotFollowing != NotificationAction.Accept,
|
||||
filter_not_followers = policy.ForNotFollowers != NotificationAction.Accept,
|
||||
filter_new_accounts = policy.ForNewAccounts != NotificationAction.Accept,
|
||||
filter_private_mentions = policy.ForPrivateMentions != NotificationAction.Accept,
|
||||
summary = await Summary(token)
|
||||
};
|
||||
|
||||
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Policy(CancellationToken token) => Json(await V2(await NotificationPolicies.Of(MyId, token), token));
|
||||
|
||||
[HttpPatch("/api/v2/notifications/policy"), HttpPut("/api/v2/notifications/policy"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> SetPolicy(CancellationToken token)
|
||||
{
|
||||
var policy = await NotificationPolicies.Of(MyId, token);
|
||||
policy.ForNotFollowing = Parse(Params.Get("for_not_following")) ?? policy.ForNotFollowing;
|
||||
policy.ForNotFollowers = Parse(Params.Get("for_not_followers")) ?? policy.ForNotFollowers;
|
||||
policy.ForNewAccounts = Parse(Params.Get("for_new_accounts")) ?? policy.ForNewAccounts;
|
||||
policy.ForPrivateMentions = Parse(Params.Get("for_private_mentions")) ?? policy.ForPrivateMentions;
|
||||
policy.ForLimitedAccounts = Parse(Params.Get("for_limited_accounts")) ?? policy.ForLimitedAccounts;
|
||||
await NotificationPolicies.Save(policy, token);
|
||||
return Json(await V2(policy, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> PolicyV1(CancellationToken token) => Json(await V1(await NotificationPolicies.Of(MyId, token), token));
|
||||
|
||||
[HttpPut("/api/v1/notifications/policy"), HttpPatch("/api/v1/notifications/policy"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> SetPolicyV1(CancellationToken token)
|
||||
{
|
||||
var policy = await NotificationPolicies.Of(MyId, token);
|
||||
static NotificationAction Filter(bool? filter, NotificationAction current) =>
|
||||
filter switch { true => current == NotificationAction.Drop ? NotificationAction.Drop : NotificationAction.Filter, false => NotificationAction.Accept, _ => current };
|
||||
policy.ForNotFollowing = Filter(Params.Bool("filter_not_following"), policy.ForNotFollowing);
|
||||
policy.ForNotFollowers = Filter(Params.Bool("filter_not_followers"), policy.ForNotFollowers);
|
||||
policy.ForNewAccounts = Filter(Params.Bool("filter_new_accounts"), policy.ForNewAccounts);
|
||||
policy.ForPrivateMentions = Filter(Params.Bool("filter_private_mentions"), policy.ForPrivateMentions);
|
||||
await NotificationPolicies.Save(policy, token);
|
||||
return Json(await V1(policy, token));
|
||||
}
|
||||
|
||||
async Task<object> Map(NotificationRequest request, CancellationToken token)
|
||||
{
|
||||
var account = await _mapper.Account(request.FromAccountId, token);
|
||||
if (account == default)
|
||||
return default;
|
||||
var post = string.IsNullOrEmpty(request.LastPostId) ? default
|
||||
: await _dbEntities.Posts.Match(p => p.ID == request.LastPostId && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return new
|
||||
{
|
||||
id = request.ID,
|
||||
created_at = MastodonJson.Time(request.CreatedAt),
|
||||
updated_at = MastodonJson.Time(request.UpdatedAt),
|
||||
notifications_count = request.NotificationsCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
account,
|
||||
last_status = post == default ? default : await _mapper.Status(post, MyId, token)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Requests(CancellationToken token)
|
||||
{
|
||||
var requests = await Page.From(Params, Limit(40, 80)).Fetch(DB.Default.Find<NotificationRequest>().Match(r => r.AvatarId == MyId), r => r.ID, token);
|
||||
Link("/api/v1/notifications/requests", requests.LastOrDefault()?.ID, requests.FirstOrDefault()?.ID);
|
||||
var mapped = new List<object>();
|
||||
foreach (var request in requests)
|
||||
if (await Map(request, token) is { } one)
|
||||
mapped.Add(one);
|
||||
return Json(mapped);
|
||||
}
|
||||
|
||||
Task<NotificationRequest> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<NotificationRequest>().Match(r => r.ID == id && r.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
[HttpGet("/api/v1/notifications/requests/merged"), Scope("read:notifications")]
|
||||
public IActionResult Merged() => Json(new { merged = true });
|
||||
|
||||
[HttpGet("/api/v1/notifications/requests/{id}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Request(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } request && await Map(request, token) is { } mapped ? Json(mapped) : NotFoundError();
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/{id}/accept"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> Accept(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } request)
|
||||
return NotFoundError();
|
||||
await NotificationPolicies.Accept(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/{id}/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } request)
|
||||
return NotFoundError();
|
||||
await NotificationPolicies.Dismiss(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/accept"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> AcceptMany(CancellationToken token)
|
||||
{
|
||||
foreach (var id in Params.List("id").Take(100))
|
||||
if (await Mine(id, token) is { } request)
|
||||
await NotificationPolicies.Accept(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/requests/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> DismissMany(CancellationToken token)
|
||||
{
|
||||
foreach (var id in Params.List("id").Take(100))
|
||||
if (await Mine(id, token) is { } request)
|
||||
await NotificationPolicies.Dismiss(request, token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class PollsController : MastodonController
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IPollService _polls;
|
||||
|
||||
public PollsController(DbEntities dbEntities, MastodonMapper mapper, IPollService polls)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
_polls = polls;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/polls/{id}"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
return post?.Poll == default ? NotFoundError() : Json(_mapper.Poll(post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/polls/{id}/votes"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Vote(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post?.Poll == default)
|
||||
return NotFoundError();
|
||||
var choices = Params.List("choices").Select(c => int.TryParse(c, out var index) ? index : -1).ToList();
|
||||
var outcome = await _polls.Vote(Me, post, choices, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
return Json(_mapper.Poll(outcome.Post, MyId, await _polls.OwnVotes(post.ID, MyId, token)));
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statistics;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Infrastructure.Geo;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ProvenanceController : MastodonController
|
||||
{
|
||||
const int MaxHosts = 40;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
const int MaxWeeks = 260;
|
||||
|
||||
readonly ISelfLocation _self;
|
||||
readonly IServerPlaces _places;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IOptionsMonitor<RegistrationOptions> _registrations;
|
||||
|
||||
public ProvenanceController(DbEntities dbEntities, ISelfLocation self, IServerPlaces places, ILocalActorService localActors,
|
||||
IOptionsMonitor<RegistrationOptions> registrations)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_self = self;
|
||||
_places = places;
|
||||
_localActors = localActors;
|
||||
_registrations = registrations;
|
||||
}
|
||||
|
||||
[HttpGet("/api/privapub/v1/statuses/{id}/provenance"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Status(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id).Match(VisibilityPolicy.IsShown).ExecuteFirstAsync(token);
|
||||
if (post?.ReblogOfPostId != default)
|
||||
post = await _dbEntities.Posts.Match(p => p.ID == post.ReblogOfPostId).Match(VisibilityPolicy.IsShown).ExecuteFirstAsync(token);
|
||||
if (post == default || post.Visibility == PostVisibility.LocalGeo || !await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return NotFoundError();
|
||||
if (!post.IsFederatedCopy)
|
||||
return Json(new Provenance { ObjectUri = post.ObjectURI, Url = post.Url, Path = "local" });
|
||||
|
||||
var record = await DB.Default.Find<ObjectRecord>().Match(r => r.PostId == post.ID).ExecuteFirstAsync(token)
|
||||
?? await DB.Default.Find<ObjectRecord>().Match(r => r.ObjectURI == post.ObjectURI).ExecuteFirstAsync(token);
|
||||
var host = record?.Host ?? (Uri.TryCreate(post.ObjectURI, UriKind.Absolute, out var uri) ? uri.Host.ToLowerInvariant() : default);
|
||||
var instance = host == default ? default : await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
|
||||
var beforeCdn = instance?.Geo?.Cdn == default ? default : (await _places.BeforeCdn(new[] { instance.Host }, token)).GetValueOrDefault(instance.Host);
|
||||
return Json(Describe(post, record, instance, beforeCdn));
|
||||
}
|
||||
|
||||
[HttpGet("/api/privapub/v1/instances/{host}"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Instance(string host, CancellationToken token)
|
||||
{
|
||||
var described = await Describe(new[] { host }, token);
|
||||
return described.Count == 0 ? NotFoundError() : Json(described[0]);
|
||||
}
|
||||
|
||||
//up to MaxHosts at once (host[]=...), in the order asked; hosts this server does not know are left out
|
||||
[HttpGet("/api/privapub/v1/instances"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> Instances(CancellationToken token) => Json(await Describe(Params.List("host"), token));
|
||||
|
||||
//the server's weekly snapshots, newest first (weeks=, at most MaxWeeks): its place as shown publicly, its CDN, its size
|
||||
[HttpGet("/api/privapub/v1/instances/{host}/history"), Scope("read", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> History(string host, CancellationToken token)
|
||||
{
|
||||
host = host?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(host) || host.Length > 253 || !await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteAnyAsync(token))
|
||||
return NotFoundError();
|
||||
var weeks = Math.Clamp(Params.Int("weeks") ?? 52, 1, MaxWeeks);
|
||||
return Json((await _places.History(host, weeks, token)).Select(s => new InstanceWeek
|
||||
{
|
||||
Week = s.Week,
|
||||
TakenAt = MastodonJson.Time(s.TakenAt),
|
||||
Reachable = s.Reachable,
|
||||
Software = s.Software,
|
||||
Version = s.SoftwareVersion,
|
||||
UsersTotal = s.UsersTotal,
|
||||
UsersActiveMonth = s.UsersActiveMonth,
|
||||
LocalPosts = s.LocalPosts,
|
||||
OpenRegistrations = s.OpenRegistrations,
|
||||
Geo = Located(PublicGeo.Project(new InstanceGeo
|
||||
{
|
||||
Country = s.Country, City = s.City, Latitude = s.Latitude, Longitude = s.Longitude, Asn = s.Asn,
|
||||
Cdn = s.Cdn, CdnDomain = s.CdnDomain, CdnSource = s.CdnSource, Ipv6 = s.Ipv6
|
||||
}), new InstanceGeo { Cdn = s.Cdn, CdnDomain = s.CdnDomain ?? CdnCatalog.ByKey(s.Cdn)?.Key, CdnSource = s.CdnSource })
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
async Task<List<InstanceDescription>> Describe(IEnumerable<string> hosts, CancellationToken token)
|
||||
{
|
||||
var wanted = hosts.Select(h => h?.Trim().ToLowerInvariant()).Where(h => !string.IsNullOrEmpty(h) && h.Length <= 253)
|
||||
.Distinct().Take(MaxHosts).ToList();
|
||||
var selfHost = Uri.TryCreate(_localActors.BaseAddress, UriKind.Absolute, out var self) ? self.Host.ToLowerInvariant() : default;
|
||||
var remote = await DB.Default.Find<RemoteInstance>().Match(i => wanted.Contains(i.Host)).ExecuteAsync(token);
|
||||
var beforeCdn = await _places.BeforeCdn(remote.Where(i => i.Geo?.Cdn != default).Select(i => i.Host), token);
|
||||
var described = new List<InstanceDescription>();
|
||||
foreach (var host in wanted)
|
||||
{
|
||||
if (host == selfHost)
|
||||
described.Add(await DescribeSelf(host, token));
|
||||
else if (remote.FirstOrDefault(i => i.Host == host) is { } instance)
|
||||
described.Add(Describe(instance, beforeCdn.GetValueOrDefault(host)));
|
||||
}
|
||||
return described;
|
||||
}
|
||||
|
||||
async Task<InstanceDescription> DescribeSelf(string host, CancellationToken token)
|
||||
{
|
||||
var geo = await _self.Get(token);
|
||||
return new InstanceDescription
|
||||
{
|
||||
Host = host,
|
||||
Software = "privapub",
|
||||
Version = BuildInfo.Ref,
|
||||
NodeName = HttpContext.RequestServices.GetRequiredService<Domain.Instance.IInstanceSettings>().Current.Title,
|
||||
Protocols = new() { "activitypub" },
|
||||
OpenRegistrations = _registrations.CurrentValue.IsOpen,
|
||||
DescribedAt = MastodonJson.Time(DateTime.UtcNow),
|
||||
Geo = Located(PublicGeo.Project(geo), geo)
|
||||
};
|
||||
}
|
||||
|
||||
//the public projection of a server's place (PublicGeo); for a CDN-fronted one, the CDN's domain, how it was found and
|
||||
//where the server was before it
|
||||
static InstanceLocation Located(PublicLocation location, InstanceGeo geo, PlaceBeforeCdn beforeCdn = default) => location == default ? default : new InstanceLocation
|
||||
{
|
||||
Precision = location.Cdn != default ? "cdn" : location.Latitude != default ? "city" : "country",
|
||||
Country = location.Country,
|
||||
City = location.City,
|
||||
Latitude = location.Latitude,
|
||||
Longitude = location.Longitude,
|
||||
Network = location.Network,
|
||||
Cdn = location.Cdn,
|
||||
CdnDomain = location.Cdn != default ? ServerPlaces.KeyOf(geo) : default,
|
||||
CdnSource = location.Cdn != default ? geo?.CdnSource : default,
|
||||
BeforeCdn = location.Cdn == default || beforeCdn == default ? default : new InstancePlace
|
||||
{
|
||||
Week = beforeCdn.Week, Country = beforeCdn.Country, City = beforeCdn.City, Latitude = beforeCdn.Latitude, Longitude = beforeCdn.Longitude
|
||||
},
|
||||
Source = geo?.Source,
|
||||
LocatedAt = geo?.LocatedAt is { } at ? MastodonJson.Time(at) : default
|
||||
};
|
||||
|
||||
Provenance Describe(PostEntity post, ObjectRecord record, RemoteInstance instance, PlaceBeforeCdn beforeCdn)
|
||||
{
|
||||
var raw = record?.Raw == default ? default : JsonNode.Parse(record.Raw) as JsonObject;
|
||||
return new Provenance
|
||||
{
|
||||
ObjectUri = post.ObjectURI,
|
||||
ObjectType = record?.ObjectType ?? post.ObjectType,
|
||||
Url = post.Url,
|
||||
Path = record == default ? "unrecorded" : record.Path == ObjectPath.Delivered ? "delivered" : "fetched",
|
||||
Refetched = record?.Refetched == true,
|
||||
Activity = record?.ActivityId == default ? default : new ProvenanceActivity
|
||||
{
|
||||
Id = record.ActivityId, Type = record.ActivityType, Actor = record.ActivityActorURI
|
||||
},
|
||||
Inbox = record?.Inbox,
|
||||
FetchedBy = record?.Path == ObjectPath.Fetched ? "instance-actor" : default,
|
||||
Signature = record?.KeyId == default || record.Path == ObjectPath.Fetched ? default : new ProvenanceSignature
|
||||
{
|
||||
Scheme = record.SignatureScheme, KeyId = record.KeyId, Algorithm = record.Algorithm, Headers = record.SignedHeaders
|
||||
},
|
||||
ReceivedAt = record == default ? default : MastodonJson.Time(record.ReceivedAt),
|
||||
Published = record?.Published is { } published ? MastodonJson.Time(published) : default,
|
||||
Updated = record?.Updated is { } updated ? MastodonJson.Time(updated) : default,
|
||||
Extensions = record?.Extensions ?? ObjectFeatures.Detect(raw),
|
||||
ContextNamespaces = record?.ContextNamespaces ?? ObjectFeatures.Namespaces(raw, record?.ActivityContext),
|
||||
Raw = raw,
|
||||
RawBytes = record?.RawBytes ?? 0,
|
||||
RawHash = record?.RawHash,
|
||||
RawTruncated = record?.RawTruncated == true,
|
||||
Revisions = record?.Revisions.Select(r => new ProvenanceRevision
|
||||
{
|
||||
ActivityId = r.ActivityId,
|
||||
Updated = r.Updated is { } at ? MastodonJson.Time(at) : default,
|
||||
ReceivedAt = MastodonJson.Time(r.ReceivedAt),
|
||||
RawHash = r.RawHash,
|
||||
Raw = r.Raw == default ? default : JsonNode.Parse(r.Raw)
|
||||
}).ToList() ?? new(),
|
||||
Instance = instance == default ? default : Describe(instance, beforeCdn)
|
||||
};
|
||||
}
|
||||
|
||||
static InstanceDescription Describe(RemoteInstance instance, PlaceBeforeCdn beforeCdn) => new()
|
||||
{
|
||||
Host = instance.Host,
|
||||
Software = instance.Software,
|
||||
Version = instance.SoftwareVersion,
|
||||
NodeName = instance.NodeName,
|
||||
Protocols = instance.Protocols ?? new(),
|
||||
OpenRegistrations = instance.OpenRegistrations,
|
||||
DescribedAt = instance.DescribedAt is { } described ? MastodonJson.Time(described) : default,
|
||||
DescriptionError = instance.DescriptionError,
|
||||
NodeInfo = instance.NodeInfo == default ? default : JsonNode.Parse(instance.NodeInfo),
|
||||
Delivery = new InstanceDelivery
|
||||
{
|
||||
ConsecutiveFailures = instance.ConsecutiveFailures,
|
||||
UnavailableUntil = instance.UnavailableUntil is { } until ? MastodonJson.Time(until) : default,
|
||||
LastSuccessAt = instance.LastSuccessAt is { } success ? MastodonJson.Time(success) : default,
|
||||
LastFailureAt = instance.LastFailureAt is { } failure ? MastodonJson.Time(failure) : default
|
||||
},
|
||||
Geo = Located(PublicGeo.Project(instance.Geo), instance.Geo, beforeCdn)
|
||||
};
|
||||
|
||||
public class Provenance
|
||||
{
|
||||
public string ObjectUri { get; set; }
|
||||
public string ObjectType { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Path { get; set; }
|
||||
public bool Refetched { get; set; }
|
||||
public ProvenanceActivity Activity { get; set; }
|
||||
public string Inbox { get; set; }
|
||||
public string FetchedBy { get; set; }
|
||||
public ProvenanceSignature Signature { get; set; }
|
||||
public string ReceivedAt { get; set; }
|
||||
public string Published { get; set; }
|
||||
public string Updated { get; set; }
|
||||
public List<string> Extensions { get; set; } = new();
|
||||
public List<string> ContextNamespaces { get; set; } = new();
|
||||
public JsonNode Raw { get; set; }
|
||||
public int RawBytes { get; set; }
|
||||
public string RawHash { get; set; }
|
||||
public bool RawTruncated { get; set; }
|
||||
public List<ProvenanceRevision> Revisions { get; set; } = new();
|
||||
public InstanceDescription Instance { get; set; }
|
||||
}
|
||||
|
||||
public class ProvenanceActivity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Actor { get; set; }
|
||||
}
|
||||
|
||||
public class ProvenanceSignature
|
||||
{
|
||||
public string Scheme { get; set; }
|
||||
public string KeyId { get; set; }
|
||||
public string Algorithm { get; set; }
|
||||
public List<string> Headers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ProvenanceRevision
|
||||
{
|
||||
public string ActivityId { get; set; }
|
||||
public string Updated { get; set; }
|
||||
public string ReceivedAt { get; set; }
|
||||
public string RawHash { get; set; }
|
||||
public JsonNode Raw { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceDescription
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Software { get; set; }
|
||||
public string Version { get; set; }
|
||||
public string NodeName { get; set; }
|
||||
public List<string> Protocols { get; set; } = new();
|
||||
public bool? OpenRegistrations { get; set; }
|
||||
public string DescribedAt { get; set; }
|
||||
public string DescriptionError { get; set; }
|
||||
public JsonNode NodeInfo { get; set; }
|
||||
public InstanceDelivery Delivery { get; set; }
|
||||
public InstanceLocation Geo { get; set; }
|
||||
}
|
||||
|
||||
//precision: "city" (coordinates to 0.1°, city and network), "country" (located only to a country) or "cdn" (the
|
||||
//CDN's name only)
|
||||
public class InstanceLocation
|
||||
{
|
||||
public string Precision { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string City { get; set; }
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public string Network { get; set; }
|
||||
public string Cdn { get; set; }
|
||||
public string CdnDomain { get; set; }
|
||||
public string CdnSource { get; set; }//"ranges", "headers" or "asn"
|
||||
public InstancePlace BeforeCdn { get; set; }//where a CDN-fronted server was located before the CDN, by its snapshots
|
||||
public string Source { get; set; }
|
||||
public string LocatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class InstancePlace
|
||||
{
|
||||
public string Week { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string City { get; set; }
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceWeek
|
||||
{
|
||||
public string Week { get; set; }
|
||||
public string TakenAt { get; set; }
|
||||
public bool Reachable { get; set; }
|
||||
public string Software { get; set; }
|
||||
public string Version { get; set; }
|
||||
public long? UsersTotal { get; set; }
|
||||
public long? UsersActiveMonth { get; set; }
|
||||
public long? LocalPosts { get; set; }
|
||||
public bool? OpenRegistrations { get; set; }
|
||||
public InstanceLocation Geo { get; set; }
|
||||
}
|
||||
|
||||
public class InstanceDelivery
|
||||
{
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public string UnavailableUntil { get; set; }
|
||||
public string LastSuccessAt { get; set; }
|
||||
public string LastFailureAt { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Infrastructure.Push;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's Web Push API (completion program R9): one subscription per access token, so per persona, made, read, changed
|
||||
// and deleted with the token itself (the `push` scope). Its parameters as Mastodon takes them, as a form or JSON:
|
||||
// subscription[endpoint], subscription[keys][p256dh], subscription[keys][auth], subscription[standard] (4.4: RFC 8291
|
||||
// and RFC 8292, else the aesgcm draft), data[alerts][<type>] and data[policy] (all, followed, follower, none). Its answer is
|
||||
// Mastodon's WebPushSubscription. What is pushed, and how, is WebPushDispatcher's and WebPushJob's.
|
||||
public class PushController : MastodonController
|
||||
{
|
||||
const int MaxEndpointLength = 2048;
|
||||
|
||||
readonly IVapid _vapid;
|
||||
readonly IOptionsMonitor<FederationOptions> _federation;
|
||||
|
||||
public PushController(IVapid vapid, IOptionsMonitor<FederationOptions> federation)
|
||||
{
|
||||
_vapid = vapid;
|
||||
_federation = federation;
|
||||
}
|
||||
|
||||
string TokenId => User.GetTokenId();
|
||||
|
||||
// a new subscription replaces the one this token had, as on Mastodon
|
||||
[HttpPost("/api/v1/push/subscription"), Scope("push")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var endpoint = Params.Get("subscription[endpoint]")?.Trim();
|
||||
if (string.IsNullOrEmpty(endpoint))
|
||||
return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: subscription");
|
||||
if (endpoint.Length > MaxEndpointLength || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || !WebPushJob.IsAllowed(uri, _federation.CurrentValue))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Endpoint is not a valid URL");
|
||||
var p256dh = Params.Get("subscription[keys][p256dh]")?.Trim();
|
||||
var auth = Params.Get("subscription[keys][auth]")?.Trim();
|
||||
if (string.IsNullOrEmpty(p256dh) || string.IsNullOrEmpty(auth))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Key p256dh can't be blank, Key auth can't be blank");
|
||||
if (!WebPushCrypto.AreUsable(WebPushCrypto.FromBase64Url(p256dh), WebPushCrypto.FromBase64Url(auth)))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Keys are not valid Web Push keys");
|
||||
var (alerts, policy, invalid) = Data();
|
||||
if (invalid != default)
|
||||
return invalid;
|
||||
var tokenId = TokenId;
|
||||
var accessToken = await AccessToken();
|
||||
if (string.IsNullOrEmpty(tokenId) || string.IsNullOrEmpty(accessToken))
|
||||
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
|
||||
|
||||
var subscription = new PushSubscription
|
||||
{
|
||||
AvatarId = MyId,
|
||||
TokenId = tokenId,
|
||||
AccessToken = accessToken,
|
||||
Endpoint = endpoint,
|
||||
P256dh = p256dh,
|
||||
Auth = auth,
|
||||
Standard = Params.Bool("subscription[standard]") == true,
|
||||
Alerts = alerts,
|
||||
Policy = policy
|
||||
};
|
||||
// (two at once with one token: the unique index on the token keeps one, and the later replaces it)
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
await WebPush.ForgetToken(tokenId);
|
||||
try
|
||||
{
|
||||
subscription.ID = default;
|
||||
await DB.Default.SaveAsync(subscription, token);
|
||||
break;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey && attempt < 2)
|
||||
{
|
||||
}
|
||||
}
|
||||
return Json(await View(subscription, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/push/subscription"), Scope("push")]
|
||||
public async Task<IActionResult> Show(CancellationToken token) =>
|
||||
await Mine(token) is { } subscription ? Json(await View(subscription, token)) : NotFoundError();
|
||||
|
||||
// Mastodon's update: the data given replaces what was there (alerts left out are off, the policy left out is all)
|
||||
[HttpPut("/api/v1/push/subscription"), Scope("push")]
|
||||
public async Task<IActionResult> Update(CancellationToken token)
|
||||
{
|
||||
var subscription = await Mine(token);
|
||||
if (subscription == default)
|
||||
return NotFoundError();
|
||||
var (alerts, policy, invalid) = Data();
|
||||
if (invalid != default)
|
||||
return invalid;
|
||||
subscription.Alerts = alerts;
|
||||
subscription.Policy = policy;
|
||||
subscription.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.Update<PushSubscription>().MatchID(subscription.ID)
|
||||
.Modify(s => s.Alerts, alerts)
|
||||
.Modify(s => s.Policy, policy)
|
||||
.Modify(s => s.UpdatedAt, subscription.UpdatedAt)
|
||||
.ExecuteAsync(token);
|
||||
return Json(await View(subscription, token));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/push/subscription"), Scope("push")]
|
||||
public async Task<IActionResult> Delete(CancellationToken token)
|
||||
{
|
||||
await WebPush.ForgetToken(TokenId);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
async Task<PushSubscription> Mine(CancellationToken token)
|
||||
{
|
||||
var tokenId = TokenId;
|
||||
var avatarId = MyId;
|
||||
return string.IsNullOrEmpty(tokenId) ? default
|
||||
: await DB.Default.Find<PushSubscription>().Match(s => s.TokenId == tokenId && s.AvatarId == avatarId).ExecuteFirstAsync(token);
|
||||
}
|
||||
|
||||
// data[alerts][<type>] (each true or false; unknown types are ignored, as Mastodon permits only its own) and
|
||||
// data[policy]
|
||||
(List<string> Alerts, PushPolicy Policy, IActionResult Invalid) Data()
|
||||
{
|
||||
var alerts = WebPush.Alerts.Where(alert => Params.Bool($"data[alerts][{alert}]") == true).ToList();
|
||||
var given = Params.Get("data[policy]");
|
||||
if (string.IsNullOrWhiteSpace(given))
|
||||
return (alerts, PushPolicy.All, default);
|
||||
return WebPush.ParsePolicy(given) is { } policy
|
||||
? (alerts, policy, default)
|
||||
: (alerts, PushPolicy.All, Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Policy is not included in the list"));
|
||||
}
|
||||
|
||||
// the bearer token of this request, wherever the client put it (OpenIddict takes the header, a form field or the
|
||||
// query): the payload carries it back, as Mastodon's does
|
||||
async Task<string> AccessToken()
|
||||
{
|
||||
var stored = await HttpContext.GetTokenAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme,
|
||||
OpenIddictValidationAspNetCoreConstants.Tokens.AccessToken);
|
||||
if (!string.IsNullOrEmpty(stored))
|
||||
return stored;
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return header["Bearer ".Length..].Trim();
|
||||
return Params.Get("access_token");
|
||||
}
|
||||
|
||||
// Mastodon's WebPushSubscription
|
||||
async Task<object> View(PushSubscription subscription, CancellationToken token) => new
|
||||
{
|
||||
id = subscription.ID,
|
||||
endpoint = subscription.Endpoint,
|
||||
standard = subscription.Standard,
|
||||
alerts = WebPush.Alerts.ToDictionary(alert => alert, alert => subscription.Alerts.Contains(alert)),
|
||||
server_key = (await _vapid.Key(token)).ForApi,
|
||||
policy = WebPush.Name(subscription.Policy)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ReactionsController : MastodonController
|
||||
{
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IReactions _reactions;
|
||||
|
||||
public ReactionsController(DbEntities dbEntities, MastodonMapper mapper, IReactions reactions)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_mapper = mapper;
|
||||
_reactions = reactions;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/pleroma/statuses/{id}/reactions"), Scope("read:statuses", requiresUser: false), AllowAnonymous]
|
||||
public async Task<IActionResult> List(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var reactions = await DB.Default.Find<Reaction>().Match(r => r.PostId == post.ID).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(reactions.Select(r => r.AccountId), token);
|
||||
return Json(reactions.GroupBy(r => r.Emoji).Select(group => new
|
||||
{
|
||||
name = group.Key,
|
||||
count = group.Count(),
|
||||
me = MyId != default && group.Any(r => r.AccountId == MyId),
|
||||
url = _mapper.ProxiedUrl(group.First().EmojiURL),
|
||||
accounts = group.Select(r => accounts.GetValueOrDefault(r.AccountId)).Where(a => a != default).ToList()
|
||||
}).OrderByDescending(r => r.count).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
|
||||
public async Task<IActionResult> React(string id, string emoji, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
if (!await _reactions.React(Me, post, Uri.UnescapeDataString(emoji), token))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: That emoji cannot be used here");
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/pleroma/statuses/{id}/reactions/{emoji}"), Scope("write:favourites")]
|
||||
public async Task<IActionResult> Unreact(string id, string emoji, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
await _reactions.Unreact(Me, post, Uri.UnescapeDataString(emoji), token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class ReportsController : MastodonController
|
||||
{
|
||||
readonly IReportService _reports;
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
public ReportsController(IReportService reports, MastodonMapper mapper)
|
||||
{
|
||||
_reports = reports;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/reports"), Scope("write:reports")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var report = await _reports.File(Me, Params.Get("account_id"), Params.List("status_ids"), Params.Get("comment"), Params.Get("category"),
|
||||
Params.List("rule_ids"), Params.Bool("forward") == true, token);
|
||||
if (report == default)
|
||||
return NotFoundError();
|
||||
return Json(new
|
||||
{
|
||||
id = report.ID,
|
||||
action_taken = false,
|
||||
action_taken_at = default(string),
|
||||
category = report.Category,
|
||||
comment = report.Comment ?? string.Empty,
|
||||
forwarded = report.Forwarded,
|
||||
created_at = MastodonJson.Time(report.CreatedAt),
|
||||
status_ids = report.PostIds,
|
||||
rule_ids = report.RuleIds,
|
||||
target_account = await _mapper.Account(report.TargetAccountId, token)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Media;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's scheduled statuses (owner decision 2026-10-04, back from the cut list): the persona's posts waiting for
|
||||
// their time, moved to another time or dropped. POST /api/v1/statuses with scheduled_at makes them.
|
||||
public class ScheduledStatusesController : MastodonController
|
||||
{
|
||||
readonly IMediaService _media;
|
||||
readonly IJobQueue _jobs;
|
||||
|
||||
public ScheduledStatusesController(IMediaService media, IJobQueue jobs)
|
||||
{
|
||||
_media = media;
|
||||
_jobs = jobs;
|
||||
}
|
||||
|
||||
public static async Task<object> View(ScheduledStatus scheduled, IMediaService media, CancellationToken token)
|
||||
{
|
||||
var p = scheduled.Params;
|
||||
var ids = p.MediaIds;
|
||||
var attachments = ids.Count == 0
|
||||
? new List<MediaAttachment>()
|
||||
: await DB.Default.Find<MediaAttachment>().Match(m => ids.Contains(m.ID)).ExecuteAsync(token);
|
||||
return new
|
||||
{
|
||||
id = scheduled.ID,
|
||||
scheduled_at = MastodonJson.Time(scheduled.ScheduledAt),
|
||||
@params = new
|
||||
{
|
||||
text = p.Text,
|
||||
poll = p.Poll == default ? default : new { options = p.Poll.Options, expires_in = p.Poll.ExpiresIn, multiple = p.Poll.Multiple, hide_totals = p.Poll.HideTotals },
|
||||
media_ids = ids.Count == 0 ? default : ids,
|
||||
sensitive = p.Sensitive,
|
||||
spoiler_text = p.SpoilerText,
|
||||
visibility = p.Visibility,
|
||||
in_reply_to_id = p.InReplyToId,
|
||||
language = p.Language,
|
||||
application_id = default(string),
|
||||
scheduled_at = default(string),
|
||||
idempotency = p.Idempotency,
|
||||
with_rate_limit = false,
|
||||
quoted_status_id = p.QuotedStatusId
|
||||
},
|
||||
media_attachments = ids.Select(id => attachments.FirstOrDefault(m => m.ID == id)).Where(m => m != default)
|
||||
.Select(m => MediaController.View(media, m)).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
Task<ScheduledStatus> Mine(string id, CancellationToken token) =>
|
||||
DB.Default.Find<ScheduledStatus>().Match(s => s.ID == id && s.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
[HttpGet("/api/v1/scheduled_statuses"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> All(CancellationToken token)
|
||||
{
|
||||
var page = await Page.From(Params, Limit(20, 40)).Fetch(DB.Default.Find<ScheduledStatus>().Match(s => s.AvatarId == MyId), s => s.ID, token);
|
||||
Link("/api/v1/scheduled_statuses", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
||||
var views = new List<object>();
|
||||
foreach (var scheduled in page)
|
||||
views.Add(await View(scheduled, _media, token));
|
||||
return Json(views);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/scheduled_statuses/{id}"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> One(string id, CancellationToken token) =>
|
||||
await Mine(id, token) is { } scheduled ? Json(await View(scheduled, _media, token)) : NotFoundError();
|
||||
|
||||
[HttpPut("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Move(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } scheduled)
|
||||
return NotFoundError();
|
||||
if (!DateTime.TryParse(Params.Get("scheduled_at"), System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
|
||||
if (await ScheduledStatuses.Refusal(MyId, at, scheduled.ID, token) is { } refusal)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
|
||||
scheduled.ScheduledAt = at;
|
||||
await DB.Default.Update<ScheduledStatus>().MatchID(scheduled.ID).Modify(s => s.ScheduledAt, at).ExecuteAsync(token);
|
||||
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
|
||||
return Json(await View(scheduled, _media, token));
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/scheduled_statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Drop(string id, CancellationToken token)
|
||||
{
|
||||
if (await Mine(id, token) is not { } scheduled)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync<ScheduledStatus>(scheduled.ID);
|
||||
// its media were the scheduled post's alone, never posted
|
||||
await _media.Trash(m => m.ScheduledStatusId == scheduled.ID, "scheduled post dropped", token);
|
||||
return Json(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public partial class SearchController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IRemotePosts _remotePosts;
|
||||
readonly AccountSearch _search;
|
||||
|
||||
public SearchController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors,
|
||||
IRemotePosts remotePosts, AccountSearch search)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_remotePosts = remotePosts;
|
||||
_search = search;
|
||||
}
|
||||
|
||||
// Anyone may search, as on Mastodon; only resolving (which fetches from other servers) and paging need a sign-in.
|
||||
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Search(CancellationToken token)
|
||||
{
|
||||
var q = Params.Get("q")?.Trim();
|
||||
var type = Params.Get("type");
|
||||
var offset = Params.Int("offset") ?? 0;
|
||||
if (MyId == default && (Params.Bool("resolve") == true || offset > 0))
|
||||
return Error(StatusCodes.Status401Unauthorized, "Search queries that resolve uris or use offset require the read:search scope");
|
||||
var resolve = Params.Bool("resolve") == true && MyId != default;
|
||||
var results = new SearchResults();
|
||||
if (string.IsNullOrEmpty(q))
|
||||
return Json(results);
|
||||
|
||||
if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => (p.ObjectURI == q || p.Url == q) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (post == default && resolve && type is null or "statuses")
|
||||
post = await _remotePosts.StoreContext(q, 0, token);
|
||||
if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
if (await _mapper.Status(post, MyId, token) is { } status)
|
||||
results.Statuses.Add(status);
|
||||
if (results.Statuses.Count == 0 && type is null or "accounts")
|
||||
{
|
||||
var local = await _localActors.FindByUri(q, token);
|
||||
var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default;
|
||||
if (local is { IsFederated: true, IsCircle: false })
|
||||
results.Accounts.Add(await _mapper.Local(local, false, token));
|
||||
else if (foreign != default)
|
||||
results.Accounts.Add(_mapper.Foreign(foreign));
|
||||
}
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
if (type is null or "accounts")
|
||||
{
|
||||
results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token);
|
||||
}
|
||||
// an account's handle asks for the account, never for posts that happen to share its words
|
||||
if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q))
|
||||
results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token);
|
||||
if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag))
|
||||
results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = await TagsController.History(_dbEntities, tag, token) });
|
||||
return Json(results);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SearchController
|
||||
{
|
||||
const int WordCandidates = 400;
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"^@?[^\s@]+@[^\s@]+$|^@[^\s@]+$")]
|
||||
private static partial System.Text.RegularExpressions.Regex Handle();
|
||||
|
||||
// Posts by their words, as Mastodon searches them: those the persona wrote, boosted, favourited, bookmarked or was
|
||||
// named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only
|
||||
async Task<List<Status>> Words(string q, int offset, int limit, CancellationToken token)
|
||||
{
|
||||
var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => p.ReblogOfPostId == null).Match(VisibilityPolicy.IsShown)
|
||||
.Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token);
|
||||
if (candidates.Count == 0)
|
||||
return new List<Status>();
|
||||
var ids = candidates.Select(p => p.ID).ToList();
|
||||
var mine = new HashSet<string>();
|
||||
mine.UnionWith((await _dbEntities.Favourites.Match(f => f.AccountId == MyId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId));
|
||||
mine.UnionWith((await MongoDB.Entities.DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId && ids.Contains(b.PostId)).ExecuteAsync(token))
|
||||
.Select(b => b.PostId));
|
||||
mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
.Select(p => p.ReblogOfPostId));
|
||||
var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList();
|
||||
var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
// a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both
|
||||
var remoteAuthors = (await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID)).ExecuteAsync(token)).ToDictionary(f => f.ID);
|
||||
var stating = remoteAuthors.Values.Where(f => f.SearchableBy != default).Select(f => f.ActorURI)
|
||||
.Concat(candidates.Where(p => p.SearchableBy != default).Select(p => p.ActorURI))
|
||||
.Where(uri => uri != default).Distinct().ToList();
|
||||
var followed = stating.Count == 0
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && stating.Contains(f.TargetActorURI) && f.State == Models.Social.FollowState.Accepted)
|
||||
.ExecuteAsync(token)).Select(f => f.TargetActorURI).ToHashSet();
|
||||
bool Searchable(PostEntity post, string author)
|
||||
{
|
||||
var remote = remoteAuthors.GetValueOrDefault(author);
|
||||
var rule = post.SearchableBy ?? remote?.SearchableBy;
|
||||
if (rule != default)
|
||||
return Federation.Objects.ObjectShapes.Searchable(rule, remote?.FollowersURL, followed.Contains(post.ActorURI ?? string.Empty));
|
||||
return remote?.IsIndexable == true || indexable.Contains(author);
|
||||
}
|
||||
var found = new List<PostEntity>();
|
||||
foreach (var post in candidates)
|
||||
{
|
||||
var author = post.AuthorAccountId ?? post.GroupUserId;
|
||||
var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId)
|
||||
|| post.Visibility == PostVisibility.Public && !post.AuthorLimited && Searchable(post, author);//a silenced author's only to who met it (R6)
|
||||
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
found.Add(post);
|
||||
if (found.Count >= offset + limit)
|
||||
break;
|
||||
}
|
||||
return await _mapper.Statuses(found.Skip(offset).Take(limit).ToList(), MyId, token);
|
||||
}
|
||||
}
|
||||
|
||||
public class StubsController : MastodonController
|
||||
{
|
||||
// Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a
|
||||
// 404 breaks some of them (Mastodon invariant 5)
|
||||
[HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult LinkTimeline() => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult IdentityProofs(string id) => Json(Array.Empty<object>());
|
||||
|
||||
[HttpGet("/api/v1/instance/languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Languages() => Json(InstanceController.LanguageCodes(HttpContext.RequestServices)
|
||||
.Select(code => new { code, name = System.Globalization.CultureInfo.GetCultureInfo(code).EnglishName }));
|
||||
|
||||
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult TranslationLanguages() => Json(new { });
|
||||
|
||||
// translation is cut (owner decision 2026-10-17): no language offers any (translation_languages), and a client that
|
||||
// asks anyway is told so, as Mastodon tells it when it can't translate
|
||||
[HttpPost("/api/v1/statuses/{id}/translate"), Scope("read:statuses")]
|
||||
public IActionResult Translate(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Translation is not available on this server");
|
||||
|
||||
// Mastodon 4.3's yearly reports (Wrapstodon): none are made here, so there are never any to show, for any year (4.4's
|
||||
// show), and marking one read changes nothing
|
||||
[HttpGet("/api/v1/annual_reports"), HttpGet("/api/v1/annual_reports/{year}"), Scope("read:accounts")]
|
||||
public IActionResult AnnualReports(string year) => Json(new { annual_reports = Array.Empty<object>(), accounts = Array.Empty<object>(), statuses = Array.Empty<object>() });
|
||||
|
||||
[HttpPost("/api/v1/annual_reports/{year}/read"), Scope("write:accounts")]
|
||||
public IActionResult ReadAnnualReport(string year) => Json(new { });
|
||||
|
||||
// Registration through the API is cut (owner decision 2026-10-17): a login is made by setup or an invitation, on
|
||||
// /clientapi. Mastodon answers this when its registrations are closed; its e-mail confirmation routes follow, since no
|
||||
// account here waits for one
|
||||
[HttpPost("/api/v1/accounts"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Register() => Error(StatusCodes.Status403Forbidden, "This action is not allowed");
|
||||
|
||||
[HttpPost("/api/v1/emails/confirmations"), Scope("write:accounts")]
|
||||
public IActionResult ResendConfirmation() =>
|
||||
Error(StatusCodes.Status403Forbidden, "This method is only available while the e-mail is awaiting confirmation");
|
||||
|
||||
[HttpGet("/api/v1/emails/check_confirmation"), Scope("read:accounts")]
|
||||
public IActionResult CheckConfirmation() => Json(true);
|
||||
|
||||
// the peers' names are not published (/instance/peers stays empty), so a search of them finds none
|
||||
[HttpGet("/api/v1/peers/search"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult SearchPeers() => Json(Array.Empty<string>());
|
||||
|
||||
// Mastodon 4.4's asynchronous refreshes are announced by a Mastodon-Async-Refresh header, which PrivaPub never sends
|
||||
// (a thread's replies are read again in the background and simply show up): there is never one to ask about
|
||||
[HttpGet("/api/v1_alpha/async_refreshes/{id}"), Scope("read")]
|
||||
public IActionResult AsyncRefresh(string id) => NotFoundError();
|
||||
|
||||
// the Mastodon admin API is cut: moderation stays on /clientapi (ModeratorController, AdminController), so every
|
||||
// admin route answers as Mastodon answers an account that may not use it, whoever asks
|
||||
[HttpGet("/api/v1/admin/{**rest}"), HttpPost("/api/v1/admin/{**rest}"), HttpPut("/api/v1/admin/{**rest}"), HttpPatch("/api/v1/admin/{**rest}"),
|
||||
HttpDelete("/api/v1/admin/{**rest}"), HttpGet("/api/v2/admin/{**rest}"), HttpPost("/api/v2/admin/{**rest}"),
|
||||
ApiExplorerSettings(IgnoreApi = true), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Admin(string rest) => Error(StatusCodes.Status403Forbidden, "This action is not allowed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class StatusesController : MastodonController
|
||||
{
|
||||
const int MaxContext = 200;
|
||||
public const int MaxPins = 5;
|
||||
|
||||
readonly IStatusService _statuses;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IMemoryCache _cache;
|
||||
readonly IQuoteService _quotes;
|
||||
readonly IOutboxPublisher _outbox;
|
||||
readonly PrivaPub.Infrastructure.Jobs.IJobQueue _jobs;
|
||||
readonly Domain.Media.IMediaService _media;
|
||||
|
||||
readonly IParticipations _participations;
|
||||
readonly Federation.Actors.IWalls _walls;
|
||||
|
||||
public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache, IQuoteService quotes,
|
||||
IOutboxPublisher outbox, PrivaPub.Infrastructure.Jobs.IJobQueue jobs, Domain.Media.IMediaService media, IParticipations participations = default,
|
||||
Federation.Actors.IWalls walls = default)
|
||||
{
|
||||
_walls = walls;
|
||||
_participations = participations;
|
||||
_jobs = jobs;
|
||||
_media = media;
|
||||
_quotes = quotes;
|
||||
_outbox = outbox;
|
||||
_statuses = statuses;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Create(CancellationToken token)
|
||||
{
|
||||
var idempotency = Request.Headers["Idempotency-Key"].ToString();
|
||||
var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}";
|
||||
if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier))
|
||||
{
|
||||
var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token);
|
||||
if (existing != default)
|
||||
return Json(await _mapper.Status(existing, MyId, token));
|
||||
}
|
||||
var asked = new ScheduledParams
|
||||
{
|
||||
Text = Params.Get("status"),
|
||||
SpoilerText = Params.Get("spoiler_text"),
|
||||
Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive,
|
||||
Visibility = Params.Get("visibility") ?? Me.Settings.DefaultVisibility,
|
||||
InReplyToId = Params.Get("in_reply_to_id"),
|
||||
Language = Params.Get("language") ?? Me.Settings.DefaultLanguage,
|
||||
MediaIds = Params.List("media_ids").ToList(),
|
||||
QuotedStatusId = Params.Get("quoted_status_id"),
|
||||
QuotePolicy = Params.Get("quote_approval_policy"),
|
||||
Idempotency = string.IsNullOrEmpty(idempotency) ? default : idempotency,
|
||||
Poll = Params.Has("poll[options]")
|
||||
? new ScheduledPoll
|
||||
{
|
||||
Options = Params.List("poll[options]").ToList(),
|
||||
ExpiresIn = Params.Int("poll[expires_in]") ?? 0,
|
||||
Multiple = Params.Bool("poll[multiple]") == true,
|
||||
HideTotals = Params.Bool("poll[hide_totals]") == true
|
||||
}
|
||||
: default
|
||||
};
|
||||
if (Params.Get("scheduled_at") is { Length: > 0 } scheduledAt)
|
||||
return await Schedule(asked, scheduledAt, cacheKey, token);
|
||||
var outcome = await _statuses.Publish(Me, ScheduledStatuses.Draft(asked), token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
if (cacheKey != default)
|
||||
_cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1));
|
||||
return Json(await _mapper.Status(outcome.Post, MyId, token));
|
||||
}
|
||||
|
||||
// scheduled_at: the post waits, as asked, for a PublishScheduled job at that time; what it would need then (words,
|
||||
// media of the persona's own, not yet posted) is checked now
|
||||
async Task<IActionResult> Schedule(ScheduledParams asked, string scheduledAt, string cacheKey, CancellationToken token)
|
||||
{
|
||||
if (cacheKey != default && _cache.TryGetValue(cacheKey + ":scheduled", out string earlier)
|
||||
&& await DB.Default.Find<ScheduledStatus>().MatchID(earlier).ExecuteFirstAsync(token) is { } waiting)
|
||||
return Json(await ScheduledStatusesController.View(waiting, _media, token));
|
||||
if (!DateTime.TryParse(scheduledAt, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var at))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Scheduled at is invalid");
|
||||
if (await ScheduledStatuses.Refusal(MyId, at, default, token) is { } refusal)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: " + refusal);
|
||||
if (string.IsNullOrWhiteSpace(asked.Text) && asked.MediaIds.Count == 0 && asked.Poll == default)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Text can't be blank");
|
||||
var mediaIds = asked.MediaIds.Distinct().ToList();
|
||||
if (mediaIds.Count > 4 || mediaIds.Count > 0 && await DB.Default.CountAsync<Models.Media.MediaAttachment>(
|
||||
m => mediaIds.Contains(m.ID) && m.OwnerAvatarId == MyId && m.PostId == null && m.ScheduledStatusId == null
|
||||
&& m.TrashedAt == null && m.ProfileOfAvatarId == null, token) != mediaIds.Count)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
|
||||
|
||||
var scheduled = new ScheduledStatus { AvatarId = MyId, ScheduledAt = at, Params = asked };
|
||||
scheduled.ID = (string)scheduled.GenerateNewID();
|
||||
// reserved in one conditional update: a post or another scheduled post taking one meanwhile wins
|
||||
if (mediaIds.Count > 0)
|
||||
{
|
||||
var reserved = await DB.Default.Update<Models.Media.MediaAttachment>()
|
||||
.Match(m => mediaIds.Contains(m.ID) && m.PostId == null && m.ScheduledStatusId == null && m.TrashedAt == null)
|
||||
.Modify(m => m.ScheduledStatusId, scheduled.ID).ExecuteAsync(token);
|
||||
if (reserved.MatchedCount != mediaIds.Count)
|
||||
{
|
||||
await DB.Default.Update<Models.Media.MediaAttachment>().Match(m => m.ScheduledStatusId == scheduled.ID)
|
||||
.Modify(m => m.ScheduledStatusId, null).ExecuteAsync(token);
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not yours, already posted or too many");
|
||||
}
|
||||
}
|
||||
await DB.Default.SaveAsync(scheduled, token);
|
||||
await _jobs.EnqueueMany(new[] { ScheduledStatuses.JobFor(scheduled, new Uri(Me.BaseAddress).Host) }, token);
|
||||
if (cacheKey != default)
|
||||
_cache.Set(cacheKey + ":scheduled", scheduled.ID, TimeSpan.FromHours(1));
|
||||
return Json(await ScheduledStatusesController.View(scheduled, _media, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Many(CancellationToken token)
|
||||
{
|
||||
var ids = Params.List("id").Take(20).ToList();
|
||||
var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token);
|
||||
var visible = new List<PostEntity>();
|
||||
foreach (var post in posts)
|
||||
if (await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
visible.Add(post);
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
var status = post == default ? default : await _mapper.Status(post, MyId, token);
|
||||
return status == default ? NotFoundError() : Json(status);
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Edit(string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await _statuses.Edit(Me, id, new StatusDraft
|
||||
{
|
||||
Text = Params.Get("status"),
|
||||
PlainText = true,
|
||||
SpoilerText = Params.Get("spoiler_text"),
|
||||
Sensitive = Params.Bool("sensitive") ?? false,
|
||||
Language = Params.Get("language"),
|
||||
MediaIds = Params.Has("media_ids") ? Params.List("media_ids") : default,
|
||||
MediaChanges = MediaAttributes()
|
||||
}, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
// Mastodon's media_attributes: a JSON body's array of objects, or a form's media_attributes[i][field] or
|
||||
// media_attributes[][field] (each field then listed in the same order)
|
||||
IReadOnlyList<MediaChange> MediaAttributes()
|
||||
{
|
||||
var changes = new List<MediaChange>();
|
||||
if (Params.Json is { ValueKind: System.Text.Json.JsonValueKind.Object } json && json.TryGetProperty("media_attributes", out var array)
|
||||
&& array.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in array.EnumerateArray().Where(i => i.ValueKind == System.Text.Json.JsonValueKind.Object).Take(4))
|
||||
{
|
||||
var hasDescription = item.TryGetProperty("description", out var description);
|
||||
changes.Add(new MediaChange(Text(item, "id"), hasDescription, hasDescription ? Text(item, "description") : default, Text(item, "focus")));
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
for (var i = 0; i < 4 && Params.Get($"media_attributes[{i}][id]") is { } id; i++)
|
||||
changes.Add(new MediaChange(id, Params.Has($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][description]"), Params.Get($"media_attributes[{i}][focus]")));
|
||||
var ids = Params.List("media_attributes[][id]");
|
||||
var descriptions = Params.List("media_attributes[][description]");
|
||||
var foci = Params.List("media_attributes[][focus]");
|
||||
for (var i = 0; i < Math.Min(ids.Count, 4); i++)
|
||||
changes.Add(new MediaChange(ids[i], i < descriptions.Count, i < descriptions.Count ? descriptions[i] : default, i < foci.Count ? foci[i] : default));
|
||||
return changes;
|
||||
|
||||
static string Text(System.Text.Json.JsonElement item, string name) => item.TryGetProperty(name, out var value)
|
||||
? value.ValueKind == System.Text.Json.JsonValueKind.String ? value.GetString() : value.ValueKind is System.Text.Json.JsonValueKind.Null ? default : value.GetRawText()
|
||||
: default;
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||
{
|
||||
var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (before == default)
|
||||
return await OffWall(id, token);
|
||||
var status = await _mapper.Status(before, MyId, token);
|
||||
var outcome = await _statuses.Remove(Me, id, token);
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
if (status != default)
|
||||
{
|
||||
status.Text = before.Text;
|
||||
status.SpoilerText = before.SpoilerText ?? string.Empty;
|
||||
}
|
||||
return Json(status);
|
||||
}
|
||||
|
||||
// someone's post on the persona's wall (FEP-400e), taken off it: as on Smithereen, the owner of a wall deletes what is on it
|
||||
async Task<IActionResult> OffWall(string id, CancellationToken token)
|
||||
{
|
||||
var walled = _walls == default
|
||||
? default
|
||||
: await _dbEntities.Posts.Match(p => p.ID == id && p.WallOwnerAccountId == MyId && p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (walled == default)
|
||||
return NotFoundError();
|
||||
var status = await _mapper.Status(walled, MyId, token);
|
||||
if (!await _walls.Remove(Me, walled, token))
|
||||
return NotFoundError();
|
||||
return Json(status);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Context(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
|
||||
var ancestors = new List<PostEntity>();
|
||||
var cursor = post;
|
||||
while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext)
|
||||
{
|
||||
cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token);
|
||||
if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token))
|
||||
break;
|
||||
ancestors.Insert(0, cursor);
|
||||
}
|
||||
|
||||
var descendants = new List<PostEntity>();
|
||||
var frontier = new List<string> { post.ID };
|
||||
while (frontier.Count > 0 && descendants.Count < MaxContext)
|
||||
{
|
||||
var parents = frontier;
|
||||
var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
||||
.Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token);
|
||||
frontier = new List<string>();
|
||||
foreach (var child in children)
|
||||
if (await VisibilityPolicy.CanSee(child, MyId, token))
|
||||
{
|
||||
descendants.Add(child);
|
||||
frontier.Add(child.ID);
|
||||
}
|
||||
}
|
||||
// a persona opening a public remote thread has its replies read from their servers, for the next look (once an hour)
|
||||
if (MyId != default)
|
||||
await _jobs.EnqueueMany(new[] { post, ancestors.FirstOrDefault() }
|
||||
.Where(p => p is { IsFederatedCopy: true, Visibility: PostVisibility.Public or PostVisibility.Unlisted }).DistinctBy(p => p.ID)
|
||||
.Select(p => Federation.Inbox.RepliesJobHandler.For(p, 1)), token);
|
||||
return Json(new Context
|
||||
{
|
||||
Ancestors = await _mapper.Statuses(ancestors, MyId, token),
|
||||
Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> History(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token);
|
||||
var edits = post.Revisions.Select(r => new StatusEdit
|
||||
{
|
||||
Content = r.ContentHtml ?? string.Empty,
|
||||
SpoilerText = r.SpoilerText ?? string.Empty,
|
||||
Sensitive = r.HasContentWarning,
|
||||
CreatedAt = MastodonJson.Time(r.EditedAt),
|
||||
Account = account,
|
||||
Emojis = _mapper.EmojisOf(post, r.Emojis, r.ContentHtml, r.SpoilerText, r.Title)
|
||||
}).ToList();
|
||||
edits.Add(new StatusEdit
|
||||
{
|
||||
Content = MastodonMapper.Content(post),
|
||||
SpoilerText = post.SpoilerText ?? string.Empty,
|
||||
Sensitive = post.HasContentWarning,
|
||||
CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate),
|
||||
Account = account,
|
||||
Emojis = _mapper.EmojisOf(post, post.Emojis, post.ContentHtml, post.Text, post.SpoilerText, post.Title)
|
||||
});
|
||||
return Json(edits);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Source(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty });
|
||||
}
|
||||
|
||||
// a persona joins or leaves a remote event (owner decision 2026-10-05); the status says how it stands
|
||||
// (privapub.event.participation)
|
||||
[HttpPost("/api/privapub/v1/statuses/{id}/join"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Join(string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await _participations.Join(Me, id, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
[HttpPost("/api/privapub/v1/statuses/{id}/leave"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Leave(string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await _participations.Leave(Me, id, token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token);
|
||||
|
||||
// PrivaPub's own, for the threadiverse's downvotes (a favourite is the upvote)
|
||||
[HttpPost("/api/v1/statuses/{id}/downvote"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Downvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, true, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/undownvote"), Scope("write:favourites")]
|
||||
public Task<IActionResult> Undownvote(string id, CancellationToken token) => Toggle(_statuses.Downvote(Me, id, false, token), id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> Reblog(string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token);
|
||||
return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error);
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")]
|
||||
public Task<IActionResult> Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token);
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> FavouritedBy(string id, CancellationToken token)
|
||||
{
|
||||
if (await Visible(id, token) == default)
|
||||
return NotFoundError();
|
||||
var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token);
|
||||
return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> RebloggedBy(string id, CancellationToken token)
|
||||
{
|
||||
if (await Visible(id, token) == default)
|
||||
return NotFoundError();
|
||||
var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
|
||||
var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token);
|
||||
return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/statuses/{id}/quotes"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Quotes(string id, CancellationToken token)
|
||||
{
|
||||
if (await Visible(id, token) == default)
|
||||
return NotFoundError();
|
||||
var query = _dbEntities.Posts.Match(p => p.QuotedPostId == id && p.QuoteState == QuoteState.Accepted && !p.DeletedAt.HasValue);
|
||||
var quotes = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
||||
var visible = new List<PostEntity>();
|
||||
foreach (var quote in quotes)
|
||||
if (quote.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quote, MyId, token))
|
||||
visible.Add(quote);
|
||||
Link($"/api/v1/statuses/{id}/quotes", quotes.LastOrDefault()?.ID, quotes.FirstOrDefault()?.ID);
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/quotes/{quotingId}/revoke"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> RevokeQuote(string id, string quotingId, CancellationToken token)
|
||||
{
|
||||
var quoted = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var quoting = quoted == default ? default : await _dbEntities.Posts.Match(p => p.ID == quotingId && p.QuotedPostId == quoted.ID).ExecuteFirstAsync(token);
|
||||
if (quoting == default || !await _quotes.RevokeLicence(quoted, quoting, token))
|
||||
return NotFoundError();
|
||||
return Json(await _mapper.Status(await _dbEntities.Posts.MatchID(quoting.ID).ExecuteFirstAsync(token), MyId, token));
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/statuses/{id}/interaction_policy"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> InteractionPolicy(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
var policy = Params.Get("quote_approval_policy");
|
||||
if (!QuotePolicies.IsKnown(policy))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Unknown quote approval policy");
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
|
||||
post.LocalQuotePolicy = policy;
|
||||
if (!post.IsLocalOnly)
|
||||
await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")]
|
||||
public async Task<IActionResult> Bookmark(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
if (post == default)
|
||||
return NotFoundError();
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Models.Social.Bookmark { AvatarId = MyId, PostId = post.ID }, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")]
|
||||
public async Task<IActionResult> Unbookmark(string id, CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<Models.Social.Bookmark>(b => b.AvatarId == MyId && b.PostId == id);
|
||||
return await Unchanged(id, token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/bookmarks"), Scope("read:bookmarks")]
|
||||
public async Task<IActionResult> Bookmarks(CancellationToken token)
|
||||
{
|
||||
var bookmarks = await Page.From(Params, Limit()).Fetch(DB.Default.Find<Models.Social.Bookmark>().Match(b => b.AvatarId == MyId), b => b.ID, token);
|
||||
var ids = bookmarks.Select(b => b.PostId).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var visible = new List<PostEntity>();
|
||||
foreach (var postId in ids.Where(posts.ContainsKey))
|
||||
if (await VisibilityPolicy.CanSee(posts[postId], MyId, token))
|
||||
visible.Add(posts[postId]);
|
||||
Link("/api/v1/bookmarks", bookmarks.LastOrDefault()?.ID, bookmarks.FirstOrDefault()?.ID);
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")]
|
||||
public Task<IActionResult> Mute(string id, CancellationToken token) => Unchanged(id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")]
|
||||
public Task<IActionResult> Unmute(string id, CancellationToken token) => Unchanged(id, token);
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Pin(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null)
|
||||
.ExecuteFirstAsync(token);
|
||||
if (post == default)
|
||||
return await Visible(id, token) is { } other
|
||||
? Error(StatusCodes.Status422UnprocessableEntity, other.ReblogOfPostId == null
|
||||
? "Validation failed: Someone else's post cannot be pinned"
|
||||
: "Validation failed: A boost cannot be pinned")
|
||||
: NotFoundError();
|
||||
if (post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Only public posts can be pinned");
|
||||
if (await DB.Default.CountAsync<Models.Social.Pin>(p => p.AvatarId == MyId, token) >= MaxPins)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can pin at most {MaxPins} posts");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
|
||||
await _outbox.PublishFeatured(MyId, post, featured: true, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unpin(string id, CancellationToken token)
|
||||
{
|
||||
var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
|
||||
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
|
||||
await _outbox.PublishFeatured(MyId, post, featured: false, token);
|
||||
return await Unchanged(id, token);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Toggle(Task<StatusOutcome> action, string id, CancellationToken token)
|
||||
{
|
||||
var outcome = await action;
|
||||
if (!outcome.Ok)
|
||||
return Error(outcome.Status, outcome.Error);
|
||||
var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token);
|
||||
return Json(await _mapper.Status(post, MyId, token));
|
||||
}
|
||||
|
||||
async Task<IActionResult> Unchanged(string id, CancellationToken token)
|
||||
{
|
||||
var post = await Visible(id, token);
|
||||
var status = post == default ? default : await _mapper.Status(post, MyId, token);
|
||||
return status == default ? NotFoundError() : Json(status);
|
||||
}
|
||||
|
||||
async Task<PostEntity> Visible(string id, CancellationToken token)
|
||||
{
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default;
|
||||
}
|
||||
|
||||
static PostVisibility Visibility(string value) => ScheduledStatuses.Visibility(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Timelines;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon's streaming API: one WebSocket on /api/v1/streaming that subscribes to streams by message, or one stream
|
||||
// per request as server-sent events on /api/v1/streaming/{stream}. Every stream needs a signed-in persona (the token
|
||||
// comes as access_token, in the Authorization header, or as the WebSocket's protocol). What a stream tells is mapped
|
||||
// for that persona as it is sent: a post it may not see, or whose author it blocked or muted, is never sent.
|
||||
public class StreamingController : MastodonController
|
||||
{
|
||||
static readonly TimeSpan KeepAlive = TimeSpan.FromSeconds(15);
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public StreamingController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming/health"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Health() => Content("OK", "text/plain");
|
||||
|
||||
// the stream a name and its parameter make, or why not: a hashtag needs its tag, a list must be the persona's own
|
||||
async Task<(Streams.Key Key, string Error)> Resolve(string name, string tag, string list, CancellationToken token)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote or Streams.Direct:
|
||||
return (new Streams.Key(name), default);
|
||||
case Streams.Hashtag or Streams.HashtagLocal:
|
||||
var normal = TagsController.Normalise(tag);
|
||||
return normal.Length == 0 ? (default, "Missing tag name parameter") : (new Streams.Key(name, normal), default);
|
||||
case Streams.List:
|
||||
return !string.IsNullOrEmpty(list) && await DB.Default.Find<PersonaList>().Match(l => l.ID == list && l.AvatarId == MyId).ExecuteAnyAsync(token)
|
||||
? (new Streams.Key(name, list), default)
|
||||
: (default, "Unknown list");
|
||||
default:
|
||||
return (default, "Unknown stream type");
|
||||
}
|
||||
}
|
||||
|
||||
// what the event says, for this persona: a status or a notification as JSON, a deleted id as itself; null when it
|
||||
// is not the persona's to see
|
||||
async Task<string> Payload(Streams.Event e, CancellationToken token)
|
||||
{
|
||||
if (e.Name is "delete" or "announcement.delete")
|
||||
return e.Id;
|
||||
if (e.Name == "announcement")
|
||||
{
|
||||
var shown = await HttpContext.RequestServices.GetRequiredService<Domain.Social.IAnnouncements>().FindShown(e.Id, token);
|
||||
return shown == default ? default
|
||||
: JsonSerializer.Serialize((await AnnouncementsController.Map(new[] { shown }, MyId, token))[0], MastodonJson.Options);
|
||||
}
|
||||
if (e.Name == "announcement.reaction")
|
||||
return JsonSerializer.Serialize(await AnnouncementsController.ReactionEvent(e.Id, e.Data, token), MastodonJson.Options);
|
||||
// a direct conversation the persona takes part in (R8): Mastodon's Conversation, as /api/v1/conversations lists it
|
||||
if (e.Name == "conversation")
|
||||
{
|
||||
var group = await _dbEntities.DmGroups.Match(g => g.ID == e.Id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.ExecuteFirstAsync(token);
|
||||
var conversation = group == default ? default
|
||||
: (await TimelinesController.ConversationsOf(_mapper, _dbEntities, MyId, new List<Models.Group.DmGroup> { group }, token)).FirstOrDefault();
|
||||
return conversation == default ? default : JsonSerializer.Serialize(conversation, MastodonJson.Options);
|
||||
}
|
||||
if (e.Name == "notification")
|
||||
{
|
||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
var mapped = notification == default ? default : (await _mapper.Notifications(new List<Notification> { notification }, MyId, token)).FirstOrDefault();
|
||||
return mapped == default ? default : JsonSerializer.Serialize(mapped, MastodonJson.Options);
|
||||
}
|
||||
var post = await _dbEntities.Posts.MatchID(e.Id).ExecuteFirstAsync(token);
|
||||
if (post == default || !await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
return default;
|
||||
var status = (await _mapper.Statuses(new[] { post }, MyId, token)).FirstOrDefault();
|
||||
return status == default ? default : JsonSerializer.Serialize(status, MastodonJson.Options);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming"), Scope("read")]
|
||||
public async Task<IActionResult> Socket(CancellationToken token)
|
||||
{
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||
return Error(StatusCodes.Status400BadRequest, "This is a WebSocket endpoint; server-sent events are at /api/v1/streaming/{stream}");
|
||||
var protocol = HttpContext.WebSockets.WebSocketRequestedProtocols.FirstOrDefault();
|
||||
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync(protocol);
|
||||
var listener = Streams.Open(MyId);
|
||||
var sending = new SemaphoreSlim(1, 1);
|
||||
async Task Send(JsonObject message)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(message.ToJsonString());
|
||||
await sending.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sending.Release();
|
||||
}
|
||||
}
|
||||
async Task Subscribe(string type, string name, string tag, string list)
|
||||
{
|
||||
var (key, error) = await Resolve(name, tag, list, token);
|
||||
if (error != default)
|
||||
{
|
||||
await Send(new JsonObject { ["error"] = error, ["status"] = 400 });
|
||||
return;
|
||||
}
|
||||
if (type == "unsubscribe")
|
||||
listener.Stop(key);
|
||||
else
|
||||
listener.Listen(key);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (Params.Get("stream") is { Length: > 0 } first)
|
||||
await Subscribe("subscribe", first, Params.Get("tag"), Params.Get("list"));
|
||||
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var reading = Task.Run(async () =>
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
while (socket.State == WebSocketState.Open && !done.IsCancellationRequested)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
WebSocketReceiveResult received;
|
||||
do
|
||||
{
|
||||
received = await socket.ReceiveAsync(buffer, done.Token);
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
done.Cancel();
|
||||
return;
|
||||
}
|
||||
text.Append(Encoding.UTF8.GetString(buffer, 0, received.Count));
|
||||
}
|
||||
while (!received.EndOfMessage && text.Length < 16384);
|
||||
JsonNode message;
|
||||
try
|
||||
{
|
||||
message = JsonNode.Parse(text.ToString());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (message?["type"]?.GetValue<string>() is "subscribe" or "unsubscribe" && message["stream"]?.GetValue<string>() is { } stream)
|
||||
await Subscribe(message["type"]!.GetValue<string>(), stream, message["tag"]?.GetValue<string>(), message["list"]?.GetValue<string>());
|
||||
}
|
||||
}, done.Token);
|
||||
await foreach (var (key, e) in listener.Queue.Reader.ReadAllAsync(done.Token))
|
||||
{
|
||||
if (!listener.Hears(key) || await Payload(e, done.Token) is not { } payload)
|
||||
continue;
|
||||
await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload });
|
||||
}
|
||||
// the queue ends only when the stream was closed from here (the persona was suspended): the socket goes too
|
||||
done.Cancel();
|
||||
await reading;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Streams.Close(listener);
|
||||
if (socket.State == WebSocketState.Open)
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, default, CancellationToken.None);
|
||||
}
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/streaming/{*stream}"), Scope("read")]
|
||||
public async Task<IActionResult> Events(string stream, CancellationToken token)
|
||||
{
|
||||
var name = stream.Replace('/', ':');
|
||||
var (key, error) = await Resolve(name, Params.Get("tag"), Params.Get("list"), token);
|
||||
if (error != default)
|
||||
return Error(StatusCodes.Status400BadRequest, error);
|
||||
Response.ContentType = "text/event-stream";
|
||||
Response.Headers.CacheControl = "no-cache";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
var listener = Streams.Open(MyId);
|
||||
listener.Listen(key);
|
||||
var writing = new SemaphoreSlim(1, 1);
|
||||
async Task Write(string text)
|
||||
{
|
||||
await writing.WaitAsync(token);
|
||||
try
|
||||
{
|
||||
await Response.WriteAsync(text, token);
|
||||
await Response.Body.FlushAsync(token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
writing.Release();
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
await Write(":)\n\n");
|
||||
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var beating = Task.Run(async () =>
|
||||
{
|
||||
while (!done.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(KeepAlive, done.Token);
|
||||
await Write(":thump\n\n");
|
||||
}
|
||||
}, done.Token);
|
||||
await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token))
|
||||
{
|
||||
if (await Payload(e, token) is not { } payload)
|
||||
continue;
|
||||
await Write($"event: {e.Name}\ndata: {payload}\n\n");
|
||||
}
|
||||
// closed from here (the persona was suspended): the response ends
|
||||
done.Cancel();
|
||||
await beating;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Streams.Close(listener);
|
||||
}
|
||||
return new EmptyResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Instance;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Follow suggestions as Mastodon's API has them (completion program R8; who and why in Domain/Social/Suggestions): v1 the
|
||||
// accounts, v2 each with its reasons; a dismissed account is never suggested to that persona again.
|
||||
public class SuggestionsController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IInstanceSettings _settings;
|
||||
|
||||
public SuggestionsController(MastodonMapper mapper, IInstanceSettings settings)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/suggestions"), Scope("read")]
|
||||
public async Task<IActionResult> V1(CancellationToken token) => Json((await Suggested(token)).Select(s => s.Account).ToList());
|
||||
|
||||
[HttpGet("/api/v2/suggestions"), Scope("read")]
|
||||
public async Task<IActionResult> V2(CancellationToken token) => Json(await Suggested(token));
|
||||
|
||||
[HttpDelete("/api/v1/suggestions/{accountId}"), Scope("read")]
|
||||
public async Task<IActionResult> Dismiss(string accountId, CancellationToken token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(accountId) && accountId.Length <= 64)
|
||||
try
|
||||
{
|
||||
await DB.Default.Update<SuggestionDismissal>().Match(d => d.AvatarId == MyId && d.AccountId == accountId)
|
||||
.Modify(b => b.SetOnInsert(d => d.CreatedAt, DateTime.UtcNow)).Option(o => o.IsUpsert = true).ExecuteAsync(token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// the candidates mapped a few at a time, until the page is full: an account no longer shown (deleted, suspended), one
|
||||
// shown limited (silenced, or its server), a community outside the staff's picks, and one the persona hides (its
|
||||
// blocked servers) are passed over
|
||||
async Task<List<Suggestion>> Suggested(CancellationToken token)
|
||||
{
|
||||
var limit = Limit(40, 80);
|
||||
var candidates = await Suggestions.For(Me, _settings.Current.StaffPicks, token);
|
||||
var suggested = new List<Suggestion>();
|
||||
for (var start = 0; start < candidates.Count && suggested.Count < limit; start += limit * 2)
|
||||
{
|
||||
var chunk = candidates.Skip(start).Take(limit * 2).ToList();
|
||||
var accounts = await _mapper.Accounts(chunk.Select(c => c.AccountId), token);
|
||||
var hidden = await Hidden.AuthorsHiddenFrom(MyId, accounts.Values.Select(a => a.Uri), forNotifications: false, token);
|
||||
foreach (var candidate in chunk)
|
||||
{
|
||||
if (accounts.GetValueOrDefault(candidate.AccountId) is not { } account || account.Limited == true || hidden.Contains(account.Uri)
|
||||
|| account.Group && candidate.Source != Suggestions.Staff)
|
||||
continue;
|
||||
suggested.Add(new Suggestion { Source = candidate.Source, Sources = candidate.Sources, Account = account });
|
||||
if (suggested.Count >= limit)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return suggested;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Hashtags as Mastodon shows them (owner decision 2026-10-04, followed tags back from the cut list): a tag with the
|
||||
// last week of its use in public posts PrivaPub holds, and whether the persona follows it; following one brings its
|
||||
// public posts to the persona's home as they arrive (Fanout).
|
||||
public class TagsController : MastodonController
|
||||
{
|
||||
const int MaxFollowed = 500;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public TagsController(DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
public static string Normalise(string name) => name?.Trim().TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
// what a hashtag can be, as Mastodon has it: letters, marks, digits and underscores, with a letter or underscore
|
||||
public static bool IsHashtag(string normal) =>
|
||||
normal.Length is > 0 and <= 100 && normal.All(c => char.IsLetterOrDigit(c) || c == '_' || char.GetUnicodeCategory(c) is
|
||||
System.Globalization.UnicodeCategory.NonSpacingMark or System.Globalization.UnicodeCategory.SpacingCombiningMark)
|
||||
&& normal.Any(c => char.IsLetter(c) || c == '_');
|
||||
|
||||
// a day's uses and authors, newest first, for the last week, as Mastodon gives them (strings, days as unix time)
|
||||
public static async Task<List<object>> History(DbEntities dbEntities, string tag, CancellationToken token)
|
||||
{
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var since = today.AddDays(-6);
|
||||
var posts = await dbEntities.Posts
|
||||
.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since)
|
||||
.Match(PrivaPub.Domain.Privacy.VisibilityPolicy.IsPublic).Match(PrivaPub.Domain.Privacy.VisibilityPolicy.IsListed)
|
||||
.Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
|
||||
.ExecuteAsync(token);
|
||||
return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
|
||||
{
|
||||
var that = posts.Where(p => p.CreationDate >= day && p.CreationDate < day.AddDays(1)).ToList();
|
||||
return (object)new
|
||||
{
|
||||
day = new DateTimeOffset(day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
|
||||
uses = that.Count.ToString(),
|
||||
accounts = that.Select(p => p.AuthorAccountId ?? p.GroupUserId).Distinct().Count().ToString()
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
Task<object> View(string tag, bool? following, CancellationToken token, bool? featuring = default) =>
|
||||
TagView(_dbEntities, _localActors.BaseAddress, tag, MyId, following, featuring, token);
|
||||
|
||||
// Mastodon's Tag: its week of use, and for a signed-in persona whether it follows it and (Mastodon 4.4) features it
|
||||
internal static async Task<object> TagView(DbEntities dbEntities, string baseAddress, string tag, string viewerId, bool? following, bool? featuring,
|
||||
CancellationToken token) => new
|
||||
{
|
||||
name = tag,
|
||||
url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}",
|
||||
history = await History(dbEntities, tag, token),
|
||||
following = following ?? (viewerId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == viewerId && t.Name == tag).ExecuteAnyAsync(token)),
|
||||
featuring = viewerId == default ? default(bool?)
|
||||
: featuring ?? await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == viewerId && t.Name == tag).ExecuteAnyAsync(token)
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/tags/{name}"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tag(string name, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
return tag.Length == 0 ? NotFoundError() : Json(await View(tag, default, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/tags/{name}/follow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Follow(string name, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
if (tag.Length == 0 || tag.Length > 100)
|
||||
return NotFoundError();
|
||||
if (!await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
|
||||
{
|
||||
if (await DB.Default.CountAsync<FollowedTag>(t => t.AvatarId == MyId, token) >= MaxFollowed)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Too many followed hashtags");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new FollowedTag { AvatarId = MyId, Name = tag }, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
return Json(await View(tag, true, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/tags/{name}/unfollow"), Scope("write:follows")]
|
||||
public async Task<IActionResult> Unfollow(string name, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
await DB.Default.DeleteAsync<FollowedTag>(t => t.AvatarId == MyId && t.Name == tag);
|
||||
return Json(await View(tag, false, token));
|
||||
}
|
||||
|
||||
// Mastodon 4.4: a hashtag featured on the persona's profile, or no longer (R8, Domain/Social/FeaturedTags)
|
||||
[HttpPost("/api/v1/tags/{name}/feature"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Feature(string name, [FromServices] Domain.Social.IFeaturedTags featured, CancellationToken token)
|
||||
{
|
||||
var (tag, error) = await featured.Feature(Me, name, token);
|
||||
return tag == default ? Error(StatusCodes.Status422UnprocessableEntity, error) : Json(await View(tag.Name, default, token, featuring: true));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/tags/{name}/unfeature"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unfeature(string name, [FromServices] Domain.Social.IFeaturedTags featured, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
if (!IsHashtag(tag))
|
||||
return NotFoundError();
|
||||
await featured.Unfeature(Me, default, tag, token);
|
||||
return Json(await View(tag, default, token, featuring: false));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Followed(CancellationToken token)
|
||||
{
|
||||
var page = await Page.From(Params, Limit(100, 200)).Fetch(DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId), t => t.ID, token);
|
||||
Link("/api/v1/followed_tags", page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
||||
var views = new List<object>();
|
||||
foreach (var followed in page)
|
||||
views.Add(await View(followed.Name, true, token));
|
||||
return Json(views);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Statuses;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
using NotificationEntity = PrivaPub.Models.Social.Notification;
|
||||
using MarkerEntity = PrivaPub.Models.Social.Marker;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
public class TimelinesController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public TimelinesController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/timelines/home"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Home(CancellationToken token)
|
||||
{
|
||||
//an exclusive list's members are read in that list, not at home
|
||||
var exclusive = (await DB.Default.Find<PersonaList>().Match(l => l.AvatarId == MyId && l.Exclusive).ExecuteAsync(token)).Select(l => l.ID).ToList();
|
||||
var apart = exclusive.Count == 0
|
||||
? new List<string>()
|
||||
: (await DB.Default.Find<PersonaListMember>().Match(m => exclusive.Contains(m.ListId)).ExecuteAsync(token)).Select(m => m.AccountId).Distinct().ToList();
|
||||
var home = _dbEntities.TimelineEntries.Match(e => e.AvatarId == MyId);
|
||||
if (apart.Count > 0)
|
||||
home.Match(e => !apart.Contains(e.AuthorAccountId));
|
||||
var entries = await Page.From(Params, Limit()).Fetch(home, e => e.PostId, token);
|
||||
var ids = entries.Select(e => e.PostId).ToList();
|
||||
// (located posts once reached their author's home: the entries left from then are passed over)
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && p.Visibility != PostVisibility.LocalGeo).Match(VisibilityPolicy.IsShown)
|
||||
.ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var statuses = await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token);
|
||||
Link("/api/v1/timelines/home", entries.LastOrDefault()?.PostId, entries.FirstOrDefault()?.PostId);
|
||||
return Json(statuses);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Public(CancellationToken token)
|
||||
{
|
||||
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic)
|
||||
.Match(VisibilityPolicy.IsListed);
|
||||
if (Params.Bool("local") == true)
|
||||
query.Match(p => !p.IsFederatedCopy);
|
||||
if (Params.Bool("remote") == true)
|
||||
query.Match(p => p.IsFederatedCopy);
|
||||
if (Params.Bool("only_media") == true)
|
||||
query.Match(p => p.Media.Count > 0);
|
||||
return await Respond(query, "/api/v1/timelines/public", token);
|
||||
}
|
||||
|
||||
// PrivaPub's own: a feed the persona reads (Lemmy's multi-community, PieFed's feed), its communities' threads,
|
||||
// newest first, as the server's reader of feeds brings them in (owner decision 2026-10-07)
|
||||
[HttpGet("/api/v1/timelines/feed/{id}"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Feed(string id, CancellationToken token)
|
||||
{
|
||||
var feed = await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token);
|
||||
if (feed is not { AvatarType: Models.User.AvatarType.Feed }
|
||||
|| !await DB.Default.Find<FeedSubscription>().Match(s => s.AvatarId == MyId && s.FeedActorURI == feed.ActorURI).ExecuteAnyAsync(token))
|
||||
return NotFoundError();
|
||||
var communities = feed.FeedCommunities;
|
||||
var ours = Me.BaseAddress + "/peasants/";
|
||||
var names = communities.Where(c => c.StartsWith(ours, StringComparison.Ordinal)).Select(c => c[ours.Length..]).ToList();
|
||||
var groups = names.Count == 0
|
||||
? new List<string>()
|
||||
: (await _dbEntities.Groups.Match(g => names.Contains(g.UserName) && !g.DeletionAt.HasValue).ExecuteAsync(token)).Select(g => g.ID).ToList();
|
||||
var query = _dbEntities.Posts.Match(p => (communities.Contains(p.AudienceURI) || groups.Contains(p.GroupId))
|
||||
&& p.ReblogOfPostId == null && p.InReplyToURI == null).Match(VisibilityPolicy.IsPublic);
|
||||
return await Respond(query, $"/api/v1/timelines/feed/{id}", token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/timelines/tag/{hashtag}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
||||
{
|
||||
var tag = hashtag.TrimStart('#').ToLowerInvariant();
|
||||
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic)
|
||||
.Match(VisibilityPolicy.IsListed);
|
||||
if (Params.Bool("local") == true)
|
||||
query.Match(p => !p.IsFederatedCopy);
|
||||
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/favourites"), Scope("read:favourites")]
|
||||
public async Task<IActionResult> Favourites(CancellationToken token)
|
||||
{
|
||||
var favourites = await Page.From(Params, Limit()).Fetch(_dbEntities.Favourites.Match(f => f.AccountId == MyId), f => f.ID, token);
|
||||
var ids = favourites.Select(f => f.PostId).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
var visible = new List<PostEntity>();
|
||||
foreach (var id in ids.Where(posts.ContainsKey))
|
||||
if (await VisibilityPolicy.CanSee(posts[id], MyId, token))
|
||||
visible.Add(posts[id]);
|
||||
Link("/api/v1/favourites", favourites.LastOrDefault()?.ID, favourites.FirstOrDefault()?.ID);
|
||||
return Json(await _mapper.Statuses(visible, MyId, token));
|
||||
}
|
||||
|
||||
// the persona's direct conversations, by their newest post (paged like Mastodon's by that post's id), each with its
|
||||
// other members and whether its newest post, someone else's, is newer than what the persona read; the ones it took
|
||||
// off its list stay off until a newer message
|
||||
[HttpGet("/api/v1/conversations"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Conversations(CancellationToken token)
|
||||
{
|
||||
var groups = await Page.From(Params, Limit(20, 40)).Fetch(
|
||||
_dbEntities.DmGroups.Match(g => !g.DeletionAt.HasValue && g.LastPostId != null && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId)),
|
||||
g => g.LastPostId, token);
|
||||
Link("/api/v1/conversations", groups.LastOrDefault()?.LastPostId, groups.FirstOrDefault()?.LastPostId);
|
||||
return Json(await ConversationsOf(groups, token));
|
||||
}
|
||||
|
||||
Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token) => ConversationsOf(_mapper, _dbEntities, MyId, groups, token);
|
||||
|
||||
// (also the `direct` stream's conversation event, StreamingController)
|
||||
internal static async Task<List<Conversation>> ConversationsOf(MastodonMapper mapper, DbEntities dbEntities, string viewerId, List<DmGroup> groups,
|
||||
CancellationToken token)
|
||||
{
|
||||
var ids = groups.Select(g => g.ID).ToList();
|
||||
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == viewerId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
|
||||
.ToDictionary(s => s.ConversationId);
|
||||
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
|
||||
|
||||
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
|
||||
var newestIds = groups.Select(g => g.LastPostId).ToList();
|
||||
var newest = (await dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.ConversationId);
|
||||
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
|
||||
if (await dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
|
||||
.ExecuteFirstAsync(token) is { } shown)
|
||||
newest[group.ID] = shown;
|
||||
|
||||
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
|
||||
var foreignIds = (await dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
|
||||
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
|
||||
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == viewerId && !m.IsForeign))
|
||||
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
|
||||
var accounts = await mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
|
||||
var statuses = (await mapper.Statuses(newest.Values.ToList(), viewerId, token)).ToDictionary(s => s.Id);
|
||||
|
||||
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
|
||||
.Select(g => new Conversation
|
||||
{
|
||||
Id = g.ID,
|
||||
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, viewerId),
|
||||
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
|
||||
LastStatus = statuses[newest[g.ID].ID]
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
async Task<DmGroup> MyConversation(string id, CancellationToken token) =>
|
||||
await _dbEntities.DmGroups.Match(g => g.ID == id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.ExecuteFirstAsync(token);
|
||||
|
||||
[HttpPost("/api/v1/conversations/{id}/read"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> ReadConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
||||
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
|
||||
return Json(conversation ?? new Conversation { Id = group.ID });
|
||||
}
|
||||
|
||||
// Mastodon's unread (R9): shown unread again until it is read
|
||||
[HttpPost("/api/v1/conversations/{id}/unread"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> UnreadConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
await ConversationStates.Unread(MyId, group.ID, token);
|
||||
var conversation = (await ConversationsOf(new List<DmGroup> { group }, token)).FirstOrDefault();
|
||||
return Json(conversation ?? new Conversation { Id = group.ID, Unread = true });
|
||||
}
|
||||
|
||||
// off the persona's list until a newer message; the messages themselves stay
|
||||
[HttpDelete("/api/v1/conversations/{id}"), Scope("write:conversations")]
|
||||
public async Task<IActionResult> RemoveConversation(string id, CancellationToken token)
|
||||
{
|
||||
var group = await MyConversation(id, token);
|
||||
if (group == default)
|
||||
return NotFoundError();
|
||||
await ConversationStates.Hide(MyId, group.ID, group.LastPostId, token);
|
||||
await ConversationStates.Read(MyId, group.ID, group.LastPostId, token);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/markers"), Scope("read:statuses")]
|
||||
public async Task<IActionResult> Markers(CancellationToken token)
|
||||
{
|
||||
var wanted = Params.List("timeline");
|
||||
var markers = await DB.Default.Find<MarkerEntity>().Match(m => m.AvatarId == MyId).ExecuteAsync(token);
|
||||
return Json(markers.Where(m => wanted.Count == 0 || wanted.Contains(m.Timeline))
|
||||
.ToDictionary(m => m.Timeline, m => new Entities.Marker { LastReadId = m.LastReadId, Version = m.Version, UpdatedAt = MastodonJson.Time(m.UpdatedAt) }));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/markers"), Scope("write:statuses")]
|
||||
public async Task<IActionResult> SaveMarkers(CancellationToken token)
|
||||
{
|
||||
var result = new Dictionary<string, Entities.Marker>();
|
||||
foreach (var timeline in new[] { "home", "notifications" })
|
||||
{
|
||||
var lastRead = Params.Get($"{timeline}[last_read_id]");
|
||||
if (string.IsNullOrEmpty(lastRead))
|
||||
continue;
|
||||
var marker = await DB.Default.UpdateAndGet<MarkerEntity>()
|
||||
.Match(m => m.AvatarId == MyId && m.Timeline == timeline)
|
||||
.Modify(m => m.LastReadId, lastRead)
|
||||
.Modify(m => m.UpdatedAt, DateTime.UtcNow)
|
||||
.Modify(b => b.Inc(m => m.Version, 1))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
result[timeline] = new Entities.Marker { LastReadId = marker.LastReadId, Version = marker.Version, UpdatedAt = MastodonJson.Time(marker.UpdatedAt) };
|
||||
// what the marker passes is read: the unread count (v1 and v2) counts only what is newer
|
||||
if (timeline == "notifications")
|
||||
await DB.Default.Update<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
|
||||
.Match(f => f.Lte(n => n.ID, lastRead)).Modify(n => n.IsRead, true).ExecuteAsync(token);
|
||||
}
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Respond(Find<PostEntity, PostEntity> query, string path, CancellationToken token)
|
||||
{
|
||||
var posts = await Page.From(Params, Limit()).Fetch(query, p => p.ID, token);
|
||||
Link(path, posts.LastOrDefault()?.ID, posts.FirstOrDefault()?.ID);
|
||||
return Json(await _mapper.Statuses(posts, MyId, token));
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationsController : MastodonController
|
||||
{
|
||||
internal static readonly Dictionary<NotificationType, string> Names = new()
|
||||
{
|
||||
[NotificationType.Mention] = "mention",
|
||||
[NotificationType.Follow] = "follow",
|
||||
[NotificationType.FollowRequest] = "follow_request",
|
||||
[NotificationType.Favourite] = "favourite",
|
||||
[NotificationType.Reblog] = "reblog",
|
||||
[NotificationType.Update] = "update",
|
||||
[NotificationType.Poll] = "poll",
|
||||
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
||||
[NotificationType.Quote] = "quote",
|
||||
[NotificationType.ModerationWarning] = "moderation_warning",
|
||||
[NotificationType.Status] = "status"//an account followed with notify posted (R8)
|
||||
};
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public NotificationsController(MastodonMapper mapper, DbEntities dbEntities)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
Find<NotificationEntity, NotificationEntity> Filtered()
|
||||
{
|
||||
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
||||
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
||||
var query = _dbEntities.Notifications.Match(n => n.AvatarId == MyId);
|
||||
// what the persona's notification policy filtered stays in its requests, unless asked for (a request's own page)
|
||||
if (Params.Bool("include_filtered") != true)
|
||||
query.Match(NotFiltered);
|
||||
if (types.Count > 0)
|
||||
query.Match(n => types.Contains(n.Type));
|
||||
if (excluded.Count > 0)
|
||||
query.Match(n => !excluded.Contains(n.Type));
|
||||
if (Params.Get("account_id") is { } accountId)
|
||||
query.Match(n => n.FromAccountId == accountId);
|
||||
return query;
|
||||
}
|
||||
|
||||
// (notifications made before the policy have no Filtered at all)
|
||||
static readonly FilterDefinition<NotificationEntity> NotFiltered = Builders<NotificationEntity>.Filter.Ne(n => n.Filtered, true);
|
||||
|
||||
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> List(CancellationToken token)
|
||||
{
|
||||
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(Filtered(), n => n.ID, token);
|
||||
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
||||
return Json(await Map(notifications, token));
|
||||
}
|
||||
|
||||
// Grouped notifications (Mastodon 4.3): api_versions.mastodon says we speak that API, so clients call it. Likes and
|
||||
// boosts of one post group together, as do follows within an hour; everything else stands alone.
|
||||
internal static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
|
||||
|
||||
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
|
||||
|
||||
internal static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
|
||||
!grouped.Contains(Names[n.Type]) ? $"ungrouped-{n.ID}"
|
||||
: n.Type == NotificationType.Follow ? $"grouped-follow-{n.CreatedAt:yyyyMMddHH}"
|
||||
: string.IsNullOrEmpty(n.PostId) ? $"ungrouped-{n.ID}"
|
||||
: $"grouped-{Names[n.Type]}-{n.PostId}";
|
||||
|
||||
// the notifications a group key stands for
|
||||
System.Linq.Expressions.Expression<Func<NotificationEntity, bool>> InGroup(string key)
|
||||
{
|
||||
var me = MyId;
|
||||
if (key.StartsWith("ungrouped-", StringComparison.Ordinal))
|
||||
{
|
||||
var id = key["ungrouped-".Length..];
|
||||
return n => n.AvatarId == me && n.ID == id;
|
||||
}
|
||||
if (key.StartsWith("grouped-follow-", StringComparison.Ordinal)
|
||||
&& DateTime.TryParseExact(key["grouped-follow-".Length..], "yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var hour))
|
||||
{
|
||||
var end = hour.AddHours(1);
|
||||
return n => n.AvatarId == me && n.Type == NotificationType.Follow && n.CreatedAt >= hour && n.CreatedAt < end;
|
||||
}
|
||||
if (key.Split('-') is ["grouped", var name, var postId] && Parse(name) is { } type)
|
||||
return n => n.AvatarId == me && n.Type == type && n.PostId == postId;
|
||||
return default;
|
||||
}
|
||||
|
||||
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
|
||||
InGroup(key) is { } filter
|
||||
? await _dbEntities.Notifications.Match(filter).Match(NotFiltered).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
|
||||
: new List<NotificationEntity>();
|
||||
|
||||
async Task<object> Groups(List<NotificationEntity> page, ISet<string> grouped, CancellationToken token)
|
||||
{
|
||||
var mapped = (await Map(page, token)).ToDictionary(n => n.Id);
|
||||
var shown = page.Where(n => mapped.ContainsKey(n.ID)).ToList();
|
||||
var groups = new List<object>();
|
||||
foreach (var group in shown.GroupBy(n => GroupKey(n, grouped)))
|
||||
{
|
||||
var newest = mapped[group.First().ID];
|
||||
var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1
|
||||
: await DB.Default.CountAsync(Builders<NotificationEntity>.Filter.And(Builders<NotificationEntity>.Filter.Where(InGroup(group.Key)), NotFiltered), token);
|
||||
groups.Add(new
|
||||
{
|
||||
group_key = group.Key,
|
||||
notifications_count = Math.Max(total, group.Count()),
|
||||
type = newest.Type,
|
||||
most_recent_notification_id = newest.Id,
|
||||
page_min_id = group.Last().ID,
|
||||
page_max_id = group.First().ID,
|
||||
latest_page_notification_at = newest.CreatedAt,
|
||||
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
|
||||
status_id = newest.Status?.Id,
|
||||
emoji = newest.Emoji,
|
||||
emoji_url = newest.EmojiUrl,
|
||||
moderation_warning = newest.ModerationWarning
|
||||
});
|
||||
}
|
||||
return new
|
||||
{
|
||||
accounts = mapped.Values.Select(n => n.Account).DistinctBy(a => a.Id).ToList(),
|
||||
statuses = mapped.Values.Where(n => n.Status != default).Select(n => n.Status).DistinctBy(s => s.Id).ToList(),
|
||||
notification_groups = groups
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupedList(CancellationToken token)
|
||||
{
|
||||
var notifications = await Page.From(Params, Limit(40, 80)).Fetch(Filtered(), n => n.ID, token);
|
||||
Link("/api/v2/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
||||
return Json(await Groups(notifications, Grouped(), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/unread_count"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupedUnreadCount(CancellationToken token)
|
||||
{
|
||||
var unread = await Filtered().Match(n => !n.IsRead).Sort(n => n.ID, Order.Descending)
|
||||
.Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
||||
var shown = (await Map(unread, token)).Select(n => n.Id).ToHashSet();
|
||||
var grouped = Grouped();
|
||||
return Json(new { count = unread.Where(n => shown.Contains(n.ID)).Select(n => GroupKey(n, grouped)).Distinct().Count() });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/{groupKey}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Group(string groupKey, CancellationToken token)
|
||||
{
|
||||
var members = await Members(groupKey, token);
|
||||
if (members.Count == 0)
|
||||
return NotFoundError();
|
||||
return Json(await Groups(members, new HashSet<string>(DefaultGrouped.Append(Names[members[0].Type])), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupAccounts(string groupKey, CancellationToken token)
|
||||
{
|
||||
var members = await Members(groupKey, token);
|
||||
if (members.Count == 0)
|
||||
return NotFoundError();
|
||||
return Json((await Map(members, token)).Select(n => n.Account).DistinctBy(a => a.Id).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/v2/notifications/{groupKey}/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> DismissGroup(string groupKey, CancellationToken token)
|
||||
{
|
||||
if (InGroup(groupKey) is not { } filter)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync(filter);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
var mapped = notification == default ? default : (await Map(new List<NotificationEntity> { notification }, token)).FirstOrDefault();
|
||||
return mapped == default ? NotFoundError() : Json(mapped);
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/clear"), HttpPost("/api/v2/notifications/clear"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> Clear(CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<NotificationEntity>(n => n.AvatarId == MyId);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/notifications/{id}/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> Dismiss(string id, CancellationToken token)
|
||||
{
|
||||
await DB.Default.DeleteAsync<NotificationEntity>(n => n.ID == id && n.AvatarId == MyId);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
|
||||
// `limit` of them, as Mastodon does
|
||||
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> UnreadCount(CancellationToken token)
|
||||
{
|
||||
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead).Match(NotFiltered)
|
||||
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
||||
return Json(new { count = (await Map(unread, token)).Count });
|
||||
}
|
||||
|
||||
Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token) =>
|
||||
_mapper.Notifications(notifications, MyId, token);
|
||||
|
||||
static NotificationType? Parse(string name) => Names.FirstOrDefault(n => n.Value == name) is { Value: not null } pair ? pair.Key : (NotificationType?)null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Trends and the directory (owner decision 2026-10-04, back from the cut list), from what PrivaPub already holds
|
||||
// (Trends). The directory lists only accounts that let themselves be discovered.
|
||||
public class TrendsController : MastodonController
|
||||
{
|
||||
readonly Trends _trends;
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public TrendsController(Trends trends, MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_trends = trends;
|
||||
_mapper = mapper;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
static List<object> History(Trends.Trend trend) => trend.History.Select(h => (object)new
|
||||
{
|
||||
day = new DateTimeOffset(h.Day, TimeSpan.Zero).ToUnixTimeSeconds().ToString(),
|
||||
uses = h.Uses.ToString(),
|
||||
accounts = h.Accounts.ToString()
|
||||
}).ToList();
|
||||
|
||||
IEnumerable<T> Window<T>(IEnumerable<T> items, int fallback, int max) =>
|
||||
items.Skip(Math.Max(0, Params.Int("offset") ?? 0)).Take(Limit(fallback, max));
|
||||
|
||||
[HttpGet("/api/v1/trends"), HttpGet("/api/v1/trends/tags"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Tags(CancellationToken token)
|
||||
{
|
||||
var followed = MyId == default
|
||||
? new HashSet<string>()
|
||||
: (await DB.Default.Find<Models.Social.FollowedTag>().Match(t => t.AvatarId == MyId).ExecuteAsync(token)).Select(t => t.Name).ToHashSet();
|
||||
return Json(Window(await _trends.Tags(token), 10, 20).Select(t => new
|
||||
{
|
||||
name = t.Key,
|
||||
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(t.Key)}",
|
||||
history = History(t),
|
||||
following = MyId == default ? default(bool?) : followed.Contains(t.Key)
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/trends/statuses"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Statuses(CancellationToken token)
|
||||
{
|
||||
var ids = Window(await _trends.Statuses(token), 20, 40).ToList();
|
||||
var posts = (await _dbEntities.Posts.Match(p => ids.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
return Json(await _mapper.Statuses(ids.Where(posts.ContainsKey).Select(id => posts[id]).ToList(), MyId, token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/trends/links"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Links(CancellationToken token)
|
||||
{
|
||||
var trends = Window(await _trends.Links(token), 10, 20).ToList();
|
||||
var sampleIds = trends.Select(t => t.SamplePostId).ToList();
|
||||
var samples = (await _dbEntities.Posts.Match(p => sampleIds.Contains(p.ID)).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||
return Json(trends.Where(t => samples.ContainsKey(t.SamplePostId)).Select(t =>
|
||||
{
|
||||
var link = samples[t.SamplePostId].Link;
|
||||
var card = _mapper.LinkCard(t.Key, link.Title, link.Description, link.ImageURL);
|
||||
return card == default ? default : (object)new
|
||||
{
|
||||
card.Url, card.Title, card.Description, card.Type, card.AuthorName, card.AuthorUrl, card.ProviderName, card.ProviderUrl,
|
||||
card.Html, card.Width, card.Height, card.Image, card.ImageDescription, card.EmbedUrl, card.Blurhash, card.Language,
|
||||
card.PublishedAt, card.Authors, History = History(t)
|
||||
};
|
||||
}).Where(c => c != default).ToList());
|
||||
}
|
||||
|
||||
// order=active: by their latest public post PrivaPub holds; order=new: by when they say they joined, which for a
|
||||
// persona is its published day (never its creation, so personas made together are not told apart by their order)
|
||||
[HttpGet("/api/v1/directory"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Directory(CancellationToken token)
|
||||
{
|
||||
var offset = Math.Max(0, Params.Int("offset") ?? 0);
|
||||
var limit = Limit(40, 80);
|
||||
var localOnly = Params.Bool("local") == true;
|
||||
var barred = await Counted.HeldBackPersonas(token);
|
||||
var wanted = offset + limit;
|
||||
var ordered = new List<string>();
|
||||
if (Params.Get("order") == "new")
|
||||
{
|
||||
// many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way
|
||||
var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue)
|
||||
.Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token);
|
||||
if (personas.Count == wanted)
|
||||
{
|
||||
var edge = personas[^1].PublishedOn;
|
||||
var known = personas.Select(a => a.ID).ToList();
|
||||
personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue
|
||||
&& a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token));
|
||||
}
|
||||
var remote = localOnly
|
||||
? new List<ForeignAvatar>()
|
||||
: await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue && f.Published != null)
|
||||
.Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token);
|
||||
if (remote.Count == wanted)
|
||||
{
|
||||
var edge = remote[^1].Published;
|
||||
var known = remote.Select(f => f.ID).ToList();
|
||||
remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue
|
||||
&& f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token));
|
||||
}
|
||||
ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName)))
|
||||
.Concat(remote.Select(f => (f.ID, Joined: f.Published.Value, Tie: Tie(f.ActorURI))))
|
||||
.OrderByDescending(a => a.Joined).ThenBy(a => a.Tie, StringComparer.Ordinal)
|
||||
.Select(a => a.ID).Skip(offset).Take(limit).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue)
|
||||
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
var seen = new HashSet<string>();
|
||||
var maxId = default(string);
|
||||
for (var round = 0; round < 20 && ordered.Count < wanted; round++)
|
||||
{
|
||||
var page = _dbEntities.Posts.Match(p => p.Visibility == Models.Post.PostVisibility.Public && !p.DeletedAt.HasValue && p.ReblogOfPostId == null);
|
||||
if (maxId != default)
|
||||
page.Match(f => f.Lt(p => p.ID, maxId));
|
||||
if (localOnly)
|
||||
page.Match(p => !p.IsFederatedCopy);
|
||||
var batch = await page.Sort(p => p.ID, Order.Descending).Limit(500)
|
||||
.Project(p => p.Include(x => x.ID).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId).Include(x => x.IsFederatedCopy))
|
||||
.ExecuteAsync(token);
|
||||
if (batch.Count == 0)
|
||||
break;
|
||||
maxId = batch[^1].ID;
|
||||
var fresh = batch.Select(p => (Id: p.AuthorAccountId ?? p.GroupUserId, p.IsFederatedCopy)).Where(a => a.Id != default && seen.Add(a.Id)).ToList();
|
||||
var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList();
|
||||
var discoverableRemote = remoteIds.Count == 0
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue)
|
||||
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet();
|
||||
ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id));
|
||||
}
|
||||
ordered = ordered.Skip(offset).Take(limit).ToList();
|
||||
}
|
||||
var accounts = await _mapper.Accounts(ordered, token);
|
||||
return Json(ordered.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList());
|
||||
}
|
||||
|
||||
static string Tie(string name) =>
|
||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(name ?? string.Empty)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Federation.Actors;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Mastodon 4.4's OAuth userinfo (OpenID Connect's shape, GET or POST, the `profile` scope): who the token's persona is,
|
||||
// for a site that signs in with it. Only what its public account says already; never its login. The authorization
|
||||
// server's metadata names it (OAuthSetup).
|
||||
public class UserinfoController : MastodonController
|
||||
{
|
||||
public const string Path = "/oauth/userinfo";
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public UserinfoController(MastodonMapper mapper, ILocalActorService localActors)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
[HttpGet(Path), HttpPost(Path), Scope("profile")]
|
||||
public async Task<IActionResult> Show(CancellationToken token)
|
||||
{
|
||||
var account = await _mapper.Local(Me, false, token);
|
||||
return Json(new
|
||||
{
|
||||
iss = _localActors.BaseAddress + "/",
|
||||
sub = Me.Uri,
|
||||
name = account.DisplayName,
|
||||
preferred_username = account.Username,
|
||||
profile = account.Url,
|
||||
picture = account.Avatar
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
namespace PrivaPub.Api.Mastodon.Entities
|
||||
{
|
||||
public class Account
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Acct { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public bool Locked { get; set; }
|
||||
public bool Bot { get; set; }
|
||||
public bool? Discoverable { get; set; }
|
||||
public bool Indexable { get; set; }
|
||||
public bool Group { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
public string Note { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Uri { get; set; }
|
||||
public string Avatar { get; set; }
|
||||
public string AvatarStatic { get; set; }
|
||||
public string Header { get; set; }
|
||||
public string HeaderStatic { get; set; }
|
||||
public int FollowersCount { get; set; }
|
||||
public int FollowingCount { get; set; }
|
||||
public int StatusesCount { get; set; }
|
||||
public string LastStatusAt { get; set; }
|
||||
public bool? HideCollections { get; set; }
|
||||
public bool Noindex { get; set; } = true;
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public List<object> Roles { get; set; } = new();
|
||||
public bool? Memorial { get; set; }
|
||||
public bool? Limited { get; set; }//silenced by this server's staff, or its server silenced (Mastodon's `limited`)
|
||||
public string AvatarDescription { get; set; } = string.Empty;
|
||||
public string HeaderDescription { get; set; } = string.Empty;
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public Source Source { get; set; }
|
||||
public Account Moved { get; set; }//the account it moved to, when that account names it among its aliases
|
||||
}
|
||||
|
||||
public class CustomEmojiEntity
|
||||
{
|
||||
public string Shortcode { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StaticUrl { get; set; }
|
||||
public bool VisibleInPicker { get; set; }
|
||||
public string Category { get; set; }
|
||||
}
|
||||
|
||||
public class Field
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Value { get; set; }
|
||||
public string VerifiedAt { get; set; }
|
||||
}
|
||||
|
||||
public class Source
|
||||
{
|
||||
public string Privacy { get; set; } = "public";
|
||||
public bool Sensitive { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string Note { get; set; } = string.Empty;
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
public int FollowRequestsCount { get; set; }
|
||||
public string AttributionDomains { get; set; }
|
||||
public string QuotePolicy { get; set; } = "public";
|
||||
}
|
||||
|
||||
public class PrivaPubStatus
|
||||
{
|
||||
public string ObjectType { get; set; }
|
||||
public bool Locked { get; set; }//its community's moderators locked it: a reply is refused
|
||||
public string Approval { get; set; }//our reply or boost its target's author must approve: "pending", "rejected"
|
||||
public string Title { get; set; }
|
||||
public string Excerpt { get; set; }
|
||||
public string Cover { get; set; }
|
||||
public PrivaPubSource Source { get; set; }
|
||||
public PrivaPubLink Link { get; set; }
|
||||
public PrivaPubVideo Video { get; set; }
|
||||
public PrivaPubAudio Audio { get; set; }
|
||||
public PrivaPubEvent Event { get; set; }
|
||||
public PrivaPubPlace Place { get; set; }//a remote post's own place (Pixelfed), shown only
|
||||
public Account Wall { get; set; }//whose wall it was written on (FEP-400e), when not its author's
|
||||
public PrivaPubVotes Votes { get; set; } = new();
|
||||
public List<PrivaPubFlair> Flairs { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubSource
|
||||
{
|
||||
public string Content { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubLink
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Image { get; set; }
|
||||
public bool FromPublisher { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubVariant
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string MediaType { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public double? Fps { get; set; }
|
||||
public long? Size { get; set; }
|
||||
public List<string> Streams { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubVideo
|
||||
{
|
||||
public double? Duration { get; set; }
|
||||
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||
public bool IsLive { get; set; }
|
||||
public int? State { get; set; }
|
||||
public long? Views { get; set; }
|
||||
public int? CommentsPolicy { get; set; }
|
||||
public bool? DownloadEnabled { get; set; }
|
||||
public string Licence { get; set; }
|
||||
public string Category { get; set; }
|
||||
public string Channel { get; set; }
|
||||
public List<PrivaPubCaption> Captions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PrivaPubCaption
|
||||
{
|
||||
public string Language { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubAudio
|
||||
{
|
||||
public double? Duration { get; set; }
|
||||
public List<PrivaPubVariant> Variants { get; set; } = new();
|
||||
public string Album { get; set; }
|
||||
public int? Position { get; set; }
|
||||
public string Licence { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubEvent
|
||||
{
|
||||
public string Start { get; set; }
|
||||
public string End { get; set; }
|
||||
public string TimeZone { get; set; }
|
||||
public bool Floating { get; set; }
|
||||
public List<PrivaPubPlace> Places { get; set; } = new();
|
||||
public string OnlineUrl { get; set; }
|
||||
public string JoinMode { get; set; }
|
||||
public int? Participants { get; set; }
|
||||
public int? Capacity { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string Category { get; set; }
|
||||
public string Participation { get; set; }//the viewer's: "pending", "accepted" or "rejected"
|
||||
}
|
||||
|
||||
public class PrivaPubPlace
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Address { get; set; }
|
||||
public string Country { get; set; }//a post's own place only
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubFlair
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Slug { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Color { get; set; }
|
||||
public string TextColor { get; set; }
|
||||
public string BackgroundColor { get; set; }
|
||||
public bool BlurImages { get; set; }
|
||||
}
|
||||
|
||||
public class PrivaPubVotes
|
||||
{
|
||||
public int Up { get; set; }
|
||||
public int Down { get; set; }
|
||||
public bool? Downvoted { get; set; }//the viewer's own downvote; null with no viewer
|
||||
}
|
||||
|
||||
public class QuoteEntity
|
||||
{
|
||||
public string State { get; set; }
|
||||
public Status QuotedStatus { get; set; }
|
||||
public string QuotedStatusId { get; set; }
|
||||
}
|
||||
|
||||
public class QuoteApprovalEntity
|
||||
{
|
||||
public List<string> Automatic { get; set; } = new();
|
||||
public List<string> Manual { get; set; } = new();
|
||||
public string CurrentUser { get; set; } = "denied";
|
||||
}
|
||||
|
||||
public class FilterEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Title { get; set; }
|
||||
public List<string> Context { get; set; } = new();
|
||||
public string ExpiresAt { get; set; }
|
||||
public string FilterAction { get; set; }
|
||||
public List<FilterKeywordEntity> Keywords { get; set; } = new();
|
||||
public List<FilterStatusEntity> Statuses { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FilterKeywordEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Keyword { get; set; }
|
||||
public bool WholeWord { get; set; }
|
||||
}
|
||||
|
||||
public class FilterStatusEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string StatusId { get; set; }
|
||||
}
|
||||
|
||||
public class FilterResultEntity
|
||||
{
|
||||
public FilterEntity Filter { get; set; }
|
||||
public List<string> KeywordMatches { get; set; }
|
||||
public List<string> StatusMatches { get; set; }
|
||||
}
|
||||
|
||||
// the v1 filter, which is one keyword of a v2 filter
|
||||
public class FilterV1Entity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Phrase { get; set; }
|
||||
public List<string> Context { get; set; } = new();
|
||||
public bool WholeWord { get; set; }
|
||||
public string ExpiresAt { get; set; }
|
||||
public bool Irreversible { get; set; }
|
||||
}
|
||||
|
||||
public class EmojiReactionEntity
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int Count { get; set; }
|
||||
public bool Me { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StaticUrl { get; set; }
|
||||
}
|
||||
|
||||
public class PleromaStatus
|
||||
{
|
||||
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PollEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string ExpiresAt { get; set; }
|
||||
public bool Expired { get; set; }
|
||||
public bool Multiple { get; set; }
|
||||
public int VotesCount { get; set; }
|
||||
public int? VotersCount { get; set; }
|
||||
public bool? Voted { get; set; }
|
||||
public List<int> OwnVotes { get; set; }
|
||||
public List<PollOptionEntity> Options { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PollOptionEntity
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int? VotesCount { get; set; }
|
||||
}
|
||||
|
||||
public class PreviewCard
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "link";
|
||||
public string AuthorName { get; set; } = string.Empty;
|
||||
public string AuthorUrl { get; set; } = string.Empty;
|
||||
public string ProviderName { get; set; } = string.Empty;
|
||||
public string ProviderUrl { get; set; } = string.Empty;
|
||||
public string Html { get; set; } = string.Empty;
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public string Image { get; set; }
|
||||
public string ImageDescription { get; set; } = string.Empty;
|
||||
public string EmbedUrl { get; set; } = string.Empty;
|
||||
public string Blurhash { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string PublishedAt { get; set; }
|
||||
public List<object> Authors { get; set; } = new();
|
||||
}
|
||||
|
||||
public class Status
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Uri { get; set; }
|
||||
public string Url { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public string InReplyToId { get; set; }
|
||||
public string InReplyToAccountId { get; set; }
|
||||
public Status Reblog { get; set; }
|
||||
public string Content { get; set; } = string.Empty;
|
||||
public string CreatedAt { get; set; }
|
||||
public string EditedAt { get; set; }
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public int RepliesCount { get; set; }
|
||||
public int ReblogsCount { get; set; }
|
||||
public int FavouritesCount { get; set; }
|
||||
public bool Reblogged { get; set; }
|
||||
public bool Favourited { get; set; }
|
||||
public bool Muted { get; set; }
|
||||
public bool Bookmarked { get; set; }
|
||||
public bool Pinned { get; set; }
|
||||
public bool Sensitive { get; set; }
|
||||
public string SpoilerText { get; set; } = string.Empty;
|
||||
public string Visibility { get; set; }
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<StatusMention> Mentions { get; set; } = new();
|
||||
public List<StatusTag> Tags { get; set; } = new();
|
||||
public PreviewCard Card { get; set; }
|
||||
public PollEntity Poll { get; set; }
|
||||
public Application Application { get; set; }
|
||||
public string Language { get; set; }
|
||||
public string Text { get; set; }
|
||||
public List<FilterResultEntity> Filtered { get; set; } = new();
|
||||
public PrivaPubStatus Privapub { get; set; }
|
||||
public List<EmojiReactionEntity> EmojiReactions { get; set; } = new();
|
||||
public PleromaStatus Pleroma { get; set; }
|
||||
public QuoteEntity Quote { get; set; }
|
||||
public int QuotesCount { get; set; }
|
||||
public QuoteApprovalEntity QuoteApproval { get; set; }
|
||||
public InteractionPolicyEntity InteractionPolicy { get; set; }//GoToSocial's: who may like, reply to and boost a remote post
|
||||
}
|
||||
|
||||
// GoToSocial's interaction_policy: "public", "followers", "following", "author", "me", or an account's URI
|
||||
public class InteractionPolicyEntity
|
||||
{
|
||||
public InteractionRuleEntity CanFavourite { get; set; }
|
||||
public InteractionRuleEntity CanReply { get; set; }
|
||||
public InteractionRuleEntity CanReblog { get; set; }
|
||||
}
|
||||
|
||||
public class InteractionRuleEntity
|
||||
{
|
||||
public List<string> AutomaticApproval { get; set; } = new();
|
||||
public List<string> ManualApproval { get; set; } = new();
|
||||
}
|
||||
|
||||
public class MediaAttachment
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string PreviewUrl { get; set; }
|
||||
public string RemoteUrl { get; set; }
|
||||
public object Meta { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Blurhash { get; set; }
|
||||
}
|
||||
|
||||
public class StatusMention
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Username { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string Acct { get; set; }
|
||||
}
|
||||
|
||||
public class StatusTag
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
public class Application
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Website { get; set; }
|
||||
public List<string> Scopes { get; set; } = new();
|
||||
public List<string> RedirectUris { get; set; } = new();
|
||||
public string RedirectUri { get; set; }
|
||||
public string ClientId { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
public string VapidKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class Relationship
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool Following { get; set; }
|
||||
public bool ShowingReblogs { get; set; }
|
||||
public bool Notifying { get; set; }
|
||||
public List<string> Languages { get; set; }
|
||||
public bool FollowedBy { get; set; }
|
||||
public bool Blocking { get; set; }
|
||||
public bool BlockedBy { get; set; }
|
||||
public bool Muting { get; set; }
|
||||
public bool MutingNotifications { get; set; }
|
||||
public bool Requested { get; set; }
|
||||
public bool RequestedBy { get; set; }
|
||||
public bool DomainBlocking { get; set; }
|
||||
public bool Endorsed { get; set; }
|
||||
public string Note { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// Mastodon's FeaturedTag: its count as a string and its last use as a day, as Mastodon's serializer writes them
|
||||
public class FeaturedTagEntity
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Url { get; set; }
|
||||
public string StatusesCount { get; set; } = "0";
|
||||
public string LastStatusAt { get; set; }
|
||||
}
|
||||
|
||||
// Mastodon's v2 Suggestion: why (source, deprecated since 4.3, and sources) and whom
|
||||
public class Suggestion
|
||||
{
|
||||
public string Source { get; set; }
|
||||
public List<string> Sources { get; set; } = new();
|
||||
public Account Account { get; set; }
|
||||
}
|
||||
|
||||
public class Notification
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
public string GroupKey { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public Status Status { get; set; }
|
||||
public string Emoji { get; set; }
|
||||
public string EmojiUrl { get; set; }
|
||||
public AccountWarning ModerationWarning { get; set; }
|
||||
}
|
||||
|
||||
public class AccountWarning
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Action { get; set; } = "none";
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public List<string> StatusIds { get; set; } = new();
|
||||
public Account TargetAccount { get; set; }
|
||||
public object Appeal { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class Context
|
||||
{
|
||||
public List<Status> Ancestors { get; set; } = new();
|
||||
public List<Status> Descendants { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StatusEdit
|
||||
{
|
||||
public string Content { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
public bool Sensitive { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
public Account Account { get; set; }
|
||||
public List<MediaAttachment> MediaAttachments { get; set; } = new();
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StatusSource
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Text { get; set; }
|
||||
public string SpoilerText { get; set; }
|
||||
}
|
||||
|
||||
public class SearchResults
|
||||
{
|
||||
public List<Account> Accounts { get; set; } = new();
|
||||
public List<Status> Statuses { get; set; } = new();
|
||||
public List<object> Hashtags { get; set; } = new();
|
||||
}
|
||||
|
||||
public class Conversation
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public bool Unread { get; set; }
|
||||
public List<Account> Accounts { get; set; } = new();
|
||||
public Status LastStatus { get; set; }
|
||||
}
|
||||
|
||||
public class Marker
|
||||
{
|
||||
public string LastReadId { get; set; }
|
||||
public int Version { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Validation.AspNetCore;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Auth;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public sealed class ScopeAttribute : Attribute
|
||||
{
|
||||
public ScopeAttribute(string scope, bool requiresUser = true)
|
||||
{
|
||||
Scope = scope;
|
||||
RequiresUser = requiresUser;
|
||||
}
|
||||
|
||||
public string Scope { get; }
|
||||
public bool RequiresUser { get; }
|
||||
}
|
||||
|
||||
[ApiController, Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
|
||||
public abstract class MastodonController : ControllerBase, IAsyncActionFilter
|
||||
{
|
||||
MastodonParams _params;
|
||||
|
||||
protected LocalActor Me { get; private set; }
|
||||
|
||||
protected string MyId => Me?.Id;
|
||||
|
||||
protected MastodonParams Params => _params;
|
||||
|
||||
[NonAction]
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
_params = await MastodonParams.Read(Request, HttpContext.RequestAborted);
|
||||
var scope = context.ActionDescriptor.EndpointMetadata.OfType<ScopeAttribute>().LastOrDefault();
|
||||
if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
// (a scope written a|b is either)
|
||||
if (scope != default && !scope.Scope.Split('|').Any(one => MastodonScopes.Grants(User.GetScopes(), one)))
|
||||
{
|
||||
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
|
||||
return;
|
||||
}
|
||||
var subject = User.GetClaim(Claims.Subject);
|
||||
Me = await Resolve(subject, HttpContext.RequestAborted);
|
||||
// a persona's token whose persona can no longer act (suspended, its login banned or deleted) is refused
|
||||
// everywhere, as Mastodon refuses a revoked token; an application's own token has no persona
|
||||
if (Me == default && !string.IsNullOrEmpty(subject) && !subject.StartsWith("app:", StringComparison.Ordinal))
|
||||
{
|
||||
context.Result = Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (scope?.RequiresUser == true && Me == default)
|
||||
{
|
||||
context.Result = Error(StatusCodes.Status401Unauthorized, "This method requires an authenticated user");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
async Task<LocalActor> Resolve(string avatarId, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(avatarId))
|
||||
return default;
|
||||
var services = HttpContext.RequestServices;
|
||||
var db = services.GetRequiredService<DbEntities>();
|
||||
var link = await db.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
|
||||
if (link == default)
|
||||
return default;
|
||||
var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
|
||||
if (root is not { IsBanned: false, DeletedAt: null })
|
||||
return default;
|
||||
var avatar = await db.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
|
||||
if (avatar is not { DeletionAt: null, SuspendedAt: null })
|
||||
return default;
|
||||
return services.GetRequiredService<ILocalActorService>().FromAvatar(avatar) is { Suspended: false } me ? me : default;
|
||||
}
|
||||
|
||||
protected JsonResult Json(object value, int status = StatusCodes.Status200OK) =>
|
||||
new(value, MastodonJson.Options) { StatusCode = status };
|
||||
|
||||
protected static JsonResult Error(int status, string message) =>
|
||||
new(new { error = message }, MastodonJson.Options) { StatusCode = status };
|
||||
|
||||
protected JsonResult NotFoundError() => Error(StatusCodes.Status404NotFound, "Record not found");
|
||||
|
||||
protected void Link(string path, string maxId, string minId)
|
||||
{
|
||||
var links = new List<string>();
|
||||
var baseUrl = $"{Request.Scheme}://{Request.Host}{path}";
|
||||
var query = Request.Query.Where(q => q.Key is not ("max_id" or "min_id" or "since_id")).Select(q => $"{q.Key}={Uri.EscapeDataString(q.Value.ToString())}").ToList();
|
||||
string With(string key, string value) => baseUrl + "?" + string.Join("&", query.Append($"{key}={value}"));
|
||||
if (maxId != default)
|
||||
links.Add($"<{With("max_id", maxId)}>; rel=\"next\"");
|
||||
if (minId != default)
|
||||
links.Add($"<{With("min_id", minId)}>; rel=\"prev\"");
|
||||
if (links.Count > 0)
|
||||
Response.Headers["Link"] = string.Join(", ", links);
|
||||
}
|
||||
|
||||
protected int Limit(int fallback = 20, int max = 40) => Math.Clamp(Params.Int("limit") ?? fallback, 1, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public static class MastodonErrors
|
||||
{
|
||||
public static IApplicationBuilder UseMastodonErrorBodies(this IApplicationBuilder app) =>
|
||||
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.UseStatusCodePages(async pages =>
|
||||
{
|
||||
var response = pages.HttpContext.Response;
|
||||
response.ContentType = "application/json; charset=utf-8";
|
||||
await response.WriteAsync(JsonSerializer.Serialize(new { error = Message(response.StatusCode) }, MastodonJson.Options));
|
||||
}));
|
||||
|
||||
public static string Message(int status) => status switch
|
||||
{
|
||||
StatusCodes.Status401Unauthorized => "The access token is invalid",
|
||||
StatusCodes.Status403Forbidden => "This action is outside the authorized scopes",
|
||||
StatusCodes.Status404NotFound => "Record not found",
|
||||
StatusCodes.Status405MethodNotAllowed => "Method not allowed",
|
||||
StatusCodes.Status413PayloadTooLarge => "Payload too large",
|
||||
StatusCodes.Status415UnsupportedMediaType => "Unsupported media type",
|
||||
StatusCodes.Status422UnprocessableEntity => "Validation failed",
|
||||
StatusCodes.Status429TooManyRequests => "Too many requests",
|
||||
>= 500 => "Something went wrong.",
|
||||
_ => "Bad request"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public static class MastodonJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DictionaryKeyPolicy = default,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
||||
};
|
||||
|
||||
public static string Time(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public static string Day(DateTime value) =>
|
||||
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00.000Z", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
{
|
||||
public sealed class MastodonParams
|
||||
{
|
||||
readonly Dictionary<string, List<string>> _values;
|
||||
|
||||
MastodonParams(Dictionary<string, List<string>> values, JsonElement? json = default)
|
||||
{
|
||||
_values = values;
|
||||
Json = json;
|
||||
}
|
||||
|
||||
// a JSON body as it came, for what flattening loses: which fields belong to the same object of an array
|
||||
public JsonElement? Json { get; }
|
||||
|
||||
public static async Task<MastodonParams> Read(HttpRequest request, CancellationToken token)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
JsonElement? json = default;
|
||||
foreach (var pair in request.Query)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync(token);
|
||||
foreach (var pair in form)
|
||||
foreach (var value in pair.Value)
|
||||
Add(values, pair.Key, value);
|
||||
}
|
||||
else if (request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && request.ContentLength != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = await JsonDocument.ParseAsync(request.Body, cancellationToken: token);
|
||||
Flatten(values, default, document.RootElement);
|
||||
json = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
return new MastodonParams(values, json);
|
||||
}
|
||||
|
||||
public static MastodonParams From(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
{
|
||||
var values = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var pair in pairs)
|
||||
Add(values, pair.Key, pair.Value);
|
||||
return new MastodonParams(values);
|
||||
}
|
||||
|
||||
public bool Has(string name) => _values.ContainsKey(name);
|
||||
|
||||
public IEnumerable<string> Names => _values.Keys;
|
||||
|
||||
public string Get(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : default;
|
||||
|
||||
public IReadOnlyList<string> List(string name) => _values.TryGetValue(name, out var list) ? list : (IReadOnlyList<string>)Array.Empty<string>();
|
||||
|
||||
public bool? Bool(string name) => Get(name)?.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "on" or "yes" => true,
|
||||
"false" or "0" or "off" or "no" or "" => false,
|
||||
_ => (bool?)null
|
||||
};
|
||||
|
||||
public int? Int(string name) => int.TryParse(Get(name), out var value) ? value : (int?)null;
|
||||
|
||||
static void Add(Dictionary<string, List<string>> values, string key, string value)
|
||||
{
|
||||
key = key.EndsWith("[]", StringComparison.Ordinal) ? key[..^2] : key;
|
||||
if (!values.TryGetValue(key, out var list))
|
||||
values[key] = list = new List<string>();
|
||||
list.Add(value);
|
||||
}
|
||||
|
||||
static void Flatten(Dictionary<string, List<string>> values, string prefix, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
Flatten(values, prefix == default ? property.Name : $"{prefix}[{property.Name}]", property.Value);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
// an empty array is still said: media_ids: [] on an edit takes every attachment away
|
||||
if (prefix != default && element.GetArrayLength() == 0)
|
||||
values.TryAdd(prefix, new List<string>());
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
Flatten(values, prefix + "[]", item);
|
||||
else
|
||||
Add(values, prefix, Scalar(item));
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Null or JsonValueKind.Undefined:
|
||||
break;
|
||||
default:
|
||||
if (prefix != default)
|
||||
Add(values, prefix, Scalar(element));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static string Scalar(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
Loaded 100 of 570 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user