mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
activitypub - signatures wip
This commit is contained in:
parent
83c8415fec
commit
a9bfe2f391
20 changed files with 601 additions and 27 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -159,4 +159,4 @@ cython_debug/
|
|||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
app/static/*.css.map
|
||||
|
|
|
@ -1,2 +1,7 @@
|
|||
# pyfedi
|
||||
|
||||
A lemmy/kbin clone written in Python with Flask.
|
||||
|
||||
- Clean, simple code.
|
||||
- Easy setup, easy to manage - few dependencies and extra software required.
|
||||
- GPL.
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
# This file is part of pyfedi, which is licensed under the GNU General Public License (GPL) version 3.0.
|
||||
# You should have received a copy of the GPL along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
from logging.handlers import SMTPHandler, RotatingFileHandler
|
||||
import os
|
||||
|
@ -5,6 +8,7 @@ from flask import Flask, request, current_app
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_bootstrap import Bootstrap5
|
||||
from flask_mail import Mail
|
||||
from flask_moment import Moment
|
||||
from flask_babel import Babel, lazy_gettext as _l
|
||||
|
@ -17,6 +21,7 @@ login = LoginManager()
|
|||
login.login_view = 'auth.login'
|
||||
login.login_message = _l('Please log in to access this page.')
|
||||
mail = Mail()
|
||||
bootstrap = Bootstrap5()
|
||||
moment = Moment()
|
||||
babel = Babel()
|
||||
|
||||
|
@ -29,6 +34,7 @@ def create_app(config_class=Config):
|
|||
migrate.init_app(app, db, render_as_batch=True)
|
||||
login.init_app(app)
|
||||
mail.init_app(app)
|
||||
bootstrap.init_app(app)
|
||||
moment.init_app(app)
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
|
||||
|
|
430
app/activitypub/signature.py
Normal file
430
app/activitypub/signature.py
Normal file
|
@ -0,0 +1,430 @@
|
|||
# code in this file is from Takahe https://github.com/jointakahe/takahe
|
||||
#
|
||||
# Copyright 2022 Andrew Godwin
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without modification,
|
||||
# are permitted provided that the following conditions are met:
|
||||
#
|
||||
# 1. Redistributions of source code must retain the above copyright notice, this
|
||||
# list of conditions and the following disclaimer.
|
||||
#
|
||||
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation and/or
|
||||
# other materials provided with the distribution.
|
||||
#
|
||||
# 3. Neither the name of the copyright holder nor the names of its contributors
|
||||
# may be used to endorse or promote products derived from this software without
|
||||
# specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Literal, TypedDict, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
import arrow
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from flask import Request
|
||||
from datetime import datetime
|
||||
from dateutil import parser
|
||||
from pyld import jsonld
|
||||
|
||||
from app.constants import DATETIME_MS_FORMAT
|
||||
|
||||
|
||||
def http_date(epoch_seconds=None):
|
||||
if epoch_seconds is None:
|
||||
epoch_seconds = arrow.utcnow().timestamp()
|
||||
formatted_date = arrow.get(epoch_seconds).format('ddd, DD MMM YYYY HH:mm:ss ZZ', 'en_US')
|
||||
return formatted_date
|
||||
|
||||
|
||||
def format_ld_date(value: datetime) -> str:
|
||||
# We chop the timestamp to be identical to the timestamps returned by
|
||||
# Mastodon's API, because some clients like Toot! (for iOS) are especially
|
||||
# picky about timestamp parsing.
|
||||
return f"{value.strftime(DATETIME_MS_FORMAT)[:-4]}Z"
|
||||
|
||||
|
||||
def parse_http_date(http_date_str):
|
||||
parsed_date = arrow.get(http_date_str, 'ddd, DD MMM YYYY HH:mm:ss Z')
|
||||
return parsed_date.datetime
|
||||
|
||||
|
||||
def parse_ld_date(value: str | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
return parser.isoparse(value).replace(microsecond=0)
|
||||
|
||||
class VerificationError(BaseException):
|
||||
"""
|
||||
There was an error with verifying the signature
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class VerificationFormatError(VerificationError):
|
||||
"""
|
||||
There was an error with the format of the signature (not if it is valid)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RsaKeys:
|
||||
@classmethod
|
||||
def generate_keypair(cls) -> tuple[str, str]:
|
||||
"""
|
||||
Generates a new RSA keypair
|
||||
"""
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
private_key_serialized = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
).decode("ascii")
|
||||
public_key_serialized = (
|
||||
private_key.public_key()
|
||||
.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
.decode("ascii")
|
||||
)
|
||||
return private_key_serialized, public_key_serialized
|
||||
|
||||
|
||||
class HttpSignature:
|
||||
"""
|
||||
Allows for calculation and verification of HTTP signatures
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def calculate_digest(cls, data, algorithm="sha-256") -> str:
|
||||
"""
|
||||
Calculates the digest header value for a given HTTP body
|
||||
"""
|
||||
if algorithm == "sha-256":
|
||||
digest = hashes.Hash(hashes.SHA256())
|
||||
digest.update(data)
|
||||
return "SHA-256=" + base64.b64encode(digest.finalize()).decode("ascii")
|
||||
else:
|
||||
raise ValueError(f"Unknown digest algorithm {algorithm}")
|
||||
|
||||
@classmethod
|
||||
def headers_from_request(cls, request: Request, header_names: list[str]) -> str:
|
||||
"""
|
||||
Creates the to-be-signed header payload from a Flask request
|
||||
"""
|
||||
headers = {}
|
||||
for header_name in header_names:
|
||||
if header_name == "(request-target)":
|
||||
value = f"{request.method.lower()} {request.path}"
|
||||
elif header_name == "content-type":
|
||||
value = request.headers.get("Content-Type", "")
|
||||
elif header_name == "content-length":
|
||||
value = request.headers.get("Content-Length", "")
|
||||
else:
|
||||
value = request.headers.get(header_name.replace("-", "_").upper(), "")
|
||||
headers[header_name] = value
|
||||
return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())
|
||||
|
||||
@classmethod
|
||||
def parse_signature(cls, signature: str) -> "HttpSignatureDetails":
|
||||
bits = {}
|
||||
for item in signature.split(","):
|
||||
name, value = item.split("=", 1)
|
||||
value = value.strip('"')
|
||||
bits[name.lower()] = value
|
||||
try:
|
||||
signature_details: HttpSignatureDetails = {
|
||||
"headers": bits["headers"].split(),
|
||||
"signature": base64.b64decode(bits["signature"]),
|
||||
"algorithm": bits["algorithm"],
|
||||
"keyid": bits["keyid"],
|
||||
}
|
||||
except KeyError as e:
|
||||
key_names = " ".join(bits.keys())
|
||||
raise VerificationError(
|
||||
f"Missing item from details (have: {key_names}, error: {e})"
|
||||
)
|
||||
return signature_details
|
||||
|
||||
@classmethod
|
||||
def compile_signature(cls, details: "HttpSignatureDetails") -> str:
|
||||
value = f'keyId="{details["keyid"]}",headers="'
|
||||
value += " ".join(h.lower() for h in details["headers"])
|
||||
value += '",signature="'
|
||||
value += base64.b64encode(details["signature"]).decode("ascii")
|
||||
value += f'",algorithm="{details["algorithm"]}"'
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def verify_signature(
|
||||
cls,
|
||||
signature: bytes,
|
||||
cleartext: str,
|
||||
public_key: str,
|
||||
):
|
||||
public_key_instance: rsa.RSAPublicKey = cast(
|
||||
rsa.RSAPublicKey,
|
||||
serialization.load_pem_public_key(public_key.encode("ascii")),
|
||||
)
|
||||
try:
|
||||
public_key_instance.verify(
|
||||
signature,
|
||||
cleartext.encode("ascii"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
except InvalidSignature:
|
||||
raise VerificationError("Signature mismatch")
|
||||
|
||||
@classmethod
|
||||
def verify_request(cls, request: Request, public_key, skip_date=False):
|
||||
"""
|
||||
Verifies that the request has a valid signature for its body
|
||||
"""
|
||||
# Verify body digest
|
||||
if "digest" in request.headers:
|
||||
expected_digest = HttpSignature.calculate_digest(request.data)
|
||||
if request.headers["digest"] != expected_digest:
|
||||
raise VerificationFormatError("Digest is incorrect")
|
||||
|
||||
# Verify date header
|
||||
if "date" in request.headers and not skip_date:
|
||||
header_date = parse_http_date(request.headers["date"])
|
||||
if abs((arrow.utcnow() - header_date).total_seconds()) > 3600:
|
||||
raise VerificationFormatError("Date is too far away")
|
||||
|
||||
# Get the signature details
|
||||
if "signature" not in request.headers:
|
||||
raise VerificationFormatError("No signature header present")
|
||||
signature_details = cls.parse_signature(request.headers["signature"])
|
||||
|
||||
# Reject unknown algorithms
|
||||
# hs2019 is used by some libraries to obfuscate the real algorithm per the spec
|
||||
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12
|
||||
if (
|
||||
signature_details["algorithm"] != "rsa-sha256"
|
||||
and signature_details["algorithm"] != "hs2019"
|
||||
):
|
||||
raise VerificationFormatError("Unknown signature algorithm")
|
||||
# Create the signature payload
|
||||
headers_string = cls.headers_from_request(request, signature_details["headers"])
|
||||
cls.verify_signature(
|
||||
signature_details["signature"],
|
||||
headers_string,
|
||||
public_key,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def signed_request(
|
||||
cls,
|
||||
uri: str,
|
||||
body: dict | None,
|
||||
private_key: str,
|
||||
key_id: str,
|
||||
content_type: str = "application/json",
|
||||
method: Literal["get", "post"] = "post",
|
||||
timeout: int = 1,
|
||||
):
|
||||
"""
|
||||
Performs an async request to the given path, with a document, signed
|
||||
as an identity.
|
||||
"""
|
||||
if "://" not in uri:
|
||||
raise ValueError("URI does not contain a scheme")
|
||||
# Create the core header field set
|
||||
uri_parts = urlparse(uri)
|
||||
date_string = http_date()
|
||||
headers = {
|
||||
"(request-target)": f"{method} {uri_parts.path}",
|
||||
"Host": uri_parts.hostname,
|
||||
"Date": date_string,
|
||||
}
|
||||
# If we have a body, add a digest and content type
|
||||
if body is not None:
|
||||
body_bytes = json.dumps(body).encode("utf8")
|
||||
headers["Digest"] = cls.calculate_digest(body_bytes)
|
||||
headers["Content-Type"] = content_type
|
||||
else:
|
||||
body_bytes = b""
|
||||
# GET requests get implicit accept headers added
|
||||
if method == "get":
|
||||
headers["Accept"] = "application/ld+json"
|
||||
# Sign the headers
|
||||
signed_string = "\n".join(
|
||||
f"{name.lower()}: {value}" for name, value in headers.items()
|
||||
)
|
||||
private_key_instance: rsa.RSAPrivateKey = cast(
|
||||
rsa.RSAPrivateKey,
|
||||
serialization.load_pem_private_key(
|
||||
private_key.encode("ascii"),
|
||||
password=None,
|
||||
),
|
||||
)
|
||||
signature = private_key_instance.sign(
|
||||
signed_string.encode("ascii"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
headers["Signature"] = cls.compile_signature(
|
||||
{
|
||||
"keyid": key_id,
|
||||
"headers": list(headers.keys()),
|
||||
"signature": signature,
|
||||
"algorithm": "rsa-sha256",
|
||||
}
|
||||
)
|
||||
|
||||
# Announce ourselves with an agent similar to Mastodon
|
||||
headers["User-Agent"] = 'pyfedi'
|
||||
|
||||
# Send the request with all those headers except the pseudo one
|
||||
del headers["(request-target)"]
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
uri,
|
||||
headers=headers,
|
||||
data=body_bytes,
|
||||
timeout=timeout,
|
||||
allow_redirects=method == "GET",
|
||||
)
|
||||
except requests.exceptions.SSLError as invalid_cert:
|
||||
# Not our problem if the other end doesn't have proper SSL
|
||||
print(f"{uri} {invalid_cert}")
|
||||
raise requests.exceptions.SSLError from invalid_cert
|
||||
except ValueError as ex:
|
||||
# Convert to a more generic error we handle
|
||||
raise requests.exceptions.RequestException(f"InvalidCodepoint: {str(ex)}") from None
|
||||
|
||||
if (
|
||||
method == "POST"
|
||||
and 400 <= response.status_code < 500
|
||||
and response.status_code != 404
|
||||
):
|
||||
raise ValueError(
|
||||
f"POST error to {uri}: {response.status_code} {response.content!r}"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class HttpSignatureDetails(TypedDict):
|
||||
algorithm: str
|
||||
headers: list[str]
|
||||
signature: bytes
|
||||
keyid: str
|
||||
|
||||
|
||||
class LDSignature:
|
||||
"""
|
||||
Creates and verifies signatures of JSON-LD documents
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def verify_signature(cls, document: dict, public_key: str) -> None:
|
||||
"""
|
||||
Verifies a document
|
||||
"""
|
||||
try:
|
||||
# Strip out the signature from the incoming document
|
||||
signature = document.pop("signature")
|
||||
# Create the options document
|
||||
options = {
|
||||
"@context": "https://w3id.org/identity/v1",
|
||||
"creator": signature["creator"],
|
||||
"created": signature["created"],
|
||||
}
|
||||
except KeyError:
|
||||
raise VerificationFormatError("Invalid signature section")
|
||||
if signature["type"].lower() != "rsasignature2017":
|
||||
raise VerificationFormatError("Unknown signature type")
|
||||
# Get the normalised hash of each document
|
||||
final_hash = cls.normalized_hash(options) + cls.normalized_hash(document)
|
||||
# Verify the signature
|
||||
public_key_instance: rsa.RSAPublicKey = cast(
|
||||
rsa.RSAPublicKey,
|
||||
serialization.load_pem_public_key(public_key.encode("ascii")),
|
||||
)
|
||||
try:
|
||||
public_key_instance.verify(
|
||||
base64.b64decode(signature["signatureValue"]),
|
||||
final_hash,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
except InvalidSignature:
|
||||
raise VerificationError("Signature mismatch")
|
||||
|
||||
@classmethod
|
||||
def create_signature(
|
||||
cls, document: dict, private_key: str, key_id: str
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Creates the signature for a document
|
||||
"""
|
||||
# Create the options document
|
||||
options: dict[str, str] = {
|
||||
"@context": "https://w3id.org/identity/v1",
|
||||
"creator": key_id,
|
||||
"created": format_ld_date(datetime.utcnow()),
|
||||
}
|
||||
# Get the normalised hash of each document
|
||||
final_hash = cls.normalized_hash(options) + cls.normalized_hash(document)
|
||||
# Create the signature
|
||||
private_key_instance: rsa.RSAPrivateKey = cast(
|
||||
rsa.RSAPrivateKey,
|
||||
serialization.load_pem_private_key(
|
||||
private_key.encode("ascii"),
|
||||
password=None,
|
||||
),
|
||||
)
|
||||
signature = base64.b64encode(
|
||||
private_key_instance.sign(
|
||||
final_hash,
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
)
|
||||
# Add it to the options document along with other bits
|
||||
options["signatureValue"] = signature.decode("ascii")
|
||||
options["type"] = "RsaSignature2017"
|
||||
return options
|
||||
|
||||
@classmethod
|
||||
def normalized_hash(cls, document) -> bytes:
|
||||
"""
|
||||
Takes a JSON-LD document and create a hash of its URDNA2015 form,
|
||||
in the same way that Mastodon does internally.
|
||||
|
||||
Reference: https://socialhub.activitypub.rocks/t/making-sense-of-rsasignature2017/347
|
||||
"""
|
||||
norm_form = jsonld.normalize(
|
||||
document,
|
||||
{"algorithm": "URDNA2015", "format": "application/n-quads"},
|
||||
)
|
||||
digest = hashes.Hash(hashes.SHA256())
|
||||
digest.update(norm_form.encode("utf8"))
|
||||
return digest.finalize().hex().encode("ascii")
|
|
@ -1,14 +1,17 @@
|
|||
import json
|
||||
import os
|
||||
from flask import current_app
|
||||
from sqlalchemy import text
|
||||
from app import db
|
||||
from app.models import User, Post, Community
|
||||
from app.models import User, Post, Community, BannedInstances
|
||||
import time
|
||||
import base64
|
||||
import requests
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from app.constants import *
|
||||
import functools
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def public_key():
|
||||
|
@ -134,3 +137,53 @@ def post_to_activity(post: Post, community: Community):
|
|||
if post.image_id is not None:
|
||||
activity_data["object"]["object"]["image"] = {"href": post.image.source_url, "type": "Image"}
|
||||
return activity_data
|
||||
|
||||
|
||||
def validate_headers(headers, body):
|
||||
if headers['content-type'] != 'application/activity+json' and headers['content-type'] != 'application/ld+json':
|
||||
return False
|
||||
|
||||
if headers['user-agent'] in banned_user_agents():
|
||||
return False
|
||||
|
||||
if instance_blocked(headers['host']):
|
||||
return False
|
||||
|
||||
return validate_header_signature(body, headers['host'], headers['date'], headers['signature'])
|
||||
|
||||
|
||||
def validate_header_signature(body: str, host: str, date: str, signature: str) -> bool:
|
||||
body = json.loads(body)
|
||||
signature = parse_signature_header(signature)
|
||||
|
||||
key_domain = urlparse(signature['key_id']).hostname
|
||||
id_domain = urlparse(body['id']).hostname
|
||||
|
||||
if urlparse(body['object']['attributedTo']).hostname != key_domain:
|
||||
raise Exception('Invalid host url.')
|
||||
|
||||
if key_domain != id_domain:
|
||||
raise Exception('Wrong domain.')
|
||||
|
||||
user = find_actor_or_create(body['actor'])
|
||||
return verify_signature(user.private_key, signature, headers)
|
||||
|
||||
def banned_user_agents():
|
||||
return [] # todo: finish this function
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=100)
|
||||
def instance_blocked(host):
|
||||
instance = BannedInstances.query.filter_by(domain=host.strip()).first()
|
||||
return instance is not None
|
||||
|
||||
|
||||
def find_actor_or_create(actor):
|
||||
if current_app.config['SERVER_NAME'] + '/c/' in actor:
|
||||
return Community.query.filter_by(name=actor).first() # finds communities formatted like https://localhost/c/*
|
||||
|
||||
user = User.query.filter_by(ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables
|
||||
if user is None:
|
||||
# todo: retrieve user details via webfinger, etc
|
||||
else:
|
||||
return user
|
||||
|
|
|
@ -4,3 +4,5 @@ POST_TYPE_LINK = 1
|
|||
POST_TYPE_ARTICLE = 2
|
||||
POST_TYPE_IMAGE = 3
|
||||
POST_TYPE_VIDEO = 4
|
||||
|
||||
DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
from datetime import datetime
|
||||
|
||||
from app.main import bp
|
||||
from flask import g, jsonify
|
||||
from flask import g, jsonify, render_template
|
||||
from flask_moment import moment
|
||||
from flask_babel import _, get_locale
|
||||
|
||||
from app.models import Community
|
||||
|
||||
|
||||
@bp.before_app_request
|
||||
def before_request():
|
||||
|
@ -15,3 +17,9 @@ def before_request():
|
|||
@bp.route('/index', methods=['GET', 'POST'])
|
||||
def index():
|
||||
return 'Hello world'
|
||||
|
||||
|
||||
@bp.route('/communities', methods=['GET'])
|
||||
def list_communities():
|
||||
communities = Community.query.all()
|
||||
return render_template('list_communities.html', communities=communities)
|
|
@ -39,7 +39,7 @@ class Community(db.Model):
|
|||
private_key = db.Column(db.Text)
|
||||
|
||||
ap_id = db.Column(db.String(255), index=True)
|
||||
ap_profile_id = db.Column(db.String(255))
|
||||
ap_profile_id = db.Column(db.String(255), index=True)
|
||||
ap_followers_url = db.Column(db.String(255))
|
||||
ap_preferred_username = db.Column(db.String(255))
|
||||
ap_discoverable = db.Column(db.Boolean, default=False)
|
||||
|
|
6
app/static/css/bootstrap.min.css
vendored
6
app/static/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
7
app/static/js/bootstrap.bundle.min.js
vendored
7
app/static/js/bootstrap.bundle.min.js
vendored
File diff suppressed because one or more lines are too long
3
app/static/structure.css
Normal file
3
app/static/structure.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
/* */
|
||||
|
||||
/*# sourceMappingURL=structure.css.map */
|
|
@ -0,0 +1 @@
|
|||
/* */
|
3
app/static/styles.css
Normal file
3
app/static/styles.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
/* */
|
||||
|
||||
/*# sourceMappingURL=styles.css.map */
|
1
app/static/styles.scss
Normal file
1
app/static/styles.scss
Normal file
|
@ -0,0 +1 @@
|
|||
/* */
|
|
@ -13,7 +13,7 @@
|
|||
<meta name="HandheldFriendly" content="True">
|
||||
<meta name="MobileOptimized" content="320">
|
||||
{% block styles %}
|
||||
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" type="text/css" rel="stylesheet" />
|
||||
{{ bootstrap.load_css() }}
|
||||
<link href="{{ url_for('static', filename='structure.css', changed=getmtime('structure.css')) }}" type="text/css" rel="stylesheet" />
|
||||
<link href="{{ url_for('static', filename='styles.css', changed=getmtime('styles.css')) }}" type="text/css" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
@ -33,7 +33,7 @@
|
|||
{% block navbar %}
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="https://www.chorebuster.net/" target="_blank">Logo</a>
|
||||
<a class="navbar-brand" href="/" target="_blank">Logo</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
|
@ -42,13 +42,11 @@
|
|||
<div class="collapse navbar-collapse" id="navbarSupportedContent" role="navigation">
|
||||
<ul class="nav navbar-nav ml-md-4">
|
||||
{% if current_user.is_anonymous %}
|
||||
<li class="nav-item"><a class="nav-link" href="https://www.chorebuster.net/">{{ _('Home') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="https://www.chorebuster.net/how-it-works.php">{{ _('How it works') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="https://www.chorebuster.net/news.php">{{ _('News') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/news">{{ _('News') }}</a></li>
|
||||
{% else %}
|
||||
<li class="nav-item"><a class="nav-link" href="https://www.chorebuster.net/">{{ _('Home') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="https://www.chorebuster.net/how-it-works.php">{{ _('How it works') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="https://www.chorebuster.net/news.php">{{ _('News') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/news">{{ _('News') }}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -58,7 +56,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="outer_container" class="container-fluid flex-shrink-0">
|
||||
<div id="outer_container" class="container-fluid flex-shrink-0 mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
|
@ -79,7 +77,7 @@
|
|||
{{ moment.include_moment() }}
|
||||
{{ moment.lang(g.locale) }}
|
||||
{% endblock %}
|
||||
<script type="text/javascript" src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
|
||||
{{ bootstrap.load_js() }}
|
||||
<script type="text/javascript" src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||
<script type="text/javascript" src="{{ url_for('static', filename='js/scripts.js', changed=getmtime('js/scripts.js')) }}"></script>
|
||||
|
||||
|
|
6
app/templates/list_communities.html
Normal file
6
app/templates/list_communities.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block app_content %}
|
||||
<p>Community list goes here.</p>
|
||||
{% endblock %}
|
|
@ -0,0 +1,63 @@
|
|||
"""additional user and community
|
||||
|
||||
Revision ID: e8fe5eff9532
|
||||
Revises: 1a9507704262
|
||||
Create Date: 2023-08-10 21:46:25.190829
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e8fe5eff9532'
|
||||
down_revision = '1a9507704262'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('instance',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('domain', sa.String(length=256), nullable=True),
|
||||
sa.Column('inbox', sa.String(length=256), nullable=True),
|
||||
sa.Column('shared_inbox', sa.String(length=256), nullable=True),
|
||||
sa.Column('outbox', sa.String(length=256), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('banned_instances', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_banned_instances_domain'), ['domain'], unique=False)
|
||||
|
||||
with op.batch_alter_table('community', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('restricted_to_mods', sa.Boolean(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('post', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('body_html', sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column('ap_create_id', sa.String(length=100), nullable=True))
|
||||
batch_op.add_column(sa.Column('ap_announce_id', sa.String(length=100), nullable=True))
|
||||
|
||||
with op.batch_alter_table('post_reply', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('body_html', sa.Text(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('post_reply', schema=None) as batch_op:
|
||||
batch_op.drop_column('body_html')
|
||||
|
||||
with op.batch_alter_table('post', schema=None) as batch_op:
|
||||
batch_op.drop_column('ap_announce_id')
|
||||
batch_op.drop_column('ap_create_id')
|
||||
batch_op.drop_column('body_html')
|
||||
|
||||
with op.batch_alter_table('community', schema=None) as batch_op:
|
||||
batch_op.drop_column('restricted_to_mods')
|
||||
|
||||
with op.batch_alter_table('banned_instances', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_banned_instances_domain'))
|
||||
|
||||
op.drop_table('instance')
|
||||
# ### end Alembic commands ###
|
|
@ -1,3 +1,7 @@
|
|||
# This file is part of pyfedi, which is licensed under the GNU General Public License (GPL) version 3.0.
|
||||
# You should have received a copy of the GPL along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from app import create_app, db, cli
|
||||
import os
|
||||
|
||||
|
|
|
@ -14,3 +14,7 @@ pyjwt==2.8.0
|
|||
SQLAlchemy-Searchable==1.4.1
|
||||
SQLAlchemy-Utils==0.41.1
|
||||
cryptography==41.0.3
|
||||
Bootstrap-Flask==2.3.0
|
||||
pycryptodome==3.18.0
|
||||
arrow==1.2.3
|
||||
pyld==2.0.3
|
||||
|
|
Loading…
Reference in a new issue