Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
BACKUPS: /var/backups/privapub.thepra.dev
|
||||||
LOCAL_URL: http://127.0.0.1:6970
|
LOCAL_URL: http://127.0.0.1:6970
|
||||||
PUBLIC_URL: https://privapub.thepra.dev
|
PUBLIC_URL: https://privapub.thepra.dev
|
||||||
|
SERVER_BACKUPS: /var/lib/privapub/backups
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
site:
|
site:
|
||||||
@@ -52,6 +53,25 @@ jobs:
|
|||||||
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
||||||
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
|
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
|
- name: Stop, sync, start
|
||||||
run: |
|
run: |
|
||||||
sudo systemctl stop "$UNIT"
|
sudo systemctl stop "$UNIT"
|
||||||
@@ -83,6 +103,46 @@ jobs:
|
|||||||
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
[ "$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=$(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; }
|
[ "$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=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
||||||
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
[ "$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"
|
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 /'
|
||||||
+13
@@ -257,6 +257,7 @@ Generated_Code/
|
|||||||
# because we have git ;-)
|
# because we have git ;-)
|
||||||
_UpgradeReport_Files/
|
_UpgradeReport_Files/
|
||||||
Backup*/
|
Backup*/
|
||||||
|
!PrivaPub/Infrastructure/Backup/
|
||||||
UpgradeLog*.XML
|
UpgradeLog*.XML
|
||||||
UpgradeLog*.htm
|
UpgradeLog*.htm
|
||||||
ServiceFabricBackup/
|
ServiceFabricBackup/
|
||||||
@@ -398,3 +399,15 @@ FodyWeavers.xsd
|
|||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
*.sln.iml
|
*.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/
|
||||||
|
.claude/worktrees/
|
||||||
|
tools/pasture/.ca/
|
||||||
|
tools/pasture/.state/
|
||||||
|
tools/pasture/out/
|
||||||
+478
@@ -0,0 +1,478 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
Planned (see `docs/ROADMAP.md`, the completion program, and the per-platform notes in `docs/INTEROP.md`): FEP-9098
|
||||||
|
custom emoji offered (R7) and 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` |
|
||||||
|
| 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` 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, 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`); `featuredTags` is `/tattoos`. A pin or an unpin
|
||||||
|
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
||||||
|
- **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.
|
||||||
|
**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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
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
|
||||||
|
|
||||||
|
- Our own posts carry no custom emoji: emoji are received and shown, not offered.
|
||||||
|
- 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,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,24 @@
|
|||||||
|
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 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,167 @@
|
|||||||
|
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 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 @@
|
|||||||
public const int GroupInvitationLength = 64;
|
public const int GroupInvitationLength = 64;
|
||||||
|
|
||||||
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
|
public const string PasswordRegex = @"^(?=.*)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$";
|
||||||
|
public const string UserNameRegex = "^[a-z0-9_]+$";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
|
|
||||||
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
StringLength(32, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", 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]
|
NoWhiteSpaces]
|
||||||
public string UserName { get; set; }
|
public string UserName { get; set; }
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
|
|
||||||
public string InvitationPassword { 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 IsDiscoverable { get; set; } = true;
|
||||||
public bool ManuallyApprovesMembers { get; set; }
|
public bool ManuallyApprovesMembers { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
public bool RemoveInvitationPassword { get; set; }
|
public bool RemoveInvitationPassword { get; set; }
|
||||||
public bool RegenerateInvitationCode { get; set; }
|
public bool RegenerateInvitationCode { get; set; }
|
||||||
public bool? IsDiscoverable { get; set; }
|
public bool? IsDiscoverable { get; set; }
|
||||||
|
public string PostingPolicy { get; set; }
|
||||||
public bool? ManuallyApprovesMembers { get; set; }
|
public bool? ManuallyApprovesMembers { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
public string Handle { get; set; }
|
public string Handle { get; set; }
|
||||||
|
public bool IsCommunity { get; set; }
|
||||||
|
public string PostingPolicy { get; set; }
|
||||||
public bool IsDiscoverable { get; set; }
|
public bool IsDiscoverable { get; set; }
|
||||||
public bool ManuallyApprovesMembers { get; set; }
|
public bool ManuallyApprovesMembers { get; set; }
|
||||||
public bool IsOwner { 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,
|
StringLength(Constants.GroupInvitationLength, MinimumLength = Constants.GroupInvitationLength,
|
||||||
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))]
|
||||||
public string InvitationCode { get; set; }
|
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 bool ThemeIsDarkMode { get; set; } = false;
|
||||||
|
|
||||||
public string InvitationPassword { get; set; }
|
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 GroupId { get; set; }
|
||||||
public string AnsweringToPostId { get; set; }
|
public string AnsweringToPostId { get; set; }
|
||||||
public bool HasContentWarning { 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
|
public class InsertDmForm
|
||||||
|
|||||||
@@ -8,12 +8,17 @@ namespace PrivaPub.ClientModels.Post
|
|||||||
public string AuthorActorURI { get; set; }
|
public string AuthorActorURI { get; set; }
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
public string DmGroupId { get; set; }
|
public string DmGroupId { get; set; }
|
||||||
|
public string Visibility { get; set; }
|
||||||
public string AnsweringToPostId { get; set; }
|
public string AnsweringToPostId { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Text { get; set; }
|
public string Text { get; set; }
|
||||||
|
public string ContentHtml { get; set; }
|
||||||
public bool HasContentWarning { get; set; }
|
public bool HasContentWarning { get; set; }
|
||||||
public bool IsFederatedCopy { get; set; }
|
public bool IsFederatedCopy { get; set; }
|
||||||
public DateTime CreationDate { 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
|
public class ViewDmGroup
|
||||||
|
|||||||
@@ -105,6 +105,15 @@ 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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
|
/// Looks up a localized string similar to Invalid password, upper-case, lower-case and number characters required..
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<data name="InvalidEmail" xml:space="preserve">
|
<data name="InvalidEmail" xml:space="preserve">
|
||||||
<value>{0} è invalida.</value>
|
<value>{0} è invalida.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="InvalidUserName" xml:space="preserve">
|
||||||
|
<value>Usa solo lettere minuscole, cifre e trattini bassi.</value>
|
||||||
|
</data>
|
||||||
<data name="InvalidPassword" xml:space="preserve">
|
<data name="InvalidPassword" xml:space="preserve">
|
||||||
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
|
<value>Password non valida, deve avere almeno un carattere grande, un carattere piccolo ed un numero.</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -132,6 +132,9 @@
|
|||||||
<data name="InvalidEmail" xml:space="preserve">
|
<data name="InvalidEmail" xml:space="preserve">
|
||||||
<value>{0} is invalid.</value>
|
<value>{0} is invalid.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="InvalidUserName" xml:space="preserve">
|
||||||
|
<value>Use only lower-case letters, digits and underscores.</value>
|
||||||
|
</data>
|
||||||
<data name="InvalidPassword" xml:space="preserve">
|
<data name="InvalidPassword" xml:space="preserve">
|
||||||
<value>Invalid password, upper-case, lower-case and number characters required.</value>
|
<value>Invalid password, upper-case, lower-case and number characters required.</value>
|
||||||
</data>
|
</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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Description.
|
/// Looks up a localized string similar to Description.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Email.
|
/// Looks up a localized string similar to Email.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Groups.
|
/// Looks up a localized string similar to Groups.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Invitation code.
|
/// Looks up a localized string similar to Invitation code.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Invitation password.
|
/// Looks up a localized string similar to Invitation password.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Language id.
|
/// Looks up a localized string similar to Language id.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to New password.
|
/// Looks up a localized string similar to New password.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Password.
|
/// Looks up a localized string similar to Password.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Prefer system theme.
|
/// Looks up a localized string similar to Prefer system theme.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Dark mode.
|
/// Looks up a localized string similar to Dark mode.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Title.
|
/// Looks up a localized string similar to Title.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Looks up a localized string similar to Username.
|
/// Looks up a localized string similar to Username.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -659,23 +236,5 @@ namespace PrivaPub.ClientModels.Resources {
|
|||||||
return ResourceManager.GetString("Username", resourceCulture);
|
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">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Описание</value>
|
<value>Описание</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Имейл</value>
|
<value>Имейл</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Емотикони</value>
|
<value>Емотикони</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Групи</value>
|
<value>Групи</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
|
||||||
<value>Да включите първия отговор в предварителния преглед на поканата? (за да се включите в дискусията по-ангажиращо)</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationCode" xml:space="preserve">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Код на поканата</value>
|
<value>Код на поканата</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Код или връзка за покана</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Парола за покана</value>
|
<value>Парола за покана</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Идентификатор на езика</value>
|
<value>Идентификатор на езика</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Име</value>
|
<value>Име</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Активиране на известията?(полезно за получаване на актуализации на приложението)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Нова парола</value>
|
<value>Нова парола</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Стара парола</value>
|
<value>Стара парола</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Само веднъж</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Парола</value>
|
<value>Парола</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Предпочитайте темата на системата</value>
|
<value>Предпочитайте темата на системата</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Код за възстановяване</value>
|
<value>Код за възстановяване</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Тъмен режим</value>
|
<value>Тъмен режим</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Кърлежи</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Заглавие</value>
|
<value>Заглавие</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Потребителско име</value>
|
<value>Потребителско име</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Popis</value>
|
<value>Popis</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-mail</value>
|
<value>E-mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Skupiny</value>
|
<value>Skupiny</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kód pozvánky</value>
|
<value>Kód pozvánky</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Pozvánkový kód nebo odkaz</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Heslo pozvánky</value>
|
<value>Heslo pozvánky</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Identifikace jazyka</value>
|
<value>Identifikace jazyka</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Název</value>
|
<value>Název</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nové heslo</value>
|
<value>Nové heslo</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Staré heslo</value>
|
<value>Staré heslo</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Pouze jednou</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Heslo</value>
|
<value>Heslo</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Preferovat systémové téma</value>
|
<value>Preferovat systémové téma</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Kód obnovy</value>
|
<value>Kód obnovy</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tmavý režim</value>
|
<value>Tmavý režim</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Klíšťata</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Název</value>
|
<value>Název</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Uživatelské jméno</value>
|
<value>Uživatelské jméno</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Beskrivelse</value>
|
<value>Beskrivelse</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-mail</value>
|
<value>E-mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupper</value>
|
<value>Grupper</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kode for indbydelse</value>
|
<value>Kode for indbydelse</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Indbydelseskode eller link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Adgangskode til invitationen</value>
|
<value>Adgangskode til invitationen</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Sprog-id</value>
|
<value>Sprog-id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Navn</value>
|
<value>Navn</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Ny adgangskode</value>
|
<value>Ny adgangskode</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Gammel adgangskode</value>
|
<value>Gammel adgangskode</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Kun én gang</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Adgangskode</value>
|
<value>Adgangskode</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Foretrækker systemtema</value>
|
<value>Foretrækker systemtema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Kode for inddrivelse</value>
|
<value>Kode for inddrivelse</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Mørk tilstand</value>
|
<value>Mørk tilstand</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Flåter</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titel</value>
|
<value>Titel</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Brugernavn</value>
|
<value>Brugernavn</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Beschreibung</value>
|
<value>Beschreibung</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-Mail</value>
|
<value>E-Mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Gruppen</value>
|
<value>Gruppen</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Einladungs-Code</value>
|
<value>Einladungs-Code</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Einladungscode oder Link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Passwort für die Einladung</value>
|
<value>Passwort für die Einladung</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Sprache id</value>
|
<value>Sprache id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Name</value>
|
<value>Name</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Neues Passwort</value>
|
<value>Neues Passwort</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Altes Passwort</value>
|
<value>Altes Passwort</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Nur einmalig</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Passwort</value>
|
<value>Passwort</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Systemthema bevorzugen</value>
|
<value>Systemthema bevorzugen</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Wiederherstellungscode</value>
|
<value>Wiederherstellungscode</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Dunkler Modus</value>
|
<value>Dunkler Modus</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Zecken</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titel</value>
|
<value>Titel</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Benutzername</value>
|
<value>Benutzername</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Περιγραφή</value>
|
<value>Περιγραφή</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Ηλεκτρονικό ταχυδρομείο</value>
|
<value>Ηλεκτρονικό ταχυδρομείο</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Ομάδες</value>
|
<value>Ομάδες</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
|
||||||
<value>Να συμπεριλάβετε την πρώτη απάντηση στην προεπισκόπηση της πρόσκλησης; (καθιστώντας την πιο ελκυστική για να συμμετάσχετε στη συζήτηση)</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationCode" xml:space="preserve">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Κωδικός πρόσκλησης</value>
|
<value>Κωδικός πρόσκλησης</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Κωδικός πρόσκλησης ή σύνδεσμος</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Κωδικός πρόσκλησης</value>
|
<value>Κωδικός πρόσκλησης</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Id γλώσσας</value>
|
<value>Id γλώσσας</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Όνομα</value>
|
<value>Όνομα</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Ενεργοποίηση ειδοποιήσεων; (χρήσιμο για να λαμβάνετε ενημερώσεις εφαρμογών)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Νέος κωδικός πρόσβασης</value>
|
<value>Νέος κωδικός πρόσβασης</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Παλαιός κωδικός πρόσβασης</value>
|
<value>Παλαιός κωδικός πρόσβασης</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Μόνο μία φορά</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Κωδικός πρόσβασης</value>
|
<value>Κωδικός πρόσβασης</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Προτιμήστε το θέμα του συστήματος</value>
|
<value>Προτιμήστε το θέμα του συστήματος</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Κωδικός ανάκτησης</value>
|
<value>Κωδικός ανάκτησης</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Σκοτεινή λειτουργία</value>
|
<value>Σκοτεινή λειτουργία</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Κρότωνες</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Τίτλος</value>
|
<value>Τίτλος</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Όνομα χρήστη</value>
|
<value>Όνομα χρήστη</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Descripción</value>
|
<value>Descripción</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Envíe un correo electrónico a</value>
|
<value>Envíe un correo electrónico a</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupos</value>
|
<value>Grupos</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Código de invitación</value>
|
<value>Código de invitación</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Código o enlace de invitación</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Contraseña de la invitación</value>
|
<value>Contraseña de la invitación</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Id. de idioma</value>
|
<value>Id. de idioma</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nombre</value>
|
<value>Nombre</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nueva contraseña</value>
|
<value>Nueva contraseña</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Contraseña antigua</value>
|
<value>Contraseña antigua</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Una sola vez</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Contraseña</value>
|
<value>Contraseña</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Prefiero el tema del sistema</value>
|
<value>Prefiero el tema del sistema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Código de recuperación</value>
|
<value>Código de recuperación</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Modo oscuro</value>
|
<value>Modo oscuro</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Garrapatas</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Título</value>
|
<value>Título</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Nombre de usuario</value>
|
<value>Nombre de usuario</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Kirjeldus</value>
|
<value>Kirjeldus</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-post</value>
|
<value>E-post</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Rühmad</value>
|
<value>Rühmad</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kutse kood</value>
|
<value>Kutse kood</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Kutse kood või link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Kutse parool</value>
|
<value>Kutse parool</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Keele id</value>
|
<value>Keele id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nimi</value>
|
<value>Nimi</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Teavituste lubamine?(kasulik, et saada rakenduse uuendusi)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Uus parool</value>
|
<value>Uus parool</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Vana parool</value>
|
<value>Vana parool</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Ainult üks kord</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Parool</value>
|
<value>Parool</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Eelistage süsteemi teemat</value>
|
<value>Eelistage süsteemi teemat</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Taastamiskood</value>
|
<value>Taastamiskood</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tume režiim</value>
|
<value>Tume režiim</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Puugid</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Pealkiri</value>
|
<value>Pealkiri</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Kasutajanimi</value>
|
<value>Kasutajanimi</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Kuvaus</value>
|
<value>Kuvaus</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Sähköposti</value>
|
<value>Sähköposti</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Ryhmät</value>
|
<value>Ryhmät</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kutsun koodi</value>
|
<value>Kutsun koodi</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Kutsukoodi tai linkki</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Kutsun salasana</value>
|
<value>Kutsun salasana</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Kielitunnus</value>
|
<value>Kielitunnus</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nimi</value>
|
<value>Nimi</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Uusi salasana</value>
|
<value>Uusi salasana</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Vanha salasana</value>
|
<value>Vanha salasana</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Vain kerran</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Salasana</value>
|
<value>Salasana</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Suositeltava järjestelmäteema</value>
|
<value>Suositeltava järjestelmäteema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Palautuskoodi</value>
|
<value>Palautuskoodi</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tumma tila</value>
|
<value>Tumma tila</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Punkit</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Otsikko</value>
|
<value>Otsikko</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Käyttäjätunnus</value>
|
<value>Käyttäjätunnus</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Description</value>
|
<value>Description</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Courriel</value>
|
<value>Courriel</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Groupes</value>
|
<value>Groupes</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Code d'invitation</value>
|
<value>Code d'invitation</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Code ou lien d'invitation</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Mot de passe de l'invitation</value>
|
<value>Mot de passe de l'invitation</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Identifiant de langue</value>
|
<value>Identifiant de langue</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nom</value>
|
<value>Nom</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nouveau mot de passe</value>
|
<value>Nouveau mot de passe</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Ancien mot de passe</value>
|
<value>Ancien mot de passe</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>En une seule fois</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Mot de passe</value>
|
<value>Mot de passe</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Préférer le thème du système</value>
|
<value>Préférer le thème du système</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Code de récupération</value>
|
<value>Code de récupération</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Mode foncé</value>
|
<value>Mode foncé</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Tiques</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titre</value>
|
<value>Titre</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Nom d'utilisateur</value>
|
<value>Nom d'utilisateur</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Leírás</value>
|
<value>Leírás</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-mail</value>
|
<value>E-mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Csoportok</value>
|
<value>Csoportok</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Meghívó kód</value>
|
<value>Meghívó kód</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Meghívó kód vagy link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Meghívó jelszó</value>
|
<value>Meghívó jelszó</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Nyelvi azonosító</value>
|
<value>Nyelvi azonosító</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Név</value>
|
<value>Név</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Új jelszó</value>
|
<value>Új jelszó</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Régi jelszó</value>
|
<value>Régi jelszó</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Csak egyszeri alkalom</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Jelszó</value>
|
<value>Jelszó</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>A rendszer témájának előnyben részesítése</value>
|
<value>A rendszer témájának előnyben részesítése</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Helyreállítási kód</value>
|
<value>Helyreállítási kód</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Sötét üzemmód</value>
|
<value>Sötét üzemmód</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Kullancsok</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Cím</value>
|
<value>Cím</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Felhasználónév</value>
|
<value>Felhasználónév</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Descrizione</value>
|
<value>Descrizione</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Email</value>
|
<value>Email</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Gruppi</value>
|
<value>Gruppi</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Codice d'invito</value>
|
<value>Codice d'invito</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Collegamento o codice di invito</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Password dell'invito</value>
|
<value>Password dell'invito</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>ID lingua</value>
|
<value>ID lingua</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nome</value>
|
<value>Nome</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Abilitare le notifiche?(utili per percepire aggiornamenti)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nuova password</value>
|
<value>Nuova password</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,78 +150,24 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Attuale password</value>
|
<value>Attuale password</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Paga una volta</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Password</value>
|
<value>Password</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Preferire il tema di sistema</value>
|
<value>Preferire il tema di sistema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Codice di recupero</value>
|
<value>Codice di recupero</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Modalità scura</value>
|
<value>Modalità scura</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Zecche</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titolo</value>
|
<value>Titolo</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Soprannome</value>
|
<value>Soprannome</value>
|
||||||
</data>
|
</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">
|
<data name="AvatarId" xml:space="preserve">
|
||||||
<value>Avatar</value>
|
<value>Avatar</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>商品説明</value>
|
<value>商品説明</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>電子メール</value>
|
<value>電子メール</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>絵文字</value>
|
<value>絵文字</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>グループ</value>
|
<value>グループ</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
|
||||||
<value>招待状のプレビューに最初の回答を含める?(議論への参加をより魅力的にするために)</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationCode" xml:space="preserve">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>招待状コード</value>
|
<value>招待状コード</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>招待コードまたはリンク</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>招待状のパスワード</value>
|
<value>招待状のパスワード</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>言語ID</value>
|
<value>言語ID</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>名前</value>
|
<value>名前</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>通知を有効にしますか?(アプリの更新情報を受け取るのに便利です)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>新しいパスワード</value>
|
<value>新しいパスワード</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>古いパスワード</value>
|
<value>古いパスワード</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>1回限り</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>パスワード</value>
|
<value>パスワード</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>システムテーマを優先する</value>
|
<value>システムテーマを優先する</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>リカバリーコード</value>
|
<value>リカバリーコード</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>ダークモード</value>
|
<value>ダークモード</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>ダニ</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>タイトル</value>
|
<value>タイトル</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>ユーザー名</value>
|
<value>ユーザー名</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Aprašymas</value>
|
<value>Aprašymas</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>El. paštas</value>
|
<value>El. paštas</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupės</value>
|
<value>Grupės</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kvietimo kodas</value>
|
<value>Kvietimo kodas</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Kvietimo kodas arba nuoroda</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Kvietimo slaptažodis</value>
|
<value>Kvietimo slaptažodis</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Kalbos ID</value>
|
<value>Kalbos ID</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Pavadinimas</value>
|
<value>Pavadinimas</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Naujas slaptažodis</value>
|
<value>Naujas slaptažodis</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Senasis slaptažodis</value>
|
<value>Senasis slaptažodis</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Tik vieną kartą</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Slaptažodis</value>
|
<value>Slaptažodis</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Pirmenybė sistemos temai</value>
|
<value>Pirmenybė sistemos temai</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Atkūrimo kodas</value>
|
<value>Atkūrimo kodas</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tamsus režimas</value>
|
<value>Tamsus režimas</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Erkės</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Pavadinimas</value>
|
<value>Pavadinimas</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Vartotojo vardas</value>
|
<value>Vartotojo vardas</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Apraksts</value>
|
<value>Apraksts</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-pasts</value>
|
<value>E-pasts</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emocijas</value>
|
<value>Emocijas</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupas</value>
|
<value>Grupas</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Uzaicinājuma kods</value>
|
<value>Uzaicinājuma kods</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Uzaicinājuma kods vai saite</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Uzaicinājuma parole</value>
|
<value>Uzaicinājuma parole</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Valodas identifikators</value>
|
<value>Valodas identifikators</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nosaukums</value>
|
<value>Nosaukums</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Jauna parole</value>
|
<value>Jauna parole</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Vecā parole</value>
|
<value>Vecā parole</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Tikai vienu reizi</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Parole</value>
|
<value>Parole</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Dodiet priekšroku sistēmas tēmai</value>
|
<value>Dodiet priekšroku sistēmas tēmai</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Atgūšanas kods</value>
|
<value>Atgūšanas kods</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tumšais režīms</value>
|
<value>Tumšais režīms</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Ērces</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Nosaukums</value>
|
<value>Nosaukums</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Lietotājvārds</value>
|
<value>Lietotājvārds</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Beschrijving</value>
|
<value>Beschrijving</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-mail</value>
|
<value>E-mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Groepen</value>
|
<value>Groepen</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Uitnodiging code</value>
|
<value>Uitnodiging code</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Uitnodigingscode of link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Uitnodiging wachtwoord</value>
|
<value>Uitnodiging wachtwoord</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Taal id</value>
|
<value>Taal id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Naam</value>
|
<value>Naam</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Meldingen inschakelen?(handig om app updates te ontvangen)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nieuw wachtwoord</value>
|
<value>Nieuw wachtwoord</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Oud wachtwoord</value>
|
<value>Oud wachtwoord</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Eenmalig</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Wachtwoord</value>
|
<value>Wachtwoord</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Voorkeur systeemthema</value>
|
<value>Voorkeur systeemthema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Herstelcode</value>
|
<value>Herstelcode</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Donkere modus</value>
|
<value>Donkere modus</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Teken</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titel</value>
|
<value>Titel</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Gebruikersnaam</value>
|
<value>Gebruikersnaam</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Opis</value>
|
<value>Opis</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-mail</value>
|
<value>E-mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupy</value>
|
<value>Grupy</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kod zaproszenia</value>
|
<value>Kod zaproszenia</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Kod lub link do zaproszenia</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Hasło do zaproszenia</value>
|
<value>Hasło do zaproszenia</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Język id</value>
|
<value>Język id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nazwa</value>
|
<value>Nazwa</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Włączyć powiadomienia? (przydatne do otrzymywania aktualizacji aplikacji)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nowe hasło</value>
|
<value>Nowe hasło</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Stare hasło</value>
|
<value>Stare hasło</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Tylko jeden raz</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Hasło</value>
|
<value>Hasło</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Preferowany motyw systemowy</value>
|
<value>Preferowany motyw systemowy</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Kod odzyskiwania</value>
|
<value>Kod odzyskiwania</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tryb ciemny</value>
|
<value>Tryb ciemny</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Kleszcze</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Tytuł</value>
|
<value>Tytuł</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Nazwa użytkownika</value>
|
<value>Nazwa użytkownika</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Descrição</value>
|
<value>Descrição</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Email</value>
|
<value>Email</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupos</value>
|
<value>Grupos</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Código de convite</value>
|
<value>Código de convite</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Código do convite ou link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Senha do convite</value>
|
<value>Senha do convite</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Identificação do idioma</value>
|
<value>Identificação do idioma</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nome</value>
|
<value>Nome</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nova senha</value>
|
<value>Nova senha</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Senha antiga</value>
|
<value>Senha antiga</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Só uma vez</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Senha</value>
|
<value>Senha</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Tema do sistema de preferências</value>
|
<value>Tema do sistema de preferências</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Código de recuperação</value>
|
<value>Código de recuperação</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Modo escuro</value>
|
<value>Modo escuro</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Carraças</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Título</value>
|
<value>Título</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Nome de usuário</value>
|
<value>Nome de usuário</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Description</value>
|
<value>Description</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Email</value>
|
<value>Email</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Groups</value>
|
<value>Groups</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Invitation code</value>
|
<value>Invitation code</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Invitation code or link</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Invitation password</value>
|
<value>Invitation password</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Language id</value>
|
<value>Language id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Name</value>
|
<value>Name</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Enable notifications?(useful to receive app updates)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>New password</value>
|
<value>New password</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,78 +150,24 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Old password</value>
|
<value>Old password</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>One time only</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Password</value>
|
<value>Password</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Prefer system theme</value>
|
<value>Prefer system theme</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Recovery code</value>
|
<value>Recovery code</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Dark mode</value>
|
<value>Dark mode</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Ticks</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Title</value>
|
<value>Title</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Username</value>
|
<value>Username</value>
|
||||||
</data>
|
</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">
|
<data name="AvatarId" xml:space="preserve">
|
||||||
<value>Avatar</value>
|
<value>Avatar</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Descriere</value>
|
<value>Descriere</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Email</value>
|
<value>Email</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupuri</value>
|
<value>Grupuri</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Codul de invitație</value>
|
<value>Codul de invitație</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Cod sau link de invitație</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Parola de invitație</value>
|
<value>Parola de invitație</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>ID-ul limbii</value>
|
<value>ID-ul limbii</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Nume</value>
|
<value>Nume</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Parolă nouă</value>
|
<value>Parolă nouă</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Parolă veche</value>
|
<value>Parolă veche</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>O singură dată</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Parola</value>
|
<value>Parola</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Preferați tema de sistem</value>
|
<value>Preferați tema de sistem</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Cod de recuperare</value>
|
<value>Cod de recuperare</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Mod întunecat</value>
|
<value>Mod întunecat</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Căpușe</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titlu</value>
|
<value>Titlu</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Nume utilizator</value>
|
<value>Nume utilizator</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Описание</value>
|
<value>Описание</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>Электронная почта</value>
|
<value>Электронная почта</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Группы</value>
|
<value>Группы</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
|
||||||
<value>Включить первый ответ в предварительный просмотр приглашения? (что делает его более привлекательным для участия в обсуждении)</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationCode" xml:space="preserve">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Код приглашения</value>
|
<value>Код приглашения</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Код приглашения или ссылка</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Пароль приглашения</value>
|
<value>Пароль приглашения</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Языковой идентификатор</value>
|
<value>Языковой идентификатор</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Имя</value>
|
<value>Имя</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Включить уведомления? (полезно для получения обновлений приложения)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Новый пароль</value>
|
<value>Новый пароль</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Старый пароль</value>
|
<value>Старый пароль</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Только один раз</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Пароль</value>
|
<value>Пароль</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Предпочитайте системную тему</value>
|
<value>Предпочитайте системную тему</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Код восстановления</value>
|
<value>Код восстановления</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Темный режим</value>
|
<value>Темный режим</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Клещи</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Название</value>
|
<value>Название</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Имя пользователя</value>
|
<value>Имя пользователя</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Popis</value>
|
<value>Popis</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-mail</value>
|
<value>E-mail</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Skupiny</value>
|
<value>Skupiny</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Pozývací kód</value>
|
<value>Pozývací kód</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Pozývací kód alebo odkaz</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Heslo pozvánky</value>
|
<value>Heslo pozvánky</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Identifikátor jazyka</value>
|
<value>Identifikátor jazyka</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Názov</value>
|
<value>Názov</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nové heslo</value>
|
<value>Nové heslo</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Staré heslo</value>
|
<value>Staré heslo</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Iba jedenkrát</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Heslo</value>
|
<value>Heslo</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Uprednostniť tému systému</value>
|
<value>Uprednostniť tému systému</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Kód obnovy</value>
|
<value>Kód obnovy</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Tmavý režim</value>
|
<value>Tmavý režim</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Kliešte</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Názov</value>
|
<value>Názov</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Používateľské meno</value>
|
<value>Používateľské meno</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Opis</value>
|
<value>Opis</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-pošta</value>
|
<value>E-pošta</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Skupine</value>
|
<value>Skupine</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Koda vabila</value>
|
<value>Koda vabila</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Koda vabila ali povezava</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Geslo za povabilo</value>
|
<value>Geslo za povabilo</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Id jezika</value>
|
<value>Id jezika</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Ime</value>
|
<value>Ime</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>Omogočite obvestila? (koristno za prejemanje posodobitev aplikacije)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Novo geslo</value>
|
<value>Novo geslo</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Staro geslo</value>
|
<value>Staro geslo</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Samo enkrat</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Geslo</value>
|
<value>Geslo</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Prednost sistemske teme</value>
|
<value>Prednost sistemske teme</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Koda za obnovitev</value>
|
<value>Koda za obnovitev</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Temni način</value>
|
<value>Temni način</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Klopi</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Naslov</value>
|
<value>Naslov</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Uporabniško ime</value>
|
<value>Uporabniško ime</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>Beskrivning</value>
|
<value>Beskrivning</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>E-post</value>
|
<value>E-post</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>Emoji</value>
|
<value>Emoji</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>Grupper</value>
|
<value>Grupper</value>
|
||||||
</data>
|
</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">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>Kod för inbjudan</value>
|
<value>Kod för inbjudan</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>Inbjudningskod eller länk</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>Lösenord för inbjudan</value>
|
<value>Lösenord för inbjudan</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>Språk-id</value>
|
<value>Språk-id</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>Namn</value>
|
<value>Namn</value>
|
||||||
</data>
|
</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">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>Nytt lösenord</value>
|
<value>Nytt lösenord</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>Gammalt lösenord</value>
|
<value>Gammalt lösenord</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>Endast en gång</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>Lösenord</value>
|
<value>Lösenord</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>Föredrar systemtema</value>
|
<value>Föredrar systemtema</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>Kod för återvinning</value>
|
<value>Kod för återvinning</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>Mörkt läge</value>
|
<value>Mörkt läge</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>Fästingar</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>Titel</value>
|
<value>Titel</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>Användarnamn</value>
|
<value>Användarnamn</value>
|
||||||
</data>
|
</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>
|
</root>
|
||||||
@@ -117,123 +117,30 @@
|
|||||||
<resheader name="writer">
|
<resheader name="writer">
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
</resheader>
|
</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">
|
<data name="Description" xml:space="preserve">
|
||||||
<value>描述</value>
|
<value>描述</value>
|
||||||
</data>
|
</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">
|
<data name="Email" xml:space="preserve">
|
||||||
<value>电子邮件</value>
|
<value>电子邮件</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Emoji" xml:space="preserve">
|
<data name="Emoji" xml:space="preserve">
|
||||||
<value>表情符号</value>
|
<value>表情符号</value>
|
||||||
</data>
|
</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">
|
<data name="Groups" xml:space="preserve">
|
||||||
<value>群体</value>
|
<value>群体</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="IncludeFirstAnswerInInvitation" xml:space="preserve">
|
|
||||||
<value>在邀请预览中包括第一个答案?(使其更加吸引人加入讨论)</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationCode" xml:space="preserve">
|
<data name="InvitationCode" xml:space="preserve">
|
||||||
<value>邀请函代码</value>
|
<value>邀请函代码</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="InvitationCodeLink" xml:space="preserve">
|
|
||||||
<value>邀请函代码或链接</value>
|
|
||||||
</data>
|
|
||||||
<data name="InvitationPassword" xml:space="preserve">
|
<data name="InvitationPassword" xml:space="preserve">
|
||||||
<value>邀请函密码</value>
|
<value>邀请函密码</value>
|
||||||
</data>
|
</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">
|
<data name="LanguageId" xml:space="preserve">
|
||||||
<value>语言标识</value>
|
<value>语言标识</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Name" xml:space="preserve">
|
<data name="Name" xml:space="preserve">
|
||||||
<value>命名</value>
|
<value>命名</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="NativeNotificationsEnabled" xml:space="preserve">
|
|
||||||
<value>启用通知?(对接收应用程序的更新很有用)</value>
|
|
||||||
</data>
|
|
||||||
<data name="NewPassword" xml:space="preserve">
|
<data name="NewPassword" xml:space="preserve">
|
||||||
<value>新密码</value>
|
<value>新密码</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -243,76 +150,22 @@
|
|||||||
<data name="OldPassword" xml:space="preserve">
|
<data name="OldPassword" xml:space="preserve">
|
||||||
<value>旧密码</value>
|
<value>旧密码</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="OneTime" xml:space="preserve">
|
|
||||||
<value>仅限一次</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
<data name="Password" xml:space="preserve">
|
||||||
<value>密码</value>
|
<value>密码</value>
|
||||||
</data>
|
</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">
|
<data name="PreferSystemTheming" xml:space="preserve">
|
||||||
<value>倾向于系统主题</value>
|
<value>倾向于系统主题</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="RecoveryCode" xml:space="preserve">
|
<data name="RecoveryCode" xml:space="preserve">
|
||||||
<value>恢复代码</value>
|
<value>恢复代码</value>
|
||||||
</data>
|
</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">
|
<data name="ThemeIsDarkMode" xml:space="preserve">
|
||||||
<value>黑暗模式</value>
|
<value>黑暗模式</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="Ticks" xml:space="preserve">
|
|
||||||
<value>蜱虫</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
<data name="Title" xml:space="preserve">
|
||||||
<value>标题</value>
|
<value>标题</value>
|
||||||
</data>
|
</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">
|
<data name="Username" xml:space="preserve">
|
||||||
<value>帐号</value>
|
<value>帐号</value>
|
||||||
</data>
|
</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>
|
</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)),
|
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
||||||
StringLength(Constants.MaxAvatarNameLength, ErrorMessageResourceName = "StringLength", 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))]
|
Display(Name = nameof(UserName), ResourceType = typeof(FieldsNameResource))]
|
||||||
public string UserName { get; set; }//preferredUsername
|
public string UserName { get; set; }//preferredUsername
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 ThemeIsDarkMode { get; set; } = false;
|
||||||
public bool ThemeIsLightGray { get; set; } = false;
|
public bool ThemeIsLightGray { get; set; } = false;
|
||||||
public bool ThemeIsDarkGray { 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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{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|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.ActiveCfg = Release|Any CPU
|
||||||
{EB2A0BD2-0150-405A-939E-5B0F6F642539}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{5E63C68C-0E60-4418-A53A-FF4EF0007080}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
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,119 @@
|
|||||||
|
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));
|
||||||
|
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,105 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
//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 new[] { "read", "write", "follow" })
|
||||||
|
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,94 @@
|
|||||||
|
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(new[] { "read", "write", "follow" }));
|
||||||
|
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);
|
||||||
|
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: 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,489 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/accounts/verify_credentials"), Scope("read:accounts")]
|
||||||
|
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.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 } ? 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).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);
|
||||||
|
if (local == default && remote == default)
|
||||||
|
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);
|
||||||
|
if (local == default && remote == default)
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
|
||||||
|
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());
|
||||||
|
|
||||||
|
[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();
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
|
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
[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());
|
||||||
|
|
||||||
|
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));
|
||||||
|
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,
|
||||||
|
FollowedBy = followedBy?.IsAccepted == true,
|
||||||
|
RequestedBy = followedBy is { IsAccepted: false }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||||
|
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"] = Array.Empty<object>(),
|
||||||
|
["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 unicode emoji's reaction: no url (custom emoji come later); `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;
|
||||||
|
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,97 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public AppsController(IOpenIddictApplicationManager applications)
|
||||||
|
{
|
||||||
|
_applications = applications;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 = string.Empty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,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,277 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
public const string Version = "4.2.0 (compatible; PrivaPub)";
|
||||||
|
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;
|
||||||
|
|
||||||
|
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations, IInstanceSettings settings,
|
||||||
|
MastodonMapper mapper)
|
||||||
|
{
|
||||||
|
_localActors = localActors;
|
||||||
|
_registrations = registrations;
|
||||||
|
_settings = settings;
|
||||||
|
_mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.BarredPersonas(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 = 0 }, 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 = 0, max_pinned_statuses = StatusesController.MaxPins },
|
||||||
|
statuses = Statuses,
|
||||||
|
media_attachments = MediaAttachments,
|
||||||
|
polls = Polls,
|
||||||
|
translation = new { enabled = false }
|
||||||
|
},
|
||||||
|
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"), 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,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,186 @@
|
|||||||
|
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.DeletedAt.HasValue && p.ReblogOfPostId == null && !p.AuthorGone)
|
||||||
|
.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 && Searchable(post, author);
|
||||||
|
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
|
||||||
|
{
|
||||||
|
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||||
|
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/suggestions"), Scope("read")]
|
||||||
|
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
[HttpGet("/api/v2/suggestions"), Scope("read")]
|
||||||
|
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
||||||
|
public IActionResult Endorsements() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
|
||||||
|
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
|
||||||
|
|
||||||
|
[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"] = "default",
|
||||||
|
["reading:expand:spoilers"] = false
|
||||||
|
});
|
||||||
|
|
||||||
|
[HttpGet("/api/v1/push/subscription"), Scope("push")]
|
||||||
|
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
|
||||||
|
|
||||||
|
// 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 { });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
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
|
||||||
|
}).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
|
||||||
|
});
|
||||||
|
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,231 @@
|
|||||||
|
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:
|
||||||
|
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);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
var beating = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(KeepAlive, token);
|
||||||
|
await Write(":thump\n\n");
|
||||||
|
}
|
||||||
|
}, 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");
|
||||||
|
}
|
||||||
|
await beating;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Streams.Close(listener);
|
||||||
|
}
|
||||||
|
return new EmptyResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<object> View(string tag, bool? following, CancellationToken token) => new
|
||||||
|
{
|
||||||
|
name = tag,
|
||||||
|
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}",
|
||||||
|
history = await History(_dbEntities, tag, token),
|
||||||
|
following = following ?? (MyId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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,435 @@
|
|||||||
|
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);
|
||||||
|
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);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
|
||||||
|
{
|
||||||
|
var ids = groups.Select(g => g.ID).ToList();
|
||||||
|
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && 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 == MyId && !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(), MyId, 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, MyId),
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
};
|
||||||
|
|
||||||
|
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"), 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.BarredPersonas(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))
|
||||||
|
.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.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.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.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))
|
||||||
|
.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)
|
||||||
|
.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,481 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,105 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
if (scope != default && !MastodonScopes.Grants(User.GetScopes(), scope.Scope))
|
||||||
|
{
|
||||||
|
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
return await services.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, avatarId, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||||
|
{
|
||||||
|
public sealed record Page(string MaxId, string SinceId, string MinId, int Limit)
|
||||||
|
{
|
||||||
|
public static Page From(MastodonParams parameters, int limit) =>
|
||||||
|
new(parameters.Get("max_id"), parameters.Get("since_id"), parameters.Get("min_id"), limit);
|
||||||
|
|
||||||
|
public async Task<List<T>> Fetch<T>(Find<T, T> query, Expression<Func<T, string>> id, CancellationToken token) where T : IEntity
|
||||||
|
{
|
||||||
|
var field = new ExpressionFieldDefinition<T, string>(id);
|
||||||
|
if (!string.IsNullOrEmpty(MaxId))
|
||||||
|
query.Match(Builders<T>.Filter.Lt(field, MaxId));
|
||||||
|
if (!string.IsNullOrEmpty(SinceId))
|
||||||
|
query.Match(Builders<T>.Filter.Gt(field, SinceId));
|
||||||
|
if (!string.IsNullOrEmpty(MinId))
|
||||||
|
query.Match(Builders<T>.Filter.Gt(field, MinId));
|
||||||
|
var ascending = !string.IsNullOrEmpty(MinId);
|
||||||
|
var sortField = new ExpressionFieldDefinition<T>(Expression.Lambda<Func<T, object>>(Expression.Convert(id.Body, typeof(object)), id.Parameters));
|
||||||
|
var items = await query.Sort(s => ascending ? s.Ascending(sortField) : s.Descending(sortField)).Limit(Limit).ExecuteAsync(token);
|
||||||
|
if (ascending)
|
||||||
|
items.Reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using PrivaPub.Api.Mastodon.Entities;
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
namespace PrivaPub.Api.Mastodon.Mappers
|
||||||
|
{
|
||||||
|
public class AccountSearch
|
||||||
|
{
|
||||||
|
readonly MastodonMapper _mapper;
|
||||||
|
readonly DbEntities _dbEntities;
|
||||||
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly IRemoteActorService _remoteActors;
|
||||||
|
|
||||||
|
public AccountSearch(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
|
||||||
|
{
|
||||||
|
_mapper = mapper;
|
||||||
|
_dbEntities = dbEntities;
|
||||||
|
_localActors = localActors;
|
||||||
|
_remoteActors = remoteActors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token);
|
||||||
|
|
||||||
|
// following=true: only accounts the persona follows, matched by the start of their name, display name or server
|
||||||
|
// (what a client offers when adding to a list), read from what PrivaPub holds and never resolved
|
||||||
|
public async Task<List<Account>> Followed(string viewerId, string q, int limit, CancellationToken token)
|
||||||
|
{
|
||||||
|
q = q?.Trim().TrimStart('@') ?? string.Empty;
|
||||||
|
var ids = (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && f.State == Models.Social.FollowState.Accepted).ExecuteAsync(token))
|
||||||
|
.Select(f => f.TargetAccountId).Where(id => !string.IsNullOrEmpty(id)).ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return new List<Account>();
|
||||||
|
var parts = q.Split('@');
|
||||||
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
||||||
|
bool Matches(string userName, string name, string domain) =>
|
||||||
|
userName?.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase) == true
|
||||||
|
&& (parts.Length < 2 || domain?.StartsWith(parts[1], StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
|| parts.Length == 1 && name?.Contains(q, StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
var found = (await _dbEntities.Avatars.Match(a => ids.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||||||
|
.Where(a => Matches(a.UserName, a.Name, localDomain)).Select(a => (a.UserName, a.ID))
|
||||||
|
.Concat((await _dbEntities.ForeignAvatars.Match(f => ids.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
|
||||||
|
.Where(f => Matches(f.UserName, f.Name, f.Domain)).Select(f => (f.UserName, f.ID)))
|
||||||
|
.OrderBy(a => a.UserName, StringComparer.OrdinalIgnoreCase).Take(limit).Select(a => a.ID).ToList();
|
||||||
|
var accounts = await _mapper.Accounts(found, token);
|
||||||
|
return found.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// gone accounts (remote ones that deleted themselves, personas of banned or deleted roots) are never found
|
||||||
|
public async Task<List<Account>> Find(string q, bool resolve, int limit, int offset, CancellationToken token)
|
||||||
|
{
|
||||||
|
q = q?.Trim().TrimStart('@');
|
||||||
|
if (string.IsNullOrEmpty(q))
|
||||||
|
return new List<Account>();
|
||||||
|
var results = new List<Account>();
|
||||||
|
var parts = q.Split('@');
|
||||||
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
||||||
|
if (parts.Length == 2 && !parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(parts[0]))
|
||||||
|
{
|
||||||
|
var userName = parts[0];
|
||||||
|
var domain = parts[1].ToLowerInvariant();
|
||||||
|
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
if (known == default && resolve)
|
||||||
|
{
|
||||||
|
var actorUri = await _remoteActors.ResolveHandle(q, token);
|
||||||
|
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
|
||||||
|
}
|
||||||
|
if (known is { DeletionAt: null } && offset == 0)
|
||||||
|
results.Add(_mapper.Foreign(known));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefix = parts[0].ToLowerInvariant();
|
||||||
|
var barred = await Domain.Privacy.Counted.BarredPersonas(token);
|
||||||
|
var locals = await _dbEntities.Avatars
|
||||||
|
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
|
||||||
|
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
|
||||||
|
.Limit(offset + limit).ExecuteAsync(token);
|
||||||
|
foreach (var avatar in locals.Skip(offset))
|
||||||
|
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
|
||||||
|
var skipRemote = Math.Max(0, offset - locals.Count);
|
||||||
|
if (results.Count < limit)
|
||||||
|
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
|
||||||
|
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
|
||||||
|
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
|
||||||
|
results.Add(_mapper.Foreign(foreign));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,687 @@
|
|||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.Api.Mastodon.Entities;
|
||||||
|
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||||
|
using PrivaPub.Domain.Media;
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Rendering;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Group;
|
||||||
|
using PrivaPub.Domain.Privacy;
|
||||||
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Models.Post;
|
||||||
|
using PrivaPub.Domain.Statuses;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
using GroupEntity = PrivaPub.Models.Group.Group;
|
||||||
|
using PostEntity = PrivaPub.Models.Post.Post;
|
||||||
|
|
||||||
|
namespace PrivaPub.Api.Mastodon.Mappers
|
||||||
|
{
|
||||||
|
public class MastodonMapper
|
||||||
|
{
|
||||||
|
readonly DbEntities _dbEntities;
|
||||||
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly IMediaProxy _proxy;
|
||||||
|
|
||||||
|
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default)
|
||||||
|
{
|
||||||
|
_dbEntities = dbEntities;
|
||||||
|
_localActors = localActors;
|
||||||
|
_proxy = proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
|
||||||
|
|
||||||
|
public string ProxiedUrl(string url) => Proxied(url);
|
||||||
|
|
||||||
|
// notifications as the persona sees them: one whose account or post it can no longer see is left out
|
||||||
|
public async Task<List<Entities.Notification>> Notifications(List<Models.Social.Notification> notifications, string viewerId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var accounts = await Accounts(notifications.Select(n => n.FromAccountId), token);
|
||||||
|
var postIds = notifications.Where(n => n.PostId != default).Select(n => n.PostId).Distinct().ToList();
|
||||||
|
var posts = postIds.Count == 0
|
||||||
|
? new List<PostEntity>()
|
||||||
|
: await _dbEntities.Posts.Match(p => postIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token);
|
||||||
|
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
|
||||||
|
var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
|
||||||
|
return notifications
|
||||||
|
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||||
|
.Select(n => new Entities.Notification
|
||||||
|
{
|
||||||
|
Id = n.ID,
|
||||||
|
Type = Controllers.NotificationsController.Names[n.Type],
|
||||||
|
CreatedAt = MastodonJson.Time(n.CreatedAt),
|
||||||
|
//the group it belongs to by default, so a notification told alone (v1, a stream) joins its group in a client
|
||||||
|
GroupKey = Controllers.NotificationsController.GroupKey(n, Controllers.NotificationsController.DefaultGrouped),
|
||||||
|
Account = accounts[n.FromAccountId],
|
||||||
|
Status = n.PostId == default || n.Type == NotificationType.ModerationWarning ? default : statuses[n.PostId],
|
||||||
|
Emoji = n.Emoji == default ? default : n.EmojiURL == default ? n.Emoji : $":{n.Emoji}:",
|
||||||
|
EmojiUrl = Proxied(n.EmojiURL),
|
||||||
|
ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning
|
||||||
|
{
|
||||||
|
Id = n.ID,
|
||||||
|
Text = n.Text ?? string.Empty,
|
||||||
|
StatusIds = n.PostId == default ? new List<string>() : new List<string> { n.PostId },
|
||||||
|
TargetAccount = warned,
|
||||||
|
CreatedAt = MastodonJson.Time(n.CreatedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
string MissingAvatar => $"{_localActors.BaseAddress}/media/missing-avatar.png";
|
||||||
|
string MissingHeader => $"{_localActors.BaseAddress}/media/missing-header.png";
|
||||||
|
|
||||||
|
public async Task<Account> Local(LocalActor actor, bool withSource, CancellationToken token)
|
||||||
|
{
|
||||||
|
var followers = await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && f.LocalActorKind == actor.Kind && f.IsAccepted, token);
|
||||||
|
var following = actor.Kind == LocalActorKind.Person
|
||||||
|
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
|
||||||
|
: 0;
|
||||||
|
var latest = await _dbEntities.Posts
|
||||||
|
.Match(Counted.By(actor))
|
||||||
|
.Sort(p => p.ID, Order.Descending)
|
||||||
|
.Limit(1)
|
||||||
|
.ExecuteFirstAsync(token);
|
||||||
|
var statuses = latest == default ? 0 : await Counted.PostsBy(actor, token);
|
||||||
|
|
||||||
|
var account = new Account
|
||||||
|
{
|
||||||
|
Id = actor.Id,
|
||||||
|
Username = actor.UserName,
|
||||||
|
Acct = actor.UserName,
|
||||||
|
DisplayName = actor.Name ?? actor.UserName,
|
||||||
|
Locked = actor.ManuallyApprovesFollowers,
|
||||||
|
Bot = actor.IsBot,
|
||||||
|
Discoverable = actor.Discoverable,
|
||||||
|
Indexable = actor.Indexable,
|
||||||
|
Noindex = !actor.Indexable,
|
||||||
|
HideCollections = true,//follower and following lists are never shown to others, only their counts
|
||||||
|
Group = actor.Kind == LocalActorKind.Group,
|
||||||
|
CreatedAt = MastodonJson.Day(actor.Published),
|
||||||
|
Note = ActivityPubRenderer.Html(actor.Summary),
|
||||||
|
Url = actor.HtmlUrl,
|
||||||
|
Uri = actor.Uri,
|
||||||
|
Avatar = actor.PictureURL ?? MissingAvatar,
|
||||||
|
AvatarStatic = actor.PictureURL ?? MissingAvatar,
|
||||||
|
Header = actor.ThumbnailURL ?? MissingHeader,
|
||||||
|
HeaderStatic = actor.ThumbnailURL ?? MissingHeader,
|
||||||
|
FollowersCount = (int)followers,
|
||||||
|
FollowingCount = (int)following,
|
||||||
|
StatusesCount = (int)statuses,
|
||||||
|
LastStatusAt = latest == default ? default : latest.CreationDate.ToString("yyyy-MM-dd"),
|
||||||
|
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = ActivityPubRenderer.FieldValue(f.Value) }).ToList()
|
||||||
|
};
|
||||||
|
if (withSource)
|
||||||
|
account.Source = new Source
|
||||||
|
{
|
||||||
|
Privacy = actor.Settings.DefaultVisibility ?? "public",
|
||||||
|
Sensitive = actor.Settings.DefaultSensitive,
|
||||||
|
Language = actor.Settings.DefaultLanguage,
|
||||||
|
QuotePolicy = actor.Settings.QuotePolicy ?? QuotePolicies.Public,
|
||||||
|
Note = actor.Summary ?? string.Empty,
|
||||||
|
Fields = actor.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||||
|
FollowRequestsCount = (int)await DB.Default.CountAsync<Follower>(f => f.LocalActorId == actor.Id && !f.IsAccepted, token)
|
||||||
|
};
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Account Foreign(ForeignAvatar foreign) => new()
|
||||||
|
{
|
||||||
|
Id = foreign.ID,
|
||||||
|
Username = foreign.UserName,
|
||||||
|
Acct = $"{foreign.UserName}@{foreign.Domain}",
|
||||||
|
DisplayName = foreign.Name ?? foreign.UserName ?? string.Empty,
|
||||||
|
Bot = foreign.AvatarType is AvatarType.Service or AvatarType.Application,
|
||||||
|
Group = foreign.AvatarType == AvatarType.Group,
|
||||||
|
Locked = foreign.IsLocked,
|
||||||
|
Discoverable = foreign.IsDiscoverable,
|
||||||
|
Indexable = foreign.IsIndexable,
|
||||||
|
Memorial = foreign.IsMemorial ? true : default(bool?),
|
||||||
|
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
|
||||||
|
Emojis = Emojis(foreign.Emojis),
|
||||||
|
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||||
|
AvatarDescription = foreign.PictureDescription ?? string.Empty,
|
||||||
|
HeaderDescription = foreign.HeaderDescription ?? string.Empty,
|
||||||
|
Note = foreign.Biography ?? string.Empty,
|
||||||
|
Url = foreign.Url ?? foreign.ActorURI,
|
||||||
|
Uri = foreign.ActorURI,
|
||||||
|
Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||||
|
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||||
|
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||||
|
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||||
|
FollowersCount = foreign.FollowersCount ?? 0,//as its server publishes them (AccountCountsJob); 0 while unknown
|
||||||
|
FollowingCount = foreign.FollowingCount ?? 0,
|
||||||
|
StatusesCount = foreign.StatusesCount ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||||
|
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, Account>> Accounts(IEnumerable<string> ids, CancellationToken token)
|
||||||
|
{
|
||||||
|
var wanted = ids.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
|
||||||
|
var accounts = new Dictionary<string, Account>();
|
||||||
|
if (wanted.Count == 0)
|
||||||
|
return accounts;
|
||||||
|
|
||||||
|
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||||||
|
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
||||||
|
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
|
||||||
|
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
||||||
|
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
|
||||||
|
foreach (var foreign in foreigns)
|
||||||
|
accounts[foreign.ID] = Foreign(foreign);
|
||||||
|
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
|
||||||
|
var movedTo = foreigns.Where(f => !string.IsNullOrEmpty(f.MovedToURL)).Select(f => f.MovedToURL).Distinct().ToList();
|
||||||
|
if (movedTo.Count > 0)
|
||||||
|
{
|
||||||
|
var targets = (await _dbEntities.ForeignAvatars.Match(f => movedTo.Contains(f.ActorURI) && !f.DeletionAt.HasValue).ExecuteAsync(token))
|
||||||
|
.ToDictionary(f => f.ActorURI);
|
||||||
|
foreach (var foreign in foreigns)
|
||||||
|
if (foreign.MovedToURL != default && targets.TryGetValue(foreign.MovedToURL, out var target) && target.AlsoKnownAs.Contains(foreign.ActorURI))
|
||||||
|
accounts[foreign.ID].Moved = Foreign(target);
|
||||||
|
}
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Status> Status(PostEntity post, string viewerId, CancellationToken token) =>
|
||||||
|
(await Statuses(new[] { post }, viewerId, nested: false, token, hideFromViewer: false)).FirstOrDefault();
|
||||||
|
|
||||||
|
public Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, CancellationToken token) =>
|
||||||
|
Statuses(posts, viewerId, nested: false, token);
|
||||||
|
|
||||||
|
// Lists leave out what the viewer blocked or muted; a status asked for by its id is still shown, as Mastodon shows it
|
||||||
|
// (only its author's block of the viewer hides it, and that is decided before mapping)
|
||||||
|
async Task<List<Status>> Statuses(IReadOnlyCollection<PostEntity> posts, string viewerId, bool nested, CancellationToken token,
|
||||||
|
bool hideFromViewer = true)
|
||||||
|
{
|
||||||
|
var originalIds = posts.Where(p => p.ReblogOfPostId != default).Select(p => p.ReblogOfPostId).Distinct().ToList();
|
||||||
|
var originals = originalIds.Count == 0
|
||||||
|
? new Dictionary<string, PostEntity>()
|
||||||
|
: (await _dbEntities.Posts.Match(p => originalIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token)).ToDictionary(p => p.ID);
|
||||||
|
var all = posts.Concat(originals.Values).ToList();
|
||||||
|
|
||||||
|
var mentionUris = all.SelectMany(p => p.Mentions).Where(m => !m.IsLocal).Select(m => m.ActorURI).Distinct().ToList();
|
||||||
|
var mentionAccounts = mentionUris.Count == 0
|
||||||
|
? new Dictionary<string, ForeignAvatar>()
|
||||||
|
: (await _dbEntities.ForeignAvatars.Match(f => mentionUris.Contains(f.ActorURI)).ExecuteAsync(token)).ToDictionary(f => f.ActorURI);
|
||||||
|
var accounts = await Accounts(all.Select(AuthorOf).Concat(all.Select(p => p.WallOwnerAccountId).Where(w => w != default)), token);
|
||||||
|
|
||||||
|
var ids = all.Select(p => p.ID).ToList();
|
||||||
|
var favourited = viewerId == default
|
||||||
|
? new HashSet<string>()
|
||||||
|
: (await _dbEntities.Favourites.Match(f => f.AccountId == viewerId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId).ToHashSet();
|
||||||
|
var downvoted = viewerId == default
|
||||||
|
? new HashSet<string>()
|
||||||
|
: (await DB.Default.Find<Downvote>().Match(d => d.AccountId == viewerId && ids.Contains(d.PostId)).ExecuteAsync(token)).Select(d => d.PostId).ToHashSet();
|
||||||
|
var bookmarked = viewerId == default
|
||||||
|
? new HashSet<string>()
|
||||||
|
: (await DB.Default.Find<Bookmark>().Match(b => b.AvatarId == viewerId && ids.Contains(b.PostId)).ExecuteAsync(token)).Select(b => b.PostId).ToHashSet();
|
||||||
|
var pinned = (await DB.Default.Find<Pin>().Match(p => ids.Contains(p.PostId)).ExecuteAsync(token)).Select(p => p.PostId).ToHashSet();
|
||||||
|
var reactions = (await DB.Default.Find<Reaction>().Match(r => ids.Contains(r.PostId)).ExecuteAsync(token))
|
||||||
|
.GroupBy(r => r.PostId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.GroupBy(r => r.Emoji)
|
||||||
|
.Select(e => new EmojiReactionEntity
|
||||||
|
{
|
||||||
|
Name = e.Key,
|
||||||
|
Count = e.Count(),
|
||||||
|
Me = viewerId != default && e.Any(r => r.AccountId == viewerId),
|
||||||
|
Url = Proxied(e.First().EmojiURL),
|
||||||
|
StaticUrl = Proxied(e.First().EmojiURL)
|
||||||
|
})
|
||||||
|
.OrderByDescending(e => e.Count)
|
||||||
|
.ToList());
|
||||||
|
var eventIds = all.Where(p => p.Event != default && p.IsFederatedCopy).Select(p => p.ID).ToList();
|
||||||
|
var participations = viewerId == default || eventIds.Count == 0
|
||||||
|
? new Dictionary<string, Models.Social.ParticipationState>()
|
||||||
|
: (await DB.Default.Find<Models.Social.Participation>().Match(p => p.AvatarId == viewerId && eventIds.Contains(p.PostId)).ExecuteAsync(token))
|
||||||
|
.ToDictionary(p => p.PostId, p => p.State);
|
||||||
|
var pollIds = all.Where(p => p.Poll != default).Select(p => p.ID).ToList();
|
||||||
|
var ownVotes = viewerId == default || pollIds.Count == 0
|
||||||
|
? new Dictionary<string, List<int>>()
|
||||||
|
: (await DB.Default.Find<PollVote>().Match(v => pollIds.Contains(v.PostId) && v.VoterAccountId == viewerId && v.IsLocalVoter).ExecuteAsync(token))
|
||||||
|
.GroupBy(v => v.PostId).ToDictionary(g => g.Key, g => g.Select(v => v.Choice).ToList());
|
||||||
|
var hidden = viewerId == default || !hideFromViewer
|
||||||
|
? new HashSet<string>()
|
||||||
|
: await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewerId, all.Select(p => p.ActorURI), forNotifications: false, token);
|
||||||
|
var filters = await Domain.Social.Filters.Of(viewerId, token);
|
||||||
|
var reblogged = viewerId == default
|
||||||
|
? new HashSet<string>()
|
||||||
|
: (await _dbEntities.Posts.Match(p => p.AuthorAccountId == viewerId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||||
|
.Select(p => p.ReblogOfPostId).ToHashSet();
|
||||||
|
|
||||||
|
Status Map(PostEntity post)
|
||||||
|
{
|
||||||
|
if (AuthorOf(post) is not { } author || !accounts.TryGetValue(author, out var account))
|
||||||
|
return default;//a post whose author we cannot show is left out, never a 500
|
||||||
|
var status = new Status
|
||||||
|
{
|
||||||
|
Id = post.ID,
|
||||||
|
Uri = post.ObjectURI,
|
||||||
|
Url = post.Url ?? post.ObjectURI,
|
||||||
|
Account = account,
|
||||||
|
InReplyToId = post.AnsweringToPostId,
|
||||||
|
InReplyToAccountId = post.InReplyToAccountId,
|
||||||
|
Content = Content(post),
|
||||||
|
CreatedAt = MastodonJson.Time(post.CreationDate),
|
||||||
|
EditedAt = post.EditedAt.HasValue ? MastodonJson.Time(post.EditedAt.Value) : default,
|
||||||
|
RepliesCount = post.RepliesCount,
|
||||||
|
ReblogsCount = post.ReblogsCount,
|
||||||
|
FavouritesCount = post.FavouritesCount,
|
||||||
|
Favourited = favourited.Contains(post.ID),
|
||||||
|
Reblogged = reblogged.Contains(post.ID),
|
||||||
|
Bookmarked = bookmarked.Contains(post.ID),
|
||||||
|
Pinned = pinned.Contains(post.ID),
|
||||||
|
Sensitive = post.HasContentWarning,
|
||||||
|
//a remote post keeps its own summary: sensitive with none (Akkoma's sensitive media, Lemmy's NSFW) hides the
|
||||||
|
//media, never the words behind an invented warning; a local post warned without words gets the default one
|
||||||
|
SpoilerText = post.SpoilerText ?? (post.HasContentWarning && !post.IsFederatedCopy ? post.Title ?? ActivityPubRenderer.ContentWarning : string.Empty),
|
||||||
|
Visibility = Visibility(post.Visibility),
|
||||||
|
Language = post.Language,
|
||||||
|
MediaAttachments = post.Media.Count > 0 ? post.Media.Select(Media).ToList() : Playable(post),
|
||||||
|
Card = Card(post),
|
||||||
|
Poll = Poll(post, viewerId, ownVotes.GetValueOrDefault(post.ID)),
|
||||||
|
Emojis = Emojis(post.Emojis),
|
||||||
|
Privapub = Extension(post, accounts, viewerId == default ? default(bool?) : downvoted.Contains(post.ID)),
|
||||||
|
EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new(),
|
||||||
|
Pleroma = new PleromaStatus { EmojiReactions = reactions.GetValueOrDefault(post.ID) ?? new() },
|
||||||
|
Mentions = post.Mentions.Where(m => !m.Silent).Select(m => Mention(m, mentionAccounts)).Where(m => m != default).ToList(),
|
||||||
|
Tags = post.Tags.Select(t => new StatusTag { Name = t, Url = ActivityPubRenderer.TagUrl(_localActors.BaseAddress, t) }).ToList(),
|
||||||
|
Filtered = filters.Of(post)
|
||||||
|
};
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localAuthors = all.Where(p => !p.IsFederatedCopy && p.GroupUserId != default).Select(p => p.GroupUserId).Distinct().ToList();
|
||||||
|
var followsAuthor = viewerId == default || localAuthors.Count == 0
|
||||||
|
? new HashSet<string>()
|
||||||
|
: await FollowedLocalAuthors(viewerId, localAuthors, token);
|
||||||
|
var quotedIds = nested ? new List<string>() : all.Where(p => p.QuoteState == QuoteState.Accepted && p.QuotedPostId != default).Select(p => p.QuotedPostId).Distinct().ToList();
|
||||||
|
var quotedPosts = new List<PostEntity>();
|
||||||
|
foreach (var quotedPost in quotedIds.Count == 0 ? new List<PostEntity>() : await _dbEntities.Posts.Match(p => quotedIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||||
|
if (quotedPost.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(quotedPost, viewerId, token))
|
||||||
|
quotedPosts.Add(quotedPost);
|
||||||
|
var quotedStatuses = quotedPosts.Count == 0
|
||||||
|
? new Dictionary<string, Status>()
|
||||||
|
: (await Statuses(quotedPosts, viewerId, nested: true, token)).ToDictionary(q => q.Id);
|
||||||
|
|
||||||
|
void Quote(Status status, PostEntity post)
|
||||||
|
{
|
||||||
|
status.QuotesCount = post.QuotesCount;
|
||||||
|
status.QuoteApproval = post.IsFederatedCopy ? Approval(post, viewerId) : LocalApproval(post, viewerId, followsAuthor.Contains(post.GroupUserId));
|
||||||
|
status.InteractionPolicy = Policies(post);
|
||||||
|
if (status.Privapub?.Event != default && participations.TryGetValue(post.ID, out var participation))
|
||||||
|
status.Privapub.Event.Participation = participation.ToString().ToLowerInvariant();
|
||||||
|
if (post.QuoteState == QuoteState.None)
|
||||||
|
return;
|
||||||
|
var state = post.QuoteState.ToString().ToLowerInvariant();
|
||||||
|
var quoted = post.QuotedPostId != default && quotedStatuses.TryGetValue(post.QuotedPostId, out var shown) ? shown : default;
|
||||||
|
status.Quote = nested
|
||||||
|
? new QuoteEntity { State = state, QuotedStatusId = post.QuotedPostId }
|
||||||
|
: new QuoteEntity { State = state, QuotedStatus = post.QuoteState == QuoteState.Accepted ? quoted : default, QuotedStatusId = post.QuotedPostId };
|
||||||
|
if (!nested && post.QuoteState == QuoteState.Accepted && quoted != default)
|
||||||
|
status.Content = WithoutQuoteFallback(status.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapped = new List<Status>();
|
||||||
|
foreach (var post in posts)
|
||||||
|
{
|
||||||
|
if (!VisibilityPolicy.Shown(post) || hidden.Contains(post.ActorURI) || post.ReblogOfPostId != default && originals.TryGetValue(post.ReblogOfPostId, out var hiddenOriginal)
|
||||||
|
&& hidden.Contains(hiddenOriginal.ActorURI))
|
||||||
|
continue;
|
||||||
|
var status = Map(post);
|
||||||
|
if (status == default)
|
||||||
|
continue;
|
||||||
|
Quote(status, post);
|
||||||
|
if (post.ReblogOfPostId != default)
|
||||||
|
{
|
||||||
|
if (!originals.TryGetValue(post.ReblogOfPostId, out var original) || Map(original) is not { } inner)
|
||||||
|
continue;
|
||||||
|
Quote(inner, original);
|
||||||
|
status.Reblog = inner;
|
||||||
|
status.Filtered = inner.Filtered;//a boost is filtered as what it boosts
|
||||||
|
status.Url = inner.Url;//a boost has no page of its own; /grunts would answer JSON to a browser
|
||||||
|
status.Content = string.Empty;
|
||||||
|
status.Reblogged = reblogged.Contains(original.ID);
|
||||||
|
}
|
||||||
|
mapped.Add(status);
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string AuthorOf(PostEntity post) => post.AuthorAccountId ?? post.GroupUserId;
|
||||||
|
|
||||||
|
static readonly AngleSharp.Html.Parser.HtmlParser Fragments = new();
|
||||||
|
|
||||||
|
public static string WithoutQuoteFallback(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html) || !html.Contains("quote-inline", StringComparison.Ordinal))
|
||||||
|
return html;
|
||||||
|
var document = Fragments.ParseDocument($"<body>{html}</body>");
|
||||||
|
foreach (var fallback in document.QuerySelectorAll(".quote-inline").ToList())
|
||||||
|
fallback.Remove();
|
||||||
|
return document.Body!.InnerHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<HashSet<string>> FollowedLocalAuthors(string viewerId, List<string> authorIds, CancellationToken token)
|
||||||
|
{
|
||||||
|
var uris = new Dictionary<string, string>();
|
||||||
|
foreach (var avatar in await _dbEntities.Avatars.Match(a => authorIds.Contains(a.ID)).ExecuteAsync(token))
|
||||||
|
uris[_localActors.FromAvatar(avatar).Uri] = avatar.ID;
|
||||||
|
var targets = uris.Keys.ToList();
|
||||||
|
return (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && targets.Contains(f.TargetActorURI) && f.State == FollowState.Accepted).ExecuteAsync(token))
|
||||||
|
.Select(f => uris[f.TargetActorURI]).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
static QuoteApprovalEntity LocalApproval(PostEntity post, string viewerId, bool viewerFollows)
|
||||||
|
{
|
||||||
|
var policy = ActivityPubRenderer.QuotableBy(post);
|
||||||
|
var everyone = policy switch { QuotePolicies.Public => new List<string> { "public" }, QuotePolicies.Followers => new List<string> { "followers" }, _ => new List<string>() };
|
||||||
|
var current = viewerId == default ? "unknown"
|
||||||
|
: viewerId == post.GroupUserId && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? "automatic"
|
||||||
|
: policy == QuotePolicies.Public || policy == QuotePolicies.Followers && viewerFollows ? "automatic"
|
||||||
|
: "denied";
|
||||||
|
return new QuoteApprovalEntity { Automatic = everyone, CurrentUser = current };
|
||||||
|
}
|
||||||
|
|
||||||
|
// a remote post's canLike, canReply and canAnnounce, in GoToSocial's client terms; null when it states none
|
||||||
|
static InteractionPolicyEntity Policies(PostEntity post)
|
||||||
|
{
|
||||||
|
if (!post.IsFederatedCopy || post.LikePolicy == default && post.ReplyPolicy == default && post.AnnouncePolicy == default)
|
||||||
|
return default;
|
||||||
|
var collections = post.ActorURI?.TrimEnd('/');
|
||||||
|
string Named(string who) => Addressing.IsPublic(who) ? "public"
|
||||||
|
: who == post.ActorURI ? "author"
|
||||||
|
: who.EndsWith("/followers", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "followers"
|
||||||
|
: who.EndsWith("/following", StringComparison.Ordinal) && who.StartsWith(collections, StringComparison.Ordinal) ? "following"
|
||||||
|
: who;
|
||||||
|
InteractionRuleEntity Rule(InteractionRule rule) => rule == default
|
||||||
|
? new InteractionRuleEntity { AutomaticApproval = new List<string> { "public" } }
|
||||||
|
: new InteractionRuleEntity { AutomaticApproval = rule.Automatic.Select(Named).Distinct().ToList(), ManualApproval = rule.Manual.Select(Named).Distinct().ToList() };
|
||||||
|
return new InteractionPolicyEntity { CanFavourite = Rule(post.LikePolicy), CanReply = Rule(post.ReplyPolicy), CanReblog = Rule(post.AnnouncePolicy) };
|
||||||
|
}
|
||||||
|
|
||||||
|
static QuoteApprovalEntity Approval(PostEntity post, string viewerId)
|
||||||
|
{
|
||||||
|
if (!post.IsFederatedCopy || post.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||||
|
return new QuoteApprovalEntity();
|
||||||
|
if (post.QuotePolicy is not { } policy)
|
||||||
|
return new QuoteApprovalEntity { Automatic = new List<string> { "public" }, CurrentUser = viewerId == default ? "unknown" : "automatic" };
|
||||||
|
static List<string> Named(IEnumerable<string> who) => who.Select(w => Addressing.IsPublic(w) ? "public" : w.EndsWith("/followers") ? "followers" : "unsupported_policy").Distinct().ToList();
|
||||||
|
return new QuoteApprovalEntity
|
||||||
|
{
|
||||||
|
Automatic = Named(policy.Automatic),
|
||||||
|
Manual = Named(policy.Manual),
|
||||||
|
CurrentUser = viewerId == default ? "unknown" : policy.Automatic.Any(Addressing.IsPublic) ? "automatic" : policy.Manual.Any(Addressing.IsPublic) ? "manual" : "denied"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Content(PostEntity post)
|
||||||
|
{
|
||||||
|
var content = post.ContentHtml ?? ActivityPubRenderer.Html(post.Text);
|
||||||
|
if (post.IsFederatedCopy && string.IsNullOrWhiteSpace(content) && !string.IsNullOrEmpty(post.Excerpt))
|
||||||
|
content = $"<p>{WebUtility.HtmlEncode(post.Excerpt)}</p>";
|
||||||
|
if (post.Event is { } happening && EventLine(happening) is { } line)
|
||||||
|
content = $"<p>{WebUtility.HtmlEncode(line)}</p>{content}";
|
||||||
|
var titled = !string.IsNullOrEmpty(post.Title) && (!post.IsFederatedCopy || post.ObjectType is not (null or "Note" or "Question"));
|
||||||
|
return titled
|
||||||
|
? $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}"
|
||||||
|
: content ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string EventLine(EventDetails happening)
|
||||||
|
{
|
||||||
|
if (happening.Start is not { } start)
|
||||||
|
return default;
|
||||||
|
var zone = Zone(happening.FloatingTime ? default : happening.TimeZone);
|
||||||
|
var local = zone == default ? start : TimeZoneInfo.ConvertTimeFromUtc(start, zone);
|
||||||
|
var when = local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
if (happening.End is { } end && end > start)
|
||||||
|
{
|
||||||
|
var localEnd = zone == default ? end : TimeZoneInfo.ConvertTimeFromUtc(end, zone);
|
||||||
|
when += " – " + localEnd.ToString(localEnd.Date == local.Date ? "HH:mm" : "yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
when += happening.FloatingTime ? " (local time)" : zone == default ? " UTC" : $" ({happening.TimeZone})";
|
||||||
|
var where = happening.Places.Select(p => p.Name ?? p.Address).FirstOrDefault(p => p != default)
|
||||||
|
?? (happening.OnlineURL != default ? "online" : default);
|
||||||
|
return where == default ? when : $"{when} · {where}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static TimeZoneInfo Zone(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
return default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PollEntity Poll(PostEntity post, string viewerId, IReadOnlyCollection<int> ownVotes)
|
||||||
|
{
|
||||||
|
if (post.Poll is not { } poll)
|
||||||
|
return default;
|
||||||
|
var expired = PollService.Ended(poll);
|
||||||
|
var hidden = poll.HideTotals && !expired && post.GroupUserId != viewerId;
|
||||||
|
return new PollEntity
|
||||||
|
{
|
||||||
|
Id = post.ID,
|
||||||
|
ExpiresAt = poll.ExpiresAt is { } ends ? MastodonJson.Time(ends) : default,
|
||||||
|
Expired = expired,
|
||||||
|
Multiple = poll.Multiple,
|
||||||
|
VotesCount = poll.Options.Sum(o => o.Votes),
|
||||||
|
VotersCount = poll.Multiple ? poll.VotersCount : poll.VotersCount ?? poll.Options.Sum(o => o.Votes),
|
||||||
|
Voted = viewerId == default ? default(bool?) : ownVotes?.Count > 0 || post.GroupUserId == viewerId,
|
||||||
|
OwnVotes = viewerId == default ? default : ownVotes?.ToList() ?? new List<int>(),
|
||||||
|
Options = poll.Options.Select(o => new PollOptionEntity { Title = o.Title, VotesCount = hidden ? default(int?) : o.Votes }).ToList(),
|
||||||
|
Emojis = Emojis(post.Emojis.Where(e => poll.Options.Any(o => o.Title.Contains($":{e.Shortcode}:"))))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CustomEmojiEntity> Emojis(IEnumerable<CustomEmoji> emojis) =>
|
||||||
|
emojis?.Select(e => new CustomEmojiEntity { Shortcode = e.Shortcode, Url = Proxied(e.URL), StaticUrl = Proxied(e.URL) }).ToList() ?? new();
|
||||||
|
|
||||||
|
PrivaPubStatus Extension(PostEntity post, Dictionary<string, Account> accounts, bool? downvoted) => new()
|
||||||
|
{
|
||||||
|
Wall = post.WallOwnerAccountId == default || post.WallOwnerAccountId == AuthorOf(post) ? default : accounts.GetValueOrDefault(post.WallOwnerAccountId),
|
||||||
|
ObjectType = post.ObjectType,
|
||||||
|
Locked = post.LockedAt.HasValue,
|
||||||
|
Approval = post.Approval is ApprovalState.Pending or ApprovalState.Rejected ? post.Approval.ToString().ToLowerInvariant() : default,
|
||||||
|
Title = post.IsFederatedCopy ? post.Title : default,
|
||||||
|
Excerpt = post.Excerpt,
|
||||||
|
Cover = Proxied(post.CoverURL),
|
||||||
|
Source = post.Source == default ? default : new PrivaPubSource { Content = post.Source.Content, MediaType = post.Source.MediaType },
|
||||||
|
Link = post.Link == default ? default : new PrivaPubLink
|
||||||
|
{
|
||||||
|
Url = post.Link.Href, Title = post.Link.Title, Description = post.Link.Description, Image = Proxied(post.Link.ImageURL), FromPublisher = post.Link.FromPublisher
|
||||||
|
},
|
||||||
|
Video = post.Video == default ? default : new PrivaPubVideo
|
||||||
|
{
|
||||||
|
Duration = post.Video.DurationSeconds,
|
||||||
|
Variants = post.Video.Variants.Select(Variant).ToList(),
|
||||||
|
IsLive = post.Video.IsLive,
|
||||||
|
State = post.Video.State,
|
||||||
|
Views = post.Video.Views,
|
||||||
|
CommentsPolicy = post.Video.CommentsPolicy,
|
||||||
|
DownloadEnabled = post.Video.DownloadEnabled,
|
||||||
|
Licence = post.Video.Licence,
|
||||||
|
Category = post.Video.Category,
|
||||||
|
Channel = post.Video.ChannelURI,
|
||||||
|
Captions = post.Video.Captions.Select(c => new PrivaPubCaption { Language = c.Language, Url = Proxied(c.Url) }).ToList()
|
||||||
|
},
|
||||||
|
Audio = post.Audio == default ? default : new PrivaPubAudio
|
||||||
|
{
|
||||||
|
Duration = post.Audio.DurationSeconds,
|
||||||
|
Variants = post.Audio.Variants.Select(Variant).ToList(),
|
||||||
|
Album = post.Audio.Album,
|
||||||
|
Position = post.Audio.Position,
|
||||||
|
Licence = post.Audio.Licence
|
||||||
|
},
|
||||||
|
Event = post.Event == default ? default : new PrivaPubEvent
|
||||||
|
{
|
||||||
|
Start = post.Event.Start is { } start ? MastodonJson.Time(start) : default,
|
||||||
|
End = post.Event.End is { } end ? MastodonJson.Time(end) : default,
|
||||||
|
TimeZone = post.Event.TimeZone,
|
||||||
|
Floating = post.Event.FloatingTime,
|
||||||
|
Places = post.Event.Places.Select(p => new PrivaPubPlace { Name = p.Name, Address = p.Address, Latitude = p.Latitude, Longitude = p.Longitude, Url = p.Url }).ToList(),
|
||||||
|
OnlineUrl = post.Event.OnlineURL,
|
||||||
|
JoinMode = post.Event.JoinMode,
|
||||||
|
Participants = post.Event.ParticipantCount,
|
||||||
|
Capacity = post.Event.Capacity,
|
||||||
|
Status = post.Event.Status,
|
||||||
|
Category = post.Event.Category
|
||||||
|
},
|
||||||
|
Place = post.Place == default ? default : new PrivaPubPlace
|
||||||
|
{
|
||||||
|
Name = post.Place.Name, Latitude = post.Place.Latitude, Longitude = post.Place.Longitude, Country = post.Place.Country
|
||||||
|
},
|
||||||
|
Votes = new PrivaPubVotes { Up = post.FavouritesCount, Down = post.DownvotesCount, Downvoted = downvoted },
|
||||||
|
Flairs = post.Flairs?.Select(f => new PrivaPubFlair
|
||||||
|
{
|
||||||
|
Id = f.Id, Name = f.Name, Slug = f.Slug, Description = f.Description, Color = f.Color, TextColor = f.TextColor,
|
||||||
|
BackgroundColor = f.BackgroundColor, BlurImages = f.BlurImages
|
||||||
|
}).ToList() ?? new()
|
||||||
|
};
|
||||||
|
|
||||||
|
PrivaPubVariant Variant(MediaVariant variant) => new()
|
||||||
|
{
|
||||||
|
Url = Proxied(variant.Url),
|
||||||
|
MediaType = variant.MediaType,
|
||||||
|
Width = variant.Width,
|
||||||
|
Height = variant.Height,
|
||||||
|
Fps = variant.Fps,
|
||||||
|
Size = variant.Size,
|
||||||
|
Streams = variant.Streams
|
||||||
|
};
|
||||||
|
|
||||||
|
List<MediaAttachment> Playable(PostEntity post)
|
||||||
|
{
|
||||||
|
var (variant, kind, duration) = post.Video is { } video ? (BestVideo(video.Variants), "video", video.DurationSeconds)
|
||||||
|
: post.Audio is { } audio ? (audio.Variants.FirstOrDefault(), "audio", audio.DurationSeconds)
|
||||||
|
: default;
|
||||||
|
if (variant == default)
|
||||||
|
return new List<MediaAttachment>();
|
||||||
|
return new List<MediaAttachment>
|
||||||
|
{
|
||||||
|
Media(new PostMedia
|
||||||
|
{
|
||||||
|
Id = new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(variant.Url))),
|
||||||
|
RemoteURL = variant.Url,
|
||||||
|
ContentType = variant.MediaType ?? kind + "/*",
|
||||||
|
RemotePreviewURL = post.CoverURL,
|
||||||
|
Width = variant.Width,
|
||||||
|
Height = variant.Height,
|
||||||
|
DurationSeconds = duration,
|
||||||
|
Description = post.Title
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MediaVariant BestVideo(IReadOnlyCollection<MediaVariant> variants)
|
||||||
|
{
|
||||||
|
var playable = variants.Where(v => v.MediaType?.StartsWith("video/") == true && (v.Streams.Count == 0 || v.Streams.Contains("audio") && v.Streams.Contains("video"))).ToList();
|
||||||
|
return playable.Where(v => v.Height <= 720).OrderByDescending(v => v.Height).FirstOrDefault()
|
||||||
|
?? playable.OrderBy(v => v.Height ?? int.MaxValue).FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviewCard Card(PostEntity post)
|
||||||
|
{
|
||||||
|
if (post.Link is { } link)
|
||||||
|
return Card(link.Href, link.Title ?? post.Title, link.Description ?? (post.Media.Count == 0 ? post.Excerpt : default), link.ImageURL);
|
||||||
|
if (post.IsFederatedCopy && post.ObjectType is "Article" or "Event" && post.Url != default && post.Media.Count == 0
|
||||||
|
|| post.IsFederatedCopy && post.ObjectType is "Video" or "Audio" && post.Url != default && post.Media.Count == 0 && Playable(post).Count == 0)
|
||||||
|
return Card(post.Url, post.Title, post.Excerpt, post.CoverURL);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PreviewCard LinkCard(string url, string title, string description, string image) => Card(url, title, description, image);
|
||||||
|
|
||||||
|
PreviewCard Card(string url, string title, string description, string image)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
return default;
|
||||||
|
return new PreviewCard
|
||||||
|
{
|
||||||
|
Url = url,
|
||||||
|
Title = title ?? uri.Host,
|
||||||
|
Description = description ?? string.Empty,
|
||||||
|
ProviderName = uri.Host,
|
||||||
|
ProviderUrl = uri.GetLeftPart(UriPartial.Authority),
|
||||||
|
Image = Proxied(image)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Visibility(PostVisibility visibility) => visibility switch
|
||||||
|
{
|
||||||
|
PostVisibility.Unlisted => "unlisted",
|
||||||
|
PostVisibility.FollowersOnly or PostVisibility.Circle => "private",
|
||||||
|
PostVisibility.Direct => "direct",
|
||||||
|
_ => "public"
|
||||||
|
};
|
||||||
|
|
||||||
|
StatusMention Mention(PostMention mention, IReadOnlyDictionary<string, ForeignAvatar> foreign)
|
||||||
|
{
|
||||||
|
if (mention.IsLocal)
|
||||||
|
{
|
||||||
|
var handle = mention.Handle?.TrimStart('@').Split('@')[0];
|
||||||
|
return new StatusMention { Id = mention.AccountId, Username = handle, Acct = handle, Url = mention.ActorURI };
|
||||||
|
}
|
||||||
|
if (!foreign.TryGetValue(mention.ActorURI, out var account))
|
||||||
|
return default;
|
||||||
|
return new StatusMention { Id = account.ID, Username = account.UserName, Acct = $"{account.UserName}@{account.Domain}", Url = account.Url ?? account.ActorURI };
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, object> Meta(PostMedia media)
|
||||||
|
{
|
||||||
|
var original = new Dictionary<string, object>();
|
||||||
|
if (media.Width > 0 && media.Height > 0)
|
||||||
|
{
|
||||||
|
original["width"] = media.Width;
|
||||||
|
original["height"] = media.Height;
|
||||||
|
original["size"] = $"{media.Width}x{media.Height}";
|
||||||
|
original["aspect"] = (double)media.Width.Value / media.Height.Value;
|
||||||
|
}
|
||||||
|
if (media.DurationSeconds is { } seconds)
|
||||||
|
original["duration"] = seconds;
|
||||||
|
var meta = new Dictionary<string, object>();
|
||||||
|
if (original.Count > 0)
|
||||||
|
meta["original"] = original;
|
||||||
|
if (media.DurationSeconds is { } length)
|
||||||
|
meta["length"] = TimeSpan.FromSeconds(length).ToString(@"h\:mm\:ss\.ff", CultureInfo.InvariantCulture);
|
||||||
|
if (FocalPoint.IsSound(media.Focus))
|
||||||
|
meta["focus"] = new { x = media.Focus[0], y = media.Focus[1] };
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaAttachment Media(PostMedia media) => new()
|
||||||
|
{
|
||||||
|
Id = media.AttachmentId ?? media.Id.ToString("N"),
|
||||||
|
// a GIF is a gifv only once it is an mp4 (ours): a GIF itself is an image, which a gifv player can't play
|
||||||
|
Type = media.Kind == "gifv" ? "gifv" : media.ContentType switch
|
||||||
|
{
|
||||||
|
{ } type when type.StartsWith("image/") => "image",
|
||||||
|
{ } type when type.StartsWith("video/") => "video",
|
||||||
|
{ } type when type.StartsWith("audio/") => "audio",
|
||||||
|
_ => "unknown"
|
||||||
|
},
|
||||||
|
Url = media.URL ?? Proxied(media.RemoteURL),
|
||||||
|
PreviewUrl = media.PreviewURL ?? Proxied(media.RemotePreviewURL)
|
||||||
|
?? (media.ContentType?.StartsWith("image/") == true || media.ContentType == default ? media.URL ?? Proxied(media.RemoteURL) : default),
|
||||||
|
RemoteUrl = media.RemoteURL,
|
||||||
|
Description = media.Description,
|
||||||
|
Blurhash = media.Blurhash,
|
||||||
|
Meta = Meta(media)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,14 +29,20 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
RootUsersService = rootUsersService;
|
RootUsersService = rootUsersService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// an administrator never removes, bans or unbans itself; the request's own list is left as it came
|
||||||
|
UsersIds Others(UsersIds usersIds)
|
||||||
|
{
|
||||||
|
var selfId = User.GetUserId();
|
||||||
|
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).ToList() };
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete, Route("/clientapi/admin/remove/users"), Authorize(Policy = Policies.IsAdmin)]
|
[HttpDelete, Route("/clientapi/admin/remove/users"), Authorize(Policy = Policies.IsAdmin)]
|
||||||
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds)
|
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds)
|
||||||
{
|
{
|
||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
usersIds.UserIdList.Remove(User.GetUserId());
|
result = await RootUsersService.RemoveUserAsync(Others(usersIds));
|
||||||
result = await RootUsersService.RemoveUserAsync(usersIds);
|
|
||||||
if (!result.IsValid)
|
if (!result.IsValid)
|
||||||
return StatusCode(result.StatusCode, result);
|
return StatusCode(result.StatusCode, result);
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()");
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveUsers)}()");
|
||||||
return BadRequest(result.Invalidate(ex.Message));
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +63,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
//var isUserResult = await UsersService.UserIsAdminAsync(User.GetUserId());
|
result = await RootUsersService.BanUserAsync(Others(usersIds));
|
||||||
//if (isUserResult.IsValid && !(bool)isUserResult.Data)
|
|
||||||
//if (isUserResult is { IsValid: true } and not { Data: bool })
|
|
||||||
// return Unauthorized();
|
|
||||||
|
|
||||||
usersIds.UserIdList.Remove(User.GetUserId());
|
|
||||||
result = await RootUsersService.BanUserAsync(usersIds);
|
|
||||||
|
|
||||||
if (!result.IsValid)
|
if (!result.IsValid)
|
||||||
return StatusCode(result.StatusCode, result);
|
return StatusCode(result.StatusCode, result);
|
||||||
@@ -73,7 +73,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()");
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(BanUsers)}()");
|
||||||
return BadRequest(result.Invalidate(ex.Message));
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +85,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
var result = new WebResult();
|
var result = new WebResult();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
//var isUserResult = await UsersService.UserIsAdminAsync(User.GetUserId());
|
result = await RootUsersService.UnbanUserAsync(Others(usersIds));
|
||||||
//if (isUserResult.IsValid && !(bool)isUserResult.Data)
|
|
||||||
// return Unauthorized();
|
|
||||||
|
|
||||||
usersIds.UserIdList.Remove(User.GetUserId());
|
|
||||||
result = await RootUsersService.UnbanUserAsync(usersIds);
|
|
||||||
if (!result.IsValid)
|
if (!result.IsValid)
|
||||||
return StatusCode(result.StatusCode, result);
|
return StatusCode(result.StatusCode, result);
|
||||||
|
|
||||||
@@ -99,31 +94,8 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()");
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UnbanUsers)}()");
|
||||||
return BadRequest(result.Invalidate(ex.Message));
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//[HttpGet, Authorize(Policy = Policies.IsAdmin)]
|
|
||||||
//public async Task<IActionResult> GetUsers()
|
|
||||||
//{
|
|
||||||
// var result = new WebResult();
|
|
||||||
// try
|
|
||||||
// {
|
|
||||||
// var isUserResult = await UsersService.UserIsAdminAsync(User.GetUserId());
|
|
||||||
// if (isUserResult.IsValid && !(bool)isUserResult.Data)
|
|
||||||
// return Unauthorized();
|
|
||||||
|
|
||||||
// result = await UsersService.GetUsersAsync();
|
|
||||||
// if (!result.IsValid)
|
|
||||||
// return StatusCode(result.StatusCode, result);
|
|
||||||
|
|
||||||
// return Ok(result.Data);
|
|
||||||
// }
|
|
||||||
// catch (Exception ex)
|
|
||||||
// {
|
|
||||||
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUsers)}()");
|
|
||||||
// return BadRequest(result.Invalidate(ex.Message));
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
|
using PrivaPub.Domain.Moderation;
|
||||||
|
using PrivaPub.Extensions;
|
||||||
|
using PrivaPub.Infrastructure.Backup;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Resources;
|
||||||
|
using PrivaPub.StaticServices;
|
||||||
|
|
||||||
|
namespace PrivaPub.Controllers.ClientToServer
|
||||||
|
{
|
||||||
|
// The server's backups from the administrator's page (owner decision 2026-10-07: it backs up and restores too). Only
|
||||||
|
// an administrator, checked again against the database; a download and a restore ask for the password again, and a
|
||||||
|
// restore for the server's host typed out. A restore asked for stops the service within seconds, and the next start
|
||||||
|
// carries it out (ServerRestore).
|
||||||
|
[ApiController,
|
||||||
|
Route("clientapi/admin/backups"),
|
||||||
|
Authorize(Policy = Policies.IsAdmin)]
|
||||||
|
public class BackupController(Backups backups, TransferStore transfers, IPasswordHasher hasher, IStringLocalizer<GenericRes> localizer,
|
||||||
|
ILogger<BackupController> logger, IModerationLog log) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet, Route("")]
|
||||||
|
public async Task<IActionResult> List(CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
var deleted = await DB.Default.Find<RootUser>().Match(u => u.DeletedAt != null).Project(u => u.Include(x => x.DeletedAt)).ExecuteAsync(token);
|
||||||
|
var code = ServerBackup.CodeMigration();
|
||||||
|
var waiting = RestoreMarker.Read(backups.Root);
|
||||||
|
var last = await DB.Default.Find<RestoreRecord>().Sort(r => r.RestoredAt, Order.Descending).ExecuteFirstAsync(token);
|
||||||
|
ServerBackup.MakeDirectory(backups.Root);
|
||||||
|
return Ok(new ViewBackups
|
||||||
|
{
|
||||||
|
Backups = [.. backups.List().Select(b => View(b, deleted.Count(u => u.DeletedAt > b.CreatedAt), code))],
|
||||||
|
Running = (await MaintenanceLock.Current(token))?.What,
|
||||||
|
Waiting = waiting == default ? default : new ViewRestoreWaiting
|
||||||
|
{
|
||||||
|
Backup = waiting.Backup,
|
||||||
|
State = waiting.State,
|
||||||
|
Attempts = waiting.Attempts,
|
||||||
|
RequestedBy = waiting.RequestedBy,
|
||||||
|
RequestedAt = waiting.RequestedAt,
|
||||||
|
Error = waiting.Error
|
||||||
|
},
|
||||||
|
LastRestore = last == default ? default : View(last),
|
||||||
|
Host = HostName,
|
||||||
|
FreeBytes = new DriveInfo(backups.Root).AvailableFreeSpace
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a backup made now, while the page polls the list
|
||||||
|
[HttpPost, Route("")]
|
||||||
|
public async Task<IActionResult> Create(CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
if (await MaintenanceLock.Current(token) is { } held)
|
||||||
|
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", held.What]));
|
||||||
|
await log.Write(admin.ID, Models.AdminActions.BackupCreate, Models.AdminTargets.Backup, default, "manual", token: token);
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (made, error) = await backups.Create("manual", dbOnly: false, CancellationToken.None);
|
||||||
|
if (made == default)
|
||||||
|
logger.LogWarning("The backup {Admin} asked for was not made: {Error}", admin.UserName, error);
|
||||||
|
else
|
||||||
|
logger.LogInformation("Backup {Id} made for {Admin}", made.Id, admin.UserName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "The backup {Admin} asked for failed", admin.UserName);
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete, Route("{id}")]
|
||||||
|
public async Task<IActionResult> Delete(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
if (RestoreMarker.Read(backups.Root) is { } waiting && (waiting.Backup == id || waiting.PreRestore == id))
|
||||||
|
return Conflict(new WebResult().Invalidate(localizer["A restore waits for this backup."]));
|
||||||
|
if (!backups.Delete(id))
|
||||||
|
return NotFound();
|
||||||
|
await log.Write(admin.ID, Models.AdminActions.BackupDelete, Models.AdminTargets.Backup, id, id, token: token);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// a link to download it with, for the password
|
||||||
|
[HttpPost, Route("{id}/ticket")]
|
||||||
|
public async Task<IActionResult> Ticket(string id, BackupPasswordForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
if (!PasswordHolds(admin, form.Password))
|
||||||
|
return UnprocessableEntity(new WebResult().Invalidate(localizer["Wrong password."]));
|
||||||
|
if (backups.Find(id) == default)
|
||||||
|
return NotFound();
|
||||||
|
var (ticket, expires) = transfers.Ticket(id);
|
||||||
|
return Ok(new ViewBackupTicket { Url = $"/clientapi/admin/backups/download/{ticket}", ExpiresAt = expires, Bytes = BackupTar.Of(backups.Root, id).Length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// the backup as one tar, for a ticket: a plain link, so the browser downloads it to disk and resumes it
|
||||||
|
[HttpGet, Route("download/{ticket}"), AllowAnonymous]
|
||||||
|
public async Task Download(string ticket, CancellationToken token)
|
||||||
|
{
|
||||||
|
var id = transfers.Redeem(ticket);
|
||||||
|
if (id == default || backups.Find(id) == default)
|
||||||
|
{
|
||||||
|
Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
|
||||||
|
var tar = BackupTar.Of(backups.Root, id);
|
||||||
|
var (from, to) = (0L, tar.Length - 1);
|
||||||
|
Response.Headers.AcceptRanges = "bytes";
|
||||||
|
Response.Headers.CacheControl = "no-store";
|
||||||
|
Response.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") { FileName = $"privapub-{id}.tar" }.ToString();
|
||||||
|
var range = Request.GetTypedHeaders().Range;
|
||||||
|
if (range is { Unit.Value: "bytes", Ranges.Count: 1 })
|
||||||
|
{
|
||||||
|
var asked = range.Ranges.First();
|
||||||
|
var start = asked.From ?? tar.Length - asked.To.GetValueOrDefault();
|
||||||
|
var end = asked.From.HasValue ? Math.Min(asked.To ?? tar.Length - 1, tar.Length - 1) : tar.Length - 1;
|
||||||
|
if (start < 0 || start > end)
|
||||||
|
{
|
||||||
|
Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable;
|
||||||
|
Response.Headers.ContentRange = $"bytes */{tar.Length}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(from, to) = (start, end);
|
||||||
|
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||||
|
Response.Headers.ContentRange = $"bytes {from}-{to}/{tar.Length}";
|
||||||
|
}
|
||||||
|
Response.ContentType = "application/x-tar";
|
||||||
|
Response.ContentLength = to - from + 1;
|
||||||
|
await tar.Write(Response.Body, from, to, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("uploads")]
|
||||||
|
public async Task<IActionResult> StartUpload(CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
return Ok(new ViewBackupUpload { Id = transfers.Start(admin.UserName), ChunkBytes = TransferStore.ChunkBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet, Route("uploads/{id}")]
|
||||||
|
public async Task<IActionResult> Upload(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
var received = transfers.Received(id);
|
||||||
|
return received < 0 ? NotFound() : Ok(new ViewBackupUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
// a piece, at the offset already received: 409 with what was received when it isn't
|
||||||
|
[HttpPut, Route("uploads/{id}"), RequestSizeLimit(TransferStore.ChunkBytes + 1024 * 1024)]
|
||||||
|
public async Task<IActionResult> Append(string id, [FromQuery] long offset, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
var (received, taken) = await transfers.Append(id, offset, Request.Body, token);
|
||||||
|
if (received < 0)
|
||||||
|
return NotFound();
|
||||||
|
var view = new ViewBackupUpload { Id = id, Received = received, ChunkBytes = TransferStore.ChunkBytes };
|
||||||
|
return taken ? Ok(view) : Conflict(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("uploads/{id}/finish")]
|
||||||
|
public async Task<IActionResult> FinishUpload(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
if (await MaintenanceLock.Current(token) is { What: "restore" })
|
||||||
|
return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", "restore"]));
|
||||||
|
var (backup, error) = await transfers.Finish(id, token);
|
||||||
|
if (backup == default)
|
||||||
|
return UnprocessableEntity(new WebResult().Invalidate(error));
|
||||||
|
return Ok(View(backup, 0, ServerBackup.CodeMigration()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete, Route("uploads/{id}")]
|
||||||
|
public async Task<IActionResult> CancelUpload(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
return transfers.Cancel(id) ? Ok() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// asked for with the password and the host typed out: the service stops within seconds and restores it as it starts
|
||||||
|
[HttpPost, Route("{id}/restore")]
|
||||||
|
public async Task<IActionResult> Restore(string id, RestoreBackupForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
if (!PasswordHolds(admin, form.Password))
|
||||||
|
return UnprocessableEntity(new WebResult().Invalidate(localizer["Wrong password."]));
|
||||||
|
if (!string.Equals(form.Host?.Trim(), HostName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return UnprocessableEntity(new WebResult().Invalidate(localizer["Type this server's host to restore it."]));
|
||||||
|
var refused = await ServerRestore.Request(backups.Context(), id, admin.UserName, token);
|
||||||
|
if (refused != default)
|
||||||
|
return UnprocessableEntity(new WebResult().Invalidate(refused));
|
||||||
|
logger.LogWarning("{Admin} asked to restore {Backup}", admin.UserName, id);
|
||||||
|
await log.Write(admin.ID, Models.AdminActions.BackupRestore, Models.AdminTargets.Backup, id, id, token: token);
|
||||||
|
return Accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
string HostName => Uri.TryCreate(backups.Host, UriKind.Absolute, out var host) ? host.Authority : backups.Host;
|
||||||
|
|
||||||
|
// the token says administrator; the database has the last word
|
||||||
|
async Task<RootUser> Administrator(CancellationToken token)
|
||||||
|
{
|
||||||
|
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
||||||
|
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PasswordHolds(RootUser root, string password) =>
|
||||||
|
!string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(root.HashedPassword) && hasher.Check(root.HashedPassword, password).verified;
|
||||||
|
|
||||||
|
static ViewBackup View(BackupInfo backup, int rootsDeletedSince, int code)
|
||||||
|
{
|
||||||
|
var manifest = backup.Manifest;
|
||||||
|
return new ViewBackup
|
||||||
|
{
|
||||||
|
Id = backup.Id,
|
||||||
|
Kind = backup.Kind,
|
||||||
|
CreatedAt = backup.CreatedAt,
|
||||||
|
Bytes = backup.Bytes,
|
||||||
|
MediaBytes = manifest.Media.Bytes,
|
||||||
|
MediaFiles = manifest.DbOnly ? manifest.Media.List.Count : manifest.Media.Files,
|
||||||
|
MediaMissing = manifest.Media.Missing,
|
||||||
|
DbOnly = manifest.DbOnly,
|
||||||
|
Consistent = manifest.Consistent,
|
||||||
|
Collections = manifest.Collections.Count,
|
||||||
|
Documents = manifest.Collections.Sum(c => c.Count),
|
||||||
|
AppCommit = manifest.AppCommit,
|
||||||
|
MigrationNumber = manifest.MigrationNumber,
|
||||||
|
RootsDeletedSince = rootsDeletedSince,
|
||||||
|
NotRestorable = manifest.Format != ArchiveManifest.CurrentFormat ? "format"
|
||||||
|
: manifest.MigrationNumber > code ? "newer"
|
||||||
|
: default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static ViewRestore View(RestoreRecord record) => new()
|
||||||
|
{
|
||||||
|
Backup = record.Backup,
|
||||||
|
BackupCreatedAt = record.BackupCreatedAt,
|
||||||
|
PreRestore = record.PreRestore,
|
||||||
|
RequestedBy = record.RequestedBy,
|
||||||
|
RestoredAt = record.RestoredAt,
|
||||||
|
Abandoned = record.Abandoned,
|
||||||
|
Error = record.Error,
|
||||||
|
Documents = record.Report.Documents,
|
||||||
|
Collections = record.Report.Collections,
|
||||||
|
MediaRestored = record.Report.MediaRestored,
|
||||||
|
MediaMissing = record.Report.MediaMissing,
|
||||||
|
RootsDeleted = record.Report.RootsDeleted,
|
||||||
|
PersonasDeleted = record.Report.PersonasDeleted,
|
||||||
|
GroupsDeleted = record.Report.GroupsDeleted,
|
||||||
|
PostsDeleted = record.Report.PostsDeleted,
|
||||||
|
RootsTombstoned = record.Report.RootsTombstoned,
|
||||||
|
PersonasTombstoned = record.Report.PersonasTombstoned,
|
||||||
|
GroupsTombstoned = record.Report.GroupsTombstoned,
|
||||||
|
PostsGone = record.Report.PostsGone,
|
||||||
|
MediaTrashed = record.Report.MediaTrashed,
|
||||||
|
ProtectiveKept = record.Report.ProtectiveKept,
|
||||||
|
SessionsEnded = record.Report.SessionsEnded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()");
|
Logger.LogError(ex, $"{nameof(DataController)}.{nameof(Languages)}()");
|
||||||
return BadRequest(result.Invalidate(ex.Message, exception: ex));
|
return BadRequest(result.Invalidate("Something went wrong.", exception: ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
|
using PrivaPub.Domain.Moderation;
|
||||||
|
using PrivaPub.Extensions;
|
||||||
|
using PrivaPub.Federation.Moderation;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Resources;
|
||||||
|
|
||||||
|
namespace PrivaPub.Controllers.ClientToServer
|
||||||
|
{
|
||||||
|
[ApiController,
|
||||||
|
Route("clientapi/admin/domainblocks"),
|
||||||
|
Authorize(Policy = Policies.IsAdmin)]
|
||||||
|
public class DomainBlockController : ControllerBase
|
||||||
|
{
|
||||||
|
readonly IDomainBlocks _domainBlocks;
|
||||||
|
readonly IStringLocalizer _localizer;
|
||||||
|
readonly Domain.Media.IMediaProxy _proxy;
|
||||||
|
readonly IModerationLog _log;
|
||||||
|
|
||||||
|
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy, IModerationLog log)
|
||||||
|
{
|
||||||
|
_domainBlocks = domainBlocks;
|
||||||
|
_localizer = localizer;
|
||||||
|
_proxy = proxy;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/admin/domainblocks/list")]
|
||||||
|
public async Task<IActionResult> List(CancellationToken token) =>
|
||||||
|
Ok((await DB.Default.Find<DomainBlock>().Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(ToView).ToList());
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/admin/domainblocks/insert")]
|
||||||
|
public async Task<IActionResult> Insert(DomainBlockForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var domain = DomainBlocks.Normalise(form.Domain);
|
||||||
|
if (!ModelState.IsValid || Uri.CheckHostName(domain) != UriHostNameType.Dns)
|
||||||
|
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||||
|
|
||||||
|
var block = await DB.Default.UpdateAndGet<DomainBlock>()
|
||||||
|
.Match(b => b.Domain == domain)
|
||||||
|
.Modify(b => b.Domain, domain)
|
||||||
|
.Modify(b => b.Severity, form.Suspend ? DomainBlockSeverity.Suspend : DomainBlockSeverity.Silence)
|
||||||
|
.Modify(b => b.RejectMedia, form.RejectMedia)
|
||||||
|
.Modify(b => b.PublicComment, form.PublicComment)
|
||||||
|
.Modify(b => b.PrivateComment, form.PrivateComment)
|
||||||
|
.Modify(b => b.Published, form.Published)
|
||||||
|
.Modify(b => b.Obfuscate, form.Obfuscate)
|
||||||
|
.Modify(b => b.SetOnInsert(x => x.CreatedAt, DateTime.UtcNow))
|
||||||
|
.Option(o => o.IsUpsert = true)
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
await _domainBlocks.Reload(token);
|
||||||
|
// what the media proxy holds of it goes with the block
|
||||||
|
if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia)
|
||||||
|
_proxy.Purge(domain);
|
||||||
|
await _log.Write(User.GetUserId(), AdminActions.DomainBlockInsert, AdminTargets.Domain, domain, domain,
|
||||||
|
note: string.Join(", ", new[] { block.Severity.ToString().ToLowerInvariant(), block.RejectMedia ? "media rejected" : default,
|
||||||
|
block.Published ? (block.Obfuscate ? "published, obfuscated" : "published") : default }.Where(n => n != default)), token: token);
|
||||||
|
return Ok(ToView(block));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/admin/domainblocks/delete")]
|
||||||
|
public async Task<IActionResult> Delete([FromQuery] string domain, CancellationToken token)
|
||||||
|
{
|
||||||
|
domain = DomainBlocks.Normalise(domain);
|
||||||
|
var deleted = await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
|
||||||
|
await _domainBlocks.Reload(token);
|
||||||
|
if (deleted.DeletedCount > 0)
|
||||||
|
await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ViewDomainBlock ToView(DomainBlock block) => new()
|
||||||
|
{
|
||||||
|
Id = block.ID,
|
||||||
|
Domain = block.Domain,
|
||||||
|
Severity = block.Severity.ToString(),
|
||||||
|
RejectMedia = block.RejectMedia,
|
||||||
|
PublicComment = block.PublicComment,
|
||||||
|
PrivateComment = block.PrivateComment,
|
||||||
|
Published = block.Published,
|
||||||
|
Obfuscate = block.Obfuscate,
|
||||||
|
CreatedAt = block.CreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,18 @@ namespace PrivaPub.Controllers.ClientToServer
|
|||||||
public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
|
public async Task<IActionResult> Approve(GroupMembershipForm form, CancellationToken token) =>
|
||||||
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token));
|
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token));
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/group/members")]
|
||||||
|
public async Task<IActionResult> Members([FromQuery] string avatarId, [FromQuery] string groupId, CancellationToken token) =>
|
||||||
|
Answer(await _groupUsersService.GetMembers(User.GetUserId(), avatarId, groupId, token));
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/group/reject")]
|
||||||
|
public async Task<IActionResult> Reject(GroupMembershipForm form, CancellationToken token) =>
|
||||||
|
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RejectMember(User.GetUserId(), form, token));
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/group/remove")]
|
||||||
|
public async Task<IActionResult> Remove(GroupMembershipForm form, CancellationToken token) =>
|
||||||
|
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token));
|
||||||
|
|
||||||
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
|
||||||
|
|
||||||
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
|
using PrivaPub.Domain.Instance;
|
||||||
|
using PrivaPub.Domain.Media;
|
||||||
|
using PrivaPub.Domain.Moderation;
|
||||||
|
using PrivaPub.Domain.Privacy;
|
||||||
|
using PrivaPub.Domain.Social;
|
||||||
|
using PrivaPub.Extensions;
|
||||||
|
using PrivaPub.Federation.Actors;
|
||||||
|
using PrivaPub.Federation.Relays;
|
||||||
|
using PrivaPub.Infrastructure;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
using PrivaPub.Resources;
|
||||||
|
|
||||||
|
namespace PrivaPub.Controllers.ClientToServer
|
||||||
|
{
|
||||||
|
// The server's own settings from the administrator's page (completion program R5): what it says of itself, its rules,
|
||||||
|
// its thumbnail, its media limits, whether its activity is published, its announcements and the relays it subscribes to.
|
||||||
|
// Only an administrator, checked again against the database. Every change is kept in the staff's log.
|
||||||
|
[ApiController,
|
||||||
|
Route("clientapi/admin"),
|
||||||
|
Authorize(Policy = Policies.IsAdmin)]
|
||||||
|
public class InstanceAdminController : ControllerBase
|
||||||
|
{
|
||||||
|
const int MaxSettingsRelays = 20;
|
||||||
|
const string Thumbnail = "thumbnail";
|
||||||
|
|
||||||
|
readonly IInstanceSettings _settings;
|
||||||
|
readonly IModerationLog _log;
|
||||||
|
readonly IStringLocalizer _localizer;
|
||||||
|
readonly ILocalActorService _localActors;
|
||||||
|
readonly ILogger<InstanceAdminController> _logger;
|
||||||
|
|
||||||
|
public InstanceAdminController(IInstanceSettings settings, IModerationLog log, IStringLocalizer<GenericRes> localizer, ILocalActorService localActors,
|
||||||
|
ILogger<InstanceAdminController> logger)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_log = log;
|
||||||
|
_localizer = localizer;
|
||||||
|
_localActors = localActors;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the token says administrator; the database has the last word
|
||||||
|
async Task<RootUser> Administrator(CancellationToken token)
|
||||||
|
{
|
||||||
|
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
||||||
|
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
|
||||||
|
}
|
||||||
|
|
||||||
|
IActionResult Refused(int status, string message) => StatusCode(status, new WebResult().Invalidate(_localizer[message], status));
|
||||||
|
|
||||||
|
#region settings
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/admin/settings")]
|
||||||
|
public async Task<IActionResult> Settings(CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
return Ok(await View(_settings.Current, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/admin/settings")]
|
||||||
|
public async Task<IActionResult> SaveSettings(ServerSettingsForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
var contact = Clean(form.ContactAccountId);
|
||||||
|
if (contact != default && !await IsContactable(contact, token))
|
||||||
|
return Refused(StatusCodes.Status422UnprocessableEntity, "The contact account must be a persona of this server.");
|
||||||
|
var rules = form.Rules ?? new();
|
||||||
|
if (rules.Count > ServerSettingsForm.MaxRules || rules.Any(r => string.IsNullOrWhiteSpace(r?.Text)))
|
||||||
|
return Refused(StatusCodes.Status422UnprocessableEntity, "Every rule needs its text, and there can be 50 at most.");
|
||||||
|
|
||||||
|
var changed = new List<string>();
|
||||||
|
var ruleActs = new List<(string Action, ServerRule Rule)>();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var saved = await _settings.Update(settings =>
|
||||||
|
{
|
||||||
|
void Set(string name, string value, Func<string> get, Action<string> set)
|
||||||
|
{
|
||||||
|
if (get() == value)
|
||||||
|
return;
|
||||||
|
set(value);
|
||||||
|
changed.Add(name);
|
||||||
|
}
|
||||||
|
Set("title", form.Title.Trim(), () => settings.Title, v => settings.Title = v);
|
||||||
|
Set("short description", Clean(form.ShortDescription), () => settings.ShortDescription, v => settings.ShortDescription = v);
|
||||||
|
Set("description", Clean(form.Description), () => settings.Description, v =>
|
||||||
|
{
|
||||||
|
settings.Description = v;
|
||||||
|
settings.DescriptionUpdatedAt = now;
|
||||||
|
});
|
||||||
|
Set("contact email", Clean(form.ContactEmail), () => settings.ContactEmail, v => settings.ContactEmail = v);
|
||||||
|
Set("contact account", contact, () => settings.ContactAccountId, v => settings.ContactAccountId = v);
|
||||||
|
Set("privacy policy", Clean(form.PrivacyPolicy), () => settings.PrivacyPolicy, v =>
|
||||||
|
{
|
||||||
|
settings.PrivacyPolicy = v;
|
||||||
|
settings.PrivacyPolicyUpdatedAt = now;
|
||||||
|
});
|
||||||
|
var terms = Clean(form.TermsOfService);
|
||||||
|
Set("terms of service", terms, () => settings.TermsOfService, v => settings.TermsOfService = v);
|
||||||
|
var effective = terms == default ? (DateTime?)null
|
||||||
|
: form.TermsEffectiveAt is { } on ? DateTime.SpecifyKind(on.Date, DateTimeKind.Utc)
|
||||||
|
: changed.Contains("terms of service") || settings.TermsEffectiveAt == default ? now.Date
|
||||||
|
: settings.TermsEffectiveAt;
|
||||||
|
if (effective != settings.TermsEffectiveAt)
|
||||||
|
{
|
||||||
|
settings.TermsEffectiveAt = effective;
|
||||||
|
if (!changed.Contains("terms of service"))
|
||||||
|
changed.Add("terms' date");
|
||||||
|
}
|
||||||
|
|
||||||
|
// rules: the form's order; a named rule keeps its id, a new one gets one, one left out is deleted but kept
|
||||||
|
var existing = settings.Rules.GroupBy(r => r.Id).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
var listed = new HashSet<string>();
|
||||||
|
var position = 0;
|
||||||
|
foreach (var asked in rules)
|
||||||
|
{
|
||||||
|
var text = asked.Text.Trim();
|
||||||
|
var hint = Clean(asked.Hint);
|
||||||
|
if (asked.Id != default && existing.TryGetValue(asked.Id, out var rule) && listed.Add(rule.Id))
|
||||||
|
{
|
||||||
|
if (rule.Text != text || rule.Hint != hint || rule.DeletedAt != default)
|
||||||
|
ruleActs.Add((AdminActions.RuleUpdate, rule));
|
||||||
|
else if (rule.Position != position)
|
||||||
|
changed.Add("rules' order");
|
||||||
|
rule.Text = text;
|
||||||
|
rule.Hint = hint;
|
||||||
|
rule.DeletedAt = default;
|
||||||
|
rule.Position = position++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var added = new ServerRule { Text = text, Hint = hint, Position = position++ };
|
||||||
|
settings.Rules.Add(added);
|
||||||
|
listed.Add(added.Id);
|
||||||
|
ruleActs.Add((AdminActions.RuleInsert, added));
|
||||||
|
}
|
||||||
|
foreach (var gone in settings.Rules.Where(r => r.DeletedAt == default && !listed.Contains(r.Id)))
|
||||||
|
{
|
||||||
|
gone.DeletedAt = now;
|
||||||
|
ruleActs.Add((AdminActions.RuleDelete, gone));
|
||||||
|
}
|
||||||
|
|
||||||
|
var media = form.Media ?? new();
|
||||||
|
var limits = settings.Media ?? new();
|
||||||
|
if (limits.QuotaBytesPerRoot != media.QuotaBytesPerRoot || limits.MaxImageBytes != media.MaxImageBytes
|
||||||
|
|| limits.MaxVideoBytes != media.MaxVideoBytes || limits.MaxSeconds != media.MaxSeconds)
|
||||||
|
{
|
||||||
|
settings.Media = new ServerMediaLimits
|
||||||
|
{
|
||||||
|
QuotaBytesPerRoot = media.QuotaBytesPerRoot is { } quota ? Math.Max(0, quota) : (long?)null,
|
||||||
|
MaxImageBytes = media.MaxImageBytes is { } image ? Math.Clamp(image, 1, MediaLimitsFromSettings.MaxImageBytes) : (long?)null,
|
||||||
|
MaxVideoBytes = media.MaxVideoBytes is { } video ? Math.Clamp(video, 1, MediaLimitsFromSettings.MaxVideoBytes) : (long?)null,
|
||||||
|
MaxSeconds = media.MaxSeconds is { } seconds ? Math.Clamp(seconds, 1, MediaLimitsFromSettings.MaxSeconds) : (int?)null
|
||||||
|
};
|
||||||
|
changed.Add("media limits");
|
||||||
|
}
|
||||||
|
if (settings.PublishActivity != form.PublishActivity)
|
||||||
|
{
|
||||||
|
settings.PublishActivity = form.PublishActivity;
|
||||||
|
changed.Add(form.PublishActivity ? "activity published" : "activity unpublished");
|
||||||
|
}
|
||||||
|
}, admin.ID, token);
|
||||||
|
|
||||||
|
if (changed.Count > 0)
|
||||||
|
await _log.Write(admin.ID, AdminActions.SettingsUpdate, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: string.Join(", ", changed), token: token);
|
||||||
|
foreach (var (action, rule) in ruleActs)
|
||||||
|
await _log.Write(admin.ID, action, AdminTargets.Rule, rule.Id, rule.Text, token: token);
|
||||||
|
return Ok(await View(saved, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// the server's thumbnail (the instance API's), from a picture checked and re-encoded like a profile picture
|
||||||
|
[HttpPost, Route("/clientapi/admin/settings/thumbnail"), RequestSizeLimit(MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024),
|
||||||
|
RequestFormLimits(MultipartBodyLengthLimit = MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024)]
|
||||||
|
public async Task<IActionResult> SaveThumbnail([FromServices] IMediaService media, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
var file = Request.HasFormContentType ? (await Request.ReadFormAsync(token)).Files["file"] : default;
|
||||||
|
var outcome = await media.ServerImage(Thumbnail, file, token);
|
||||||
|
if (!outcome.Ok)
|
||||||
|
return StatusCode(outcome.Status, new WebResult().Invalidate(outcome.Error, outcome.Status));
|
||||||
|
var previous = default(string);
|
||||||
|
var saved = await _settings.Update(settings =>
|
||||||
|
{
|
||||||
|
previous = settings.ThumbnailMediaId;
|
||||||
|
settings.ThumbnailMediaId = outcome.Attachment.ID;
|
||||||
|
settings.ThumbnailUrl = media.Url(outcome.Attachment.FilePath);
|
||||||
|
}, admin.ID, token);
|
||||||
|
if (previous != default)
|
||||||
|
await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail replaced", token);
|
||||||
|
await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "replaced", token: token);
|
||||||
|
return Ok(await View(saved, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete, Route("/clientapi/admin/settings/thumbnail")]
|
||||||
|
public async Task<IActionResult> DeleteThumbnail([FromServices] IMediaService media, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
var previous = default(string);
|
||||||
|
var saved = await _settings.Update(settings =>
|
||||||
|
{
|
||||||
|
previous = settings.ThumbnailMediaId;
|
||||||
|
settings.ThumbnailMediaId = default;
|
||||||
|
settings.ThumbnailUrl = default;
|
||||||
|
}, admin.ID, token);
|
||||||
|
if (previous != default)
|
||||||
|
{
|
||||||
|
await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail removed", token);
|
||||||
|
await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "removed", token: token);
|
||||||
|
}
|
||||||
|
return Ok(await View(saved, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a local persona whose login may act: the contact is shown to everyone as the one who runs the server
|
||||||
|
static async Task<bool> IsContactable(string avatarId, CancellationToken token) =>
|
||||||
|
await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token)
|
||||||
|
&& !(await Counted.BarredPersonas(token)).Contains(avatarId);
|
||||||
|
|
||||||
|
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
|
||||||
|
|
||||||
|
async Task<ViewServerSettings> View(ServerSettings settings, CancellationToken token)
|
||||||
|
{
|
||||||
|
var services = HttpContext.RequestServices;
|
||||||
|
var configured = services.GetRequiredService<IConfiguration>().GetSection("Media").Get<MediaOptions>() ?? new MediaOptions();
|
||||||
|
var effective = services.GetRequiredService<IOptionsMonitor<MediaOptions>>().CurrentValue;
|
||||||
|
var contact = settings.ContactAccountId == default ? default
|
||||||
|
: await DB.Default.Find<Avatar>().Match(a => a.ID == settings.ContactAccountId && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||||
|
var updatedBy = settings.UpdatedBy == default || settings.UpdatedBy == AdminAction.Cli ? settings.UpdatedBy
|
||||||
|
: (await DB.Default.Find<RootUser>().MatchID(settings.UpdatedBy).ExecuteFirstAsync(token))?.UserName;
|
||||||
|
var limits = settings.Media ?? new();
|
||||||
|
return new ViewServerSettings
|
||||||
|
{
|
||||||
|
Title = settings.Title,
|
||||||
|
ShortDescription = settings.ShortDescription,
|
||||||
|
Description = settings.Description,
|
||||||
|
DescriptionUpdatedAt = settings.DescriptionUpdatedAt,
|
||||||
|
ContactEmail = settings.ContactEmail,
|
||||||
|
ContactAccountId = settings.ContactAccountId,
|
||||||
|
ContactAccount = contact == default ? default : new ViewContactAccount
|
||||||
|
{
|
||||||
|
Id = contact.ID,
|
||||||
|
UserName = contact.UserName,
|
||||||
|
Name = contact.Name,
|
||||||
|
Handle = _localActors.FromAvatar(contact).Handle
|
||||||
|
},
|
||||||
|
ThumbnailUrl = settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}",
|
||||||
|
ThumbnailIsDefault = settings.ThumbnailUrl == default,
|
||||||
|
PrivacyPolicy = settings.PrivacyPolicy,
|
||||||
|
PrivacyPolicyUpdatedAt = settings.PrivacyPolicyUpdatedAt,
|
||||||
|
TermsOfService = settings.TermsOfService,
|
||||||
|
TermsEffectiveAt = settings.TermsEffectiveAt,
|
||||||
|
Rules = settings.Rules.OrderBy(r => r.DeletedAt != default).ThenBy(r => r.Position).Select(r => new ViewServerRule
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
Text = r.Text,
|
||||||
|
Hint = r.Hint,
|
||||||
|
Position = r.Position,
|
||||||
|
IsDeleted = r.DeletedAt != default,
|
||||||
|
DeletedAt = r.DeletedAt
|
||||||
|
}).ToList(),
|
||||||
|
Relays = await RelayViews(settings, token),
|
||||||
|
Media = new ViewMediaLimits
|
||||||
|
{
|
||||||
|
QuotaBytesPerRoot = limits.QuotaBytesPerRoot,
|
||||||
|
MaxImageBytes = limits.MaxImageBytes,
|
||||||
|
MaxVideoBytes = limits.MaxVideoBytes,
|
||||||
|
MaxSeconds = limits.MaxSeconds,
|
||||||
|
ConfiguredQuotaBytesPerRoot = configured.QuotaBytesPerRoot,
|
||||||
|
ConfiguredMaxImageBytes = configured.MaxImageBytes,
|
||||||
|
ConfiguredMaxVideoBytes = configured.MaxVideoBytes,
|
||||||
|
ConfiguredMaxSeconds = configured.MaxSeconds,
|
||||||
|
EffectiveQuotaBytesPerRoot = effective.QuotaBytesPerRoot,
|
||||||
|
EffectiveMaxImageBytes = effective.MaxImageBytes,
|
||||||
|
EffectiveMaxVideoBytes = effective.MaxVideoBytes,
|
||||||
|
EffectiveMaxSeconds = effective.MaxSeconds
|
||||||
|
},
|
||||||
|
RegistrationMode = services.GetRequiredService<IOptionsMonitor<RegistrationOptions>>().CurrentValue.Mode.ToString(),
|
||||||
|
PublishActivity = settings.PublishActivity,
|
||||||
|
UpdatedAt = settings.UpdatedAt,
|
||||||
|
UpdatedBy = updatedBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region announcements
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/admin/announcements/list")]
|
||||||
|
public async Task<IActionResult> Announcements([FromServices] IAnnouncements announcements, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
var views = new List<ViewAnnouncement>();
|
||||||
|
foreach (var announcement in await announcements.All(token))
|
||||||
|
views.Add(await View(announcement, token));
|
||||||
|
return Ok(views);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/admin/announcements/insert")]
|
||||||
|
public async Task<IActionResult> InsertAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
if (Invalid(form) is { } refused)
|
||||||
|
return refused;
|
||||||
|
var made = await announcements.Insert(Draft(form), token);
|
||||||
|
await _log.Write(admin.ID, AdminActions.AnnouncementInsert, AdminTargets.Announcement, made.ID, made.Text,
|
||||||
|
note: made.IsPublished ? "published" : "draft", token: token);
|
||||||
|
return Ok(await View(made, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/admin/announcements/update")]
|
||||||
|
public async Task<IActionResult> UpdateAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
if (Invalid(form) is { } refused)
|
||||||
|
return refused;
|
||||||
|
var updated = await announcements.Update(form.Id, Draft(form), token);
|
||||||
|
if (updated == default)
|
||||||
|
return NotFound();
|
||||||
|
await _log.Write(admin.ID, AdminActions.AnnouncementUpdate, AdminTargets.Announcement, updated.ID, updated.Text,
|
||||||
|
note: updated.IsPublished ? "published" : "draft", token: token);
|
||||||
|
return Ok(await View(updated, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/admin/announcements/delete")]
|
||||||
|
public async Task<IActionResult> DeleteAnnouncement([FromServices] IAnnouncements announcements, [FromQuery] string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
var deleted = await announcements.Delete(id, token);
|
||||||
|
if (deleted == default)
|
||||||
|
return NotFound();
|
||||||
|
await _log.Write(admin.ID, AdminActions.AnnouncementDelete, AdminTargets.Announcement, deleted.ID, deleted.Text, token: token);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
IActionResult Invalid(AnnouncementForm form) =>
|
||||||
|
string.IsNullOrWhiteSpace(form.Text)
|
||||||
|
? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement needs its text.")
|
||||||
|
: form.StartsAt != default && form.EndsAt != default && form.EndsAt < form.StartsAt
|
||||||
|
? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement can't end before it starts.")
|
||||||
|
: default;
|
||||||
|
|
||||||
|
static AnnouncementDraft Draft(AnnouncementForm form) => new(form.Text, form.StartsAt, form.EndsAt, form.AllDay, form.IsPublished);
|
||||||
|
|
||||||
|
static async Task<ViewAnnouncement> View(Announcement announcement, CancellationToken token)
|
||||||
|
{
|
||||||
|
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => r.AnnouncementId == announcement.ID).ExecuteAsync(token);
|
||||||
|
return new ViewAnnouncement
|
||||||
|
{
|
||||||
|
Id = announcement.ID,
|
||||||
|
Text = announcement.Text,
|
||||||
|
Html = announcement.Html,
|
||||||
|
StartsAt = announcement.StartsAt,
|
||||||
|
EndsAt = announcement.EndsAt,
|
||||||
|
AllDay = announcement.AllDay,
|
||||||
|
IsPublished = announcement.IsPublished,
|
||||||
|
IsShown = announcement.IsPublished && (announcement.EndsAt == default || announcement.EndsAt > DateTime.UtcNow),
|
||||||
|
PublishedAt = announcement.PublishedAt,
|
||||||
|
CreatedAt = announcement.CreatedAt,
|
||||||
|
UpdatedAt = announcement.UpdatedAt,
|
||||||
|
Reads = await DB.Default.CountAsync<AnnouncementRead>(r => r.AnnouncementId == announcement.ID, token),
|
||||||
|
Reactions = reactions.GroupBy(r => r.Name).Select(g => new ViewAnnouncementReaction { Name = g.Key, Count = g.Count() }).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region relays
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/admin/relays")]
|
||||||
|
public async Task<IActionResult> Relays(CancellationToken token)
|
||||||
|
{
|
||||||
|
if (await Administrator(token) == default)
|
||||||
|
return Forbid();
|
||||||
|
return Ok(await RelayViews(_settings.Current, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a relay named by the settings: subscribed to at once (in the background: resolving it is a fetch), the page polls
|
||||||
|
[HttpPost, Route("/clientapi/admin/relays/insert")]
|
||||||
|
public async Task<IActionResult> InsertRelay([FromServices] IRelays relays, RelayForm form, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
var address = form.Address?.Trim();
|
||||||
|
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo)
|
||||||
|
|| !string.IsNullOrEmpty(uri.Fragment))
|
||||||
|
return Refused(StatusCodes.Status422UnprocessableEntity, "A relay is named by the https address of its actor or its inbox.");
|
||||||
|
if (relays.Configured.Contains(address))
|
||||||
|
return Refused(StatusCodes.Status409Conflict, "The configuration names this relay already.");
|
||||||
|
if (_settings.Current.Relays.Contains(address))
|
||||||
|
return Ok(await RelayViews(_settings.Current, token));
|
||||||
|
if (_settings.Current.Relays.Count >= MaxSettingsRelays)
|
||||||
|
return Refused(StatusCodes.Status422UnprocessableEntity, "The settings name 20 relays at most.");
|
||||||
|
var saved = await _settings.Update(settings =>
|
||||||
|
{
|
||||||
|
if (!settings.Relays.Contains(address))
|
||||||
|
settings.Relays.Add(address);
|
||||||
|
}, admin.ID, token);
|
||||||
|
await _log.Write(admin.ID, AdminActions.RelayInsert, AdminTargets.Relay, address, address, token: token);
|
||||||
|
Reconcile(relays);
|
||||||
|
return Accepted(await RelayViews(saved, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// a relay the settings named: unsubscribed at once (its Undo is queued); the configuration's are removed only there
|
||||||
|
[HttpPost, Route("/clientapi/admin/relays/delete")]
|
||||||
|
public async Task<IActionResult> DeleteRelay([FromServices] IRelays relays, [FromQuery] string address, CancellationToken token)
|
||||||
|
{
|
||||||
|
var admin = await Administrator(token);
|
||||||
|
if (admin == default)
|
||||||
|
return Forbid();
|
||||||
|
address = address?.Trim();
|
||||||
|
if (relays.Configured.Contains(address))
|
||||||
|
return Refused(StatusCodes.Status409Conflict, "This relay is named by the configuration (Federation:Relays): remove it there.");
|
||||||
|
if (string.IsNullOrEmpty(address) || !_settings.Current.Relays.Contains(address))
|
||||||
|
return NotFound();
|
||||||
|
var saved = await _settings.Update(settings => settings.Relays.Remove(address), admin.ID, token);
|
||||||
|
await _log.Write(admin.ID, AdminActions.RelayDelete, AdminTargets.Relay, address, address, token: token);
|
||||||
|
Reconcile(relays);
|
||||||
|
return Accepted(await RelayViews(saved, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Reconcile(IRelays relays) => _ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await relays.Reconcile(CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Relay subscriptions could not be brought up to date");
|
||||||
|
}
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
async Task<List<ViewRelay>> RelayViews(ServerSettings settings, CancellationToken token)
|
||||||
|
{
|
||||||
|
var configured = HttpContext.RequestServices.GetRequiredService<IRelays>().Configured;
|
||||||
|
var subscriptions = (await DB.Default.Find<RelaySubscription>().ExecuteAsync(token)).GroupBy(s => s.Configured).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
ViewRelay View(string address, string source)
|
||||||
|
{
|
||||||
|
var subscription = subscriptions.GetValueOrDefault(address);
|
||||||
|
return new ViewRelay
|
||||||
|
{
|
||||||
|
Address = address,
|
||||||
|
Source = source,
|
||||||
|
State = subscription == default ? "none" : subscription.State.ToString().ToLowerInvariant(),
|
||||||
|
ActorURI = subscription?.ActorURI,
|
||||||
|
RequestedAt = subscription?.RequestedAt,
|
||||||
|
AnsweredAt = subscription?.AnsweredAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return configured.Select(a => View(a, "configuration"))
|
||||||
|
.Concat((settings.Relays ?? new()).Where(a => !configured.Contains(a)).Select(a => View(a, "settings")))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,124 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
using MongoDB.Entities;
|
||||||
|
|
||||||
|
using PrivaPub.ClientModels;
|
||||||
|
using PrivaPub.ClientModels.Admin;
|
||||||
|
using PrivaPub.Domain.Instance;
|
||||||
|
using PrivaPub.Domain.Moderation;
|
||||||
|
using PrivaPub.Extensions;
|
||||||
|
using PrivaPub.Models;
|
||||||
|
using PrivaPub.Models.Social;
|
||||||
|
using PrivaPub.Models.User;
|
||||||
|
|
||||||
namespace PrivaPub.Controllers.ClientToServer
|
namespace PrivaPub.Controllers.ClientToServer
|
||||||
{
|
{
|
||||||
[ApiController,
|
[ApiController,
|
||||||
Route("clientapi/moderator")]
|
Route("clientapi/moderator"),
|
||||||
|
Authorize(Policy = Policies.IsModerator)]
|
||||||
public class ModeratorController : ControllerBase
|
public class ModeratorController : ControllerBase
|
||||||
{
|
{
|
||||||
|
const int MaxLogPage = 100;
|
||||||
|
|
||||||
|
readonly IInstanceSettings _settings;
|
||||||
|
readonly IModerationLog _log;
|
||||||
|
|
||||||
|
public ModeratorController(IInstanceSettings settings, IModerationLog log)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet, Route("/clientapi/moderator/reports")]
|
||||||
|
public async Task<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token)
|
||||||
|
{
|
||||||
|
var rules = _settings.Current.Rules.ToDictionary(r => r.Id, r => r.Text ?? string.Empty);
|
||||||
|
return Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
|
||||||
|
.Select(r => new ViewReport
|
||||||
|
{
|
||||||
|
ID = r.ID,
|
||||||
|
Reporter = r.ReporterAvatarId != default ? "local" : r.ReporterActorURI,
|
||||||
|
TargetAccountId = r.TargetAccountId,
|
||||||
|
TargetActorURI = r.TargetActorURI,
|
||||||
|
PostIds = r.PostIds,
|
||||||
|
ObjectURIs = r.ObjectURIs,
|
||||||
|
Category = r.Category,
|
||||||
|
RuleIds = r.RuleIds ?? new(),
|
||||||
|
Rules = (r.RuleIds ?? new()).Select(id => rules.GetValueOrDefault(id, string.Empty)).ToList(),
|
||||||
|
Comment = r.Comment,
|
||||||
|
Forwarded = r.Forwarded,
|
||||||
|
IsResolved = r.IsResolved,
|
||||||
|
CreatedAt = r.CreatedAt,
|
||||||
|
ResolvedAt = r.ResolvedAt,
|
||||||
|
RemoteResolvedAt = r.RemoteResolvedAt,
|
||||||
|
RemoteResolvedBy = r.RemoteResolvedBy
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
|
||||||
|
public async Task<IActionResult> Resolve(string id, CancellationToken token)
|
||||||
|
{
|
||||||
|
var by = User.GetUserId();
|
||||||
|
var result = await DB.Default.Update<Report>().MatchID(id)
|
||||||
|
.Modify(r => r.IsResolved, true)
|
||||||
|
.Modify(r => r.ResolvedAt, DateTime.UtcNow)
|
||||||
|
.Modify(r => r.ResolvedBy, by)
|
||||||
|
.ExecuteAsync(token);
|
||||||
|
if (result.MatchedCount == 0)
|
||||||
|
return NotFound();
|
||||||
|
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
|
||||||
|
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all
|
||||||
|
// of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or
|
||||||
|
// themselves.
|
||||||
|
[HttpGet, Route("/clientapi/moderator/log")]
|
||||||
|
public async Task<IActionResult> Log([FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var me = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
||||||
|
if (me is not { IsBanned: false, DeletedAt: null } || !me.Policies.Contains(Policies.IsModerator))
|
||||||
|
return Forbid();
|
||||||
|
var admin = me.Policies.Contains(Policies.IsAdmin);
|
||||||
|
limit = Math.Clamp(limit, 1, MaxLogPage);
|
||||||
|
var query = DB.Default.Find<AdminAction>();
|
||||||
|
if (!string.IsNullOrEmpty(before))
|
||||||
|
{
|
||||||
|
if (!MongoDB.Bson.ObjectId.TryParse(before, out _))
|
||||||
|
return BadRequest();
|
||||||
|
query.Match(f => f.Lt(a => a.ID, before));
|
||||||
|
}
|
||||||
|
if (!admin)
|
||||||
|
query.Match(a => a.TargetKind != AdminTargets.Root);
|
||||||
|
var actions = await query.Sort(a => a.ID, Order.Descending).Limit(limit).ExecuteAsync(token);
|
||||||
|
var actorIds = actions.Select(a => a.ByRootId).Where(id => id != default && id != AdminAction.Cli).Distinct().ToList();
|
||||||
|
var names = admin && actorIds.Count > 0
|
||||||
|
? (await DB.Default.Find<RootUser>().Match(u => actorIds.Contains(u.ID)).Project(u => u.Include(x => x.UserName)).ExecuteAsync(token))
|
||||||
|
.ToDictionary(u => u.ID, u => u.UserName)
|
||||||
|
: new Dictionary<string, string>();
|
||||||
|
return Ok(new ViewAdminActionsPage
|
||||||
|
{
|
||||||
|
Items = actions.Select(a => new ViewAdminAction
|
||||||
|
{
|
||||||
|
Id = a.ID,
|
||||||
|
At = a.At,
|
||||||
|
By = a.ByRootId == AdminAction.Cli ? AdminAction.Cli
|
||||||
|
: a.ByRootId == default ? default
|
||||||
|
: admin ? names.GetValueOrDefault(a.ByRootId, a.ByRootId)
|
||||||
|
: a.ByRootId == me.ID ? me.UserName
|
||||||
|
: default,
|
||||||
|
ByYou = a.ByRootId == me.ID,
|
||||||
|
Action = a.Action,
|
||||||
|
TargetKind = a.TargetKind,
|
||||||
|
TargetId = a.TargetId,
|
||||||
|
TargetLabel = a.TargetLabel,
|
||||||
|
ReportId = a.ReportId,
|
||||||
|
Note = a.Note
|
||||||
|
}).ToList(),
|
||||||
|
Next = actions.Count == limit ? actions[^1].ID : default
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 535 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user