diff --git a/poetry.lock b/poetry.lock index f96f001..b8982a8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +[[package]] +name = "aenum" +version = "3.1.15" +description = "Advanced Enumerations (compatible with Python's stdlib Enum), NamedTuples, and NamedConstants" +optional = false +python-versions = "*" +files = [ + {file = "aenum-3.1.15-py2-none-any.whl", hash = "sha256:27b1710b9d084de6e2e695dab78fe9f269de924b51ae2850170ee7e1ca6288a5"}, + {file = "aenum-3.1.15-py3-none-any.whl", hash = "sha256:e0dfaeea4c2bd362144b87377e2c61d91958c5ed0b4daf89cb6f45ae23af6288"}, + {file = "aenum-3.1.15.tar.gz", hash = "sha256:8cbd76cd18c4f870ff39b24284d3ea028fbe8731a58df3aa581e434c575b9559"}, +] + [[package]] name = "atomicwrites" version = "1.4.0" @@ -195,6 +207,23 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "clientapi-forgejo" +version = "1.0.0" +description = "Forgejo API." +optional = false +python-versions = "*" +files = [ + {file = "clientapi_forgejo-1.0.0-py3-none-any.whl", hash = "sha256:67c5551838857eac9f697785c4affe462e085f2d4d9a7fb851d770d31ed2d94f"}, + {file = "clientapi_forgejo-1.0.0.tar.gz", hash = "sha256:94b339372650e0398088023c98c3516f12cbb600bb453dd4142b52d8711a7dc7"}, +] + +[package.dependencies] +aenum = "*" +pydantic = ">=1.10.5,<2" +python-dateutil = "*" +urllib3 = ">=1.25.3,<2.1.0" + [[package]] name = "colorama" version = "0.4.4" @@ -455,6 +484,24 @@ SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +[[package]] +name = "loguru" +version = "0.7.2" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5" +files = [ + {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, + {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"] + [[package]] name = "markupsafe" version = "2.0.1" @@ -689,6 +736,65 @@ files = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +[[package]] +name = "pydantic" +version = "1.10.18" +description = "Data validation and settings management using python type hints" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, + {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, + {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, + {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, + {file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, + {file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, + {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, + {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, + {file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, + {file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, + {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, + {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, + {file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, + {file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, + {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, + {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, + {file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, + {file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, + {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, + {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, + {file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, + {file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, + {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, + {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, + {file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, + {file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, + {file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, +] + +[package.dependencies] +typing-extensions = ">=4.2.0" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + [[package]] name = "pygments" version = "2.13.0" @@ -1052,13 +1158,13 @@ urllib3 = ">=1.26.0" [[package]] name = "typing-extensions" -version = "4.0.1" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, - {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -1102,6 +1208,20 @@ files = [ [package.extras] watchdog = ["watchdog"] +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + [[package]] name = "zipp" version = "3.7.0" @@ -1120,4 +1240,4 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>= [metadata] lock-version = "2.0" python-versions = ">=3.8,<4.0.0" -content-hash = "3c54043af6bd9117fe0a28662c707018331a2ce2d91e10cf29137a80256e4de0" +content-hash = "49a16ba341bf565decdbda3c7f050c9007eceb08d43535ac0f54c0ea4c6bb447" diff --git a/pyproject.toml b/pyproject.toml index 372d331..e418c3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ python-slugify = "^5.0.2" PyYAML = "^6.0" Flask-Micropub = "^0.2.8" pillow = "^10.0.0" +clientapi-forgejo = "^1.0.0" +loguru = "^0.7.2" [tool.poetry.dev-dependencies] pytest = "^6.2.5" diff --git a/src/microcosm/__init__.py b/src/microcosm/__init__.py index 1e35f22..bce4845 100644 --- a/src/microcosm/__init__.py +++ b/src/microcosm/__init__.py @@ -3,12 +3,16 @@ import requests import os import functools import dotenv -import giteapy -import giteapy.rest import time import base64 import mimetypes +import sys +from loguru import logger + +import logging + +import clientapi_forgejo as forgejo from werkzeug.datastructures import FileStorage import yaml @@ -24,39 +28,47 @@ from flask import Flask, jsonify, request, Response, Blueprint dotenv.load_dotenv() PERMITTED_DOMAIN = os.environ.get( - 'PERMITTED_DOMAINS', 'https://brainsteam.co.uk/').split(';') + "PERMITTED_DOMAINS", "https://brainsteam.co.uk/" +).split(";") - -ENTITY_TYPE_PLURAL_MAP = { - "reply": "replies", - "watch": "watches" -} +ENTITY_TYPE_PLURAL_MAP = {"reply": "replies", "watch": "watches"} core_bp = Blueprint("core", __name__) + class InvalidRequestException(Exception): """Class of exception raised when the server receives an invalid request""" +# create a custom handler +class InterceptHandler(logging.Handler): + def emit(self, record): + logger_opt = logger.opt(depth=6, exception=record.exc_info) + logger_opt.log(record.levelno, record.getMessage()) + + def create_app(): app = Flask(__name__) - app.config['SECRET_KEY'] = 'my super secret key' - - #app.config.from_file(os.path.join(os.getcwd(), "config.yaml"), yaml.safe_load) + app.config["SECRET_KEY"] = "my super secret key" + + # app.config.from_file(os.path.join(os.getcwd(), "config.yaml"), yaml.safe_load) from .indieauth import micropub, auth_bp from .webmentions import webhook_bp - print(app.config) - micropub.init_app(app, os.environ.get('INDIEAUTH_CLIENT_ID', 'test.com')) + micropub.init_app(app, os.environ.get("INDIEAUTH_CLIENT_ID", "test.com")) app.register_blueprint(auth_bp) app.register_blueprint(core_bp) app.register_blueprint(webhook_bp) + logger.add(sys.stderr, level=logging.WARN, backtrace=True, diagnose=True) + + # logger.start() + app.logger.addHandler(InterceptHandler()) return app @@ -65,19 +77,24 @@ def authed_endpoint(f): @functools.wraps(f) def wrapper(*args, **kwargs): - authtok = request.headers.get('Authorization') + authtok = request.headers.get("Authorization") if authtok is None: return { "error": "unauthorized", - "error_description": "An auth token was not provided" + "error_description": "An auth token was not provided", }, 401 - auth = requests.get("https://tokens.indieauth.com/token", headers={ - "Authorization": authtok, "Accept": "application/json"}).json() + auth = requests.get( + "https://tokens.indieauth.com/token", + headers={"Authorization": authtok, "Accept": "application/json"}, + ).json() - if auth.get('me','') not in PERMITTED_DOMAIN: - return {"error": "insufficient_scope", "error_description": f"User \"{auth.get('me','')}\" not permitted to post here"}, 401 + if auth.get("me", "") not in PERMITTED_DOMAIN: + return { + "error": "insufficient_scope", + "error_description": f"User \"{auth.get('me','')}\" not permitted to post here", + }, 401 return f(*args, *kwargs) @@ -86,63 +103,69 @@ def authed_endpoint(f): _api_client = None + class InvalidRequestException(Exception): """Invalid Request""" -def process_photo_url(created_at: datetime, doc: Dict[str, List[str]], suffix: str = ""): +def process_photo_url( + created_at: datetime, doc: Dict[str, List[str]], suffix: str = "" +): """Process photo submitted via URL""" now_ts = int(time.mktime(created_at.timetuple())) - + photo_urls = [] - if isinstance(doc['photo'], str): - doc['photo'] = [doc['photo']] + if isinstance(doc["photo"], str): + doc["photo"] = [doc["photo"]] - - for i, photo in enumerate(doc['photo']): + for i, photo in enumerate(doc["photo"]): - if(isinstance(photo, str)): - photo = {"value": photo, "alt": ""} + if isinstance(photo, str): + photo = {"url": photo, "alt": ""} - - if os.environ.get('MICROPUB_IMAGE_STRATEGY') == 'copy': + if os.environ.get("MICROPUB_IMAGE_STRATEGY") == "copy": # download the photo - r = requests.get(photo['value']) + r = requests.get(photo["url"]) - ext = os.path.splitext(photo['value'])[1] + ext = os.path.splitext(photo["url"])[1] # generate local filename - filename = os.path.join(os.environ.get( - 'MICROPUB_MEDIA_PATH'), created_at.strftime("%Y/%m/%d"), str(now_ts) + f"{now_ts}_{suffix}_{i}_{ext}") - - photo_url = os.path.join(os.environ.get( - 'MICROPUB_MEDIA_URL_PREFIX'), created_at.strftime("%Y/%m/%d"), str(now_ts) + f"{now_ts}_{suffix}_{i}_{ext}") + filename = os.path.join( + os.environ.get("MICROPUB_MEDIA_PATH"), + created_at.strftime("%Y/%m/%d"), + str(now_ts) + f"{now_ts}_{suffix}_{i}_{ext}", + ) - photo_urls.append((photo_url, photo['alt'])) + photo_url = os.path.join( + os.environ.get("MICROPUB_MEDIA_URL_PREFIX"), + created_at.strftime("%Y/%m/%d"), + str(now_ts) + f"{now_ts}_{suffix}_{i}_{ext}", + ) + + photo_urls.append((photo_url, photo["alt"])) # make directory if needed if not os.path.exists(os.path.dirname(filename)): os.makedirs(os.path.dirname(filename)) - with open(filename, 'wb') as f: + with open(filename, "wb") as f: f.write(r.content) - else: - photo_urls.append((photo['value'], photo['alt'])) - + photo_urls.append((photo["value"], photo["alt"])) return photo_urls -def process_photo_upload(created_at: datetime, file: FileStorage, suffix: str=""): + +def process_photo_upload(created_at: datetime, file: FileStorage, suffix: str = ""): """Process photo directly uploaded to micropub""" now_ts = int(time.mktime(created_at.timetuple())) - if os.environ.get('MICROPUB_IMAGE_STRATEGY') == 'copy': + if os.environ.get("MICROPUB_IMAGE_STRATEGY") == "copy": file.mimetype @@ -152,10 +175,16 @@ def process_photo_upload(created_at: datetime, file: FileStorage, suffix: str="" ext = mimetypes.guess_extension(file.mimetype) # generate local filename - filename = os.path.join(os.environ.get( - 'MICROPUB_MEDIA_PATH'), created_at.strftime("%Y/%m/%d"), f"{now_ts}_{suffix}{ext}") - photo_url = os.path.join(os.environ.get( - 'MICROPUB_MEDIA_URL_PREFIX'), created_at.strftime("%Y/%m/%d"), f"{now_ts}_{suffix}{ext}") + filename = os.path.join( + os.environ.get("MICROPUB_MEDIA_PATH"), + created_at.strftime("%Y/%m/%d"), + f"{now_ts}_{suffix}{ext}", + ) + photo_url = os.path.join( + os.environ.get("MICROPUB_MEDIA_URL_PREFIX"), + created_at.strftime("%Y/%m/%d"), + f"{now_ts}_{suffix}{ext}", + ) # make directory if needed if not os.path.exists(os.path.dirname(filename)): @@ -167,11 +196,9 @@ def process_photo_upload(created_at: datetime, file: FileStorage, suffix: str="" else: return None - - -def init_frontmatter(created_at: datetime, post_type: str, name: Optional[str]=None): +def init_frontmatter(created_at: datetime, post_type: str, name: Optional[str] = None): now_ts = int(time.mktime(created_at.timetuple())) @@ -183,100 +210,117 @@ def init_frontmatter(created_at: datetime, post_type: str, name: Optional[str]=N else: slug = str(now_ts) + url = os.path.join( + "/", + ENTITY_TYPE_PLURAL_MAP.get(post_type, post_type + "s"), + created_at.strftime("%Y/%m/%d"), + slug, + ) - url = os.path.join("/", ENTITY_TYPE_PLURAL_MAP.get(post_type, post_type + "s"), - created_at.strftime("%Y/%m/%d"), slug) + print(os.environ.get("CONTENT_PREFIX")) - print(os.environ.get( - 'CONTENT_PREFIX')) - - file_path = os.path.join(os.environ.get( - 'CONTENT_PREFIX'), ENTITY_TYPE_PLURAL_MAP.get(post_type, post_type + "s"), created_at.strftime("%Y/%m/%d"), slug + ".md") + file_path = os.path.join( + os.environ.get("CONTENT_PREFIX"), + ENTITY_TYPE_PLURAL_MAP.get(post_type, post_type + "s"), + created_at.strftime("%Y/%m/%d"), + slug + ".md", + ) frontmatter = { - "post_meta": ['date'], + "post_meta": ["date"], "url": url, "type": ENTITY_TYPE_PLURAL_MAP.get(post_type, post_type + "s"), - "date": created_at.isoformat(sep='T'), + "date": created_at.isoformat(sep="T"), } return frontmatter, file_path - def detect_entry_type(doc: dict) -> str: """Given a dictionary object from either form or json, detect type of post""" - - if 'hypothesis-link' in doc: + if "hypothesis-link" in doc: entry_type = "annotation" - elif ('in-reply-to' in doc) or ('u-in-reply-to' in doc): + elif ("in-reply-to" in doc) or ("u-in-reply-to" in doc): entry_type = "reply" - elif ('bookmark-of' in doc) or ('u-bookmark-of' in doc): + elif ("bookmark-of" in doc) or ("u-bookmark-of" in doc): entry_type = "bookmark" - elif ('repost-of' in doc) or ('u-repost-of' in doc): + elif ("repost-of" in doc) or ("u-repost-of" in doc): entry_type = "repost" - elif ('like-of' in doc) or ('u-like-of' in doc): + elif ("like-of" in doc) or ("u-like-of" in doc): entry_type = "like" - elif ('read-of' in doc): + elif "read-of" in doc: entry_type = "read" - elif ('watch-of' in doc): + elif "watch-of" in doc: entry_type = "watch" - elif ('name' in doc) or ('p-name' in doc): + elif ("name" in doc) or ("p-name" in doc): entry_type = "post" else: entry_type = "note" - return entry_type -def capture_frontmatter_props(doc: Dict[str, Union[str, List[str]]], frontmatter: Dict[str, Union[str,List[str]]]): +def capture_frontmatter_props( + doc: Dict[str, Union[str, List[str]]], frontmatter: Dict[str, Union[str, List[str]]] +): - keys = ['summary', 'bookmark-of', 'in-reply-to', 'repost-of', 'like-of', 'read-of', 'watch-of', 'listen-of', 'read-status', 'rating'] - - keys += [f'u-{key}' for key in keys] + keys = [ + "summary", + "bookmark-of", + "in-reply-to", + "repost-of", + "like-of", + "read-of", + "watch-of", + "listen-of", + "read-status", + "rating", + ] + + keys += [f"u-{key}" for key in keys] for key in keys: if key in doc: - if isinstance(doc[key], dict) and ('type' in doc[key]): - - if doc[key]['type'][0] == 'h-cite': - - if 'citations' not in frontmatter: - frontmatter['citations'] = [] - frontmatter['citations'].append(doc[key]['properties']) + if isinstance(doc[key], dict) and ("type" in doc[key]): + + if doc[key]["type"][0] == "h-cite": + + if "citations" not in frontmatter: + frontmatter["citations"] = [] + frontmatter["citations"].append(doc[key]["properties"]) elif isinstance(doc[key], list) and (len(doc[key]) < 2): frontmatter[key] = doc[key][0] else: frontmatter[key] = doc[key] - if 'hypothesis-link' in doc: + if "hypothesis-link" in doc: # get the hypothesis data and store it - r = requests.get(doc['hypothesis-link'][0]) + r = requests.get(doc["hypothesis-link"][0]) - frontmatter['hypothesis-meta'] = r.json() + frontmatter["hypothesis-meta"] = r.json() - if 'category' in doc: - if isinstance(doc['category'], list): - categories = doc['category'] + if "category" in doc: + if isinstance(doc["category"], list): + categories = doc["category"] else: - categories = [doc['category']] - elif 'p-category' in doc: - categories = doc['p-category'] + categories = [doc["category"]] + elif "p-category" in doc: + categories = doc["p-category"] else: - categories = request.form.getlist('category[]') + categories = request.form.getlist("category[]") if len(categories) > 0: - frontmatter['tags'] = categories + frontmatter["tags"] = categories + def process_multipart_post(): doc = request.form.to_dict(flat=True) @@ -285,88 +329,86 @@ def process_multipart_post(): now = datetime.now() - frontmatter, file_path = init_frontmatter(now, entry_type, doc.get('name')) + frontmatter, file_path = init_frontmatter(now, entry_type, doc.get("name")) capture_frontmatter_props(doc, frontmatter) + if "name" in doc: + frontmatter["title"] = doc["name"] - if 'name' in doc: - frontmatter['title'] = doc['name'] + if ("photo" in doc) or ("photo" in request.files) or ("photo[]" in request.files): + frontmatter["photo"] = [] - if ('photo' in doc) or ('photo' in request.files) or ('photo[]' in request.files): - - frontmatter['photo'] = [] - - if 'photo[]' in request.files: - photos = request.files.getlist('photo[]') + if "photo[]" in request.files: + photos = request.files.getlist("photo[]") docstr = "" for i, photo in enumerate(photos): photo_url = process_photo_upload(now, photo, suffix=i) - if 'thumbnail' not in frontmatter: - frontmatter['thumbnail'] = photo_url + if "thumbnail" not in frontmatter: + frontmatter["thumbnail"] = photo_url - frontmatter['photo'].append(photo_url) + frontmatter["photo"].append(photo_url) - docstr += f"\n\n" + docstr += f'\n\n' docstr += f"\n\n {doc['content']}" else: - if 'photo' in doc: + if "photo" in doc: photo_objects = process_photo_url(now, doc) - else: - photo_objects = [ (process_photo_upload(now, request.files['photo']), "") ] - - frontmatter['photo'] = [ {"value": photo[0], "alt": photo[1]} for photo in photo_objects] - frontmatter['thumbnail'] = photo_objects[0][0] + photo_objects = [ + (process_photo_upload(now, request.files["photo"]), "") + ] + frontmatter["photo"] = [ + {"value": photo[0], "alt": photo[1]} for photo in photo_objects + ] + frontmatter["thumbnail"] = photo_objects[0][0] docstr = "" for photo in photo_objects: docstr += f"\"{photo[1]}\" \n\n {doc['content']}" else: - docstr = doc.get('content','') if 'content' in doc else "" + docstr = doc.get("content", "") if "content" in doc else "" - if 'mp-syndicate-to' in doc: - frontmatter['mp-syndicate-to'] = doc['mp-syndicate-to'].split(",") + if "mp-syndicate-to" in doc: + frontmatter["mp-syndicate-to"] = doc["mp-syndicate-to"].split(",") - for url in doc['mp-syndicate-to'].split(","): - docstr += f"\n" - - if 'mp-syndicate-to[]' in request.form: - frontmatter['mp-syndicate-to'] = request.form.getlist('mp-syndicate-to[]') + for url in doc["mp-syndicate-to"].split(","): + docstr += f'\n' - for url in request.form.getlist('mp-syndicate-to[]'): - docstr += f"\n" + if "mp-syndicate-to[]" in request.form: + frontmatter["mp-syndicate-to"] = request.form.getlist("mp-syndicate-to[]") + + for url in request.form.getlist("mp-syndicate-to[]"): + docstr += f'\n' return docstr, frontmatter, file_path -def process_image_alt_texts(doc): +def process_image_alt_texts(doc): alts = [] - if isinstance(doc['photo'], str): - doc['photo'] = [doc['photo']] + if isinstance(doc["photo"], str): + doc["photo"] = [doc["photo"]] - - for i, photo in enumerate(doc['photo']): + for i, photo in enumerate(doc["photo"]): if isinstance(photo, dict): - alts.append(doc['alt']) + alts.append(doc["alt"]) else: alts.append("") return alts - def process_json_post(): """Process JSON POST submission""" @@ -374,45 +416,45 @@ def process_json_post(): body = request.get_json() # get post type - take the first item in the array - if body['type'][0] != 'h-entry': - return jsonify({"error":"invalid_format"}), 400 + if body["type"][0] != "h-entry": + return jsonify({"error": "invalid_format"}), 400 - props = body['properties'] + props = body["properties"] entry_type = detect_entry_type(props) - if 'published' in props: + if "published" in props: from dateutil import parser - - now = parser.parse(props['published'][0]) + + now = parser.parse(props["published"][0]) else: now = datetime.now() - frontmatter, file_path = init_frontmatter(now, entry_type, props.get('name')) + frontmatter, file_path = init_frontmatter(now, entry_type, props.get("name")) capture_frontmatter_props(props, frontmatter) - if 'name' in props: - frontmatter['title'] = props['name'][0] + if "name" in props: + frontmatter["title"] = props["name"][0] docstr = "" - if 'photo' in props: + if "photo" in props: - photo_objects = process_photo_url(now, props) + photo_objects = process_photo_url(now, props) - - frontmatter['photo'] = [ {"value": photo[0], "alt": photo[1]} for photo in photo_objects] - frontmatter['thumbnail'] = frontmatter['photo'][0]['value'] + frontmatter["photo"] = [ + {"value": photo[0], "alt": photo[1]} for photo in photo_objects + ] + frontmatter["thumbnail"] = frontmatter["photo"][0]["value"] docstr = "" for photo in photo_objects: - docstr += f"\"{photo[1]}\" \n\n" + docstr += f'{photo[1]} \n\n' - - for content in props.get('content', []): + for content in props.get("content", []): if isinstance(content, dict): - if 'html' in content: + if "html" in content: docstr += f"\n\n {content.get('html')}" else: @@ -420,19 +462,20 @@ def process_json_post(): return docstr, frontmatter, file_path -def get_api_client() -> giteapy.RepositoryApi: + +def get_api_client() -> forgejo.RepositoryApi: global _api_client if _api_client is None: - config = giteapy.Configuration() - config.host = os.environ.get('GITEA_URL') - config.api_key['access_token'] = os.environ.get('GITEA_API_KEY') - _api_client = giteapy.RepositoryApi(giteapy.ApiClient(config)) + config = forgejo.Configuration() + config.host = os.environ.get("GITEA_URL") + config.api_key["Token"] = os.environ.get("GITEA_API_KEY") + _api_client = forgejo.RepositoryApi(forgejo.ApiClient(config)) return _api_client -@core_bp.route('/', methods=['POST']) +@core_bp.route("/", methods=["POST"]) @authed_endpoint def req(): @@ -443,33 +486,38 @@ def req(): frontmatter_str = yaml.dump(frontmatter) content = base64.encodebytes( - f"---\n{frontmatter_str}\n---\n\n{docstr}".encode("utf8")).decode("utf8") + f"---\n{frontmatter_str}\n---\n\n{docstr}".encode("utf8") + ).decode("utf8") api = get_api_client() - body = giteapy.CreateFileOptions(content=content) + body = forgejo.CreateFileOptions(content=content) try: - r = api.repo_create_file(os.environ.get( - 'GITEA_REPO_OWNER'), os.environ.get('GITEA_REPO_NAME'), file_path, body) + r = api.repo_create_file( + os.environ.get("GITEA_REPO_OWNER"), + os.environ.get("GITEA_REPO_NAME"), + file_path, + body, + ) - return Response(status=202, headers={"Location": frontmatter['url']}) + return Response(status=202, headers={"Location": frontmatter["url"]}) except Exception as e: - return {"error": str(e)}, 500 + logger.error(e, exc_info=True) + return {"error": str(e)}, 500 def parse_categories(): - strategy = os.environ.get('MICROPUB_CATEGORY_LIST_STRATEGY') + strategy = os.environ.get("MICROPUB_CATEGORY_LIST_STRATEGY") - if strategy == 'feed': - tree = ElementTree.parse(os.environ.get('MICROPUB_CATEGORY_LIST_FILE')) - tags = tree.findall('.//item/title') + if strategy == "feed": + tree = ElementTree.parse(os.environ.get("MICROPUB_CATEGORY_LIST_FILE")) + tags = tree.findall(".//item/title") - - return {"categories": [tag.text for tag in tags] } + return {"categories": [tag.text for tag in tags]} def get_syndication_targets(): @@ -484,9 +532,9 @@ def get_syndication_targets(): continue target_def = { - "uid": os.environ.get(f"SYNDICATION_TARGET_{target}_URL", target), + "uid": os.environ.get(f"SYNDICATION_TARGET_{target}_URL", target), "name": os.environ.get(f"SYNDICATION_TARGET_{target}_NAME", target), - } + } defs.append(target_def) return defs @@ -497,7 +545,7 @@ def get_syndication_targets(): def media_endpoint(): now = datetime.now() - url = process_photo_upload(now, request.files['file']) + url = process_photo_upload(now, request.files["file"]) return Response(status=201, headers={"Location": url}) @@ -505,50 +553,33 @@ def media_endpoint(): def generate_config_json(): return { - "media-endpoint": os.environ.get(f"MICROCOSM_BASE_URL", request.base_url) + "media", + "media-endpoint": os.environ.get(f"MICROCOSM_BASE_URL", request.base_url) + + "media", "syndicate-to": get_syndication_targets(), "post-types": [ - { - "type": "note", - "name": "Note" - }, - { - "type": "article", - "name": "Blog Post" - }, - { - "type": "photo", - "name": "Photo" - }, - { - "type": "reply", - "name": "Reply" - }, - { - "type": "bookmark", - "name": "Bookmark" - }, - { - "type": "like", - "name":"Like" - } - ] + {"type": "note", "name": "Note"}, + {"type": "article", "name": "Blog Post"}, + {"type": "photo", "name": "Photo"}, + {"type": "reply", "name": "Reply"}, + {"type": "bookmark", "name": "Bookmark"}, + {"type": "like", "name": "Like"}, + ], } -@core_bp.route("/", methods=['GET']) +@core_bp.route("/", methods=["GET"]) @authed_endpoint def index(): - if request.args.get('q') == 'config': + if request.args.get("q") == "config": return generate_config_json() - elif request.args.get('q') == 'category': + elif request.args.get("q") == "category": return parse_categories() - - elif request.args.get('q') == 'syndicate-to': + + elif request.args.get("q") == "syndicate-to": return {"syndicate-to": get_syndication_targets()} -if __name__ == '__main__': +if __name__ == "__main__": app.run(debug=False) diff --git a/src/microcosm/indieauth.py b/src/microcosm/indieauth.py index a5c5b8f..6642d2b 100644 --- a/src/microcosm/indieauth.py +++ b/src/microcosm/indieauth.py @@ -65,6 +65,7 @@ def indieauth_callback(resp): """.format(resp.me, resp.next_url, resp.error) + @auth_bp.route('/micropub-callback') @micropub.authorized_handler def micropub_callback(resp):