{api,fed/in}: expose remotes claims wrt poll vote anonymitiy

Most of the major micro-blogging AP implementations do not make
votes or voter identity available to anyone by regular means.
However, this is but an informally adopted common practice.

Smithereen may (depending on the decision of the creating user)
disclose who voted and what everyone voted for. This information
is made publically available to everyone, including via ActivityPub
(eventhough the AP vote collections show some type and data
 inconsistencies between the inline and standalone version at the
 time of writing. It is necessary to fetch the standalone collections
 for the full information.)

Smithereen does indicate whether a poll will disclose votes and voter
identies and when this is kept secret. But of course, for this info
to be visible to our users we will need to first pick up the hint
from Smithereen and forward it in our Masto API responses.

Example: https://friends.grishka.me/posts/1116518
This commit is contained in:
Oneric 2026-04-04 00:00:00 +00:00
commit 11982eb249
10 changed files with 576 additions and 3 deletions

View file

@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Mastodon-compatible `avatar_description` and `header_description` parameters are added to account API responses and as input for `PATCH /api/v1/accounts/update_credentials`
- `pleroma.background_image_descripption` is added to account API responses
- `pleroma_background_image_descripption` is added as a new parameter to `PATCH /api/v1/accounts/update_credentials`
- `GET /api/v1/statuses/:id` contains the new `poll.akkoma.anonymous` parameter if `poll` is non-null.
It relays if and whether the source instance promised to keep votes anonymous or disclose votes with voter identity.
There are no plans to enable creating non-anonymous polls in Akkoma, but some implementations do.
### Fixed
- fix date-time format in `* /api/v1/markers` to strictly conform to Mastodons ISO 8061 subset

View file

@ -49,6 +49,15 @@ Has these additional fields under the `pleroma` object:
- `parent_visible`: If the parent of this post is visible to the user or not.
- `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
Furthermore, has the following additional attributes under the `poll.akkoma` object *(if `poll` is non-null)*:
- `anonymous`: relays whether the poll creator promised to process votes anonymously *(`true`)*,
publishes votes with voter identity to some parties or the public *(`false`)*
or did not indicate how votes are processed (`null`).
Note this assumes any remotes claim about its process are true and
even if this is truthfully set to `true`, while no regular users ought to have access to voter identities,
the server operator of the polls home instance may in principle still be able to
extract voter identites via the database or side channels.
The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes:
- `content_type`: The content type of the status source.

View file

@ -29,6 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do
field(:closed, ObjectValidators.DateTime)
field(:voters, {:array, ObjectValidators.ObjectID}, default: [])
field(:nonAnonymous, :boolean)
embeds_many(:anyOf, QuestionOptionsValidator)
embeds_many(:oneOf, QuestionOptionsValidator)
end

View file

@ -58,6 +58,17 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
}
},
description: "Possible answers for the poll."
},
akkoma: %Schema{
type: :object,
properties: %{
anonymous: %Schema{
type: :boolean,
nullable: true,
description:
"Whether the poll creator indicates votes are processed anonymously (true), votes with voter identity will be published to some parties or the public (false) or did not indicate anything about vote confidentiality (null)"
}
}
}
},
example: %{
@ -81,7 +92,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
votes_count: 4
}
],
emojis: []
emojis: [],
akkoma: %{
anonymous: true
}
}
})
end

View file

@ -11,6 +11,12 @@ defmodule Pleroma.Web.MastodonAPI.PollView do
{end_time, expired} = end_time_and_expired(object)
{options, votes_count} = options_and_votes_count(options)
anonymous =
case object.data["nonAnonymous"] do
nil -> nil
val -> !val
end
poll = %{
# Mastodon uses separate ids for polls, but an object can't have
# more than one poll embedded so object id is fine
@ -21,7 +27,10 @@ defmodule Pleroma.Web.MastodonAPI.PollView do
votes_count: votes_count,
voters_count: voters_count(multiple, object),
options: options,
emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"])
emojis: Pleroma.Web.MastodonAPI.StatusView.build_emojis(object.data["emoji"]),
akkoma: %{
anonymous: anonymous
}
}
if params[:for] do

View file

@ -0,0 +1,112 @@
{
"type": "Question",
"id": "https://smithereen.example/posts/poll-anon",
"attributedTo": "https://smithereen.example/users/1",
"content": "<p>Example poll for which the identity and choices of voters will be kept secret</p>",
"name": "Какую русскую раскладку клавиатуры вы используете на macOS?",
"published": "2024-04-19T13:34:51Z",
"tag": [],
"url": "https://smithereen.example/posts/poll-anon",
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://smithereen.example/users/1/followers"
],
"cc": [],
"replies": {
"type": "Collection",
"id": "https://smithereen.example/posts/poll-anon/replies",
"first": {
"type": "CollectionPage",
"items": [],
"partOf": "https://smithereen.example/posts/poll-anon/replies",
"next": "https://smithereen.example/posts/poll-anon/replies?page=1"
}
},
"sensitive": false,
"likes": {
"type": "Collection",
"id": "https://smithereen.example/posts/poll-anon/likes",
"first": {
"type": "CollectionPage",
"items": [],
"partOf": "https://smithereen.example/posts/poll-anon/likes",
"next": "https://smithereen.example/posts/poll-anon/likes?page=1"
}
},
"interactionPolicy": {
"canQuote": {
"automaticApproval": "https://www.w3.org/ns/activitystreams#Public"
}
},
"oneOf": [
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-anon#options/8352",
"name": "Просто \"русская\"",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8352/votes",
"totalItems": 11,
"items": []
}
},
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-anon#options/8353",
"name": "Русская — ПК",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8353/votes",
"totalItems": 6,
"items": []
}
},
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-anon#options/8354",
"name": "Другая",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8354/votes",
"totalItems": 1,
"items": []
}
},
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-anon#options/8355",
"name": "Не пользуюсь macOS и/или русским языком",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8355/votes",
"totalItems": 17,
"items": []
}
}
],
"votersCount": 35,
"nonAnonymous": false,
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"sensitive": "as:sensitive",
"gts": "https://gotosocial.org/ns#",
"interactionPolicy": {
"@id": "gts:interactionPolicy",
"@type": "@id"
},
"canQuote": {
"@id": "gts:canQuote",
"@type": "@id"
},
"automaticApproval": {
"@id": "gts:automaticApproval",
"@type": "@id"
},
"toot": "http://joinmastodon.org/ns#",
"sm": "http://smithereen.software/ns#",
"votersCount": "toot:votersCount",
"nonAnonymous": "sm:nonAnonymous"
}
]
}

View file

@ -0,0 +1,112 @@
{
"type": "Question",
"id": "https://smithereen.example/posts/poll-disclosed",
"attributedTo": "https://smithereen.example/users/1",
"content": "<p>Example poll for which all votes will be published with voter identity</p>",
"name": "Какую русскую раскладку клавиатуры вы используете на macOS?",
"published": "2024-04-19T13:34:51Z",
"tag": [],
"url": "https://smithereen.example/posts/poll-disclosed",
"to": [
"https://www.w3.org/ns/activitystreams#Public",
"https://smithereen.example/users/1/followers"
],
"cc": [],
"replies": {
"type": "Collection",
"id": "https://smithereen.example/posts/poll-disclosed/replies",
"first": {
"type": "CollectionPage",
"items": [],
"partOf": "https://smithereen.example/posts/poll-disclosed/replies",
"next": "https://smithereen.example/posts/poll-disclosed/replies?page=1"
}
},
"sensitive": false,
"likes": {
"type": "Collection",
"id": "https://smithereen.example/posts/poll-disclosed/likes",
"first": {
"type": "CollectionPage",
"items": [],
"partOf": "https://smithereen.example/posts/poll-disclosed/likes",
"next": "https://smithereen.example/posts/poll-disclosed/likes?page=1"
}
},
"interactionPolicy": {
"canQuote": {
"automaticApproval": "https://www.w3.org/ns/activitystreams#Public"
}
},
"oneOf": [
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-disclosed#options/8352",
"name": "Просто \"русская\"",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8352/votes",
"totalItems": 11,
"items": []
}
},
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-disclosed#options/8353",
"name": "Русская — ПК",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8353/votes",
"totalItems": 6,
"items": []
}
},
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-disclosed#options/8354",
"name": "Другая",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8354/votes",
"totalItems": 1,
"items": []
}
},
{
"type": "Note",
"id": "https://smithereen.example/posts/poll-disclosed#options/8355",
"name": "Не пользуюсь macOS и/или русским языком",
"replies": {
"type": "Collection",
"id": "https://smithereen.example/activitypub/objects/polls/2231/options/8355/votes",
"totalItems": 17,
"items": []
}
}
],
"votersCount": 35,
"nonAnonymous": true,
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"sensitive": "as:sensitive",
"gts": "https://gotosocial.org/ns#",
"interactionPolicy": {
"@id": "gts:interactionPolicy",
"@type": "@id"
},
"canQuote": {
"@id": "gts:canQuote",
"@type": "@id"
},
"automaticApproval": {
"@id": "gts:automaticApproval",
"@type": "@id"
},
"toot": "http://joinmastodon.org/ns#",
"sm": "http://smithereen.software/ns#",
"votersCount": "toot:votersCount",
"nonAnonymous": "sm:nonAnonymous"
}
]
}

250
test/fixtures/smithereen/user.json vendored Normal file
View file

@ -0,0 +1,250 @@
{
"type": "Person",
"id": "https://smithereen.example/users/1",
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": "<a href=\"https://grishka.me\" rel=\"me\">https://grishka.me</a>"
},
{
"type": "PropertyValue",
"name": "Matrix",
"value": "<a href=\"https://matrix.to/#/@grishk:matrix.org\" rel=\"me\">@grishk:matrix.org</a>"
},
{
"type": "PropertyValue",
"name": "Telegram",
"value": "<a href=\"https://t.me/grishka\" rel=\"me\">grishka</a>"
},
{
"type": "PropertyValue",
"name": "Twitter",
"value": "<a href=\"https://twitter.com/grishka11\" rel=\"me\">grishka11</a>"
},
{
"type": "PropertyValue",
"name": "Git",
"value": "<a href=\"https://github.com/grishka\" rel=\"me\">https://github.com/grishka</a>"
}
],
"name": "Григорий Клюшников",
"icon": {
"type": "Image",
"width": 573,
"height": 572,
"cropRegion": [
0.26422762870788574,
0.3766937553882599,
0.7113820910453796,
0.9728997349739075
],
"image": {
"type": "Image",
"url": "https://smithereen.example/i/-QttwTPRJpjwkRrB1MuI1zS--NGKf8r-K18LZAkqQ6g/q:93/bG9jYWw6Ly8vcy91cGxvYWRzLzAxLzAwLzAwLzloTWJvTzgyQzZnSV9GeHZVVVFUdUgzT19mNC0wVUczRmFfTS53ZWJw.jpg",
"mediaType": "image/jpeg",
"width": 1280,
"height": 960
},
"url": "https://smithereen.example/i/EWM-QfEuhSLizijJLta-0NWvN0KT2YJNfclg-Uvkxag/c:573:572:nowe:338:362/q:93/bG9jYWw6Ly8vcy91cGxvYWRzLzAxLzAwLzAwLzloTWJvTzgyQzZnSV9GeHZVVVFUdUgzT19mNC0wVUczRmFfTS53ZWJw.jpg",
"mediaType": "image/jpeg"
},
"summary": "<p>I mostly post in Russian here. My English-language account is <a href=\"https://mastodon.social/@grishka\">@grishka@mastodon.social</a>.</p>",
"url": "https://smithereen.example/grishka",
"preferredUsername": "grishka",
"inbox": "https://smithereen.example/users/1/inbox",
"outbox": "https://smithereen.example/users/1/outbox",
"followers": "https://smithereen.example/users/1/followers",
"following": "https://smithereen.example/users/1/following",
"endpoints": {
"sharedInbox": "https://smithereen.example/activitypub/sharedInbox",
"collectionSimpleQuery": "https://smithereen.example/users/1/collectionQuery"
},
"publicKey": {
"id": "https://smithereen.example/users/1#main-key",
"owner": "https://smithereen.example/users/1",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjlakm+i/d9ER/hIeR7Kf\niFW+SdLZj2SkKIeM8cmR+YFJuh9ghFqXrkFEjcaqUnAFqe5gYDNSQACnDLA8y4Dn\nzjfGNIohKAnRoa9x6GORmfKQvcnjaTZ53S1NvUiPPyc0Pv/vfCtY/Ab0CEXe5BLq\nL38oZn817Jf7pBrPRTYH7m012kvwAUTT6k0Y8lPITBEG7nzYbbuGcrN9Y/RDdwE0\n8jmBXlZ45bahRH3VNXVpQE17dCzJB+7k+iJ1R7YCoI+DuMlBYGXGE2KVk46NZTuL\nnOjFV9SyXfWX4/SrJM4oxev+SX2N75tQgmNZmVVHeqg2ZcbC0WCfNjJOi2HHS9Mu\njwIDAQAB\n-----END PUBLIC KEY-----\n"
},
"wall": "https://smithereen.example/users/1/wall",
"wallComments": "https://smithereen.example/users/1/wallComments",
"discoverable": true,
"indexable": true,
"status": {
"type": "ActorStatus",
"id": "https://smithereen.example/users/1/statuses/1747352717",
"attributedTo": "https://smithereen.example/users/1",
"content": "Гришка, верни стену!!!1",
"published": "2025-05-15T23:45:17.460Z"
},
"firstName": "Григорий",
"lastName": "Клюшников",
"middleName": "Александрович",
"vcard:bday": "1993-01-22",
"gender": "http://schema.org#Male",
"supportsFriendRequests": true,
"friends": "https://smithereen.example/users/1/friends",
"groups": "https://smithereen.example/users/1/groups",
"capabilities": {
"supportsFriendRequests": true
},
"privacySettings": {
"wallPosting": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
},
"wallPostVisibility": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
},
"commenting": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
},
"groupInvitations": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
},
"directMessages": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
},
"photoTagging": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
},
"photoTagList": {
"allowedTo": [
"https://www.w3.org/ns/activitystreams#Public"
]
}
},
"activities": "Разрабатываю для федивёрса, приближаю уход централизованных соцсетей в небытие, возвращаю 2007й, бесконечно ругаюсь на ИТ-индустрию",
"interests": "Децентрализация, социальные сети, программирование, user experience, исследования о продлении жизни и лечении старения",
"favoriteTvShows": "Black Mirror, Stargate, Upload",
"favoriteQuotes": "\"Когда доделаем и оттестируем\"\n\"Всё невозможное возможно, знаю точно\"",
"politicalViews": "sm:Libertarian",
"personalPriority": "sm:ImprovingTheWorld",
"smokingViews": "sm:Negative",
"alcoholViews": "sm:Negative",
"vcard:Address": "St Petersburg",
"relationshipStatus": "sm:Complicated",
"photoAlbums": "https://smithereen.example/users/1/albums",
"manuallyApprovesFollowers": false,
"taggedPhotos": "https://smithereen.example/users/1/tagged",
"suspended": false,
"featured": "https://smithereen.example/users/1/pinnedPosts",
"apps": "https://smithereen.example/users/1/apps",
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"value": "http://schema.org#value",
"PropertyValue": "http://schema.org#PropertyValue",
"sm": "http://smithereen.software/ns#",
"cropRegion": {
"@id": "sm:cropRegion",
"@container": "@list"
},
"wall": {
"@id": "sm:wall",
"@type": "@id"
},
"wallComments": {
"@id": "sm:wallComments",
"@type": "@id"
},
"collectionSimpleQuery": "sm:collectionSimpleQuery",
"toot": "http://joinmastodon.org/ns#",
"discoverable": "toot:discoverable",
"indexable": "toot:indexable",
"status": {
"@id": "sm:status",
"@type": "@id"
},
"ActorStatus": "sm:ActorStatus",
"sc": "http://schema.org#",
"firstName": "sc:givenName",
"lastName": "sc:familyName",
"middleName": "sc:additionalName",
"gender": {
"@id": "sc:gender",
"@type": "sc:GenderType"
},
"maidenName": "sm:maidenName",
"friends": {
"@id": "sm:friends",
"@type": "@id"
},
"groups": {
"@id": "sm:groups",
"@type": "@id"
},
"vcard": "http://www.w3.org/2006/vcard/ns#",
"capabilities": "litepub:capabilities",
"supportsFriendRequests": "sm:supportsFriendRequests",
"litepub": "http://litepub.social/ns#",
"privacySettings": "sm:privacySettings",
"allowedTo": "sm:allowedTo",
"except": "sm:except",
"wallPosting": "sm:wallPosting",
"wallPostVisibility": "sm:wallPostVisibility",
"commenting": "sm:commenting",
"groupInvitations": "sm:groupInvitations",
"directMessages": "sm:directMessages",
"photoTagging": "sm:photoTagging",
"photoTagList": "sm:photoTagList",
"activities": "sm:activities",
"interests": "sm:interests",
"favoriteTvShows": "sm:favoriteTvShows",
"favoriteQuotes": "sm:favoriteQuotes",
"politicalViews": {
"@id": "sm:politicalViews",
"@type": "@id"
},
"personalPriority": {
"@id": "sm:personalPriority",
"@type": "@id"
},
"smokingViews": {
"@id": "sm:smokingViews",
"@type": "@id"
},
"alcoholViews": {
"@id": "sm:alcoholViews",
"@type": "@id"
},
"relationshipStatus": {
"@id": "sm:relationshipStatus",
"@type": "@id"
},
"relationshipPartner": {
"@id": "sm:relationshipPartner",
"@type": "@id"
},
"photoAlbums": {
"@id": "sm:photoAlbums",
"@type": "@id"
},
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"taggedPhotos": {
"@id": "sm:taggedPhotos",
"@type": "@id"
},
"suspended": "toot:suspended",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"apps": {
"@id": "sm:apps",
"@type": "@id"
}
},
"https://w3id.org/security/v1"
]
}

View file

@ -43,7 +43,10 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
%{title: "why are you even asking?", votes_count: 0}
],
votes_count: 0,
voters_count: nil
voters_count: nil,
akkoma: %{
anonymous: nil
}
}
result = PollView.render("show.json", %{object: object})
@ -137,6 +140,27 @@ defmodule Pleroma.Web.MastodonAPI.PollViewTest do
assert result[:expired] == false
end
test "does not make false claims in absence of vote anonymity information" do
object = Object.normalize("https://skippers-bin.com/notes/7x9tmrp97i", fetch: true)
result = PollView.render("show.json", %{object: object})
assert result[:akkoma][:anonymous] == nil
end
test "indicates promise of anonymity" do
object = Object.normalize("https://smithereen.example/posts/poll-anon", fetch: true)
result = PollView.render("show.json", %{object: object})
assert result[:akkoma][:anonymous] == true
end
test "indicates assured vote and identity disclosure" do
object = Object.normalize("https://smithereen.example/posts/poll-disclosed", fetch: true)
result = PollView.render("show.json", %{object: object})
assert result[:akkoma][:anonymous] == false
end
test "doesn't strips HTML tags" do
user = insert(:user)

View file

@ -1812,6 +1812,45 @@ defmodule HttpRequestMock do
}}
end
def get("https://smithereen.example/users/1" = url, _, _, _) do
{:ok,
%Tesla.Env{
status: 200,
url: url,
body: File.read!("test/fixtures/smithereen/user.json"),
headers: [
{"content-type",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}
]
}}
end
def get("https://smithereen.example/posts/poll-disclosed" = url, _, _, _) do
{:ok,
%Tesla.Env{
status: 200,
url: url,
body: File.read!("test/fixtures/smithereen/poll_nonanonymous.json"),
headers: [
{"content-type",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}
]
}}
end
def get("https://smithereen.example/posts/poll-anon" = url, _, _, _) do
{:ok,
%Tesla.Env{
status: 200,
url: url,
body: File.read!("test/fixtures/smithereen/poll_anonymous.json"),
headers: [
{"content-type",
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""}
]
}}
end
def get(url, query, body, headers) do
{:error,
"Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{inspect(headers)}"}