1
0
mirror of https://git.sr.ht/~tsileo/microblog.pub synced 2025-06-05 21:59:23 +02:00

12 Commits

Author SHA1 Message Date
a6321f52d8 Add task to reset password 2022-09-15 22:47:36 +02:00
4e1e4d0ea8 Tweak actor update 2022-09-15 22:19:01 +02:00
110f7df962 Fix GIF upload handling 2022-09-14 08:38:54 +02:00
4c86cd4be3 Always show followers/following page when admin 2022-09-13 22:33:20 +02:00
df06defbef Tweak docs 2022-09-13 21:23:32 +02:00
b2f268682c New config item to hide followers/following 2022-09-13 21:03:35 +02:00
567595bb4b Tweak inbox processing 2022-09-13 21:03:11 +02:00
91b8bb26b7 Bugfixes 2022-09-13 21:02:47 +02:00
bd4d5a004a Improve Announce handling 2022-09-13 07:59:35 +02:00
04da8725ed Improve fetch 2022-09-12 08:04:16 +02:00
0c7a19749d Tweak docs about moving 2022-09-11 19:37:35 +02:00
2a37034775 Fix move task 2022-09-11 19:26:41 +02:00
16 changed files with 201 additions and 28 deletions

View File

@ -61,6 +61,10 @@ class ObjectNotFoundError(Exception):
pass
class ObjectUnavailableError(Exception):
pass
class FetchErrorTypeEnum(str, enum.Enum):
TIMEOUT = "TIMEOUT"
NOT_FOUND = "NOT_FOUND"
@ -167,6 +171,8 @@ async def fetch(
# Special handling for deleted object
if resp.status_code == 410:
raise ObjectIsGoneError(f"{url} is gone")
elif resp.status_code in [401, 403]:
raise ObjectUnavailableError(f"not allowed to fetch {url}")
elif resp.status_code == 404:
raise ObjectNotFoundError(f"{url} not found")

View File

@ -85,6 +85,8 @@ async def get_lookup(
error = ap.FetchErrorTypeEnum.TIMEOUT
except (ap.ObjectNotFoundError, ap.ObjectIsGoneError):
error = ap.FetchErrorTypeEnum.NOT_FOUND
except (ap.ObjectUnavailableError):
error = ap.FetchErrorTypeEnum.UNAUHTORIZED
except Exception:
logger.exception(f"Failed to lookup {query}")
error = ap.FetchErrorTypeEnum.INTERNAL_ERROR

View File

@ -1422,7 +1422,8 @@ async def _handle_update_activity(
updated_actor = RemoteActor(wrapped_object)
if (
from_actor.ap_id != updated_actor.ap_id
or from_actor.ap_type != updated_actor.ap_type
or ap.as_list(from_actor.ap_type)[0] not in ap.ACTOR_TYPES
or ap.as_list(updated_actor.ap_type)[0] not in ap.ACTOR_TYPES
or from_actor.handle != updated_actor.handle
):
raise ValueError(
@ -1783,6 +1784,12 @@ async def _handle_announce_activity(
announced_raw_object = await ap.fetch(
announce_activity.activity_object_ap_id
)
# Some software return objects wrapped in a Create activity (like
# python-federation)
if ap.as_list(announced_raw_object["type"])[0] == "Create":
announced_raw_object = await ap.get_object(announced_raw_object)
announced_actor = await fetch_actor(
db_session, ap.get_actor_id(announced_raw_object)
)
@ -1844,6 +1851,7 @@ async def _process_transient_object(
if ap_type in ["Add", "Remove"]:
logger.info(f"Dropping unsupported {ap_type} object")
else:
# FIXME(ts): handle transient create
logger.warning(f"Received unknown {ap_type} object")
return None
@ -1854,6 +1862,28 @@ async def save_to_inbox(
raw_object: ap.RawObject,
sent_by_ap_actor_id: str,
) -> None:
# Special case for server sending the actor as a payload (like python-federation)
if ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
if ap.get_id(raw_object) == sent_by_ap_actor_id:
updated_actor = RemoteActor(raw_object)
try:
actor = await fetch_actor(db_session, sent_by_ap_actor_id)
except ap.ObjectNotFoundError:
logger.warning("Actor not found")
return
# Update the actor
actor.ap_actor = updated_actor.ap_actor
await db_session.commit()
return
else:
logger.warning(
f"Reveived an actor payload {raw_object} from " f"{sent_by_ap_actor_id}"
)
return
try:
actor = await fetch_actor(db_session, ap.get_id(raw_object["actor"]))
except ap.ObjectNotFoundError:
@ -1867,7 +1897,7 @@ async def save_to_inbox(
logger.warning(f"Server {actor.server} is blocked")
return
if "id" not in raw_object:
if "id" not in raw_object or not raw_object["id"]:
await _process_transient_object(db_session, raw_object, actor)
return None

View File

@ -102,6 +102,9 @@ class Config(pydantic.BaseModel):
emoji: str | None = None
also_known_as: str | None = None
hides_followers: bool = False
hides_following: bool = False
inbox_retention_days: int = 15
# Config items to make tests easier
@ -144,6 +147,8 @@ _SCHEME = "https" if CONFIG.https else "http"
ID = f"{_SCHEME}://{DOMAIN}"
USERNAME = CONFIG.username
MANUALLY_APPROVES_FOLLOWERS = CONFIG.manually_approves_followers
HIDES_FOLLOWERS = CONFIG.hides_followers
HIDES_FOLLOWING = CONFIG.hides_following
PRIVACY_REPLACE = None
if CONFIG.privacy_replace:
PRIVACY_REPLACE = {pr.domain: pr.replace_by for pr in CONFIG.privacy_replace}

View File

@ -115,11 +115,8 @@ async def _get_public_key(db_session: AsyncSession, key_id: str) -> Key:
# might race to fetch each other key
try:
actor = await ap.fetch(key_id, disable_httpsig=True)
except httpx.HTTPStatusError as http_err:
if http_err.response.status_code in [401, 403]:
actor = await ap.fetch(key_id, disable_httpsig=False)
else:
raise
except ap.ObjectUnavailableError:
actor = await ap.fetch(key_id, disable_httpsig=False)
if actor["type"] == "Key":
# The Key is not embedded in the Person

View File

@ -26,7 +26,7 @@ async def new_ap_incoming_activity(
raw_object: ap.RawObject,
) -> models.IncomingActivity | None:
ap_id: str
if "id" not in raw_object:
if "id" not in raw_object or ap.as_list(raw_object["type"])[0] in ap.ACTOR_TYPES:
if "@context" not in raw_object:
logger.warning(f"Dropping invalid object: {raw_object}")
return None

View File

@ -38,4 +38,9 @@ async def lookup(db_session: AsyncSession, query: str) -> Actor | RemoteObject:
if ap.as_list(ap_obj["type"])[0] in ap.ACTOR_TYPES:
return RemoteActor(ap_obj)
else:
# Some software return objects wrapped in a Create activity (like
# python-federation)
if ap.as_list(ap_obj["type"])[0] == "Create":
ap_obj = await ap.get_object(ap_obj)
return await RemoteObject.from_raw_object(ap_obj)

View File

@ -403,6 +403,20 @@ async def _build_followx_collection(
return collection_page
async def _empty_followx_collection(
db_session: AsyncSession,
model_cls: Type[models.Following | models.Follower],
path: str,
) -> ap.RawObject:
total_items = await db_session.scalar(select(func.count(model_cls.id)))
return {
"@context": ap.AS_CTX,
"id": ID + path,
"type": "OrderedCollection",
"totalItems": total_items,
}
@app.get("/followers")
async def followers(
request: Request,
@ -413,15 +427,27 @@ async def followers(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
page=page,
next_cursor=next_cursor,
if config.HIDES_FOLLOWERS:
return ActivityPubResponse(
await _empty_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
)
)
)
else:
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Follower,
path="/followers",
page=page,
next_cursor=next_cursor,
)
)
if config.HIDES_FOLLOWERS and not is_current_user_admin(request):
raise HTTPException(status_code=404)
# We only show the most recent 20 followers on the public website
followers_result = await db_session.scalars(
@ -460,15 +486,27 @@ async def following(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request):
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
page=page,
next_cursor=next_cursor,
if config.HIDES_FOLLOWING:
return ActivityPubResponse(
await _empty_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
)
)
)
else:
return ActivityPubResponse(
await _build_followx_collection(
db_session=db_session,
model_cls=models.Following,
path="/following",
page=page,
next_cursor=next_cursor,
)
)
if config.HIDES_FOLLOWING and not is_current_user_admin(request):
raise HTTPException(status_code=404)
# We only show the most recent 20 follows on the public website
following = (

View File

@ -419,3 +419,5 @@ _templates.env.filters["privacy_replace_url"] = privacy_replace.replace_url
_templates.env.globals["JS_HASH"] = config.JS_HASH
_templates.env.globals["CSS_HASH"] = config.CSS_HASH
_templates.env.globals["BASE_URL"] = config.BASE_URL
_templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS
_templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING

View File

@ -36,8 +36,12 @@
{% if articles_count %}
<li>{{ header_link("articles", "Articles") }}</li>
{% endif %}
{% if not HIDES_FOLLOWERS or is_admin %}
<li>{{ header_link("followers", "Followers") }} <span class="counter">{{ followers_count }}</span></li>
{% endif %}
{% if not HIDES_FOLLOWING or is_admin %}
<li>{{ header_link("following", "Following") }} <span class="counter">{{ following_count }}</span></li>
{% endif %}
<li>{{ header_link("get_remote_follow", "Remote follow") }}</li>
</ul>
</nav>

View File

@ -20,6 +20,8 @@
<div class="box error-box">
{% if error.value == "NOT_FOUND" %}
<p>The remote object is unavailable.</p>
{% elif error.value == "UNAUTHORIZED" %}
<p>Missing permissions to fetch the remote object.</p>
{% elif error.value == "TIMEOUT" %}
<p>Lookup timed out, please try refreshing the page.</p>
{% else %}

View File

@ -46,7 +46,7 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
width = None
height = None
if f.content_type.startswith("image"):
if f.content_type.startswith("image") and not f.content_type == "image/gif":
with Image.open(f.file) as _original_image:
# Fix image orientation (as we will remove the info from the EXIF
# metadata)

View File

@ -81,6 +81,9 @@ async def external_urls(
soup = BeautifulSoup(ro.content, "html5lib")
for link in soup.find_all("a"):
h = link.get("href")
if not h:
continue
ph = urlparse(h)
mimetype, _ = mimetypes.guess_type(h)
if (

View File

@ -58,6 +58,26 @@ manually_approves_followers = true
The default value is `false`.
### Hiding followers
If you wish to hide your followers, add this config item to `profile.toml`:
```toml
hides_followers = true
```
The default value is `false`.
### Hiding following
If you wish to hide your following, add this config item to `profile.toml`:
```toml
hides_following = true
```
The default value is `false`.
### Privacy replace
You can define domain to be rewrited to more "privacy friendly" alternatives, like [Invidious](https://invidious.io/)
@ -241,6 +261,8 @@ If you want to move followers from your existing account, ensure it is supported
For [Mastodon you can look at Moving or leaving accounts](https://docs.joinmastodon.org/user/moving/).
If you wish to move **to** another instance, see [Moving to another instance](/user_guide.html#moving-to-another-instance).
First you need to grab the "ActivityPub actor URL" for your existing account:
### Python edition
@ -323,6 +345,8 @@ If you want to migrate to another instance, you have the ability to move your ex
Your new account should reference the existing one, refer to your software configuration (for example [Moving or leaving accounts from the Mastodon doc](https://docs.joinmastodon.org/user/moving/)).
If you wish to move **from** another instance, see [Moving from another instance](/user_guide.html#moving-from-another-instance).
Execute the Move task:
#### Python edition

View File

@ -264,7 +264,7 @@ def move_to(ctx, moved_to):
)
return
await send_move(db_session, moved_to)
await send_move(db_session, new_actor.ap_id)
print("Done")
@ -312,3 +312,18 @@ def yunohost_config(
summary=summary,
password=password,
)
@task
def reset_password(ctx):
# type: (Context) -> None
import bcrypt
from prompt_toolkit import prompt
new_password = bcrypt.hashpw(
prompt("New admin password: ", is_password=True).encode(), bcrypt.gensalt()
).decode()
print()
print("Update data/profile.toml with:")
print(f'admin_password = "{new_password}"')

View File

@ -1,3 +1,5 @@
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
@ -31,7 +33,19 @@ def test_followers__ap(client, db) -> None:
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
assert response.json()["id"].endswith("/followers")
json_resp = response.json()
assert json_resp["id"].endswith("/followers")
assert "first" in json_resp
def test_followers__ap_hides_followers(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
response = client.get("/followers", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
json_resp = response.json()
assert json_resp["id"].endswith("/followers")
assert "first" not in json_resp
def test_followers__html(client, db) -> None:
@ -40,14 +54,40 @@ def test_followers__html(client, db) -> None:
assert response.headers["content-type"].startswith("text/html")
def test_followers__html_hides_followers(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWERS", True):
response = client.get("/followers", headers={"Accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("text/html")
def test_following__ap(client, db) -> None:
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
assert response.json()["id"].endswith("/following")
json_resp = response.json()
assert json_resp["id"].endswith("/following")
assert "first" in json_resp
def test_following__ap_hides_following(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
response = client.get("/following", headers={"Accept": ap.AP_CONTENT_TYPE})
assert response.status_code == 200
assert response.headers["content-type"] == ap.AP_CONTENT_TYPE
json_resp = response.json()
assert json_resp["id"].endswith("/following")
assert "first" not in json_resp
def test_following__html(client, db) -> None:
response = client.get("/following")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
def test_following__html_hides_following(client, db) -> None:
with mock.patch("app.main.config.HIDES_FOLLOWING", True):
response = client.get("/following", headers={"Accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("text/html")