diff --git a/app/__init__.py b/app/__init__.py index 261d6fed..17d6b14a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -18,7 +18,7 @@ from sqlalchemy_searchable import make_searchable from config import Config -db = SQLAlchemy(session_options={"autoflush": False}) # engine_options={'pool_size': 5, 'max_overflow': 10} +db = SQLAlchemy() # engine_options={'pool_size': 5, 'max_overflow': 10} # session_options={"autoflush": False} migrate = Migrate() login = LoginManager() login.login_view = 'auth.login' diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 792c6c96..811122f5 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -2,7 +2,7 @@ from datetime import datetime from app import db, constants, cache from app.activitypub import bp -from flask import request, Response, current_app, abort, jsonify, json +from flask import request, Response, current_app, abort, jsonify, json, g from app.activitypub.signature import HttpSignature from app.community.routes import show_community @@ -290,7 +290,7 @@ def shared_inbox(): community = find_actor_or_create(community_ap_id) user = find_actor_or_create(user_ap_id) if user and community: - user.last_seen = community.last_active = utcnow() + user.last_seen = community.last_active = g.site.last_active = utcnow() object_type = request_json['object']['type'] new_content_types = ['Page', 'Article', 'Link', 'Note'] @@ -403,7 +403,7 @@ def shared_inbox(): community = find_actor_or_create(community_ap_id) user = find_actor_or_create(user_ap_id) if user and community: - user.last_seen = community.last_active = utcnow() + user.last_seen = community.last_active = g.site.last_active = utcnow() object_type = request_json['object']['object']['type'] new_content_types = ['Page', 'Article', 'Link', 'Note'] if object_type in new_content_types: # create a new post diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 64cf71df..8fa140ef 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -5,7 +5,7 @@ from typing import Union, Tuple from flask import current_app, request from sqlalchemy import text from app import db, cache -from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow +from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, Site import time import base64 import requests @@ -410,32 +410,34 @@ def is_activitypub_request(): def lemmy_site_data(): + site = Site.query.get(1) data = { "site_view": { "site": { "id": 1, - "name": "PieFed", - "sidebar": "Rules:\n- [Don't be a dick](https://lemmy.nz/post/63098)\n\n[FAQ](https://lemmy.nz/post/31318) ~ [NZ Community List ](https://lemmy.nz/post/63156) ~ [Join Matrix chatroom](https://lemmy.nz/post/169187)", - "published": "2023-06-02T09:46:21.972257", - "updated": "2023-11-03T03:22:35.594456", + "name": site.name, + "sidebar": site.sidebar, + "published": site.created_at.isoformat(), + "updated": site.updated.isoformat(), "icon": "https://lemmy.nz/pictrs/image/d308ef8d-4381-4a7a-b047-569ed5b8dd88.png", "banner": "https://lemmy.nz/pictrs/image/68beebd5-4e01-44b6-bd4e-008b0d443ac1.png", - "description": "PieFed development", - "actor_id": "https://lemmy.nz/", - "last_refreshed_at": "2023-06-02T09:46:21.960383", - "inbox_url": "https://lemmy.nz/site_inbox", - "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx6cROxTmUbuWDHM3DcIx\nAWVy4O+cYlnMU3s89gbzhgVioPHqoajDbxNzVavqLd093ZhGPG6pEoGAGEgI9zG/\nnxpCcRC8uoMcu6Yh8E707VWRXFiXDsONyldBKnFmQouQDFAEmPaEOkYX3l1Qe6Q+\np4XKQRcD5hZWMvJVYpGsEa1euOcKrZvQffA+HQ1xcbU2Kts92ZiGkuXcEzOT8YR2\nX82Y/JkpeGkFlW4AociJ1ohfsH9i4OV+C215SgpCPxnEa9oEpluOvql8d7lg0yPA\nIisxtLb6hQtx5hiueILv7WB7kq1dh57RZQmvt7fuBsEk9rK5Lqc/ee9hxseqZKH8\nxwIDAQAB\n-----END PUBLIC KEY-----\n", + "description": site.description, + "actor_id": f"https://{current_app.config['SERVER_NAME']}/", + "last_refreshed_at": site.updated.isoformat(), + "inbox_url": f"https://{current_app.config['SERVER_NAME']}/inbox", + "public_key": site.public_key, "instance_id": 1 }, "local_site": { "id": 1, "site_id": 1, "site_setup": True, - "enable_downvotes": True, - "enable_nsfw": True, - "community_creation_admin_only": True, + "enable_downvotes": site.enable_downvotes, + "enable_nsfw": site.enable_nsfw, + "enable_nsfl": site.enable_nsfl, + "community_creation_admin_only": site.community_creation_admin_only, "require_email_verification": True, - "application_question": "This is a New Zealand instance with a focus on New Zealand content. Most content can be accessed from any instance, see https://join-lemmy.org to find one that suits you.\n\nBecause of a Lemmy-wide spam issue, we have needed to turn on the requirement to apply for an account. We will approve you as soon as possible after reviewing your response.\n\nRemember if you didn't provide an email address, you won't be able to get notified you've been approved, so don't forget to check back.\n\nWhere are you from?", + "application_question": site.application_question, "private_instance": False, "default_theme": "browser", "default_post_listing_type": "All", @@ -445,10 +447,10 @@ def lemmy_site_data(): "federation_enabled": True, "captcha_enabled": True, "captcha_difficulty": "medium", - "published": "2023-06-02T09:46:22.153520", - "updated": "2023-11-03T03:22:35.600601", - "registration_mode": "RequireApplication", - "reports_email_admins": True + "published": site.created_at.isoformat(), + "updated": site.updated.isoformat(), + "registration_mode": site.registration_mode, + "reports_email_admins": site.reports_email_admins }, "local_site_rate_limit": { "id": 1, @@ -465,7 +467,7 @@ def lemmy_site_data(): "comment_per_second": 600, "search": 999, "search_per_second": 600, - "published": "2023-06-02T09:46:22.156933" + "published": site.created_at.isoformat(), }, "counts": { "id": 1, diff --git a/app/admin/forms.py b/app/admin/forms.py index 0581f53f..e6bb5746 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -1,10 +1,37 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, SubmitField, HiddenField, BooleanField, TextAreaField +from flask_wtf.file import FileRequired, FileAllowed +from wtforms import StringField, PasswordField, SubmitField, HiddenField, BooleanField, TextAreaField, SelectField, \ + FileField, IntegerField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length from flask_babel import _, lazy_gettext as _l -class AdminForm(FlaskForm): +class SiteProfileForm(FlaskForm): + name = StringField(_l('Name')) + description = StringField(_l('Tagline')) + icon = FileField(_('Icon'), validators=[ + FileAllowed(['jpg', 'jpeg', 'png', 'webp'], 'Images only!') + ]) + sidebar = TextAreaField(_l('Sidebar')) + legal_information = TextAreaField(_l('Legal information')) + submit = SubmitField(_l('Save')) + + +class SiteMiscForm(FlaskForm): + enable_downvotes = BooleanField(_l('Enable downvotes')) + allow_local_image_posts = BooleanField(_l('Allow local image posts')) + remote_image_cache_days = IntegerField(_l('Days to cache images from remote instances for')) + enable_nsfw = BooleanField(_l('Allow NSFW communities and posts')) + enable_nsfl = BooleanField(_l('Allow NSFL communities and posts')) + community_creation_admin_only = BooleanField(_l('Only admins can create new local communities')) + reports_email_admins = BooleanField(_l('Notify admins about reports, not just moderators')) + types = [('Open', _l('Open')), ('RequireApplication', _l('Require application')), ('Closed', _l('Closed'))] + registration_mode = SelectField(_l('Registration mode'), choices=types, default=1, coerce=str) + application_question = TextAreaField(_l('Question to ask people applying for an account')) + submit = SubmitField(_l('Save')) + + +class FederationForm(FlaskForm): use_allowlist = BooleanField(_l('Allowlist instead of blocklist')) allowlist = TextAreaField(_l('Allow federation with these instances')) use_blocklist = BooleanField(_l('Blocklist instead of allowlist')) diff --git a/app/admin/routes.py b/app/admin/routes.py index cc2e07cf..6d1be420 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -6,8 +6,8 @@ from flask_babel import _ from sqlalchemy import text, desc from app import db -from app.admin.forms import AdminForm -from app.models import AllowedInstances, BannedInstances, ActivityPubLog +from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm +from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site from app.utils import render_template, permission_required, set_setting, get_setting from app.admin import bp @@ -16,7 +16,81 @@ from app.admin import bp @login_required @permission_required('change instance settings') def admin_home(): - form = AdminForm() + return render_template('admin/home.html', title=_('Admin')) + + +@bp.route('/site', methods=['GET', 'POST']) +@login_required +@permission_required('change instance settings') +def admin_site(): + form = SiteProfileForm() + site = Site.query.get(1) + if site is None: + site = Site() + if form.validate_on_submit(): + site.name = form.name.data + site.description = form.description.data + site.sidebar = form.sidebar.data + site.legal_information = form.legal_information.data + site.updated = utcnow() + if site.id is None: + db.session.add(site) + db.session.commit() + flash('Settings saved.') + elif request.method == 'GET': + form.name.data = site.name + form.description.data = site.description + form.sidebar.data = site.sidebar + form.legal_information.data = site.legal_information + return render_template('admin/site.html', title=_('Site profile'), form=form) + + +@bp.route('/misc', methods=['GET', 'POST']) +@login_required +@permission_required('change instance settings') +def admin_misc(): + form = SiteMiscForm() + site = Site.query.get(1) + if site is None: + site = Site() + if form.validate_on_submit(): + site.enable_downvotes = form.enable_downvotes.data + site.allow_local_image_posts = form.allow_local_image_posts.data + site.remote_image_cache_days = form.remote_image_cache_days.data + site.enable_nsfw = form.enable_nsfw.data + site.enable_nsfl = form.enable_nsfl.data + site.community_creation_admin_only = form.community_creation_admin_only.data + site.reports_email_admins = form.reports_email_admins.data + site.registration_mode = form.registration_mode.data + site.application_question = form.application_question.data + site.updated = utcnow() + if site.id is None: + db.session.add(site) + db.session.commit() + flash('Settings saved.') + elif request.method == 'GET': + form.enable_downvotes.data = site.enable_downvotes + form.allow_local_image_posts.data = site.allow_local_image_posts + form.remote_image_cache_days.data = site.remote_image_cache_days + form.enable_nsfw.data = site.enable_nsfw + form.enable_nsfl.data = site.enable_nsfl + form.community_creation_admin_only.data = site.community_creation_admin_only + form.reports_email_admins.data = site.reports_email_admins + form.registration_mode.data = site.registration_mode + form.application_question.data = site.application_question + return render_template('admin/misc.html', title=_('Misc settings'), form=form) + + +@bp.route('/federation', methods=['GET', 'POST']) +@login_required +@permission_required('change instance settings') +def admin_federation(): + form = FederationForm() + site = Site.query.get(1) + if site is None: + site = Site() + # todo: finish form + site.updated = utcnow() if form.validate_on_submit(): if form.use_allowlist.data: set_setting('use_allowlist', True) @@ -41,7 +115,7 @@ def admin_home(): instances = AllowedInstances.query.all() form.allowlist.data = '\n'.join([instance.domain for instance in instances]) - return render_template('admin/home.html', title=_('Admin settings'), form=form) + return render_template('admin/federation.html', title=_('Federation settings'), form=form) @bp.route('/activities', methods=['GET']) @@ -49,7 +123,7 @@ def admin_home(): @permission_required('change instance settings') def admin_activities(): db.session.query(ActivityPubLog).filter( - ActivityPubLog.created_at < aware_utcnow() - timedelta(days=3)).delete() + ActivityPubLog.created_at < utcnow() - timedelta(days=3)).delete() db.session.commit() return render_template('admin/activities.html', title=_('ActivityPub Log'), diff --git a/app/auth/routes.py b/app/auth/routes.py index 72ef2ea5..46c0744d 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -160,3 +160,8 @@ def verify_email(token): @bp.route('/validation_required') def validation_required(): return render_template('auth/validation_required.html') + + +@bp.route('/permission_denied') +def permission_denied(): + return render_template('auth/permission_denied.html') diff --git a/app/cli.py b/app/cli.py index 6374f356..124b6095 100644 --- a/app/cli.py +++ b/app/cli.py @@ -8,10 +8,11 @@ from app import db import click import os +from app.activitypub.signature import RsaKeys from app.auth.email import send_verification_email from app.auth.util import random_token from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \ - utcnow + utcnow, Site, Instance from app.utils import file_get_contents, retrieve_block_list @@ -53,6 +54,9 @@ def register(app): db.drop_all() db.configure_mappers() db.create_all() + private_key, public_key = RsaKeys.generate_keypair() + db.session.add(Site(name="PieFed", description='', public_key=public_key, private_key=private_key)) + db.session.add(Instance(domain=app.config['SERVER_NAME'], software='PieFed')) db.session.add(Settings(name='allow_nsfw', value=json.dumps(False))) db.session.add(Settings(name='allow_nsfl', value=json.dumps(False))) db.session.add(Settings(name='allow_dislike', value=json.dumps(True))) diff --git a/app/community/routes.py b/app/community/routes.py index f2057c98..4c0f414a 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -37,7 +37,7 @@ def add_local(): rules=form.rules.data, nsfw=form.nsfw.data, private_key=private_key, public_key=public_key, ap_profile_id='https://' + current_app.config['SERVER_NAME'] + '/c/' + form.url.data, - subscriptions_count=1) + subscriptions_count=1, instance_id=1) icon_file = request.files['icon_file'] if icon_file and icon_file.filename != '': file = save_icon_file(icon_file) @@ -89,7 +89,7 @@ def show_community(community: Community): # If nothing has changed since their last visit, return HTTP 304 current_etag = f"{community.id}_{hash(community.last_active)}" - if current_user.is_anonymous and request_etag_matches(current_etag): + if current_user.is_anonymous and request_etag_matches(current_etag): return return_304(current_etag) page = request.args.get('page', 1, type=int) @@ -314,7 +314,7 @@ def add_post(actor): form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] if form.validate_on_submit(): - post = Post(user_id=current_user.id, community_id=form.communities.data) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) save_post(form, post) community.post_count += 1 community.last_active = utcnow() diff --git a/app/community/util.py b/app/community/util.py index 6749a1d4..98b20abb 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -3,7 +3,7 @@ from typing import List import requests from PIL import Image, ImageOps -from flask import request, abort +from flask import request, abort, g from flask_login import current_user from pillow_heif import register_heif_opener @@ -63,6 +63,7 @@ def search_for_community(address: str): ap_domain=server, public_key=community_json['publicKey']['publicKeyPem'], # language=community_json['language'][0]['identifier'] # todo: language + # todo: set instance_id ) if 'icon' in community_json: # todo: retrieve icon, save to disk, save more complete File record @@ -237,6 +238,7 @@ def save_post(form, post: Post): post.score = 1 db.session.add(postvote) db.session.add(post) + g.site.last_active = utcnow() def remove_old_file(file_id): diff --git a/app/main/routes.py b/app/main/routes.py index 56b7e96c..f98c5520 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,22 +1,51 @@ +from sqlalchemy.sql.operators import or_ from app import db, cache -from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER +from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, SUBSCRIPTION_OWNER from app.main import bp -from flask import g, session, flash, request +from flask import g, session, flash, request, current_app, url_for, redirect, make_response from flask_moment import moment from flask_login import current_user from flask_babel import _, get_locale -from sqlalchemy import select +from sqlalchemy import select, desc from sqlalchemy_searchable import search -from app.utils import render_template, get_setting, gibberish -from app.models import Community, CommunityMember +from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains +from app.models import Community, CommunityMember, Post, Site, User @bp.route('/', methods=['GET', 'POST']) @bp.route('/index', methods=['GET', 'POST']) def index(): verification_warning() - return render_template('index.html') + + # If nothing has changed since their last visit, return HTTP 304 + current_etag = f"home_{hash(str(g.site.last_active))}" + if current_user.is_anonymous and request_etag_matches(current_etag): + return return_304(current_etag) + + page = request.args.get('page', 1, type=int) + + if current_user.is_anonymous: + posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) + else: + posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(CommunityMember.is_banned == False) + posts = posts.join(User, CommunityMember.user_id == User.id).filter(User.id == current_user.id) + domains_ids = blocked_domains(current_user.id) + if domains_ids: + posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None)) + + posts = posts.order_by(desc(Post.last_active)).paginate(page=page, per_page=100, error_out=False) + + next_url = url_for('main.index', page=posts.next_num) if posts.has_next else None + prev_url = url_for('main.index', page=posts.prev_num) if posts.has_prev and page != 1 else None + + active_communities = Community.query.filter_by(banned=False).order_by(desc(Community.last_active)).limit(5).all() + + return render_template('index.html', posts=posts, active_communities=active_communities, + POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, + SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, + etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url, + rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed_name=f"Posts on " + g.site.name) @bp.route('/communities', methods=['GET']) @@ -30,7 +59,8 @@ def list_communities(): communities = db.session.scalars(query).all() return render_template('list_communities.html', communities=communities, search=search_param, title=_('Communities'), - SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER) + SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, + SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER) @bp.route('/communities/local', methods=['GET']) @@ -49,6 +79,18 @@ def list_subscribed_communities(): SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER) +@bp.route('/login') +def login(): + return redirect(url_for('auth.login')) + + +@bp.route('/robots.txt') +def robots(): + resp = make_response(render_template('robots.txt')) + resp.mimetype = 'text/plain' + return resp + + @bp.route('/test') def test(): ... diff --git a/app/models.py b/app/models.py index a3b3744a..469087a8 100644 --- a/app/models.py +++ b/app/models.py @@ -383,6 +383,8 @@ class User(UserMixin, db.Model): files = File.query.join(Post).filter(Post.user_id == self.id).all() for file in files: file.delete_from_disk() + db.session.query(Report).filter(Report.reporter_id == self.id).delete() + db.session.query(Report).filter(Report.suspect_user_id == self.id).delete() db.session.query(ActivityLog).filter(ActivityLog.user_id == self.id).delete() db.session.query(PostVote).filter(PostVote.user_id == self.id).delete() db.session.query(PostReplyVote).filter(PostReplyVote.user_id == self.id).delete() @@ -448,6 +450,7 @@ class Post(db.Model): ranking = db.Column(db.Integer, default=0) # used for 'hot' ranking language = db.Column(db.String(10)) edited_at = db.Column(db.DateTime) + reports = db.Column(db.Integer, default=0) # how many times this post has been reported. Set to -1 to ignore reports ap_id = db.Column(db.String(255), index=True) ap_create_id = db.Column(db.String(100)) @@ -468,6 +471,7 @@ class Post(db.Model): return cls.query.filter_by(ap_id=ap_id).first() def delete_dependencies(self): + db.session.query(Report).filter(Report.suspect_post_id == self.id).delete() db.session.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id IN (SELECT id FROM post_reply WHERE post_id = :post_id)'), {'post_id': self.id}) db.session.execute(text('DELETE FROM post_reply WHERE post_id = :post_id'), {'post_id': self.id}) @@ -520,6 +524,7 @@ class PostReply(db.Model): ranking = db.Column(db.Integer, default=0, index=True) # used for 'hot' sorting language = db.Column(db.String(10)) edited_at = db.Column(db.DateTime) + reports = db.Column(db.Integer, default=0) # how many times this post has been reported. Set to -1 to ignore reports ap_id = db.Column(db.String(255), index=True) ap_create_id = db.Column(db.String(100)) @@ -752,6 +757,33 @@ class Report(db.Model): created_at = db.Column(db.DateTime, default=utcnow) updated = db.Column(db.DateTime, default=utcnow) + +class Site(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(256)) + description = db.Column(db.String(256)) + icon_id = db.Column(db.Integer, db.ForeignKey('file.id')) + sidebar = db.Column(db.Text, default='') + legal_information = db.Column(db.Text, default='') + public_key = db.Column(db.Text) + private_key = db.Column(db.Text) + enable_downvotes = db.Column(db.Boolean, default=True) + allow_local_image_posts = db.Column(db.Boolean, default=True) + remote_image_cache_days = db.Column(db.Integer, default=30) + enable_nsfw = db.Column(db.Boolean, default=False) + enable_nsfl = db.Column(db.Boolean, default=False) + community_creation_admin_only = db.Column(db.Boolean, default=False) + reports_email_admins = db.Column(db.Boolean, default=True) + registration_mode = db.Column(db.String(20), default='Closed') + application_question = db.Column(db.Text, default='') + allow_or_block_list = db.Column(db.Integer, default=2) # 1 = allow list, 2 = block list + allowlist = db.Column(db.Text, default='') + blocklist = db.Column(db.Text, default='') + created_at = db.Column(db.DateTime, default=utcnow) + updated = db.Column(db.DateTime, default=utcnow) + last_active = db.Column(db.DateTime, default=utcnow) + + @login.user_loader def load_user(id): return User.query.get(int(id)) diff --git a/app/post/routes.py b/app/post/routes.py index d8d95abc..d317c2f6 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -1,6 +1,6 @@ from datetime import datetime -from flask import redirect, url_for, flash, current_app, abort, request +from flask import redirect, url_for, flash, current_app, abort, request, g from flask_login import login_user, logout_user, current_user, login_required from flask_babel import _ from sqlalchemy import or_, desc @@ -419,6 +419,7 @@ def post_delete(post_id: int): post.delete_dependencies() post.flush_cache() db.session.delete(post) + g.site.last_active = community.last_active = utcnow() db.session.commit() flash(_('Post deleted.')) return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) @@ -440,6 +441,7 @@ def post_report(post_id: int): url=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}", author_id=current_user.id) db.session.add(notification) + post.reports += 1 # todo: Also notify admins for certain types of report db.session.commit() diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html new file mode 100644 index 00000000..f5f55110 --- /dev/null +++ b/app/templates/admin/_nav.html @@ -0,0 +1,7 @@ + diff --git a/app/templates/admin/activities.html b/app/templates/admin/activities.html index 2fc5e7ad..d2c3102a 100644 --- a/app/templates/admin/activities.html +++ b/app/templates/admin/activities.html @@ -2,6 +2,12 @@ {% from 'bootstrap/form.html' import render_form %} {% block app_content %} +
+
+ {% include 'admin/_nav.html' %} +
+
+
diff --git a/app/templates/admin/federation.html b/app/templates/admin/federation.html new file mode 100644 index 00000000..b2a82362 --- /dev/null +++ b/app/templates/admin/federation.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} + +
+
+ {% include 'admin/_nav.html' %} +
+
+ + +
+
+ {{ render_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/home.html b/app/templates/admin/home.html index c0abaaaf..4c47482f 100644 --- a/app/templates/admin/home.html +++ b/app/templates/admin/home.html @@ -2,9 +2,17 @@ {% from 'bootstrap/form.html' import render_form %} {% block app_content %} +
- {{ render_form(form) }} + {% include 'admin/_nav.html' %} +
+
+ +
+
+

Welcome to the admin area. This page will eventually have some helpful guides and links for new admins but + for now it does not.

{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/misc.html b/app/templates/admin/misc.html new file mode 100644 index 00000000..0fe862c2 --- /dev/null +++ b/app/templates/admin/misc.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} + +
+
+ {% include 'admin/_nav.html' %} +
+
+ +
+
+ {{ render_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/site.html b/app/templates/admin/site.html new file mode 100644 index 00000000..c8f484ef --- /dev/null +++ b/app/templates/admin/site.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} + +
+
+ {% include 'admin/_nav.html' %} +
+
+ +
+
+
+ {{ form.csrf_token() }} + {{ render_field(form.name) }} + {{ render_field(form.description) }} + {{ render_field(form.icon) }} + {{ render_field(form.sidebar) }} + {{ render_field(form.legal_information) }} + {{ render_field(form.submit) }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/permission_denied.html b/app/templates/auth/permission_denied.html new file mode 100644 index 00000000..20149f24 --- /dev/null +++ b/app/templates/auth/permission_denied.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block app_content %} +
+ +
+ +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 91923518..93a54928 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -58,7 +58,7 @@ {% block navbar %}
{{ community.post_reply_count }} {{ moment(community.last_active).fromNow(refresh=True) }} {% if current_user.is_authenticated %} - {% if community_membership(current_user, community) == SUBSCRIPTION_MEMBER %} + {% if community_membership(current_user, community) in [SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER] %} Unsubscribe {% elif community_membership(current_user, community) == SUBSCRIPTION_PENDING %} Pending diff --git a/app/templates/robots.txt b/app/templates/robots.txt new file mode 100644 index 00000000..e69de29b diff --git a/app/utils.py b/app/utils.py index 14a67b31..11cde09e 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,5 +1,6 @@ import random from datetime import datetime +from typing import List import markdown2 import math @@ -17,7 +18,7 @@ from sqlalchemy import text from wtforms.fields import SelectField, SelectMultipleField from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput from app import db, cache -from app.models import Settings, Domain, Instance, BannedInstances, User, Community +from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock # Flask's render_template function, with support for themes added @@ -226,7 +227,7 @@ def user_access(permission: str, user_id: int) -> bool: return has_access is not None -@cache.memoize(timeout=500) +@cache.memoize(timeout=86400) def community_membership(user: User, community: Community) -> int: # @cache.memoize works with User.subscribed but cache.delete_memoized does not, making it bad to use on class methods. # however cache.memoize and cache.delete_memoized works fine with normal functions @@ -235,6 +236,12 @@ def community_membership(user: User, community: Community) -> int: return user.subscribed(community.id) +@cache.memoize(timeout=86400) +def blocked_domains(user_id) -> List[int]: + blocks = DomainBlock.query.filter_by(user_id=user_id) + return [block.domain_id for block in blocks] + + def retrieve_block_list(): try: response = requests.get('https://github.com/rimu/no-qanon/blob/master/domains.txt', timeout=1) diff --git a/migrations/versions/72f3326bdf54_site_settings.py b/migrations/versions/72f3326bdf54_site_settings.py new file mode 100644 index 00000000..ed4e6311 --- /dev/null +++ b/migrations/versions/72f3326bdf54_site_settings.py @@ -0,0 +1,66 @@ +"""site settings + +Revision ID: 72f3326bdf54 +Revises: 238faf5c9b8d +Create Date: 2023-12-16 20:22:16.446742 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '72f3326bdf54' +down_revision = '238faf5c9b8d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('site', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=256), nullable=True), + sa.Column('description', sa.String(length=256), nullable=True), + sa.Column('icon_id', sa.Integer(), nullable=True), + sa.Column('sidebar', sa.Text(), nullable=True), + sa.Column('legal_information', sa.Text(), nullable=True), + sa.Column('public_key', sa.Text(), nullable=True), + sa.Column('private_key', sa.Text(), nullable=True), + sa.Column('enable_downvotes', sa.Boolean(), nullable=True), + sa.Column('allow_local_image_posts', sa.Boolean(), nullable=True), + sa.Column('remote_image_cache_days', sa.Integer(), nullable=True), + sa.Column('enable_nsfw', sa.Boolean(), nullable=True), + sa.Column('enable_nsfl', sa.Boolean(), nullable=True), + sa.Column('community_creation_admin_only', sa.Boolean(), nullable=True), + sa.Column('reports_email_admins', sa.Boolean(), nullable=True), + sa.Column('registration_mode', sa.String(length=20), nullable=True), + sa.Column('application_question', sa.Text(), nullable=True), + sa.Column('allow_or_block_list', sa.Integer(), nullable=True), + sa.Column('allowlist', sa.Text(), nullable=True), + sa.Column('blocklist', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated', sa.DateTime(), nullable=True), + sa.Column('last_active', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['icon_id'], ['file.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('reports', sa.Integer(), nullable=True)) + + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.add_column(sa.Column('reports', sa.Integer(), 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('reports') + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_column('reports') + + op.drop_table('site') + # ### end Alembic commands ### diff --git a/pyfedi.py b/pyfedi.py index 9762bc9d..3b6c9990 100644 --- a/pyfedi.py +++ b/pyfedi.py @@ -6,6 +6,7 @@ from app import create_app, db, cli import os, click from flask import session, g, json from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE +from app.models import Site from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership app = create_app() @@ -40,6 +41,7 @@ with app.app_context(): def before_request(): session['nonce'] = gibberish() g.locale = str(get_locale()) + g.site = Site.query.get(1) @app.after_request