From b2f268682ce7cd7c590607e522b934fa1b36ee12 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Tue, 13 Sep 2022 21:03:35 +0200 Subject: [PATCH] New config item to hide followers/following --- app/config.py | 5 +++ app/main.py | 70 ++++++++++++++++++++++++++++++--------- app/templates.py | 2 ++ app/templates/header.html | 4 +++ tests/test_public.py | 44 ++++++++++++++++++++++-- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/app/config.py b/app/config.py index e27f8bd..687bf65 100644 --- a/app/config.py +++ b/app/config.py @@ -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} diff --git a/app/main.py b/app/main.py index ea357ab..62e5e69 100644 --- a/app/main.py +++ b/app/main.py @@ -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: + 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: + raise HTTPException(status_code=404) # We only show the most recent 20 follows on the public website following = ( diff --git a/app/templates.py b/app/templates.py index f338d64..8d4e016 100644 --- a/app/templates.py +++ b/app/templates.py @@ -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 diff --git a/app/templates/header.html b/app/templates/header.html index 1fe7d98..5c7467e 100644 --- a/app/templates/header.html +++ b/app/templates/header.html @@ -36,8 +36,12 @@ {% if articles_count %}
  • {{ header_link("articles", "Articles") }}
  • {% endif %} + {% if not HIDES_FOLLOWERS %}
  • {{ header_link("followers", "Followers") }} {{ followers_count }}
  • + {% endif %} + {% if not HIDES_FOLLOWING %}
  • {{ header_link("following", "Following") }} {{ following_count }}
  • + {% endif %}
  • {{ header_link("get_remote_follow", "Remote follow") }}
  • diff --git a/tests/test_public.py b/tests/test_public.py index 083d518..1c94f3d 100644 --- a/tests/test_public.py +++ b/tests/test_public.py @@ -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")