mirror of https://git.sr.ht/~tsileo/microblog.pub
Compare commits
13 Commits
2.0.0-rc.1
...
v2
Author | SHA1 | Date |
---|---|---|
Thomas Sileo | 9c8693ea55 | |
Thomas Sileo | febd8c3d26 | |
Thomas Sileo | a5290af5c8 | |
Thomas Sileo | 2cec800332 | |
Thomas Sileo | 3c07494809 | |
Thomas Sileo | 2433fa01cd | |
Thomas Sileo | 3169890a39 | |
Thomas Sileo | 4e1bb330aa | |
Thomas Sileo | 625f399309 | |
Thomas Sileo | 2bd6c98538 | |
Thomas Sileo | f13376de84 | |
Alexey Shpakovsky | c97070e3d8 | |
João Costa | c1692a296d |
|
@ -124,6 +124,7 @@ class Config(pydantic.BaseModel):
|
||||||
key_path: str | None = None
|
key_path: str | None = None
|
||||||
|
|
||||||
session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default
|
session_timeout: int = 3600 * 24 * 3 # in seconds, 3 days by default
|
||||||
|
csrf_token_exp: int = 3600
|
||||||
|
|
||||||
disabled_notifications: list[str] = []
|
disabled_notifications: list[str] = []
|
||||||
|
|
||||||
|
@ -263,7 +264,7 @@ def verify_csrf_token(
|
||||||
if redirect_url:
|
if redirect_url:
|
||||||
please_try_again = f'<a href="{redirect_url}">please try again</a>'
|
please_try_again = f'<a href="{redirect_url}">please try again</a>'
|
||||||
try:
|
try:
|
||||||
csrf_serializer.loads(csrf_token, max_age=1800)
|
csrf_serializer.loads(csrf_token, max_age=CONFIG.csrf_token_exp)
|
||||||
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
|
except (itsdangerous.BadData, itsdangerous.SignatureExpired):
|
||||||
logger.exception("Failed to verify CSRF token")
|
logger.exception("Failed to verify CSRF token")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
@ -60,7 +60,7 @@ def _set_next_try(
|
||||||
if not outgoing_activity.tries:
|
if not outgoing_activity.tries:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
if outgoing_activity.tries == _MAX_RETRIES:
|
if outgoing_activity.tries >= _MAX_RETRIES:
|
||||||
outgoing_activity.is_errored = True
|
outgoing_activity.is_errored = True
|
||||||
outgoing_activity.next_try = None
|
outgoing_activity.next_try = None
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -10,6 +10,8 @@ from fastapi import Form
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.security import HTTPBasic
|
||||||
|
from fastapi.security import HTTPBasicCredentials
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
@ -26,6 +28,8 @@ from app.redirect import redirect
|
||||||
from app.utils import indieauth
|
from app.utils import indieauth
|
||||||
from app.utils.datetime import now
|
from app.utils.datetime import now
|
||||||
|
|
||||||
|
basic_auth = HTTPBasic()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,6 +45,7 @@ async def well_known_authorization_server(
|
||||||
"revocation_endpoint": request.url_for("indieauth_revocation_endpoint"),
|
"revocation_endpoint": request.url_for("indieauth_revocation_endpoint"),
|
||||||
"revocation_endpoint_auth_methods_supported": ["none"],
|
"revocation_endpoint_auth_methods_supported": ["none"],
|
||||||
"registration_endpoint": request.url_for("oauth_registration_endpoint"),
|
"registration_endpoint": request.url_for("oauth_registration_endpoint"),
|
||||||
|
"introspection_endpoint": request.url_for("oauth_introspection_endpoint"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -378,6 +383,8 @@ async def _check_access_token(
|
||||||
class AccessTokenInfo:
|
class AccessTokenInfo:
|
||||||
scopes: list[str]
|
scopes: list[str]
|
||||||
client_id: str | None
|
client_id: str | None
|
||||||
|
access_token: str
|
||||||
|
exp: int
|
||||||
|
|
||||||
|
|
||||||
async def verify_access_token(
|
async def verify_access_token(
|
||||||
|
@ -409,6 +416,13 @@ async def verify_access_token(
|
||||||
if access_token.indieauth_authorization_request
|
if access_token.indieauth_authorization_request
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
access_token=access_token.access_token,
|
||||||
|
exp=int(
|
||||||
|
(
|
||||||
|
access_token.created_at.replace(tzinfo=timezone.utc)
|
||||||
|
+ timedelta(seconds=access_token.expires_in)
|
||||||
|
).timestamp()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -434,6 +448,13 @@ async def check_access_token(
|
||||||
if access_token.indieauth_authorization_request
|
if access_token.indieauth_authorization_request
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
access_token=access_token.access_token,
|
||||||
|
exp=int(
|
||||||
|
(
|
||||||
|
access_token.created_at.replace(tzinfo=timezone.utc)
|
||||||
|
+ timedelta(seconds=access_token.expires_in)
|
||||||
|
).timestamp()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
@ -474,3 +495,58 @@ async def indieauth_revocation_endpoint(
|
||||||
content={},
|
content={},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token_introspection")
|
||||||
|
async def oauth_introspection_endpoint(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials = Depends(basic_auth),
|
||||||
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
|
token: str = Form(),
|
||||||
|
) -> JSONResponse:
|
||||||
|
registered_client = (
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.OAuthClient).where(
|
||||||
|
models.OAuthClient.client_id == credentials.username,
|
||||||
|
models.OAuthClient.client_secret == credentials.password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
if not registered_client:
|
||||||
|
raise HTTPException(status_code=401, detail="unauthenticated")
|
||||||
|
|
||||||
|
access_token = (
|
||||||
|
await db_session.scalars(
|
||||||
|
select(models.IndieAuthAccessToken)
|
||||||
|
.where(models.IndieAuthAccessToken.access_token == token)
|
||||||
|
.join(
|
||||||
|
models.IndieAuthAuthorizationRequest,
|
||||||
|
models.IndieAuthAccessToken.indieauth_authorization_request_id
|
||||||
|
== models.IndieAuthAuthorizationRequest.id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
models.IndieAuthAuthorizationRequest.client_id == credentials.username
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
if not access_token:
|
||||||
|
return JSONResponse(content={"active": False})
|
||||||
|
|
||||||
|
is_token_valid, _ = await _check_access_token(db_session, token)
|
||||||
|
if not is_token_valid:
|
||||||
|
return JSONResponse(content={"active": False})
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"active": True,
|
||||||
|
"client_id": credentials.username,
|
||||||
|
"scope": access_token.scope,
|
||||||
|
"exp": int(
|
||||||
|
(
|
||||||
|
access_token.created_at.replace(tzinfo=timezone.utc)
|
||||||
|
+ timedelta(seconds=access_token.expires_in)
|
||||||
|
).timestamp()
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
|
|
@ -1413,6 +1413,7 @@ async def serve_proxy_media(
|
||||||
_filter_proxy_resp_headers(
|
_filter_proxy_resp_headers(
|
||||||
proxy_resp,
|
proxy_resp,
|
||||||
[
|
[
|
||||||
|
"content-encoding",
|
||||||
"content-length",
|
"content-length",
|
||||||
"content-type",
|
"content-type",
|
||||||
"content-range",
|
"content-range",
|
||||||
|
@ -1694,9 +1695,9 @@ async def _gen_rss_feed(
|
||||||
|
|
||||||
fe = fg.add_entry()
|
fe = fg.add_entry()
|
||||||
fe.id(outbox_object.url)
|
fe.id(outbox_object.url)
|
||||||
|
if outbox_object.name is not None:
|
||||||
# Atom feeds require a title
|
fe.title(outbox_object.name)
|
||||||
if not is_rss:
|
elif not is_rss: # Atom feeds require a title
|
||||||
fe.title(outbox_object.url)
|
fe.title(outbox_object.url)
|
||||||
|
|
||||||
fe.link(href=outbox_object.url)
|
fe.link(href=outbox_object.url)
|
||||||
|
|
|
@ -132,7 +132,7 @@ async def post_micropub_endpoint(
|
||||||
h = form_data["h"]
|
h = form_data["h"]
|
||||||
entry_type = f"h-{h}"
|
entry_type = f"h-{h}"
|
||||||
|
|
||||||
logger.info(f"Creating {entry_type}")
|
logger.info(f"Creating {entry_type=} with {access_token_info=}")
|
||||||
|
|
||||||
if entry_type != "h-entry":
|
if entry_type != "h-entry":
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
@ -150,7 +150,7 @@ async def post_micropub_endpoint(
|
||||||
else:
|
else:
|
||||||
content = form_data["content"]
|
content = form_data["content"]
|
||||||
|
|
||||||
public_id = await send_create(
|
public_id, _ = await send_create(
|
||||||
db_session,
|
db_session,
|
||||||
"Note",
|
"Note",
|
||||||
content,
|
content,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import enum
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
@ -436,7 +437,7 @@ class OutboxObjectAttachment(Base):
|
||||||
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
outbox_object_id = Column(Integer, ForeignKey("outbox.id"), nullable=False)
|
||||||
|
|
||||||
upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False)
|
upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False)
|
||||||
upload = relationship(Upload, uselist=False)
|
upload: Mapped["Upload"] = relationship(Upload, uselist=False)
|
||||||
|
|
||||||
|
|
||||||
class IndieAuthAuthorizationRequest(Base):
|
class IndieAuthAuthorizationRequest(Base):
|
||||||
|
@ -459,7 +460,9 @@ class IndieAuthAccessToken(Base):
|
||||||
__tablename__ = "indieauth_access_token"
|
__tablename__ = "indieauth_access_token"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
created_at: Mapped[datetime] = Column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=now
|
||||||
|
)
|
||||||
|
|
||||||
# Will be null for personal access tokens
|
# Will be null for personal access tokens
|
||||||
indieauth_authorization_request_id = Column(
|
indieauth_authorization_request_id = Column(
|
||||||
|
@ -470,9 +473,9 @@ class IndieAuthAccessToken(Base):
|
||||||
uselist=False,
|
uselist=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token = Column(String, nullable=False, unique=True, index=True)
|
access_token: Mapped[str] = Column(String, nullable=False, unique=True, index=True)
|
||||||
refresh_token = Column(String, nullable=True, unique=True, index=True)
|
refresh_token = Column(String, nullable=True, unique=True, index=True)
|
||||||
expires_in = Column(Integer, nullable=False)
|
expires_in: Mapped[int] = Column(Integer, nullable=False)
|
||||||
scope = Column(String, nullable=False)
|
scope = Column(String, nullable=False)
|
||||||
is_revoked = Column(Boolean, nullable=False, default=False)
|
is_revoked = Column(Boolean, nullable=False, default=False)
|
||||||
was_refreshed = Column(Boolean, nullable=False, default=False, server_default="0")
|
was_refreshed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
|
|
@ -151,7 +151,7 @@ def _set_next_try(
|
||||||
if not outgoing_activity.tries:
|
if not outgoing_activity.tries:
|
||||||
raise ValueError("Should never happen")
|
raise ValueError("Should never happen")
|
||||||
|
|
||||||
if outgoing_activity.tries == _MAX_RETRIES:
|
if outgoing_activity.tries >= _MAX_RETRIES:
|
||||||
outgoing_activity.is_errored = True
|
outgoing_activity.is_errored = True
|
||||||
outgoing_activity.next_try = None
|
outgoing_activity.next_try = None
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -102,6 +102,8 @@ async def _prune_old_inbox_objects(
|
||||||
models.InboxObject.ap_type.in_(["Note"]),
|
models.InboxObject.ap_type.in_(["Note"]),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
# Keep Move object as they are linked to notifications
|
||||||
|
models.InboxObject.ap_type.not_in(["Move"]),
|
||||||
# Filter by retention days
|
# Filter by retention days
|
||||||
models.InboxObject.ap_published_at
|
models.InboxObject.ap_published_at
|
||||||
< now() - timedelta(days=INBOX_RETENTION_DAYS),
|
< now() - timedelta(days=INBOX_RETENTION_DAYS),
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
{% elif notif.notification_type.value == "unblock" %}
|
{% elif notif.notification_type.value == "unblock" %}
|
||||||
{{ notif_actor_action(notif, "was unblocked") }}
|
{{ notif_actor_action(notif, "was unblocked") }}
|
||||||
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
{{ utils.display_actor(notif.actor, actors_metadata) }}
|
||||||
{%- elif notif.notification_type.value == "move" %}
|
{%- elif notif.notification_type.value == "move" and notif.inbox_object %}
|
||||||
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
{# for move notif, the actor is the target and the inbox object the Move activity #}
|
||||||
<div class="actor-action">
|
<div class="actor-action">
|
||||||
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.inbox_object.actor.ap_id }}">
|
<a href="{{ url_for("admin_profile") }}?actor_id={{ notif.inbox_object.actor.ap_id }}">
|
||||||
|
|
|
@ -60,7 +60,7 @@ async def save_upload(db_session: AsyncSession, f: UploadFile) -> models.Upload:
|
||||||
destination_image.putdata(original_image.getdata())
|
destination_image.putdata(original_image.getdata())
|
||||||
destination_image.save(
|
destination_image.save(
|
||||||
dest_filename,
|
dest_filename,
|
||||||
format=_original_image.format,
|
format=_original_image.format, # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(dest_filename, "rb") as dest_f:
|
with open(dest_filename, "rb") as dest_f:
|
||||||
|
|
|
@ -25,9 +25,10 @@ As these two config items define your ActivityPub handle `@handle@domain`.
|
||||||
|
|
||||||
You can tweak your profile by tweaking these items:
|
You can tweak your profile by tweaking these items:
|
||||||
|
|
||||||
- `name`
|
- `name`: The name shown with your profile.
|
||||||
- `summary` (using Markdown)
|
- `summary`: The summary or 'bio' part of your profile, written in Markdown.
|
||||||
- `icon_url`
|
- `icon_url`: Your profile image or avatar.
|
||||||
|
- `image_url`: This provides a 'header' or 'banner' image. Note that it is not shown by the default Microblog.pub templates. It will be used by Mastodon (which uses a 3:1 ratio image) and Pleroma. Pixelfed and Peertube, for example, don't show these images by default.
|
||||||
|
|
||||||
Whenever one of these config items is updated, an `Update` activity will be sent to all known servers to update your remote profile.
|
Whenever one of these config items is updated, an `Update` activity will be sent to all known servers to update your remote profile.
|
||||||
|
|
||||||
|
@ -35,6 +36,15 @@ The server will need to be restarted for taking changes into account.
|
||||||
|
|
||||||
Before restarting the server, you can ensure you haven't made any mistakes by running the [configuration checking task](/user_guide.html#configuration-checking).
|
Before restarting the server, you can ensure you haven't made any mistakes by running the [configuration checking task](/user_guide.html#configuration-checking).
|
||||||
|
|
||||||
|
Note that currently `image_url` is not used anywhere in microblog.pub itself, but other clients/servers do occasionally use it when showing remote profiles as a background image.
|
||||||
|
Also, this image _can_ be used in microblog.pub - just add this:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="{{ local_actor.image_url | media_proxy_url }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
to an appropriate place of your template (most likely, `header.html`).
|
||||||
|
For more information, see a section about [custom templates](/user_guide.html#custom-templates) further in this document.
|
||||||
|
|
||||||
### Profile metadata
|
### Profile metadata
|
||||||
|
|
||||||
|
@ -161,10 +171,35 @@ $secondary-color: #32cd32;
|
||||||
|
|
||||||
See `app/scss/main.scss` to see what variables can be overridden.
|
See `app/scss/main.scss` to see what variables can be overridden.
|
||||||
|
|
||||||
|
You will need to [recompile CSS](#recompiling-css-files) after doing any CSS changes (for actual css files to be updates) and restart microblog.pub (for css link in HTML documents to be updated with a new checksum - otherwise, browsers that downloaded old CSS will keep using it).
|
||||||
|
|
||||||
|
#### Custom favicon
|
||||||
|
|
||||||
|
By default, microblog.pub favicon is a square of `$primary-color` CSS color (see above section on how to redefine CSS colors).
|
||||||
|
You can change it to any icon you like - just save a desired file as `data/favicon.ico`.
|
||||||
|
After that, run the "[recompile CSS](#recompiling-css-files)" task to copy it to `app/static/favicon.ico`.
|
||||||
|
|
||||||
#### Custom templates
|
#### Custom templates
|
||||||
|
|
||||||
If you'd like to customize your instance's theme beyond CSS, you can modify the app's HTML by placing templates in `data/templates` which overwrite the defaults in `app/templates`.
|
If you'd like to customize your instance's theme beyond CSS, you can modify the app's HTML by placing templates in `data/templates` which overwrite the defaults in `app/templates`.
|
||||||
|
|
||||||
|
Templates are written using [Jinja](https://jinja.palletsprojects.com/en/latest/templates/) templating language.
|
||||||
|
Moreover, `utils.html` has scoped blocks around the body of every macro.
|
||||||
|
This allows macros to be overridden individually in `data/templates/utils.html`, without copying the whole file.
|
||||||
|
For example, to only override the display of a specific actor's name/icon, you can create `data/templates/utils.html` file with following content:
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{% extends "app/utils.html" %}
|
||||||
|
|
||||||
|
{% block display_actor %}
|
||||||
|
{% if actor.ap_id == "https://me.example.com" %}
|
||||||
|
<!-- custom actor display -->
|
||||||
|
{% else %}
|
||||||
|
{{ super() }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
#### Custom Content Security Policy (CSP)
|
#### Custom Content Security Policy (CSP)
|
||||||
|
|
||||||
You can override the default Content Security Policy by adding a line in `data/profile.toml`:
|
You can override the default Content Security Policy by adding a line in `data/profile.toml`:
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue