Porównaj commity

...

30 Commity
0.10.1 ... main

Autor SHA1 Wiadomość Data
Andrew Godwin 7c34ac78ed Write a release checklist and do a couple things on it 2024-02-06 14:49:35 -07:00
Henri Dickson 72eb6a6271
add application/activity+json to accept header to improve compatibility (#694) 2024-02-05 21:40:04 -05:00
Jamie Bliss b2223ddf42
Back out push notification changes 2024-02-05 21:18:59 -05:00
Jamie Bliss 045a499ddf
Fix docs 2024-02-05 20:59:22 -05:00
Jamie Bliss 0fa48578f2
Write release notes for 0.11.0 2024-02-05 20:53:09 -05:00
Henri Dickson f86f3a49e4
Fix when report ap message does not have content field (#689) 2024-01-08 19:48:21 -07:00
Henri Dickson 2f4daa02bd
Add missing validator to initial migration (#687) 2024-01-04 08:59:26 -07:00
Henri Dickson 798222dcdb
Post update/delete also fanout to those who liked/boosted it but not following the author (#684) 2023-12-31 11:06:30 -07:00
Henri Dickson 74b3ac551a
Fix accept/reject follow request (#683) 2023-12-27 11:48:09 -07:00
Henri Dickson 4a09379e09
Fix federating with GoToSocial (#682) 2023-12-26 10:26:03 -07:00
Henri Dickson 448092d6d9
Improve identity deletion (#678) 2023-12-16 23:49:59 +00:00
Henri Dickson 5d508a17ec
Basic protection against invalid domain names (#680) 2023-12-13 09:04:41 +00:00
Jamie Bliss d07482f5a8
Allow statusless posts (#677) 2023-12-07 16:32:18 -07:00
Henri Dickson 123c20efb1
When remote follows local, hold off sending Accept if remote identity is not fully fetched (#676) 2023-12-06 11:08:41 -07:00
Karthik Balakrishnan 83607779cd
Fix README: 0.10.1 is latest release (#675) 2023-12-01 09:11:18 -07:00
Andrew Godwin 837320f461 Invert pruning exit codes 2023-12-01 00:03:09 -07:00
Rob 5f28d702f8
Make max_media_attachments configurable by admin (#669) 2023-11-28 09:52:04 -07:00
Henri Dickson ac7fef4b28
Do not fetch webfinger if when querying identity on local domain (#668) 2023-11-26 21:00:58 -07:00
Henri Dickson 6855e74c6f
Do not retry unmute if mute never expires 2023-11-26 12:46:31 -07:00
Henri Dickson a58d7ccd8f
Do not make local identities outdated (#667) 2023-11-26 11:19:18 -07:00
Rob 1a728ea023
Add s3-insecure to pydantic checker (#665) 2023-11-26 11:13:55 -07:00
Humberto Rocha b031880e41
Extract json parser to core and use in fetch_actor (#663) 2023-11-20 11:46:51 -07:00
Humberto Rocha 81d019ad0d
Improve search api json parsing (#662) 2023-11-19 11:32:35 -07:00
Henri Dickson 5267e4108c
Allow unicode characters in hashtag (#659) 2023-11-19 09:58:20 -07:00
Henri Dickson b122e2beda
Fix fetching post from another takahe by searching its url (#661) 2023-11-18 21:03:51 -07:00
Rob ae1bfc49a7
Add s3-insecure for S3 backend (#658) 2023-11-17 21:49:06 -07:00
Osma Ahvenlampi 1ceef59bec
Module-specific loggers and minor reformatting (#657) 2023-11-16 10:27:20 -07:00
Humberto Rocha 2f546dfa74
Do not canonicalise non json content in the search endpoint (#654) 2023-11-15 15:00:56 -07:00
Andrew Godwin cc9e397f60 Ensure post pruning has a random selection element 2023-11-14 00:04:18 -07:00
Andrew Godwin dc397903b2 Fix release date formatting 2023-11-13 12:18:30 -07:00
40 zmienionych plików z 551 dodań i 112 usunięć

Wyświetl plik

@ -3,7 +3,7 @@
A *beta* Fediverse server for microblogging. Not fully polished yet -
we're still working towards a 1.0!
**Current version: [0.9](https://docs.jointakahe.org/en/latest/releases/0.9/)**
**Current version: [0.11.0](https://docs.jointakahe.org/en/latest/releases/0.11/)**
Key features:

Wyświetl plik

@ -27,16 +27,18 @@ class Command(BaseCommand):
sys.exit(2)
# Find a set of posts that match the initial criteria
print(f"Running query to find up to {number} old posts...")
posts = Post.objects.filter(
local=False,
created__lt=timezone.now()
- datetime.timedelta(days=settings.SETUP.REMOTE_PRUNE_HORIZON),
).exclude(
Q(interactions__identity__local=True)
| Q(visibility=Post.Visibilities.mentioned)
)[
:number
]
posts = (
Post.objects.filter(
local=False,
created__lt=timezone.now()
- datetime.timedelta(days=settings.SETUP.REMOTE_PRUNE_HORIZON),
)
.exclude(
Q(interactions__identity__local=True)
| Q(visibility=Post.Visibilities.mentioned)
)
.order_by("?")[:number]
)
post_ids_and_uris = dict(posts.values_list("object_uri", "id"))
print(f" found {len(post_ids_and_uris)}")
@ -71,10 +73,11 @@ class Command(BaseCommand):
# Delete them
if not final_post_ids:
sys.exit(1)
sys.exit(0)
print("Deleting...")
_, deleted = Post.objects.filter(id__in=final_post_ids).delete()
print("Deleted:")
for model, model_deleted in deleted.items():
print(f" {model}: {model_deleted}")
sys.exit(1)

Wyświetl plik

@ -46,6 +46,8 @@ from users.models.identity import Identity, IdentityStates
from users.models.inbox_message import InboxMessage
from users.models.system_actor import SystemActor
logger = logging.getLogger(__name__)
class PostStates(StateGraph):
new = State(try_interval=300)
@ -581,7 +583,7 @@ class Post(StatorModel):
domain=domain,
fetch=True,
)
if identity is not None:
if identity is not None and not identity.deleted:
mentions.add(identity)
return mentions
@ -763,6 +765,9 @@ class Post(StatorModel):
targets = set()
for mention in self.mentions.all():
targets.add(mention)
if self.visibility in [Post.Visibilities.public, Post.Visibilities.unlisted]:
for interaction in self.interactions.all():
targets.add(interaction.identity)
# Then, if it's not mentions only, also deliver to followers and all hashtag followers
if self.visibility != Post.Visibilities.mentioned:
for follower in self.author.inbound_follows.filter(
@ -897,7 +902,7 @@ class Post(StatorModel):
# don't have content, but this shouldn't be a total failure
post.content = get_value_or_map(data, "content", "contentMap")
except ActivityPubFormatError as err:
logging.warning(f"{err} on {post.url}")
logger.warning("%s on %s", err, post.url)
post.content = None
# Document types have names, not summaries
post.summary = data.get("summary") or data.get("name")
@ -993,8 +998,10 @@ class Post(StatorModel):
try:
cls.ensure_object_uri(post.in_reply_to, reason=post.object_uri)
except ValueError:
logging.warning(
f"Cannot fetch ancestor of Post={post.pk}, ancestor_uri={post.in_reply_to}"
logger.warning(
"Cannot fetch ancestor of Post=%s, ancestor_uri=%s",
post.pk,
post.in_reply_to,
)
else:
parent.calculate_stats()

Wyświetl plik

@ -9,6 +9,8 @@ from activities.models import (
)
from users.models import Identity
logger = logging.getLogger(__name__)
class PostService:
"""
@ -99,7 +101,7 @@ class PostService:
try:
Post.ensure_object_uri(object_uri, reason=reason)
except ValueError:
logging.error(
logger.error(
f"Cannot fetch ancestor Post={self.post.pk}, ancestor_uri={object_uri}"
)
break

Wyświetl plik

@ -1,6 +1,7 @@
import httpx
from activities.models import Hashtag, Post
from core.json import json_from_response
from core.ld import canonicalise
from users.models import Domain, Identity, IdentityStates
from users.models.system_actor import SystemActor
@ -81,7 +82,12 @@ class SearchService:
return None
if response.status_code >= 400:
return None
document = canonicalise(response.json(), include_security=True)
json_data = json_from_response(response)
if not json_data:
return None
document = canonicalise(json_data, include_security=True)
type = document.get("type", "unknown").lower()
# Is it an identity?

Wyświetl plik

@ -41,7 +41,7 @@ def instance_info_v1(request):
"accounts": {},
"statuses": {
"max_characters": Config.system.post_length,
"max_media_attachments": 4,
"max_media_attachments": Config.system.max_media_attachments,
"characters_reserved_per_url": 23,
},
"media_attachments": {
@ -102,7 +102,7 @@ def instance_info_v2(request) -> dict:
"accounts": {"max_featured_tags": 0},
"statuses": {
"max_characters": Config.system.post_length,
"max_media_attachments": 4,
"max_media_attachments": Config.system.max_media_attachments,
"characters_reserved_per_url": 23,
},
"media_attachments": {

Wyświetl plik

@ -39,7 +39,7 @@ class PostPollSchema(Schema):
class PostStatusSchema(Schema):
status: str
status: str | None
in_reply_to_id: str | None = None
sensitive: bool = False
spoiler_text: str | None = None
@ -82,9 +82,9 @@ def post_for_id(request: HttpRequest, id: str) -> Post:
@api_view.post
def post_status(request, details: PostStatusSchema) -> schemas.Status:
# Check text length
if len(details.status) > Config.system.post_length:
if details.status and len(details.status) > Config.system.post_length:
raise ApiError(400, "Status is too long")
if len(details.status) == 0 and not details.media_ids:
if not details.status and not details.media_ids:
raise ApiError(400, "Status is empty")
# Grab attachments
attachments = [get_object_or_404(PostAttachment, pk=id) for id in details.media_ids]
@ -103,7 +103,7 @@ def post_status(request, details: PostStatusSchema) -> schemas.Status:
pass
post = Post.create_local(
author=request.identity,
content=details.status,
content=details.status or "",
summary=details.spoiler_text,
sensitive=details.sensitive,
visibility=visibility_map[details.visibility],

Wyświetl plik

@ -38,7 +38,7 @@ class FediverseHtmlParser(HTMLParser):
r"(^|[^\w\d\-_/])@([\w\d\-_]+(?:@[\w\d\-_\.]+[\w\d\-_]+)?)"
)
HASHTAG_REGEX = re.compile(r"\B#([a-zA-Z0-9(_)]+\b)(?!;)")
HASHTAG_REGEX = re.compile(r"\B#([\w()]+\b)(?!;)")
EMOJI_REGEX = re.compile(r"\B:([a-zA-Z0-9(_)-]+):\B")

32
core/json.py 100644
Wyświetl plik

@ -0,0 +1,32 @@
import json
from httpx import Response
JSON_CONTENT_TYPES = [
"application/json",
"application/ld+json",
"application/activity+json",
]
def json_from_response(response: Response) -> dict | None:
content_type, *parameters = (
response.headers.get("Content-Type", "invalid").lower().split(";")
)
if content_type not in JSON_CONTENT_TYPES:
return None
charset = None
for parameter in parameters:
key, value = parameter.split("=")
if key.strip() == "charset":
charset = value.strip()
if charset:
return json.loads(response.content.decode(charset))
else:
# if no charset informed, default to
# httpx json for encoding inference
return response.json()

Wyświetl plik

@ -8,6 +8,8 @@ from pyld import jsonld
from core.exceptions import ActivityPubFormatError
logger = logging.getLogger(__name__)
schemas = {
"unknown": {
"contentType": "application/ld+json",
@ -630,7 +632,7 @@ def builtin_document_loader(url: str, options={}):
# Get URL without scheme
pieces = urllib_parse.urlparse(url)
if pieces.hostname is None:
logging.info(f"No host name for json-ld schema: {url!r}")
logger.info(f"No host name for json-ld schema: {url!r}")
return schemas["unknown"]
key = pieces.hostname + pieces.path.rstrip("/")
try:
@ -641,7 +643,7 @@ def builtin_document_loader(url: str, options={}):
return schemas[key]
except KeyError:
# return an empty context instead of throwing an error
logging.info(f"Ignoring unknown json-ld schema: {url!r}")
logger.info(f"Ignoring unknown json-ld schema: {url!r}")
return schemas["unknown"]

Wyświetl plik

@ -214,6 +214,7 @@ class Config(models.Model):
content_warning_text: str = "Content Warning"
post_length: int = 500
max_media_attachments: int = 4
post_minimum_interval: int = 3 # seconds
identity_min_length: int = 2
identity_max_per_user: int = 5

Wyświetl plik

@ -27,12 +27,14 @@ if SENTRY_ENABLED:
set_context = sentry_sdk.set_context
set_tag = sentry_sdk.set_tag
start_transaction = sentry_sdk.start_transaction
start_span = sentry_sdk.start_span
else:
configure_scope = noop_context
push_scope = noop_context
set_context = noop
set_tag = noop
start_transaction = noop_context
start_span = noop_context
def set_takahe_app(name: str):

Wyświetl plik

@ -19,6 +19,8 @@ from pyld import jsonld
from core.ld import format_ld_date
logger = logging.getLogger(__name__)
class VerificationError(BaseException):
"""
@ -190,7 +192,7 @@ class HttpSignature:
body: dict | None,
private_key: str,
key_id: str,
content_type: str = "application/json",
content_type: str = "application/activity+json",
method: Literal["get", "post"] = "post",
timeout: TimeoutTypes = settings.SETUP.REMOTE_TIMEOUT,
):
@ -217,7 +219,7 @@ class HttpSignature:
body_bytes = b""
# GET requests get implicit accept headers added
if method == "get":
headers["Accept"] = "application/ld+json"
headers["Accept"] = "application/activity+json,application/ld+json"
# Sign the headers
signed_string = "\n".join(
f"{name.lower()}: {value}" for name, value in headers.items()
@ -259,7 +261,7 @@ class HttpSignature:
)
except SSLError as invalid_cert:
# Not our problem if the other end doesn't have proper SSL
logging.info(f"{uri} {invalid_cert}")
logger.info("Invalid cert on %s %s", uri, invalid_cert)
raise SSLCertVerificationError(invalid_cert) from invalid_cert
except InvalidCodepoint as ex:
# Convert to a more generic error we handle

Wyświetl plik

@ -172,3 +172,37 @@ We use `HTMX <https://htmx.org/>`_ for dynamically loading content, and
`Hyperscript <https://hyperscript.org/>`_ for most interactions rather than raw
JavaScript. If you can accomplish what you need with these tools, please use them
rather than adding JS.
Cutting a release
-----------------
In order to make a release of Takahē, follow these steps:
* Create or update the release document (in ``/docs/releases``) for the
release; major versions get their own document, minor releases get a
subheading in the document for their major release.
* Go through the git commit history since the last release in order to write
a reasonable summary of features.
* Be sure to include the little paragraphs at the end about contributing and
the docker tag, and an Upgrade Notes section that at minimum mentions
migrations and if they're normal or weird (even if there aren't any, it's
nice to call that out).
* If it's a new doc, make sure you include it in ``docs/releases/index.rst``!
* Update the version number in ``/takahe/__init__.py``
* Update the version number in ``README.md``
* Make a commit containing these changes called ``Releasing 1.23.45``.
* Tag that commit with a tag in the format ``1.23.45``.
* Wait for the GitHub Actions to run and publish the docker images (around 20
minutes as the ARM build is a bit slow)
* Post on the official account announcing the relase and linking to the
now-published release notes.

Wyświetl plik

@ -167,6 +167,11 @@ If you omit the keys or the endpoint URL, then Takahē will try to use implicit
authentication for them. The keys, if included, should be urlencoded, as AWS
secret keys commonly contain eg + characters.
With the above examples, Takahē connects to an S3 bucket using **HTTPS**. If
you wish to connect to an S3 bucket using **HTTP** (for example, to connect to
an S3 API endpoint on a private network), replace `s3` in the examples above
with `s3-insecure`.
Your S3 bucket *must* be set to allow publically-readable files, as Takahē will
set all files it uploads to be ``public-read``. We randomise uploaded file
names to prevent enumeration attacks.

Wyświetl plik

@ -2,6 +2,7 @@
====
*0.10.0 Released: 2023/11/12*
*0.10.1 Released: 2023/11/13*
This release is a polish release that mostly focuses on performance, stability

Wyświetl plik

@ -1,21 +1,54 @@
0.11
====
*Released: Not Yet Released*
*Released: 2024-02-05*
Notes TBD.
This is largely a bugfix and catch up release.
Some highlights:
* Python 3.10 has been dropped. The new minimum Python version is 3.11
* Jamie (`@astraluma@tacobelllabs.net <https://tacobelllabs.net/@astraluma>`_)
has officially joined the project
* If your S3 does not use TLS, you must use ``s3-insecure`` in your
configuration
* Takahē now supports unicode hashtags
* Add a Maximum Media Attachments setting
* Inverted the pruning command exit codes
* Posts are no longer required to have text content
And some interoperability bugs:
* Fixed a bug with GoToSocial
* Attempted to fix follows from Misskey family
* Correctly handle when a federated report doesn't have content
In additions, there's many bugfixes and minor changes, including:
* Several JSON handling improvements
* Post pruning now has a random element to it
* More specific loggers
* Don't make local identities stale
* Don't try to unmute when there's no expiration
* Don't try to WebFinger local users
* Synchronize follow accepting and profile fetching
* Perform some basic domain validity
* Correctly reject more operations when the identity is deleted
* Post edit fanouts for likers/boosters
If you'd like to help with code, design, or other areas, see
:doc:`/contributing` to see how to get in touch.
You can download images from `Docker Hub <https://hub.docker.com/r/jointakahe/takahe>`_,
or use the image name ``jointakahe/takahe:0.11``.
Upgrade Notes
-------------
VAPID keys and Push notifications
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Migrations
~~~~~~~~~~
Takahē now supports push notifications if you supply a valid VAPID keypair as
the ``TAKAHE_VAPID_PUBLIC_KEY`` and ``TAKAHE_VAPID_PRIVATE_KEY`` environment
variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_.
Note that users of apps may need to sign out and in again to their accounts for
the app to notice that it can now do push notifications. Some apps, like Elk,
may cache the fact your server didn't support it for a while.
There are new database migrations; they are backwards-compatible and should
not present any major database load.

Wyświetl plik

@ -7,6 +7,7 @@ Versions
.. toctree::
:maxdepth: 1
0.11
0.10
0.9
0.8

Wyświetl plik

@ -0,0 +1,15 @@
Upgrade Notes
-------------
VAPID keys and Push notifications
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Takahē now supports push notifications if you supply a valid VAPID keypair as
the ``TAKAHE_VAPID_PUBLIC_KEY`` and ``TAKAHE_VAPID_PRIVATE_KEY`` environment
variables. You can generate a keypair via `https://web-push-codelab.glitch.me/`_.
Note that users of apps may need to sign out and in again to their accounts for
the app to notice that it can now do push notifications. Some apps, like Elk,
may cache the fact your server didn't support it for a while.

Wyświetl plik

@ -128,11 +128,11 @@ Identity pruning removes any identity that isn't:
* A liker or booster of a local post
We recommend you run the pruning commands on a scheduled basis (i.e. like
a cronjob). They will return a ``0`` exit code if they deleted something and
a ``1`` exit code if they found nothing to delete, if you want to put them in
a cronjob). They will return a ``1`` exit code if they deleted something and
a ``0`` exit code if they found nothing to delete, if you want to put them in
a loop that runs until deletion is complete::
while ./manage.py pruneposts; do sleep 1; done
until ./manage.py pruneposts; do sleep 1; done
Caching

Wyświetl plik

@ -8,6 +8,8 @@ from core.models import Config
from stator.models import StatorModel
from stator.runner import StatorRunner
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Runs a Stator runner"
@ -80,7 +82,7 @@ class Command(BaseCommand):
if not models:
models = StatorModel.subclasses
models = [model for model in models if model not in excluded]
logging.info(
logger.info(
"Running for models: " + " ".join(m._meta.label_lower for m in models)
)
# Run a runner
@ -94,4 +96,4 @@ class Command(BaseCommand):
try:
runner.run()
except KeyboardInterrupt:
logging.critical("Ctrl-C received")
logger.critical("Ctrl-C received")

Wyświetl plik

@ -11,6 +11,8 @@ from django.utils.functional import classproperty
from stator.exceptions import TryAgainLater
from stator.graph import State, StateGraph
logger = logging.getLogger(__name__)
class StateField(models.CharField):
"""
@ -189,7 +191,7 @@ class StatorModel(models.Model):
# If it's a manual progression state don't even try
# We shouldn't really be here in this case, but it could be a race condition
if current_state.externally_progressed:
logging.warning(
logger.warning(
f"Warning: trying to progress externally progressed state {self.state}!"
)
return None
@ -203,7 +205,7 @@ class StatorModel(models.Model):
except TryAgainLater:
pass
except BaseException as e:
logging.exception(e)
logger.exception(e)
else:
if next_state:
# Ensure it's a State object

Wyświetl plik

@ -14,6 +14,8 @@ from core import sentry
from core.models import Config
from stator.models import StatorModel, Stats
logger = logging.getLogger(__name__)
class LoopingTimer:
"""
@ -84,7 +86,7 @@ class StatorRunner:
self.scheduling_timer = LoopingTimer(self.schedule_interval)
self.deletion_timer = LoopingTimer(self.delete_interval)
# For the first time period, launch tasks
logging.info("Running main task loop")
logger.info("Running main task loop")
try:
with sentry.configure_scope() as scope:
while True:
@ -137,18 +139,18 @@ class StatorRunner:
pass
# Wait for tasks to finish
logging.info("Waiting for tasks to complete")
logger.info("Waiting for tasks to complete")
self.executor.shutdown()
# We're done
logging.info("Complete")
logger.info("Complete")
def alarm_handler(self, signum, frame):
"""
Called when SIGALRM fires, which means we missed a schedule loop.
Just exit as we're likely deadlocked.
"""
logging.warning("Watchdog timeout exceeded")
logger.warning("Watchdog timeout exceeded")
os._exit(2)
def load_config(self):
@ -163,13 +165,14 @@ class StatorRunner:
"""
with sentry.start_transaction(op="task", name="stator.run_scheduling"):
for model in self.models:
num = self.handled.get(model._meta.label_lower, 0)
if num or settings.DEBUG:
logging.info(
f"{model._meta.label_lower}: Scheduling ({num} handled)"
)
self.submit_stats(model)
model.transition_clean_locks()
with sentry.start_span(description=model._meta.label_lower):
num = self.handled.get(model._meta.label_lower, 0)
if num or settings.DEBUG:
logger.info(
f"{model._meta.label_lower}: Scheduling ({num} handled)"
)
self.submit_stats(model)
model.transition_clean_locks()
def submit_stats(self, model: type[StatorModel]):
"""
@ -239,7 +242,7 @@ class StatorRunner:
try:
task.result()
except BaseException as e:
logging.exception(e)
logger.exception(e)
def run_single_cycle(self):
"""
@ -269,11 +272,11 @@ def task_transition(instance: StatorModel, in_thread: bool = True):
result = instance.transition_attempt()
duration = time.monotonic() - started
if result:
logging.info(
logger.info(
f"{instance._meta.label_lower}: {instance.pk}: {instance.state} -> {result} ({duration:.2f}s)"
)
else:
logging.info(
logger.info(
f"{instance._meta.label_lower}: {instance.pk}: {instance.state} unchanged ({duration:.2f}s)"
)
if in_thread:
@ -289,7 +292,7 @@ def task_deletion(model: type[StatorModel], in_thread: bool = True):
deleted = model.transition_delete_due()
if not deleted:
break
logging.info(f"{model._meta.label_lower}: Deleted {deleted} stale items")
logger.info(f"{model._meta.label_lower}: Deleted {deleted} stale items")
time.sleep(1)
if in_thread:
close_old_connections()

Wyświetl plik

@ -1 +1 @@
__version__ = "0.10.1"
__version__ = "0.11.0"

Wyświetl plik

@ -28,7 +28,7 @@ class ImplicitHostname(AnyUrl):
class MediaBackendUrl(AnyUrl):
host_required = False
allowed_schemes = {"s3", "gs", "local"}
allowed_schemes = {"s3", "s3-insecure", "gs", "local"}
def as_bool(v: str | list[str] | None):
@ -432,7 +432,7 @@ if SETUP.MEDIA_BACKEND:
if parsed.hostname is not None:
port = parsed.port or 443
GS_CUSTOM_ENDPOINT = f"https://{parsed.hostname}:{port}"
elif parsed.scheme == "s3":
elif (parsed.scheme == "s3") or (parsed.scheme == "s3-insecure"):
STORAGES["default"]["BACKEND"] = "core.uploads.TakaheS3Storage"
AWS_STORAGE_BUCKET_NAME = parsed.path.lstrip("/")
AWS_QUERYSTRING_AUTH = False
@ -441,8 +441,14 @@ if SETUP.MEDIA_BACKEND:
AWS_ACCESS_KEY_ID = parsed.username
AWS_SECRET_ACCESS_KEY = urllib.parse.unquote(parsed.password)
if parsed.hostname is not None:
port = parsed.port or 443
AWS_S3_ENDPOINT_URL = f"https://{parsed.hostname}:{port}"
if parsed.scheme == "s3-insecure":
s3_default_port = 80
s3_scheme = "http"
else:
s3_default_port = 443
s3_scheme = "https"
port = parsed.port or s3_default_port
AWS_S3_ENDPOINT_URL = f"{s3_scheme}://{parsed.hostname}:{port}"
if SETUP.MEDIA_URL is not None:
media_url_parsed = urllib.parse.urlparse(SETUP.MEDIA_URL)
AWS_S3_CUSTOM_DOMAIN = media_url_parsed.hostname

Wyświetl plik

@ -0,0 +1,116 @@
import pytest
from pytest_httpx import HTTPXMock
test_account_json = r"""
{
"@context":[
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers":"as:manuallyApprovesFollowers",
"toot":"http://joinmastodon.org/ns#",
"featured":{
"@id":"toot:featured",
"@type":"@id"
},
"featuredTags":{
"@id":"toot:featuredTags",
"@type":"@id"
},
"movedTo":{
"@id":"as:movedTo",
"@type":"@id"
},
"schema":"http://schema.org#",
"PropertyValue":"schema:PropertyValue",
"value":"schema:value",
"discoverable":"toot:discoverable",
"Device":"toot:Device",
"deviceId":"toot:deviceId",
"messageType":"toot:messageType",
"cipherText":"toot:cipherText",
"suspended":"toot:suspended",
"memorial":"toot:memorial",
"indexable":"toot:indexable"
}
],
"id":"https://search.example.com/users/searchtest",
"type":"Person",
"following":"https://search.example.com/users/searchtest/following",
"followers":"https://search.example.com/users/searchtest/followers",
"inbox":"https://search.example.com/users/searchtest/inbox",
"outbox":"https://search.example.com/users/searchtest/outbox",
"featured":"https://search.example.com/users/searchtest/collections/featured",
"featuredTags":"https://search.example.com/users/searchtest/collections/tags",
"preferredUsername":"searchtest",
"name":"searchtest",
"summary":"<p>Just a test (àáâãäåæ)</p>",
"url":"https://search.example.com/@searchtest",
"manuallyApprovesFollowers":false,
"discoverable":true,
"indexable":false,
"published":"2018-05-09T00:00:00Z",
"memorial":false,
"devices":"https://search.example.com/users/searchtest/collections/devices",
"endpoints":{
"sharedInbox":"https://search.example.com/inbox"
}
}
"""
@pytest.mark.django_db
def test_search_not_found(httpx_mock: HTTPXMock, api_client):
httpx_mock.add_response(status_code=404)
response = api_client.get(
"/api/v2/search",
content_type="application/json",
data={
"q": "https://notfound.example.com",
},
).json()
assert response["accounts"] == []
assert response["statuses"] == []
assert response["hashtags"] == []
@pytest.mark.django_db
@pytest.mark.parametrize(
"encoding",
[
"utf-8",
"iso-8859-1",
],
)
@pytest.mark.parametrize(
"content_type",
[
"application/json",
"application/ld+json",
"application/activity+json",
],
)
def test_search(
content_type: str,
encoding: str,
httpx_mock: HTTPXMock,
api_client,
):
httpx_mock.add_response(
headers={"Content-Type": f"{content_type}; charset={encoding}"},
content=test_account_json.encode(encoding),
)
response = api_client.get(
"/api/v2/search",
content_type="application/json",
data={
"q": "https://search.example.com/users/searchtest",
},
).json()
assert len(response["accounts"]) == 1
assert response["accounts"][0]["acct"] == "searchtest@search.example.com"
assert response["accounts"][0]["username"] == "searchtest"
assert response["accounts"][0]["note"] == "<p>Just a test (àáâãäåæ)</p>"

Wyświetl plik

@ -56,6 +56,32 @@ def test_post_status(api_client, identity):
assert response.status_code == 404
@pytest.mark.django_db
def test_post_statusless(api_client, identity):
"""
Tests we can post with media but no status
"""
# Create media attachment
attachment = PostAttachment.objects.create(
mimetype="image/webp",
name=None,
state=PostAttachmentStates.fetched,
author=identity,
)
# Post new one
response = api_client.post(
"/api/v1/statuses",
content_type="application/json",
data={
"media_ids": [attachment.id],
},
)
assert 200 <= response.status_code < 300
body = response.json()
assert body["content"] == "<p></p>"
assert body["media_attachments"][0]["description"] is None
@pytest.mark.django_db
def test_mention_format(api_client, identity, remote_identity):
"""

Wyświetl plik

@ -1,4 +1,5 @@
import pytest
from django.template.defaultfilters import linebreaks_filter
from core.html import FediverseHtmlParser
@ -101,6 +102,16 @@ def test_parser(identity):
assert parser.plain_text == "@TeSt@ExamPle.com"
assert parser.mentions == {"test@example.com"}
# Ensure hashtags are parsed and linkified in local posts
parser = FediverseHtmlParser(
linebreaks_filter("#tag1-x,#tag2 #标签。"), find_hashtags=True
)
assert (
parser.html
== '<p><a href="/tags/tag1/" rel="tag">#tag1</a>-x,<a href="/tags/tag2/" rel="tag">#tag2</a> <a href="/tags/标签/" rel="tag">#标签</a>。</p>'
)
assert parser.hashtags == {"tag1", "tag2", "标签"}
# Ensure hashtags are linked, even through spans, but not within hrefs
parser = FediverseHtmlParser(
'<a href="http://example.com#notahashtag">something</a> <span>#</span>hashtag <a href="https://example.com/tags/hashtagtwo/">#hashtagtwo</a>',

Wyświetl plik

@ -3,6 +3,36 @@ import pytest
from users.models import Domain
def test_valid_domain():
"""
Tests that a valid domain is valid
"""
assert Domain.is_valid_domain("example.com")
assert Domain.is_valid_domain("xn----gtbspbbmkef.xn--p1ai")
assert Domain.is_valid_domain("underscore_subdomain.example.com")
assert Domain.is_valid_domain("something.versicherung")
assert Domain.is_valid_domain("11.com")
assert Domain.is_valid_domain("a.cn")
assert Domain.is_valid_domain("sub1.sub2.sample.co.uk")
assert Domain.is_valid_domain("somerandomexample.xn--fiqs8s")
assert not Domain.is_valid_domain("über.com")
assert not Domain.is_valid_domain("example.com:4444")
assert not Domain.is_valid_domain("example.-com")
assert not Domain.is_valid_domain("foo@bar.com")
assert not Domain.is_valid_domain("example.")
assert not Domain.is_valid_domain("example.com.")
assert not Domain.is_valid_domain("-example.com")
assert not Domain.is_valid_domain("_example.com")
assert not Domain.is_valid_domain("_example._com")
assert not Domain.is_valid_domain("example_.com")
assert not Domain.is_valid_domain("example")
assert not Domain.is_valid_domain("a......b.com")
assert not Domain.is_valid_domain("a.123")
assert not Domain.is_valid_domain("123.123")
assert not Domain.is_valid_domain("123.123.123.123")
@pytest.mark.django_db
def test_recursive_block():
"""

Wyświetl plik

@ -43,7 +43,7 @@ class Command(BaseCommand):
identity_ids = identities.values_list("id", flat=True)
print(f" found {len(identity_ids)}")
if not identity_ids:
sys.exit(1)
sys.exit(0)
# Delete them
print("Deleting...")
@ -51,3 +51,4 @@ class Command(BaseCommand):
print("Deleted:")
for model, model_deleted in deleted.items():
print(f" {model}: {model_deleted}")
sys.exit(1)

Wyświetl plik

@ -9,6 +9,7 @@ from django.db import migrations, models
import core.snowflake
import core.uploads
import stator.models
import users.models.domain
import users.models.follow
import users.models.identity
import users.models.inbox_message
@ -58,7 +59,12 @@ class Migration(migrations.Migration):
fields=[
(
"domain",
models.CharField(max_length=250, primary_key=True, serialize=False),
models.CharField(
max_length=250,
primary_key=True,
serialize=False,
validators=[users.models.domain._domain_validator],
),
),
(
"service_domain",

Wyświetl plik

@ -37,7 +37,7 @@ class BlockStates(StateGraph):
"""
# Mutes don't send but might need expiry
if instance.mute:
return cls.awaiting_expiry
return cls.awaiting_expiry if instance.expires else cls.sent
# Remote blocks should not be here, local blocks just work
if not instance.source.local or instance.target.local:
return cls.sent
@ -195,8 +195,7 @@ class Block(StatorModel):
raise ValueError("You cannot mute from a remote Identity")
block = cls.maybe_get(source=source, target=target, mute=True)
if block is not None:
if not block.active:
block.state = BlockStates.new # type:ignore
block.state = BlockStates.new # type:ignore
if duration:
block.expires = timezone.now() + datetime.timedelta(seconds=duration)
block.include_notifications = include_notifications

Wyświetl plik

@ -1,5 +1,6 @@
import json
import logging
import re
import ssl
from functools import cached_property
from typing import Optional
@ -8,12 +9,15 @@ import httpx
import pydantic
import urlman
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from core.models import Config
from stator.models import State, StateField, StateGraph, StatorModel
from users.schemas import NodeInfo
logger = logging.getLogger(__name__)
class DomainStates(StateGraph):
outdated = State(try_interval=60 * 30, force_initial=True)
@ -51,6 +55,14 @@ class DomainStates(StateGraph):
return cls.outdated
def _domain_validator(value: str):
if not Domain.is_valid_domain(value):
raise ValidationError(
"%(value)s is not a valid domain",
params={"value": value},
)
class Domain(StatorModel):
"""
Represents a domain that a user can have an account on.
@ -69,7 +81,9 @@ class Domain(StatorModel):
display domains for now, until we start doing better probing.
"""
domain = models.CharField(max_length=250, primary_key=True)
domain = models.CharField(
max_length=250, primary_key=True, validators=[_domain_validator]
)
service_domain = models.CharField(
max_length=250,
null=True,
@ -117,6 +131,19 @@ class Domain(StatorModel):
class Meta:
indexes: list = []
@classmethod
def is_valid_domain(cls, domain: str) -> bool:
"""
Check if a domain is valid, domain must be lowercase
"""
return (
re.match(
r"^(?:[a-z0-9](?:[a-z0-9-_]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-_]{0,61}[a-z]$",
domain,
)
is not None
)
@classmethod
def get_remote_domain(cls, domain: str) -> "Domain":
return cls.objects.get_or_create(domain=domain.lower(), local=False)[0]
@ -209,13 +236,14 @@ class Domain(StatorModel):
and response.status_code < 500
and response.status_code not in [401, 403, 404, 406, 410]
):
logging.warning(
f"Client error fetching nodeinfo: {str(ex)}",
logger.warning(
"Client error fetching nodeinfo: %d %s %s",
response.status_code,
nodeinfo20_url,
ex,
extra={
"code": response.status_code,
"content": response.content,
"domain": self.domain,
"nodeinfo20_url": nodeinfo20_url,
},
)
return None
@ -223,11 +251,12 @@ class Domain(StatorModel):
try:
info = NodeInfo(**response.json())
except (json.JSONDecodeError, pydantic.ValidationError) as ex:
logging.warning(
f"Client error decoding nodeinfo: {str(ex)}",
logger.warning(
"Client error decoding nodeinfo: %s %s",
nodeinfo20_url,
ex,
extra={
"domain": self.domain,
"nodeinfo20_url": nodeinfo20_url,
},
)
return None

Wyświetl plik

@ -11,11 +11,13 @@ from users.models.block import Block
from users.models.identity import Identity
from users.models.inbox_message import InboxMessage
logger = logging.getLogger(__name__)
class FollowStates(StateGraph):
unrequested = State(try_interval=600)
pending_approval = State(externally_progressed=True)
accepting = State(try_interval=24 * 60 * 60)
accepting = State(try_interval=600)
rejecting = State(try_interval=24 * 60 * 60)
accepted = State(externally_progressed=True)
undone = State(try_interval=24 * 60 * 60)
@ -79,7 +81,9 @@ class FollowStates(StateGraph):
except httpx.RequestError:
return
return cls.pending_approval
# local/remote follow local, check manually_approve
# local/remote follow local, check deleted & manually_approve
if instance.target.deleted:
return cls.rejecting
if instance.target.manually_approves_followers:
from activities.models import TimelineEvent
@ -90,6 +94,9 @@ class FollowStates(StateGraph):
@classmethod
def handle_accepting(cls, instance: "Follow"):
if not instance.source.local:
# Don't send Accept if remote identity wasn't fetch yet
if not instance.source.inbox_uri:
return
# send an Accept object to the source server
try:
instance.target.signed_request(
@ -275,7 +282,7 @@ class Follow(StatorModel):
"""
return {
"type": "Accept",
"id": self.uri + "#accept",
"id": f"{self.target.actor_uri}follow/{self.id}/#accept",
"actor": self.target.actor_uri,
"object": self.to_ap(),
}
@ -286,7 +293,7 @@ class Follow(StatorModel):
"""
return {
"type": "Reject",
"id": self.uri + "#reject",
"id": f"{self.target.actor_uri}follow/{self.id}/#reject",
"actor": self.target.actor_uri,
"object": self.to_ap(),
}
@ -350,7 +357,7 @@ class Follow(StatorModel):
try:
follow = cls.by_ap(data, create=True)
except Identity.DoesNotExist:
logging.info(
logger.info(
"Identity not found for incoming Follow", extra={"data": data}
)
return
@ -367,7 +374,7 @@ class Follow(StatorModel):
try:
follow = cls.by_ap(data["object"])
except (cls.DoesNotExist, Identity.DoesNotExist):
logging.info(
logger.info(
"Follow or Identity not found for incoming Accept",
extra={"data": data},
)
@ -389,7 +396,7 @@ class Follow(StatorModel):
try:
follow = cls.by_ap(data["object"])
except (cls.DoesNotExist, Identity.DoesNotExist):
logging.info(
logger.info(
"Follow or Identity not found for incoming Reject",
extra={"data": data},
)
@ -419,7 +426,7 @@ class Follow(StatorModel):
try:
follow = cls.by_ap(data["object"])
except (cls.DoesNotExist, Identity.DoesNotExist):
logging.info(
logger.info(
"Follow or Identity not found for incoming Undo", extra={"data": data}
)
return

Wyświetl plik

@ -14,6 +14,7 @@ from lxml import etree
from core.exceptions import ActorMismatchError
from core.html import ContentRenderer, FediverseHtmlParser
from core.json import json_from_response
from core.ld import (
canonicalise,
format_ld_date,
@ -37,6 +38,8 @@ from users.models.domain import Domain
from users.models.inbox_message import InboxMessage
from users.models.system_actor import SystemActor
logger = logging.getLogger(__name__)
class IdentityStates(StateGraph):
"""
@ -117,12 +120,47 @@ class IdentityStates(StateGraph):
@classmethod
def handle_deleted(cls, instance: "Identity"):
from activities.models import FanOut
from activities.models import (
FanOut,
Post,
PostInteraction,
PostInteractionStates,
PostStates,
TimelineEvent,
)
from users.models import Bookmark, Follow, FollowStates, HashtagFollow, Report
if not instance.local:
return cls.updated
# Delete local data
TimelineEvent.objects.filter(identity=instance).delete()
Bookmark.objects.filter(identity=instance).delete()
HashtagFollow.objects.filter(identity=instance).delete()
Report.objects.filter(source_identity=instance).delete()
# Nullify all fields and fanout
instance.name = ""
instance.summary = ""
instance.metadata = []
instance.aliases = []
instance.icon_uri = ""
instance.discoverable = False
instance.image.delete(save=False)
instance.icon.delete(save=False)
instance.save()
cls.targets_fan_out(instance, FanOut.Types.identity_edited)
# Delete all posts and interactions
Post.transition_perform_queryset(instance.posts, PostStates.deleted)
PostInteraction.transition_perform_queryset(
instance.interactions, PostInteractionStates.undone
)
# Fanout the deletion and unfollow from both directions
cls.targets_fan_out(instance, FanOut.Types.identity_deleted)
for follower in Follow.objects.filter(target=instance):
follower.transition_perform(FollowStates.rejecting)
for following in Follow.objects.filter(source=instance):
following.transition_perform(FollowStates.undone)
return cls.deleted_fanned_out
@classmethod
@ -136,7 +174,7 @@ class IdentityStates(StateGraph):
@classmethod
def handle_updated(cls, instance: "Identity"):
if instance.state_age > Config.system.identity_max_age:
if not instance.local and instance.state_age > Config.system.identity_max_age:
return cls.outdated
@ -397,6 +435,8 @@ class Identity(StatorModel):
domain = domain.domain
else:
domain = domain.lower()
domain_instance = Domain.get_domain(domain)
local = domain_instance.local if domain_instance else local
with transaction.atomic():
try:
@ -672,10 +712,11 @@ class Identity(StatorModel):
"""
Marks the identity and all of its related content as deleted.
"""
# Move all posts to deleted
from activities.models.post import Post, PostStates
from api.models import Authorization, Token
Post.transition_perform_queryset(self.posts, PostStates.deleted)
# Remove all login tokens
Authorization.objects.filter(identity=self).delete()
Token.objects.filter(identity=self).delete()
# Remove all users from ourselves and mark deletion date
self.users.set([])
self.deleted = timezone.now()
@ -872,15 +913,18 @@ class Identity(StatorModel):
# Their account got deleted, so let's do the same.
Identity.objects.filter(pk=self.pk).delete()
if status_code < 500 and status_code not in [401, 403, 404, 406, 410]:
logging.info(
logger.info(
"Client error fetching actor: %d %s", status_code, self.actor_uri
)
return False
json_data = json_from_response(response)
if not json_data:
return False
try:
document = canonicalise(response.json(), include_security=True)
document = canonicalise(json_data, include_security=True)
except ValueError:
# servers with empty or invalid responses are inevitable
logging.info(
logger.info(
"Invalid response fetching actor %s",
self.actor_uri,
extra={
@ -942,10 +986,10 @@ class Identity(StatorModel):
self.domain = Domain.get_remote_domain(webfinger_domain)
except TryAgainLater:
# continue with original domain when webfinger times out
logging.info("WebFinger timed out: %s", self.actor_uri)
logger.info("WebFinger timed out: %s", self.actor_uri)
pass
except ValueError as exc:
logging.info(
logger.info(
"Can't parse WebFinger: %s %s",
exc.args[0],
self.actor_uri,

Wyświetl plik

@ -172,7 +172,7 @@ class Report(StatorModel):
subject_post=subject_post,
source_domain=Domain.get_remote_domain(domain_id),
type="remote",
complaint=data.get("content"),
complaint=str(data.get("content", "")),
)
def to_ap(self):

Wyświetl plik

@ -19,6 +19,8 @@ from users.models import (
User,
)
logger = logging.getLogger(__name__)
class IdentityService:
"""
@ -226,7 +228,7 @@ class IdentityService:
state__in=PostInteractionStates.group_active(),
)
except MultipleObjectsReturned as exc:
logging.exception("%s on %s", exc, object_uri)
logger.exception("%s on %s", exc, object_uri)
pass
except Post.DoesNotExist:
# ignore 404s...

Wyświetl plik

@ -18,6 +18,8 @@ def by_handle_or_404(request, handle, local=True, fetch=False) -> Identity:
domain = domain_instance.domain
else:
username, domain = handle.split("@", 1)
if not Domain.is_valid_domain(domain):
raise Http404("Invalid domain")
# Resolve the domain to the display domain
domain_instance = Domain.get_domain(domain)
if domain_instance is None:

Wyświetl plik

@ -26,6 +26,8 @@ from users.models import Identity, InboxMessage, SystemActor
from users.models.domain import Domain
from users.shortcuts import by_handle_or_404
logger = logging.getLogger(__name__)
class HttpResponseUnauthorized(HttpResponse):
status_code = 401
@ -147,7 +149,7 @@ class Inbox(View):
# This ensures that the signature used for the headers matches the actor
# described in the payload.
if "actor" not in document:
logging.warning("Inbox error: unspecified actor")
logger.warning("Inbox error: unspecified actor")
return HttpResponseBadRequest("Unspecified actor")
identity = Identity.by_actor_uri(document["actor"], create=True, transient=True)
@ -167,7 +169,7 @@ class Inbox(View):
domain = Domain.get_remote_domain(actor_url_parts.hostname)
if identity.blocked or domain.recursively_blocked():
# I love to lie! Throw it away!
logging.info(
logger.info(
"Inbox: Discarded message from blocked %s %s",
"domain" if domain.recursively_blocked() else "user",
identity.actor_uri,
@ -196,21 +198,21 @@ class Inbox(View):
request,
identity.public_key,
)
logging.debug(
logger.debug(
"Inbox: %s from %s has good HTTP signature",
document_type,
identity,
)
else:
logging.info(
logger.info(
"Inbox: New actor, no key available: %s",
document["actor"],
)
except VerificationFormatError as e:
logging.warning("Inbox error: Bad HTTP signature format: %s", e.args[0])
logger.warning("Inbox error: Bad HTTP signature format: %s", e.args[0])
return HttpResponseBadRequest(e.args[0])
except VerificationError:
logging.warning("Inbox error: Bad HTTP signature from %s", identity)
logger.warning("Inbox error: Bad HTTP signature from %s", identity)
return HttpResponseUnauthorized("Bad signature")
# Mastodon advices not implementing LD Signatures, but
@ -224,18 +226,18 @@ class Inbox(View):
creator, create=True, transient=True
)
if not creator_identity.public_key:
logging.info("Inbox: New actor, no key available: %s", creator)
logger.info("Inbox: New actor, no key available: %s", creator)
# if we can't verify it, we don't keep it
document.pop("signature")
else:
LDSignature.verify_signature(document, creator_identity.public_key)
logging.debug(
logger.debug(
"Inbox: %s from %s has good LD signature",
document["type"],
creator_identity,
)
except VerificationFormatError as e:
logging.warning("Inbox error: Bad LD signature format: %s", e.args[0])
logger.warning("Inbox error: Bad LD signature format: %s", e.args[0])
return HttpResponseBadRequest(e.args[0])
except VerificationError:
# An invalid LD Signature might also indicate nothing but
@ -243,14 +245,14 @@ class Inbox(View):
# Strip it out if we can't verify it.
if "signature" in document:
document.pop("signature")
logging.info(
logger.info(
"Inbox: Stripping invalid LD signature from %s %s",
creator_identity,
document["id"],
)
if not ("signature" in request or "signature" in document):
logging.debug(
logger.debug(
"Inbox: %s from %s is unauthenticated. That's OK.",
document["type"],
identity,

Wyświetl plik

@ -38,6 +38,10 @@ class BasicSettings(AdminSettingsPage):
"title": "Maximum Post Length",
"help_text": "The maximum number of characters allowed per post",
},
"max_media_attachments": {
"title": "Maximum Media Attachments",
"help_text": "The maximum number of media attachments allowed per post.\nA value other than 4 may be unsupported by clients.",
},
"post_minimum_interval": {
"title": "Minimum Posting Interval",
"help_text": "The minimum number of seconds a user must wait between posts",
@ -129,6 +133,7 @@ class BasicSettings(AdminSettingsPage):
],
"Posts": [
"post_length",
"max_media_attachments",
"post_minimum_interval",
"content_warning_text",
"hashtag_unreviewed_are_public",