From e679de5991148e408addf6333a4e1bce9be5ef3d Mon Sep 17 00:00:00 2001 From: Mattia Succurro Date: Fri, 13 Jun 2025 03:01:32 +0200 Subject: [PATCH] 0.3.0: initial commit + Dockerfile + rewrite --- .gitignore | 32 ++ CHANGELOG.md | 17 + Dockerfile | 17 + LICENSE | 54 +++ README.md | 3 + alembic/env.py | 79 ++++ alembic/script.py.mako | 28 ++ alembic/versions/c7c2d5b8f71c_.py | 50 +++ alembic/versions/fc9d1a0dc94e_.py | 132 +++++++ docker-run.sh | 12 + favicon.ico | Bin 0 -> 318 bytes freak/__init__.py | 145 +++++++ freak/__main__.py | 4 + freak/ajax.py | 72 ++++ freak/algorithms.py | 31 ++ freak/cli.py | 17 + freak/filters.py | 78 ++++ freak/iding.py | 42 ++ freak/models.py | 364 ++++++++++++++++++ freak/rest/__init__.py | 51 +++ freak/search.py | 25 ++ freak/static/js/lib.js | 150 ++++++++ freak/static/sass/base.sass | 126 ++++++ freak/static/sass/constants.sass | 19 + freak/static/sass/content.sass | 38 ++ freak/static/sass/layout.sass | 302 +++++++++++++++ freak/static/sass/mobile.sass | 21 + freak/static/sass/style.sass | 4 + freak/templates/400.html | 13 + freak/templates/403.html | 13 + freak/templates/404.html | 13 + freak/templates/405.html | 13 + freak/templates/451.html | 15 + freak/templates/500.html | 16 + freak/templates/about.html | 30 ++ freak/templates/admin/admin_base.html | 26 ++ freak/templates/admin/admin_home.html | 9 + .../templates/admin/admin_report_detail.html | 23 ++ freak/templates/admin/admin_reports.html | 21 + freak/templates/base.html | 119 ++++++ freak/templates/create.html | 32 ++ freak/templates/createguild.html | 34 ++ freak/templates/edit.html | 25 ++ freak/templates/feed.html | 63 +++ freak/templates/landing.html | 25 ++ freak/templates/login.html | 40 ++ freak/templates/macros/create.html | 35 ++ freak/templates/macros/embed.html | 19 + freak/templates/macros/feed.html | 133 +++++++ freak/templates/macros/icon.html | 11 + freak/templates/macros/nav.html | 36 ++ freak/templates/macros/title.html | 16 + freak/templates/privacy.html | 136 +++++++ freak/templates/register.html | 61 +++ freak/templates/reports/report_404.html | 20 + freak/templates/reports/report_base.html | 61 +++ freak/templates/reports/report_comment.html | 22 ++ freak/templates/reports/report_done.html | 19 + freak/templates/reports/report_post.html | 22 ++ freak/templates/reports/report_self.html | 19 + freak/templates/rules.html | 192 +++++++++ freak/templates/search.html | 32 ++ freak/templates/singlepost.html | 71 ++++ freak/templates/terms.html | 115 ++++++ freak/templates/userfeed.html | 35 ++ freak/utils.py | 40 ++ freak/website/__init__.py | 27 ++ freak/website/about.py | 29 ++ freak/website/accounts.py | 103 +++++ freak/website/admin.py | 79 ++++ freak/website/create.py | 74 ++++ freak/website/detail.py | 100 +++++ freak/website/edit.py | 36 ++ freak/website/frontpage.py | 62 +++ freak/website/reports.py | 56 +++ pyproject.toml | 34 ++ robots.txt | 9 + 77 files changed, 4147 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/c7c2d5b8f71c_.py create mode 100644 alembic/versions/fc9d1a0dc94e_.py create mode 100644 docker-run.sh create mode 100644 favicon.ico create mode 100644 freak/__init__.py create mode 100644 freak/__main__.py create mode 100644 freak/ajax.py create mode 100644 freak/algorithms.py create mode 100644 freak/cli.py create mode 100644 freak/filters.py create mode 100644 freak/iding.py create mode 100644 freak/models.py create mode 100644 freak/rest/__init__.py create mode 100644 freak/search.py create mode 100644 freak/static/js/lib.js create mode 100644 freak/static/sass/base.sass create mode 100644 freak/static/sass/constants.sass create mode 100644 freak/static/sass/content.sass create mode 100644 freak/static/sass/layout.sass create mode 100644 freak/static/sass/mobile.sass create mode 100644 freak/static/sass/style.sass create mode 100644 freak/templates/400.html create mode 100644 freak/templates/403.html create mode 100644 freak/templates/404.html create mode 100644 freak/templates/405.html create mode 100644 freak/templates/451.html create mode 100644 freak/templates/500.html create mode 100644 freak/templates/about.html create mode 100644 freak/templates/admin/admin_base.html create mode 100644 freak/templates/admin/admin_home.html create mode 100644 freak/templates/admin/admin_report_detail.html create mode 100644 freak/templates/admin/admin_reports.html create mode 100644 freak/templates/base.html create mode 100644 freak/templates/create.html create mode 100644 freak/templates/createguild.html create mode 100644 freak/templates/edit.html create mode 100644 freak/templates/feed.html create mode 100644 freak/templates/landing.html create mode 100644 freak/templates/login.html create mode 100644 freak/templates/macros/create.html create mode 100644 freak/templates/macros/embed.html create mode 100644 freak/templates/macros/feed.html create mode 100644 freak/templates/macros/icon.html create mode 100644 freak/templates/macros/nav.html create mode 100644 freak/templates/macros/title.html create mode 100644 freak/templates/privacy.html create mode 100644 freak/templates/register.html create mode 100644 freak/templates/reports/report_404.html create mode 100644 freak/templates/reports/report_base.html create mode 100644 freak/templates/reports/report_comment.html create mode 100644 freak/templates/reports/report_done.html create mode 100644 freak/templates/reports/report_post.html create mode 100644 freak/templates/reports/report_self.html create mode 100644 freak/templates/rules.html create mode 100644 freak/templates/search.html create mode 100644 freak/templates/singlepost.html create mode 100644 freak/templates/terms.html create mode 100644 freak/templates/userfeed.html create mode 100644 freak/utils.py create mode 100644 freak/website/__init__.py create mode 100644 freak/website/about.py create mode 100644 freak/website/accounts.py create mode 100644 freak/website/admin.py create mode 100644 freak/website/create.py create mode 100644 freak/website/detail.py create mode 100644 freak/website/edit.py create mode 100644 freak/website/frontpage.py create mode 100644 freak/website/reports.py create mode 100644 pyproject.toml create mode 100644 robots.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dde8af --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +node_modules/ +__pycache__/ +**.pyc +**.pyo +**.egg-info +**~ +.*.swp +\#*\# +.\#* +alembic.ini +.env +.env.* +.venv +env +venv +venv-*/ +config/ +conf/ +config.json +data/ +.build/ +dist/ +/target +.err +.vscode +/run.sh +**/static/css +#**/static/js +**.priv.js +/ver.sh +ROADMAP.md +docker-compose.yml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d01566 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## 0.3.0 + +- Initial commit +- Post and read comments on posts +- Public timeline +- Create +guilds +- Reporting +- Edit own posts +- Admins can remove reported posts +- Upvotes and downvotes + +## 0.2.0 and earlier + +*Releases before 0.3.0 are lost for good, and for a good reason.* + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93eab79 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.13-slim + +WORKDIR /usr/src/app + +RUN pip install -U pip setuptools + +COPY pyproject.toml docker-run.sh . +COPY .env.prod .env +COPY freak freak + +RUN pip install -e . + +VOLUME ["/opt/live-app"] + +EXPOSE 5000 + +CMD ["/usr/bin/bash", "docker-run.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca9ed85 --- /dev/null +++ b/LICENSE @@ -0,0 +1,54 @@ +Copyright (c) 2021-2025 Sakuragasaki46 + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + You must give any other recipients of the Work or Derivative Works a copy of this License; and + You must cause any modified files to carry prominent notices stating that You changed the files; and + You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..347ffd9 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Freak + +(´ω\`) \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6540d01 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,79 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from freak.models import Base +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..480b130 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/c7c2d5b8f71c_.py b/alembic/versions/c7c2d5b8f71c_.py new file mode 100644 index 0000000..ce29710 --- /dev/null +++ b/alembic/versions/c7c2d5b8f71c_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: c7c2d5b8f71c +Revises: fc9d1a0dc94e +Create Date: 2025-06-12 09:21:17.960836 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c7c2d5b8f71c' +down_revision: Union[str, None] = 'fc9d1a0dc94e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('freak_comment', sa.Column('removed_at', sa.DateTime(), nullable=True)) + op.add_column('freak_comment', sa.Column('removed_by_id', sa.BigInteger(), nullable=True)) + op.add_column('freak_comment', sa.Column('removed_reason', sa.SmallInteger(), nullable=True)) + op.create_foreign_key('user_banner_id', 'freak_comment', 'freak_user', ['removed_by_id'], ['id']) + op.add_column('freak_post', sa.Column('removed_at', sa.DateTime(), nullable=True)) + op.add_column('freak_post', sa.Column('removed_by_id', sa.BigInteger(), nullable=True)) + op.add_column('freak_post', sa.Column('removed_reason', sa.SmallInteger(), nullable=True)) + op.create_foreign_key('user_banner_id', 'freak_post', 'freak_user', ['removed_by_id'], ['id']) + op.add_column('freak_user', sa.Column('banned_reason', sa.SmallInteger(), server_default=sa.text('0'), nullable=True)) + op.alter_column('freak_user', 'ban_reason', new_column_name='banned_message') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('freak_user', 'banned_message', new_column_name='ban_reason') + op.drop_column('freak_user', 'banned_reason') + op.drop_constraint('user_banner_id', 'freak_post', type_='foreignkey') + op.drop_column('freak_post', 'removed_reason') + op.drop_column('freak_post', 'removed_by_id') + op.drop_column('freak_post', 'removed_at') + op.drop_constraint('user_banner_id', 'freak_comment', type_='foreignkey') + op.drop_column('freak_comment', 'removed_reason') + op.drop_column('freak_comment', 'removed_by_id') + op.drop_column('freak_comment', 'removed_at') + # ### end Alembic commands ### diff --git a/alembic/versions/fc9d1a0dc94e_.py b/alembic/versions/fc9d1a0dc94e_.py new file mode 100644 index 0000000..a1aee26 --- /dev/null +++ b/alembic/versions/fc9d1a0dc94e_.py @@ -0,0 +1,132 @@ +"""empty message + +Revision ID: fc9d1a0dc94e +Revises: +Create Date: 2025-06-11 18:23:07.871471 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'fc9d1a0dc94e' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('freak_comment', 'author_id', + existing_type=sa.BIGINT(), + nullable=True) + op.drop_index('idx_16573_comment_parent_comment_id', table_name='freak_comment') + op.drop_index('idx_16573_comment_parent_post_id', table_name='freak_comment') + op.drop_index('idx_16573_comment_pub_date', table_name='freak_comment') + op.drop_index('idx_16573_comment_user_id', table_name='freak_comment') + op.create_index(op.f('ix_freak_comment_created_at'), 'freak_comment', ['created_at'], unique=False) + op.alter_column('freak_post', 'author_id', + existing_type=sa.BIGINT(), + nullable=True) + op.drop_index('idx_16568_post_community_id', table_name='freak_post') + op.drop_index('idx_16568_post_pub_date', table_name='freak_post') + op.drop_index('idx_16568_post_user_id', table_name='freak_post') + op.add_column('freak_postreport', sa.Column('created_ip', sa.String(length=64), nullable=False)) + op.alter_column('freak_postreport', 'target_type', + existing_type=sa.SMALLINT(), + nullable=False) + op.alter_column('freak_postreport', 'target_id', + existing_type=sa.BIGINT(), + nullable=False) + op.alter_column('freak_postreport', 'reason_code', + existing_type=sa.SMALLINT(), + nullable=False) + op.alter_column('freak_topic', 'created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.drop_index('idx_16563_community_created_on', table_name='freak_topic') + op.drop_index('idx_16563_community_name', table_name='freak_topic') + op.create_index(op.f('ix_freak_topic_created_at'), 'freak_topic', ['created_at'], unique=False) + op.create_unique_constraint(None, 'freak_topic', ['name']) + op.alter_column('freak_user', 'joined_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.alter_column('freak_user', 'is_administrator', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.alter_column('freak_user', 'is_disabled_by_user', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.alter_column('freak_user', 'karma', + existing_type=sa.BIGINT(), + nullable=False, + existing_server_default=sa.text('0')) + op.drop_index('idx_16578_user_join_date', table_name='freak_user') + op.drop_index('idx_16578_user_username', table_name='freak_user') + op.create_unique_constraint(None, 'freak_user', ['username']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'freak_user', type_='unique') + op.create_index('idx_16578_user_username', 'freak_user', ['username'], unique=True) + op.create_index('idx_16578_user_join_date', 'freak_user', ['joined_at'], unique=False) + op.alter_column('freak_user', 'karma', + existing_type=sa.BIGINT(), + nullable=True, + existing_server_default=sa.text('0')) + op.alter_column('freak_user', 'is_disabled_by_user', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('freak_user', 'is_administrator', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('freak_user', 'joined_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.drop_constraint(None, 'freak_topic', type_='unique') + op.drop_index(op.f('ix_freak_topic_created_at'), table_name='freak_topic') + op.create_index('idx_16563_community_name', 'freak_topic', ['name'], unique=True) + op.create_index('idx_16563_community_created_on', 'freak_topic', ['created_at'], unique=False) + op.alter_column('freak_topic', 'created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.alter_column('freak_postreport', 'reason_code', + existing_type=sa.SMALLINT(), + nullable=True) + op.alter_column('freak_postreport', 'target_id', + existing_type=sa.BIGINT(), + nullable=True) + op.alter_column('freak_postreport', 'target_type', + existing_type=sa.SMALLINT(), + nullable=True) + op.drop_column('freak_postreport', 'created_ip') + op.create_index('idx_16568_post_user_id', 'freak_post', ['author_id'], unique=False) + op.create_index('idx_16568_post_pub_date', 'freak_post', ['created_at'], unique=False) + op.create_index('idx_16568_post_community_id', 'freak_post', ['topic_id'], unique=False) + op.alter_column('freak_post', 'author_id', + existing_type=sa.BIGINT(), + nullable=False) + op.drop_index(op.f('ix_freak_comment_created_at'), table_name='freak_comment') + op.create_index('idx_16573_comment_user_id', 'freak_comment', ['author_id'], unique=False) + op.create_index('idx_16573_comment_pub_date', 'freak_comment', ['created_at'], unique=False) + op.create_index('idx_16573_comment_parent_post_id', 'freak_comment', ['parent_post_id'], unique=False) + op.create_index('idx_16573_comment_parent_comment_id', 'freak_comment', ['parent_comment_id'], unique=False) + op.alter_column('freak_comment', 'author_id', + existing_type=sa.BIGINT(), + nullable=False) + # ### end Alembic commands ### diff --git a/docker-run.sh b/docker-run.sh new file mode 100644 index 0000000..760d11e --- /dev/null +++ b/docker-run.sh @@ -0,0 +1,12 @@ +#!/usr/bin/bash + +start-app() { + [[ ! -d /opt/live-app ]] && exit 1 + cd /usr/src/app + cp -rv /opt/live-app/{freak,pyproject.toml,.env,docker-run.sh} ./ + pip install -e . + flask --app freak run --host=0.0.0.0 +} + +[[ "$1" = "" ]] && start-app + diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..87285092bbe9d46738a0580db4543a4d96c29c04 GIT binary patch literal 318 zcmb79F%E+;5OV}lM=lJV3lqQKQGB3J<0tR~x-qm;urGi*uyB#jcX2LpfWT#D%q6-2 zJOOls5idgfinr&Sq@6J&q?FF~UJ)6bh;^ePWHxf!Hu0aOt3TLv&WUMK_s|vnyYqwH hD4U)26vLkse=1-s_%|N8g0%SoENjN}1%&>Wu>szA90C9U literal 0 HcmV?d00001 diff --git a/freak/__init__.py b/freak/__init__.py new file mode 100644 index 0000000..4903958 --- /dev/null +++ b/freak/__init__.py @@ -0,0 +1,145 @@ + + +from sqlite3 import ProgrammingError +import warnings +from flask import ( + Flask, abort, flash, g, jsonify, redirect, render_template, + request, send_from_directory, url_for +) +import datetime, time, re, os, sys, string, json, html, dotenv +from flask_login import LoginManager +from flask_wtf.csrf import CSRFProtect +from sqlalchemy import select +from werkzeug.routing import BaseConverter +from sassutils.wsgi import SassMiddleware + +__version__ = '0.3.0' + +APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__)) + +dotenv.load_dotenv(os.path.join(APP_BASE_DIR, '.env')) + +correct_database_url = os.environ["DATABASE_URL"] + +def fix_database_url(): + if os.getenv('DATABASE_URL') != correct_database_url: + warnings.warn('mod_wsgi got the database wrong!', RuntimeWarning) + app.config['SQLALCHEMY_DATABASE_URI'] = correct_database_url + + +app = Flask(__name__) +app.secret_key = os.getenv('SECRET_KEY') +app.config['SQLALCHEMY_DATABASE_URI'] = correct_database_url +app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + +from .models import db, User, Post +from .iding import id_from_b32l, id_to_b32l + +# SASS +app.wsgi_app = SassMiddleware(app.wsgi_app, dict( + freak=('static/sass', 'static/css', '/static/css', True) +)) + +class SlugConverter(BaseConverter): + regex = r'[a-z0-9]+(?:-[a-z0-9]+)*' + +class B32lConverter(BaseConverter): + regex = r'_?[a-z2-7]+' + def to_url(self, value): + return id_to_b32l(value) + def to_python(self, value): + return id_from_b32l(value) + +app.url_map.converters['slug'] = SlugConverter +app.url_map.converters['b32l'] = B32lConverter + +db.init_app(app) + +csrf = CSRFProtect(app) + +login_manager = LoginManager(app) +login_manager.login_view = 'accounts.login' + +from . import filters + + +PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split() + +@app.context_processor +def _inject_variables(): + return { + 'app_name': os.getenv('APP_NAME'), + 'domain_name': os.getenv('DOMAIN_NAME'), + 'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)), + 'private_scripts': [x for x in PRIVATE_ASSETS if x.endswith('.js')], + 'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')], + 'jquery_url': os.getenv('JQUERY_URL') or 'https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js', + 'post_count': Post.count(), + 'user_count': User.active_count() + } + +@login_manager.user_loader +def _inject_user(userid): + try: + return db.session.execute(select(User).where(User.id == userid)).scalar() + except Exception: + warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning) + g.no_user = True + return None + +@app.errorhandler(ProgrammingError) +def error_db(body): + g.no_user = True + warnings.warn(f'No database access! (url is {app.config['SQLALCHEMY_DATABASE_URI']})', RuntimeWarning) + fix_database_url() + if request.method in ('HEAD', 'GET') and not 'retry' in request.args: + return redirect(request.url + ('&' if '?' in request.url else '?') + 'retry=1'), 307, {'cache-control': 'private,no-cache,must-revalidate,max-age=0'} + return render_template('500.html'), 500 + +@app.errorhandler(400) +def error_400(body): + return render_template('400.html'), 400 + +@app.errorhandler(403) +def error_403(body): + return render_template('403.html'), 403 + +@app.errorhandler(404) +def error_404(body): + return render_template('404.html'), 404 + +@app.errorhandler(405) +def error_405(body): + return render_template('405.html'), 405 + +@app.errorhandler(451) +def error_451(body): + return render_template('451.html'), 451 + +@app.errorhandler(500) +def error_500(body): + g.no_user = True + return render_template('500.html'), 500 + +@app.route('/favicon.ico') +def favicon_ico(): + return send_from_directory(APP_BASE_DIR, 'favicon.ico') + +@app.route('/robots.txt') +def robots_txt(): + return send_from_directory(APP_BASE_DIR, 'robots.txt') + + +from .website import blueprints +for bp in blueprints: + app.register_blueprint(bp) + +from .ajax import bp +app.register_blueprint(bp) + +from .rest import rest_bp +app.register_blueprint(rest_bp) + + + + diff --git a/freak/__main__.py b/freak/__main__.py new file mode 100644 index 0000000..df77c43 --- /dev/null +++ b/freak/__main__.py @@ -0,0 +1,4 @@ + +from .cli import main + +main() \ No newline at end of file diff --git a/freak/ajax.py b/freak/ajax.py new file mode 100644 index 0000000..83a4185 --- /dev/null +++ b/freak/ajax.py @@ -0,0 +1,72 @@ + +''' +AJAX hooks for the website. + +2025 DEPRECATED in favor of /v1/ (REST) +''' + +import re +from flask import Blueprint, request +from .models import Topic, db, User, Post, PostUpvote +from flask_login import current_user, login_required + +bp = Blueprint('ajax', __name__) + +@bp.route('/username_availability/') +@bp.route('/ajax/username_availability/') +def username_availability(username: str): + is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None + + if is_valid: + user = db.session.execute(db.select(User).where(User.username == username)).scalar() + + is_available = user is None or user == current_user + else: + is_available = False + + return { + 'status': 'ok', + 'is_valid': is_valid, + 'is_available': is_available, + } + +@bp.route('/guild_name_availability/') +def guild_name_availability(name: str): + is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None + + if is_valid: + gd = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar() + + is_available = gd is None + else: + is_available = False + + return { + 'status': 'ok', + 'is_valid': is_valid, + 'is_available': is_available, + } + +@bp.route('/comments//upvote', methods=['POST']) +@login_required +def post_upvote(id): + o = request.form['o'] + p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + + if p is None: + return { 'status': 'fail', 'message': 'Post not found' }, 404 + + if o == '1': + db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True)) + db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False)) + elif o == '0': + db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id)) + elif o == '-1': + db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False)) + db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True)) + else: + return { 'status': 'fail', 'message': 'Invalid score' }, 400 + + db.session.commit() + return { 'status': 'ok', 'count': p.upvotes() } + diff --git a/freak/algorithms.py b/freak/algorithms.py new file mode 100644 index 0000000..efc7bf6 --- /dev/null +++ b/freak/algorithms.py @@ -0,0 +1,31 @@ + + +from flask_login import current_user +from sqlalchemy import func, select +from .models import db, Post, Topic, User + +def cuser() -> User: + return current_user if current_user.is_authenticated else None + +def public_timeline(): + return select(Post).join(User, User.id == Post.author_id).where( + Post.privacy == 0, User.not_suspended(), Post.not_removed() + ).order_by(Post.created_at.desc()) + +def topic_timeline(topic_name): + return select(Post).join(Topic).join(User, User.id == Post.author_id).where( + Post.privacy == 0, Topic.name == topic_name, User.not_suspended(), Post.not_removed() + ).order_by(Post.created_at.desc()) + +def user_timeline(user_id): + return select(Post).join(User, User.id == Post.author_id).where( + Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed() + ).order_by(Post.created_at.desc()) + +def top_guilds_query(): + q_post_count = func.count().label('post_count') + qr = select(Topic, q_post_count)\ + .join(Post, Post.topic_id == Topic.id).group_by(Topic)\ + .having(q_post_count > 5).order_by(q_post_count.desc()) + return qr + diff --git a/freak/cli.py b/freak/cli.py new file mode 100644 index 0000000..0426802 --- /dev/null +++ b/freak/cli.py @@ -0,0 +1,17 @@ + + +import argparse +import os +from . import __version__ as version + + +def make_parser(): + parser = argparse.ArgumentParser() + parser.add_argument('--version', '-v', action='version', version=version) + return parser + +def main(): + args = make_parser().parse_args() + + print(f'Visit ') + diff --git a/freak/filters.py b/freak/filters.py new file mode 100644 index 0000000..4c1d0be --- /dev/null +++ b/freak/filters.py @@ -0,0 +1,78 @@ + +import re, markdown +from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor +import xml.etree.ElementTree as etree +from markupsafe import Markup + + +from . import app + +from .iding import id_to_b32l + +#### MARKDOWN EXTENSIONS #### + +class StrikethroughExtension(markdown.extensions.Extension): + def extendMarkdown(self, md: markdown.Markdown, md_globals=None): + postprocessor = StrikethroughPostprocessor(md) + md.postprocessors.register(postprocessor, 'strikethrough', 0) + +class StrikethroughPostprocessor(markdown.postprocessors.Postprocessor): + pattern = re.compile(r"~~(((?!~~).)+)~~", re.DOTALL) + + def run(self, html): + return re.sub(self.pattern, self.convert, html) + + def convert(self, match): + return '' + match.group(1) + '' + + +### XXX it currently only detects spoilers that are not at the beginning of the line. To be fixed. +class SpoilerExtension(markdown.extensions.Extension): + def extendMarkdown(self, md: markdown.Markdown, md_globals=None): + md.inlinePatterns.register(SimpleTagInlineProcessor(r'()>!(.*?)!<', 'span class="spoiler"'), 'spoiler', 14) + + @classmethod + def patch_blockquote_processor(cls): + """Patch BlockquoteProcessor to make Spoiler prevail over blockquotes.""" + from markdown.blockprocessors import BlockQuoteProcessor + BlockQuoteProcessor.RE = re.compile(r'(^|\n)[ ]{0,3}>(?!!)[ ]?(.*)') + +# make spoilers prevail over blockquotes +SpoilerExtension.patch_blockquote_processor() + +class MentionPattern(InlineProcessor): + def __init__(self, regex, url_prefix: str): + super().__init__(regex) + self.url_prefix = url_prefix + def handleMatch(self, m, data): + el = etree.Element('a') + el.attrib['href'] = self.url_prefix + m.group(1) + el.text = m.group(0) + return el, m.start(0), m.end(0) + +class PingExtension(markdown.extensions.Extension): + def extendMarkdown(self, md: markdown.Markdown, md_globals=None): + md.inlinePatterns.register(MentionPattern(r'@([a-zA-Z0-9_-]{2,32})', '/@'), 'ping_mention', 14) + md.inlinePatterns.register(MentionPattern(r'\+([a-zA-Z0-9_-]{2,32})', '/+'), 'ping_mention', 14) + +@app.template_filter() +def to_markdown(text, toc = False): + extensions = [ + 'tables', 'footnotes', 'fenced_code', 'sane_lists', + StrikethroughExtension(), SpoilerExtension(), + ## XXX untested + PingExtension() + ] + if toc: + extensions.append('toc') + return Markup(markdown.Markdown(extensions=extensions).convert(text)) + +@app.template_filter() +def to_b32l(n): + return id_to_b32l(n) + + +@app.template_filter() +def append(text, l): + l.append(text) + return None diff --git a/freak/iding.py b/freak/iding.py new file mode 100644 index 0000000..b028f53 --- /dev/null +++ b/freak/iding.py @@ -0,0 +1,42 @@ +""" +PSA: this module is for the LEGACY (v2) iding. + +For the SIQ-based ID's (upcoming 0.4), see suou.iding +""" + +import base64 +import os +import time + +epoch = 1577833200000 +machine_id = int(os.getenv("MACHINE_ID", "0")) +machine_counter = 0 + +def new_id(*, from_date = None): + global machine_counter + + if from_date: + curtime = from_date.timestamp() + else: + curtime = time.time() + + return ( + ((int(curtime * 1000) - epoch) << 22) | + ((machine_id % 32) << 17) | + ((os.getpid() % 32) << 12) | + ## XXX two digits are not getting employed! + ((machine_counter := machine_counter + 1) % 1024) + ) + +def id_to_b32l(n): + return ( + '_' if n < 0 else '' + ) + base64.b32encode( + (-n if n < 0 else n).to_bytes(10, 'big') + ).decode().lstrip('A').lower() + +def id_from_b32l(s, *, n_bytes=10): + return (-1 if s.startswith('_') else 1) * int.from_bytes( + base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big' + ) + diff --git a/freak/models.py b/freak/models.py new file mode 100644 index 0000000..b43055c --- /dev/null +++ b/freak/models.py @@ -0,0 +1,364 @@ + + +from __future__ import annotations + +from collections import namedtuple +import datetime +from functools import lru_cache +from operator import or_ +from threading import Lock +from sqlalchemy import Column, String, ForeignKey, and_, text, \ + CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \ + SmallInteger, select, insert, update, create_engine, Table +from sqlalchemy.orm import Relationship, declarative_base, relationship +from flask_sqlalchemy import SQLAlchemy +from flask_login import AnonymousUserMixin +from werkzeug.security import check_password_hash +import os +from .iding import new_id, id_to_b32l +from .utils import age_and_days, get_remote_addr, timed_cache + + +## Constants and enums + +USER_ACTIVE = 0 +USER_INACTIVE = 1 +USER_BANNED = 2 + +ReportReason = namedtuple('ReportReason', 'num_code code description') + +post_report_reasons = [ + ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'), + ReportReason(121, 'csam', 'Child abuse or endangerment'), + ReportReason(142, 'revenge_sxm', 'Revenge porn'), + ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'), + ReportReason(171, 'xxx', 'Pornography'), + ReportReason(111, 'tasteless', 'Extreme violence / gore'), + ReportReason(180, 'impersonation', 'Impersonation'), + ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'), + ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'), + ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'), + ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'), + ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'), + ReportReason(190, 'false_information', 'False or deceiving information'), + ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)') +] + +REPORT_REASON_STRINGS = { **{x.num_code: x.description for x in post_report_reasons}, **{x.code: x.description for x in post_report_reasons} } + +REPORT_REASONS = {x.code: x.num_code for x in post_report_reasons} + +REPORT_TARGET_POST = 1 +REPORT_TARGET_COMMENT = 2 + +REPORT_UPDATE_PENDING = 0 +REPORT_UPDATE_COMPLETE = 1 +REPORT_UPDATE_REJECTED = 2 +REPORT_UPDATE_ON_HOLD = 3 + +## END constants and enums + +Base = declarative_base() +db = SQLAlchemy(model_class=Base) + +def create_session_interactively(): + '''Create a session for querying the database in Python REPL.''' + engine = create_engine(os.getenv('DATABASE_URL')) + return db.Session(bind = engine) + +CSI = create_session_interactively + +## TODO replace with suou.declarative_base() - upcoming 0.4 +class BaseModel(Base): + __abstract__ = True + + id = Column(BigInteger, primary_key=True, default=new_id) + +## Many-to-many relationship keys for some reasons have to go +## BEFORE other table definitions. +## I (Sakuragasaki46) take no accountability; blame SQLAlchemy development. + +PostUpvote = Table( + 'freak_post_upvote', + Base.metadata, + Column('post_id', BigInteger, ForeignKey('freak_post.id'), primary_key=True), + Column('voter_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True), + Column('is_downvote', Boolean, server_default=text('0')) +) + +class User(BaseModel): + __tablename__ = 'freak_user' + + id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + + username = Column(String(32), CheckConstraint(text("username = lower(username) and username ~ '^[a-z0-9_-]+$'"), name="user_username_valid"), unique=True, nullable=False) + display_name = Column(String(64), nullable=False) + passhash = Column(String(256), nullable=False) + email = Column(String(256), CheckConstraint(text("email IS NULL OR (email = lower(email) AND email LIKE '_%@_%.__%')"), name='user_email_valid'), nullable=True) + gdpr_birthday = Column(Date, nullable=False) + joined_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False) + joined_ip = Column(String(64), default=get_remote_addr, nullable=False) + is_administrator = Column(Boolean, server_default=text('0'), nullable=False) + is_disabled_by_user = Column(Boolean, server_default=text('0'), nullable=False) + karma = Column(BigInteger, server_default=text('0'), nullable=False) + legacy_id = Column(BigInteger, nullable=True) + # TODO add pronouns and biography (upcoming 0.4) + + # moderation + banned_at = Column(DateTime, nullable=True) + banned_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) + banned_reason = Column(SmallInteger, server_default=text('0'), nullable=True) + banned_until = Column(DateTime, nullable=True) + banned_message = Column(String(256), nullable=True) + + # utilities + #posts = relationship("Post", back_populates='author', ) + upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters') + #comments = relationship("Comment", back_populates='author') + ## XXX posts and comments relationships are temporarily disabled because they make + ## SQLAlchemy fail initialization of models — bricking the app. + ## Posts are queried manually anyway + + @property + def is_disabled(self): + return self.banned_at is not None or self.is_disabled_by_user + + @property + def is_active(self): + return not self.is_disabled + + @property + def is_authenticated(self): + return True + + @property + def is_anonymous(self): + return False + + def get_id(self): + return str(self.id) + + def url(self): + return f'/@{self.username}' + + @timed_cache(ttl=3600) + def age(self): + return age_and_days(self.gdpr_birthday)[0] + + def simple_info(self): + """ + Return essential informations for representing a user in the REST + """ + ## XXX change func name? + return dict( + id = id_to_b32l(self.id), + username = self.username, + display_name = self.display_name, + age = self.age() + ## TODO add badges? + ) + + def reward(self, points=1): + with Lock(): + db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points)) + db.session.commit() + + def can_create_community(self): + return self.karma > 15 + + def handle(self): + return f'@{self.username}' + + def check_password(self, password): + return check_password_hash(self.passhash, password) + + @classmethod + @timed_cache(1800) + def active_count(cls) -> int: + active_th = datetime.datetime.now() - datetime.timedelta(days=30) + return db.session.execute(select(func.count(User.id)).select_from(cls).join(Post, Post.author_id == User.id).where(Post.created_at >= active_th).group_by(User.id)).scalar() + + def __repr__(self): + return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>' + + @classmethod + def not_suspended(cls): + return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now()) + +class Topic(BaseModel): + __tablename__ = 'freak_topic' + + id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + + name = Column(String(32), CheckConstraint(text("name = lower(name) AND name ~ '^[a-z0-9_-]+$'"), name='topic_name_valid'), unique=True, nullable=False) + display_name = Column(String(64), nullable=False) + description = Column(String(4096), nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp(), index=True, nullable=False) + owner_id = Column(BigInteger, ForeignKey('freak_user.id', name='topic_owner_id'), nullable=True) + language = Column(String(16), server_default=text("'en-US'")) + privacy = Column(SmallInteger, server_default=text('0')) + + legacy_id = Column(BigInteger, nullable=True) + + def url(self): + return f'/+{self.name}' + + def handle(self): + return f'+{self.name}' + + # utilities + posts = relationship('Post', back_populates='topic') + + +POST_TYPE_DEFAULT = 0 +POST_TYPE_LINK = 1 + +class Post(BaseModel): + __tablename__ = 'freak_post' + + id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + + slug = Column(String(64), CheckConstraint("slug IS NULL OR (slug = lower(slug) AND slug ~ '^[a-z0-9_-]+$')", name='post_slug_valid'), nullable=True) + title = Column(String(256), nullable=False) + post_type = Column(SmallInteger, server_default=text('0')) + author_id = Column(BigInteger, ForeignKey('freak_user.id', name='post_author_id'), nullable=True) + topic_id = Column(BigInteger, ForeignKey('freak_topic.id', name='post_topic_id'), nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp()) + created_ip = Column(String(64), default=get_remote_addr, nullable=False) + updated_at = Column(DateTime, nullable=True) + privacy = Column(SmallInteger, server_default=text('0')) + is_locked = Column(Boolean, server_default=text('false')) + + source_url = Column(String(1024), nullable=True) + text_content = Column(String(65536), nullable=True) + + legacy_id = Column(BigInteger, nullable=True) + + removed_at = Column(DateTime, nullable=True) + removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) + removed_reason = Column(SmallInteger, nullable=True) + + # utilities + author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts") + topic = relationship("Topic", back_populates="posts", lazy='selectin') + comments = relationship("Comment", back_populates="parent_post") + upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts') + + def topic_or_user(self) -> Topic | User: + return self.topic or self.author + + def url(self): + return self.topic_or_user().url() + '/comments/' + id_to_b32l(self.id) + '/' + (self.slug or '') + + def generate_slug(self): + return slugify.slugify(self.title, max_length=64) + + def upvotes(self) -> int: + return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar() + - db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar()) + + def upvoted_by(self, user: User | AnonymousUserMixin | None): + if not user or not user.is_authenticated: + return 0 + v = db.session.execute(db.select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone() + if v: + if v.is_downvote: + return -1 + return 1 + return 0 + + def top_level_comments(self, limit=None): + return db.session.execute(select(Comment).where(Comment.parent_comment == None, Comment.parent_post == self).order_by(Comment.created_at.desc()).limit(limit)).scalars() + + def report_url(self) -> str: + return '/report/post/' + id_to_b32l(self.id) + + def report_count(self) -> int: + return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() + + @classmethod + @timed_cache(1800) + def count(cls): + return db.session.execute(select(func.count('*')).select_from(cls)).scalar() + + @property + def is_removed(self) -> bool: + return self.removed_at is not None + + @classmethod + def not_removed(cls): + return Post.removed_at == None + + @classmethod + def visible_by(cls, user: User): + return or_(Post.author_id == user.id, Post.privacy.in_((0, 1))) + + +class Comment(BaseModel): + __tablename__ = 'freak_comment' + + # tweak to allow remote_side to work + ## XXX will be changed in 0.4 to suou.id_column() + id = Column(BigInteger, primary_key=True, default=new_id, unique=True) + + author_id = Column(BigInteger, ForeignKey('freak_user.id', name='comment_author_id'), nullable=True) + parent_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id'), nullable=False) + parent_comment_id = Column(BigInteger, ForeignKey('freak_comment.id', name='comment_parent_comment_id'), nullable=True) + text_content = Column(String(16384), nullable=False) + created_at = Column(DateTime, server_default=func.current_timestamp(), index=True) + created_ip = Column(String(64), default=get_remote_addr, nullable=False) + updated_at = Column(DateTime, nullable=True) + is_locked = Column(Boolean, server_default=text('false')) + + legacy_id = Column(BigInteger, nullable=True) + + removed_at = Column(DateTime, nullable=True) + removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True) + removed_reason = Column(SmallInteger, nullable=True) + + author = relationship('User', foreign_keys=[author_id])#, back_populates='comments') + parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id]) + parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id]) + child_comments = relationship("Comment", back_populates="parent_comment") + + def url(self): + return self.parent_post.url() + '/comment/' + id_to_b32l(self.id) + + def report_url(self) -> str: + return '/report/comment/' + id_to_b32l(self.id) + + def report_count(self) -> int: + return db.session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2)))).scalar() + + @property + def is_removed(self) -> bool: + return self.removed_at is not None + + @classmethod + def not_removed(cls): + return Post.removed_at == None + +class PostReport(BaseModel): + __tablename__ = 'freak_postreport' + + author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True) + target_type = Column(SmallInteger, nullable=False) + target_id = Column(BigInteger, nullable=False) + reason_code = Column(SmallInteger, nullable=False) + update_status = Column(SmallInteger, server_default=text('0')) + created_at = Column(DateTime, server_default=func.current_timestamp()) + created_ip = Column(String(64), default=get_remote_addr, nullable=False) + + author = relationship('User') + + def target(self): + if self.target_type == REPORT_TARGET_POST: + return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar() + elif self.target_type == REPORT_TARGET_COMMENT: + return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar() + else: + return self.target_id + +# PostUpvote table is at the top !! + + diff --git a/freak/rest/__init__.py b/freak/rest/__init__.py new file mode 100644 index 0000000..3e3013c --- /dev/null +++ b/freak/rest/__init__.py @@ -0,0 +1,51 @@ + + +from flask import Blueprint +from flask_restx import Resource, Api + +from freak.iding import id_to_b32l + +from ..models import Post, User, db + +rest_bp = Blueprint('rest', __name__, url_prefix='/v1') +rest = Api(rest_bp) + +@rest.route('/nurupo') +class Nurupo(Resource): + def get(self): + return dict(nurupo='ga') + +## TODO coverage of REST is still partial, but it's planned +## to get complete sooner or later + +@rest.route('/user/') +class UserInfo(Resource): + def get(self, id: int): + u: User | None = db.session.execute(db.select(User).where(User.id == id)).scalar() + if u is None: + return dict(error='User not found'), 404 + uj = dict( + id = id_to_b32l(u.id), + username = u.username, + display_name = u.display_name, + joined_at = u.joined_at.isoformat('T'), + karma = u.karma, + age = u.age() + ) + return dict(users={id_to_b32l(id): uj}) + +@rest.route('/post/') +class SinglePost(Resource): + def get(self, id: int): + p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + if p is None: + return dict(error='Not found'), 404 + pj = dict( + id = id_to_b32l(p.id), + title = p.title, + author = p.author.simple_info(), + to = p.topic_or_user().handle(), + created_at = p.created_at.isoformat('T') + ) + + return dict(posts={id_to_b32l(id): pj}) \ No newline at end of file diff --git a/freak/search.py b/freak/search.py new file mode 100644 index 0000000..b1f46f6 --- /dev/null +++ b/freak/search.py @@ -0,0 +1,25 @@ + + + +from typing import Iterable +from sqlalchemy import Column, Select, select, or_ + + +class SearchQuery: + keywords: Iterable[str] + + def __init__(self, keywords: str | Iterable[str]): + if isinstance(keywords, str): + keywords = keywords.split() + self.keywords = keywords + def select(self, table: type, attrs: Iterable[Column]) -> Select: + if not attrs: + raise TypeError + sq: Select = select(table) + for kw in self.keywords: + or_cond = [] + for attr in attrs: + or_cond.append(attr.ilike(f"%{kw.replace('%', r'\%')}%")) + sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0]) + return sq + diff --git a/freak/static/js/lib.js b/freak/static/js/lib.js new file mode 100644 index 0000000..7bebaed --- /dev/null +++ b/freak/static/js/lib.js @@ -0,0 +1,150 @@ +(function(){ + // UNUSED! Period is disallowed regardless now + function checkUsername(u){ + return ( + /^\./.test(u)? 'You cannot start username with a period.': + /\.$/.test(u)? 'You cannot end username with a period.': + /\.\./.test(u)? 'You cannot have more than one period in a row.': + u.match(/\.(com|net|org|txt)$/)? 'Your username cannot end with .' + forbidden_extensions[1]: + 'ok' + ); + } + + + function attachUsernameInput(){ + const usernameInputs = document.getElementsByClassName('username-input'); + for(var i=0;i { + if (['ok', void 0].indexOf(resp.status) < 0){ + usernameInputMessage.innerHTML = 'Sorry, there was an unknown error.'; + usernameInputMessage.className = 'username-input-message error'; + return; + } + if (resp.is_available){ + usernameInputMessage.innerHTML = "The username @" + value + " is available!"; + usernameInputMessage.className = 'username-input-message success'; + return; + } else { + usernameInputMessage.innerHTML = "Sorry, someone else has this username already :("; + usernameInputMessage.className = 'username-input-message error'; + return; + } + }); + } + }; + usernameInputMessage.className = 'username-input-message'; + usernameInput.parentNode.appendChild(usernameInputMessage); + })(usernameInputs[i]); + } + + async function requestUsernameAvailability(u, endpoint){ + return fetch(endpoint.replace('$1', encodeURIComponent(u)) + ).then((e) => e.json()); + } + + function enablePostVotes(){ + for (let elem of document.querySelectorAll('.upvote-button')){ + (function(e){ + let p; + for (p = e; p && p != document.body && !p.hasAttribute('data-endpoint'); p = p.parentElement); + + if (!p) return; + + let endpoint = p.getAttribute('data-endpoint'); + + + if (!endpoint || !/^[a-z0-9_]+$/.test(endpoint)) { + console.warn('missing endpoint!'); + return; + } + + + let buttonUp = e.querySelector('.upvote-button-up'), buttonDown = e.querySelector('.upvote-button-down'), + upvoteCount = e.querySelector('.upvote-count'); + let currentScore = buttonUp.classList.contains('active')? 1 : buttonDown.classList.contains('active')? -1 : 0; + + buttonUp.addEventListener('click', function(){ + sendVote(endpoint, (currentScore === 1? 0 : 1)).then((e) => { + buttonDown.classList.remove('active'); + buttonDown.querySelector('i').className = 'icon icon-downvote'; + if(currentScore === 1) { + buttonUp.classList.remove('active'); + buttonUp.querySelector('i').className = 'icon icon-upvote'; + } else { + buttonUp.classList.add('active'); + buttonUp.querySelector('i').className = 'icon icon-upvote_fill'; + } + upvoteCount.textContent = e.count !== void 0? e.count : upvoteCount.textContent; + currentScore = currentScore === 1? 0 : 1; + }); + }); + + buttonDown.addEventListener('click', function(){ + sendVote(endpoint, (currentScore === -1? 0 : -1)).then((e) => { + buttonUp.classList.remove('active'); + buttonUp.querySelector('i').className = 'icon icon-upvote'; + if(currentScore === -1) { + buttonDown.classList.remove('active'); + buttonDown.querySelector('i').className = 'icon icon-downvote'; + } else { + buttonDown.classList.add('active'); + buttonDown.querySelector('i').className = 'icon icon-downvote_fill'; + } + upvoteCount.textContent = e.count !== void 0? e.count : upvoteCount.textContent; + currentScore = currentScore === -1? 0 : -1; + }); + }); + })(elem); + } + } + + function getCsrfToken(){ + return document.querySelector('meta[name="csrf_token"]').content; + } + + async function sendVote(endpoint, score){ + return fetch(`/comments/${endpoint}/upvote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: 'o=' + encodeURIComponent(score) + '&csrf_token=' + encodeURIComponent(getCsrfToken()) + }).then(e => e.json()); + } + + function main() { + attachUsernameInput(); + enablePostVotes(); + } + + main(); + +})(); diff --git a/freak/static/sass/base.sass b/freak/static/sass/base.sass new file mode 100644 index 0000000..71772ab --- /dev/null +++ b/freak/static/sass/base.sass @@ -0,0 +1,126 @@ + +@import "constants.sass" + +* + box-sizing: border-box + +\:root + --light-text-primary: #181818 + --light-text-alt: #444 + --light-border: #999 + --light-accent: #ff7300 + --light-success: #73af00 + --light-error: #e04433 + --light-canvas: #eaecee + --light-background: #f9f9f9 + --light-bg-sharp: #fdfdff + + --dark-text-primary: #e8e8e8 + --dark-text-alt: #c0cad3 + --dark-border: #777 + --dark-accent: #ff7300 + --dark-success: #93cf00 + --dark-error: #e04433 + --dark-canvas: #0a0a0e + --dark-background: #181a21 + --dark-bg-sharp: #080808 + + --text-primary: var(--light-text-primary) + --text-alt: var(--light-text-alt) + --border: var(--light-border) + --accent: var(--light-accent) + --success: var(--light-success) + --error: var(--light-error) + --canvas: var(--light-canvas) + --background: var(--light-background) + --bg-sharp: var(--light-bg-sharp) + +@media (prefers-color-scheme: dark) + \:root + --text-primary: var(--dark-text-primary) + --text-alt: var(--dark-text-alt) + --border: var(--dark-border) + --accent: var(--dark-accent) + --success: var(--dark-success) + --error: var(--dark-error) + --canvas: var(--dark-canvas) + --background: var(--dark-background) + --bg-sharp: var(--dark-bg-sharp) + +body.color-scheme-light + --text-primary: var(--light-text-primary) + --text-alt: var(--light-text-alt) + --border: var(--light-border) + --accent: var(--light-accent) + --success: var(--light-success) + --error: var(--light-error) + --canvas: var(--light-canvas) + --background: var(--light-background) + --bg-sharp: var(--light-bg-sharp) + +body.color-scheme-dark + --text-primary: var(--dark-text-primary) + --text-alt: var(--dark-text-alt) + --border: var(--dark-border) + --accent: var(--dark-accent) + --success: var(--dark-success) + --error: var(--dark-error) + --canvas: var(--dark-canvas) + --background: var(--dark-background) + --bg-sharp: var(--dark-bg-sharp) + + +body, input, select, button + font-family: $ui-fonts + +body + line-height: $ui-line-height + font-size: $ui-size + +input, button, select + font-family: inherit + font-size: inherit + line-height: inherit + +textarea + font-family: $monospace-fonts + +input:not([type="submit"], [type="button"], [type="reset"]), textarea + background: var(--bg-sharp) + color: var(--text-main) + border: var(--border) + border-radius: $corner-radius + +body + color: var(--text-primary) + background-color: var(--canvas) + +.card + background-color: var(--background) + border: var(--canvas) 1px solid + border-radius: $corner-radius + margin: 12px auto + padding: 12px + max-width: 960px + +.a11y + overflow: hidden + width: 0 + height: 0 + display: inline-block + +.centered + text-align: center + font-size: 110% + +a + &:link, &:visited + color: var(--accent) + transition: ease 5s + +img + max-width: 100% + max-height: 100vh + +.faint + opacity: .75 \ No newline at end of file diff --git a/freak/static/sass/constants.sass b/freak/static/sass/constants.sass new file mode 100644 index 0000000..cdaec0a --- /dev/null +++ b/freak/static/sass/constants.sass @@ -0,0 +1,19 @@ + +$ui-fonts: system-ui, -apple-system, BlinkMacSystemFont, "Noto Sans", sans-serif +$heading-fonts: $ui-fonts +$content-fonts: $ui-fonts +$monospace-fonts: monospace + +$ui-line-height: 1.5 +$content-line-height: 1.5 + +$ui-size: 18px +$content-size: 18px +$h1-size: $ui-size * 2.5 +$h2-size: $ui-size * 1.85 +$h3-size: $ui-size * 1.55 +$h4-size: $ui-size * 1.3 +$h5-size: $ui-size * 1.1 +$h6-size: $ui-size * 1.0 + +$corner-radius: 9px \ No newline at end of file diff --git a/freak/static/sass/content.sass b/freak/static/sass/content.sass new file mode 100644 index 0000000..5b0b4b3 --- /dev/null +++ b/freak/static/sass/content.sass @@ -0,0 +1,38 @@ + +.content + margin: 2em auto + max-width: 1280px + +blockquote + padding-left: 1em + border-left: 4px solid var(--border) + margin-left: 0 + + [dir="rtl"] & + padding-left: 0 + border-left: 0 + padding-right: 1em + border-right: 4px solid var(--border) + +.success + color: var(--success) + +.error + color: var(--error) + +.callout + color: var(--text-alt) + +.message-content + p + margin: 4px 0 + ul + margin: 4px 0 + padding: 0 + > li + margin: 0 + + +h1, h2, h3, h4, h5, h6 + font-weight: 500 + diff --git a/freak/static/sass/layout.sass b/freak/static/sass/layout.sass new file mode 100644 index 0000000..ff7e034 --- /dev/null +++ b/freak/static/sass/layout.sass @@ -0,0 +1,302 @@ + +@import "constants.sass" + + +.content-container + display: flex + flex-direction: row-reverse + align-items: start + justify-content: flex-start + +.content-nav + width: 320px + font-size: smaller + + +.content-main + flex: 1 + +main + min-height: 70vh + + +// __ header styles __ // +header.header + background-color: var(--background) + display: flex + justify-content: space-between + overflow: hidden + height: 3em + padding: .75em 1.5em + margin: -12px + line-height: 1 + h1 + margin: 0 + padding: 0 + font-size: 1.5em + .metanav + align-self: flex-end + font-size: 1.5em + margin: auto + margin-inline-start: 2em + ul + list-style: none + padding: 0 + margin: 0 + > li + margin: 0 6px + + ul, ul > li + display: flex + flex-direction: row + align-items: center + justify-content: flex-end + + &, > ul, > ul > li:has(.mini-search-bar) + flex: 1 + .header-username + > * + display: block + font-size: .5em + line-height: 1.25 + .icon + font-size: inherit + a + text-decoration: none + + .mini-search-bar + display: flex + flex-direction: row + align-items: center + justify-content: flex-end + flex: 1 + font-size: 1.2rem + + [type="search"] + flex: 1 + border-radius: 0 + border: 0 + border-bottom: 2px solid var(--border) + background-color: inherit + + :focus + background-color: var(--bg-sharp) + border-color: var(--accent) + + [type="submit"] + height: 0 + width: 0 + padding: 0 + margin: 0 + border-radius: 0 + opacity: 0 + overflow: hidden + + + a + display: none + +// __ aside styles __ // +aside.card + overflow: hidden + > :first-child + background-color: var(--accent) + padding: 12px + margin: -12px -12px 0 -12px + position: relative + > ul + list-style: none + margin: 0 + padding: 0 + > li + border-bottom: 1px solid var(--canvas) + padding: 12px + &:last-child + border-bottom: none + + +.flash + border-color: yellow + background-color: #fff00040 + +ul.timeline + list-style: none + padding: 0 1em + > li + border-bottom: 1px solid var(--border) + margin-bottom: 6px + &:last-child + border-bottom: 0 + margin-bottom: 0 + +ul.inline + list-style: none + padding: 0 + margin: 0 + > li + display: inline + &::before + content: ' · ' + margin: 0 .5em + &:first-child::before + content: '' + +ul.message-options + color: var(--text-alt) + list-style: none + padding: 0 + font-size: smaller + margin-bottom: -4px + +.post-frame + margin-left: 3em + position: relative + min-height: 6em + clear: right + [dir="rtl"] & + margin-left: 0 + margin-right: 3em + + .message-stats + position: absolute + left: -3em + top: 0 + display: flex + flex-direction: column + width: 2em + text-align: center + line-height: 1.0 + + [dir="rtl"] & + right: -3em + left: unset + + > * + display: flex + flex-direction: column + + strong + font-size: smaller + + a + text-decoration: none + margin: .25em 0 + +.message-meta + font-size: smaller + color: var(--text-alt) + +.shorten + max-height: 18em + overflow-y: hidden + position: relative + + &::after + content: '' + position: absolute + z-index: 10 + top: 16em + left: 0 + width: 100% + height: 2em + display: block + background: linear-gradient(to bottom, rgba(0,0,0,0) 0%, var(--background) 100%) + + +.comments-button + .comment-count + display: inline-block + min-width: 1em + text-align: center + +i.icon + font-size: inherit + font-style: normal + +form.boundaryless + flex: 1 + background: transparent + color: inherit + border: 0 + border-top: 1px solid var(--border) + border-bottom: 1px solid var(--border) + + dd + display: flex + flex-direction: row + width: 100% + box-sizing: border-box + margin: 0 + + textarea, input[type="text"] + width: 100% + + textarea + min-height: 4em + + p input[type="text"] + width: unset + +.big-search-bar + form + display: flex + flex-direction: row + font-size: 1.6em + width: 80% + margin: auto + > [type="search"] + flex: 1 + border-bottom: 2px solid var(--border) + +footer.footer + text-align: center + font-size: smaller + ul + list-style: none + padding: 0 + margin: 0 + > li + display: inline-block + margin: 0 2em + +textarea.comment-area + width: 100% + +button, [type="submit"], [type="reset"], [type="button"] + background-color: transparent + color: var(--accent) + border: 1px solid var(--accent) + border-radius: 6px + padding: 6px 12px + margin: 6px + cursor: pointer + + &.primary + background-color: var(--accent) + color: var(--bg-main) + + &[disabled] + opacity: .5 + cursor: not-allowed + + &:first-child + margin-inline-start: 0 + + &:last-child + margin-inline-end: 0 + +.button-row-right + display: flex + flex-direction: row + justify-content: flex-end + +.comment-frame + border: 1px solid var(--border) + padding: 12px + border-radius: 24px + border-start-start-radius: 0 + min-width: 50% + width: 0 + margin-right: auto + + + diff --git a/freak/static/sass/mobile.sass b/freak/static/sass/mobile.sass new file mode 100644 index 0000000..25a8e02 --- /dev/null +++ b/freak/static/sass/mobile.sass @@ -0,0 +1,21 @@ + +@media screen and (max-width: 800px) + .content-container + display: block + + .content-nav, .content-main + width: 100% + +@media screen and (max-width: 960px) + .header-username + display: none + + header.header + .mini-search-bar + display: none + + + a + display: inline-block + + ul > li:has(.mini-search-bar) + flex: unset \ No newline at end of file diff --git a/freak/static/sass/style.sass b/freak/static/sass/style.sass new file mode 100644 index 0000000..cc8c18c --- /dev/null +++ b/freak/static/sass/style.sass @@ -0,0 +1,4 @@ +@import "base.sass" +@import "layout.sass" +@import "content.sass" +@import "mobile.sass" diff --git a/freak/templates/400.html b/freak/templates/400.html new file mode 100644 index 0000000..dc67d98 --- /dev/null +++ b/freak/templates/400.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %} + U _ U; on {{ app_name }} +{% endblock %} + +{% block body %} +
+

Bad Request

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/403.html b/freak/templates/403.html new file mode 100644 index 0000000..0826e46 --- /dev/null +++ b/freak/templates/403.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %} + X _ X; on {{ app_name }} +{% endblock %} + +{% block body %} +
+

Access Denied

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/404.html b/freak/templates/404.html new file mode 100644 index 0000000..dae2961 --- /dev/null +++ b/freak/templates/404.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %} + O _ O; on {{ app_name }} +{% endblock %} + +{% block body %} +
+

Not Found

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/405.html b/freak/templates/405.html new file mode 100644 index 0000000..c3cde64 --- /dev/null +++ b/freak/templates/405.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %} + O _ O; on {{ app_name }} +{% endblock %} + +{% block body %} +
+

Method Not Allowed

+ +

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/451.html b/freak/templates/451.html new file mode 100644 index 0000000..84fc5b2 --- /dev/null +++ b/freak/templates/451.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} + +{% block title %} +{{ title_tag('WTH!?', false) }} +{% endblock %} + +{% block body %} +
+

Unavailable for Legal Reasons

+ +

This content is not available in your region because making it available would get us in trouble with the law.

+

Back to homepage.

+
+{% endblock %} diff --git a/freak/templates/500.html b/freak/templates/500.html new file mode 100644 index 0000000..144a90c --- /dev/null +++ b/freak/templates/500.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + + +{% block title %} + % _ % + +{% endblock %} + +{% block body %} +
+

Internal Server Error

+ +

It's on us. Refresh the page.

+
+{% endblock %} + diff --git a/freak/templates/about.html b/freak/templates/about.html new file mode 100644 index 0000000..b7be8b7 --- /dev/null +++ b/freak/templates/about.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} + +{% block title %}{{ title_tag('About') }}{% endblock %} + +{% block heading %} +

About

+{% endblock %} + +{% block content %} +
+

Stats

+
    +
  • No. of posts: {{ post_count }}
  • +
  • No. of active users (posters in the last 30 days): {{ user_count }}
  • +
+ +

Software versions

+
    +
  • Python: {{ python_version }}
  • +
  • SQLAlchemy: {{ sa_version }}
  • +
  • Flask: {{ flask_version }}
  • +
  • {{ app_name }}: {{ app_version }}
  • +
+ +

License

+

Source code is available at: https://github.com/sakuragasaki46/freak

+
+{% endblock %} + diff --git a/freak/templates/admin/admin_base.html b/freak/templates/admin/admin_base.html new file mode 100644 index 0000000..11a0306 --- /dev/null +++ b/freak/templates/admin/admin_base.html @@ -0,0 +1,26 @@ + + + + {% from "macros/title.html" import title_tag with context %} + {{ title_tag("Admin") }} + + + + + + + +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %}{% endblock %} +
+ + + + diff --git a/freak/templates/admin/admin_home.html b/freak/templates/admin/admin_home.html new file mode 100644 index 0000000..ad49860 --- /dev/null +++ b/freak/templates/admin/admin_home.html @@ -0,0 +1,9 @@ +{% extends "admin/admin_base.html" %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/freak/templates/admin/admin_report_detail.html b/freak/templates/admin/admin_report_detail.html new file mode 100644 index 0000000..8844c5a --- /dev/null +++ b/freak/templates/admin/admin_report_detail.html @@ -0,0 +1,23 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/embed.html" import embed_post with context %} + +{% block content %} +

Report detail #{{ report.id }}

+
    +
  • Reason: {{ report_reasons[report.reason_code] }}
  • +
  • Status: {{ ['Unreviewed', 'Complete', 'Rejected', 'On hold'][report.update_status] }}
  • +
+ +

Detail

+ {% if report.target_type in (1, 2) %} + {{ embed_post(report.target()) }} + {% else %} +

Unknown media type

+ {% endif %} +
+ + + + +
+{% endblock %} diff --git a/freak/templates/admin/admin_reports.html b/freak/templates/admin/admin_reports.html new file mode 100644 index 0000000..3bc9197 --- /dev/null +++ b/freak/templates/admin/admin_reports.html @@ -0,0 +1,21 @@ +{% extends "admin/admin_base.html" %} +{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %} + +{% block content %} +
    + {% for report in report_list %} +
  • + +

    #{{ report.id }} (detail)

    +
    • Reason: {{ report_reasons[report.reason_code] }}
    • +
    • Status: {{ ['Unreviewed', 'Complete', 'Rejected', 'On hold'][report.update_status] }}
    • +
    +
  • + {% endfor %} + {% if report_list.has_next %} + {{ stop_scrolling(report_list.page) }} + {% else %} + {{ no_more_scrolling(report_list.page) }} + {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/freak/templates/base.html b/freak/templates/base.html new file mode 100644 index 0000000..0038452 --- /dev/null +++ b/freak/templates/base.html @@ -0,0 +1,119 @@ + + + + + + + {% block title %} + {{ app_name }} + {% endblock %} + + + + {# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #} + {% for private_style in private_styles %} + + {% endfor %} + + + + +
+

{{ app_name }}

+
+ +
+
+
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block body %} +
+ {% block heading %}{% endblock %} +
+
+
+ {% block nav %}{% endblock %} +
+
+ {% block content %}{% endblock %} +
+
+ {% endblock %} +
+ + + + {% block scripts %}{% endblock %} + {% for private_script in private_scripts %} + + {% endfor %} + + diff --git a/freak/templates/create.html b/freak/templates/create.html new file mode 100644 index 0000000..49af222 --- /dev/null +++ b/freak/templates/create.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/create.html" import privacy_select with context %} + +{% block title %}{{ title_tag('New post', False) }}{% endblock %} + +{% block heading %} +

Create

+{% endblock %} + +{% block content %} + +
+
+ +
+

Posting as {{ current_user.handle() }}

+

Post to:

+
+ Title: +
+
+ Text: + +
+ {#
Add a file...#} +
{{ privacy_select() }}
+
+
+
+
+{% endblock %} diff --git a/freak/templates/createguild.html b/freak/templates/createguild.html new file mode 100644 index 0000000..b004482 --- /dev/null +++ b/freak/templates/createguild.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/create.html" import disabled_if with context %} + +{% block title %} +{{ title_tag('Create a guild', False) }} +{% endblock %} + +{% block heading %} +

Create a guild

+{% endblock %} + +{% block content %} + +
+
+ +
+

URL of the guild: +

+

Must be alphanumeric and unique. May not be changed later: choose wisely!

+
+
+

Display name:

+

Will be shown in title bar and search engines.

+
+
+

Description: (will be shown in sidebar)

+ +
+
+
+
+ +{% endblock %} diff --git a/freak/templates/edit.html b/freak/templates/edit.html new file mode 100644 index 0000000..631255d --- /dev/null +++ b/freak/templates/edit.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/create.html" import privacy_select with context %} + +{% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %} + +{% block heading %} +

Editing: {{ p.title }}

+{% endblock %} + +{% block content %} +
+
+ +
+ Text: + +
+
{{ privacy_select(p.privacy) }}
+
+
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/freak/templates/feed.html b/freak/templates/feed.html new file mode 100644 index 0000000..01057a4 --- /dev/null +++ b/freak/templates/feed.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %} +{% from "macros/title.html" import title_tag with context %} + +{# set feed_title = 'For you' if feed_type == 'foryou' and not feed_title %} +{% set feed_title = 'Explore' if feed_type == 'explore' and not feed_title #} + + +{% block title %} +{{ title_tag(feed_title) }} +{% endblock %} + +{% block heading %} +

{{ feed_title }}

+{% endblock %} + +{% block nav %} + {% if top_communities %} + {% from "macros/nav.html" import nav_top_communities with context %} + {{ nav_top_communities(top_communities) }} + {% endif %} + + {% if feed_type == 'topic' %} + {% from "macros/nav.html" import nav_topic with context %} + {{ nav_topic(topic) }} + {% endif %} + + + +{% endblock %} + +{% block content %} +
    + {% for p in l %} +
  • + {{ feed_post(p) }} +
  • + {% endfor %} + + {% if l.has_next %} + {{ stop_scrolling(l.page) }} + {% else %} + {{ no_more_scrolling(l.page) }} + {% endif %} +
+ + {# TODO: pagination #} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/freak/templates/landing.html b/freak/templates/landing.html new file mode 100644 index 0000000..81facef --- /dev/null +++ b/freak/templates/landing.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} + +{% block title %}{{ title_tag(None) }}{% endblock %} + +{% block heading %} +

Welcome to {{ app_name }}!

+{% endblock %} + +{% block content %} +
+

+ {{ app_name }} is a social media platform made by people like you.
+ Log in or sign up to see {{ post_count }} posts + and talk with {{ user_count }} users right now! +

+
+{% endblock %} + +{% block nav %} +{% if top_communities %} + {% from "macros/nav.html" import nav_top_communities with context %} + {{ nav_top_communities(top_communities) }} + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/freak/templates/login.html b/freak/templates/login.html new file mode 100644 index 0000000..ccef54d --- /dev/null +++ b/freak/templates/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/icon.html" import icon with context %} + +{% block title %} +{{ title_tag('Log in', False) }} +{% endblock %} + +{% block heading %} +

Log in

+{% endblock %} + +{% block content %} + {% if error %} +

Error: {{ error }}

+ {% endif %} + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +

Don’t have an account? Register.

+{% endblock %} diff --git a/freak/templates/macros/create.html b/freak/templates/macros/create.html new file mode 100644 index 0000000..1155e08 --- /dev/null +++ b/freak/templates/macros/create.html @@ -0,0 +1,35 @@ + +{% macro checked_if(cond) %} +{% if cond -%} +checked="" +{%- endif %} +{% endmacro %} + +{% macro disabled_if(cond) %} +{% if cond -%} +disabled="" +{%- endif %} +{% endmacro %} + +{% macro privacy_select(value = 0) %} +
    +
  • +
  • +
  • +
  • +
+{% endmacro %} + +{% macro comment_area(url) %} +
+ + +
+ +
+
+ + +
+
+{% endmacro %} \ No newline at end of file diff --git a/freak/templates/macros/embed.html b/freak/templates/macros/embed.html new file mode 100644 index 0000000..bdeec44 --- /dev/null +++ b/freak/templates/macros/embed.html @@ -0,0 +1,19 @@ +{% macro embed_post(p) %} +
+

{{ p.title }}

+
Posted by @{{ p.author.username }} + {% if p.parent_post %} + as a comment on post “{{ p.parent_post.title }}” + {% elif p.topic %} + on +{{ p.topic.name }} + {% else %} + on their user page + {% endif %} + - +
+ +
+ {{ p.text_content | to_markdown }} +
+
+{% endmacro %} \ No newline at end of file diff --git a/freak/templates/macros/feed.html b/freak/templates/macros/feed.html new file mode 100644 index 0000000..6457a35 --- /dev/null +++ b/freak/templates/macros/feed.html @@ -0,0 +1,133 @@ + +{% from "macros/icon.html" import icon, callout with context %} + +{% macro feed_post(p) %} +
+

{{ p.title }}

+
Posted by @{{ p.author.username }} + {% if p.topic %} + on +{{ p.topic.name }} + {% else %} + on their user page + {% endif %} + - +
+
+ {{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }} + {{ comment_count(p.comments | count) }} +
+ +
+ {{ p.text_content | to_markdown }} +
+
+{% endmacro %} + +{% macro feed_upvote(postid, count, uservote=0) %} +
+ {% if uservote > 0 %} + + upvoted + + {% else %} + + upvote + + {% endif %} + {{ count }} + {% if uservote < 0 %} + + downvoted + + {% else %} + + downvote + + {% endif %} +
+{% endmacro %} + +{% macro single_comment(comment) %} +
+ {% if comment.is_removed %} + {% call callout('delete') %}Removed comment{% endcall %} + {% else %} +
+ {% if comment.author %} + {{ comment.author.handle() }} + {% else %} + deleted account + {% endif %} + {% if comment.author and comment.author == comment.parent_post.author %} + (OP) + {% endif %} + {# TODO add is_distinguished i.e. official comment #} + - + +
+ +
+ {{ comment.text_content | to_markdown }} +
+ + {% endif %} +
+{% endmacro %} + +{% macro comment_count(c) %} +
+ + {{ c }} + comments +
+{% endmacro %} + +{% macro stop_scrolling(page_n = 1) %} +{% set choices1 = [ + 'STOP SCROLLING!', + 'Scrolling is bad for your health', + 'You scrolled enough for today', + 'There is grass out there', + 'Scrolling turns you into an extremist', + 'Is your time really worth this little?', + 'You learn nothing from social media' +] %} +{% set choices2 = [ + 'Nevermind', + 'I understand the risks', + 'I can\'t touch grass', + 'Get me some more anyway!', + 'I can\'t quit right now', + 'A little more~' +] %} +{% set choice1 = choices1 | random %} +{% set choice2 = choices2 | random %} +
+

{{ choice1 }}

+

{{ choice2 }}

+
+{% endmacro %} + +{% macro no_more_scrolling(page_n = 1) %} +{% set choices1 = [ + 'Congratulations, you are a no lifer', + 'Grass is still waiting out there', + 'You could have done something more productive tho' +] %} +
  • +

    You have reached the rock bottom + {%- if page_n > 10 or page_n + (range(10) | random) > 10 -%} + . {{ choices1 | random }} + {% endif %} +

  • +{% endmacro %} \ No newline at end of file diff --git a/freak/templates/macros/icon.html b/freak/templates/macros/icon.html new file mode 100644 index 0000000..aad078b --- /dev/null +++ b/freak/templates/macros/icon.html @@ -0,0 +1,11 @@ + +{% macro icon(name, fill = False) %} + +{% endmacro %} + +{% macro callout(useicon = "spoiler") %} +
    + {{ icon(useicon) }} + {{ caller() }} +
    +{% endmacro %} \ No newline at end of file diff --git a/freak/templates/macros/nav.html b/freak/templates/macros/nav.html new file mode 100644 index 0000000..3a63238 --- /dev/null +++ b/freak/templates/macros/nav.html @@ -0,0 +1,36 @@ + +{% macro nav_topic(topic) %} + +{% endmacro %} + +{% macro nav_user(user) %} + +{% endmacro %} + +{% macro nav_top_communities(top_communities) %} + +{% endmacro %} \ No newline at end of file diff --git a/freak/templates/macros/title.html b/freak/templates/macros/title.html new file mode 100644 index 0000000..f2ba8fc --- /dev/null +++ b/freak/templates/macros/title.html @@ -0,0 +1,16 @@ + +{% macro title_tag(name, robots=True) %} + +{%- if name -%} +{{ name }}; on {{ app_name }} +{%- else -%} +{{ app_name }} +{%- endif -%} + +{% if robots %} + +{% else %} + +{% endif %} + +{% endmacro %} \ No newline at end of file diff --git a/freak/templates/privacy.html b/freak/templates/privacy.html new file mode 100644 index 0000000..03d8f43 --- /dev/null +++ b/freak/templates/privacy.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} + +{% block title %}{{ title_tag('Privacy Policy') }}{% endblock %} + +{% block content %} +
    + {% filter to_markdown %} +# Privacy Policy + +This is a non-authoritative copy of the actual Privacy Policy, always updated at . + +This privacy policy explains how we use personal data we collect when you use +this website. + +## Who are we + +**New Digital Spirit** is a pending-registration limited liability company based in \[REDACTED], Italy. Our website with updated contact information is . + +Contact details: \[REDACTED] + +## What are our domains + +The New Digital Spirit Network includes these domains (and all relative subdomains): + +* sakuragasaki46.net; +* sakux.moe; +* yusur.moe; +* sfio.moe; +* newdigitalspirit.com; +* ndspir.it; +* cittadeldank.it; +* rinascitasentimentale.it; +* ilterrestre.org; +* yusurland.xyz; +* laprimaparola.info; +* faxrizz.xyz; +* lacasadimimiebubu.com; +* strozeromail.com; +* other domains owned for brand protection reasons, with no content and that redirect to the former. + +## What data do we collect + +All websites in the New Digital Spirit Network collect the following data, as a part of automatic and intentional logging: + +* **IP Addresses and User Agent Strings**. + +Additionally, all sites where login is allowed collect the following data: + +* **Session Cookies** - used for login +* **E-mail Addresses** - stored for password resets +* **Dates of Birth** - for legal compliance and terms enforcing reasons +* **User-Generated Content** - of various nature, provided by the user. The user is accountable for all of the data they upload, including sensitive information. + +## Our use of cookies + +We currently use transactional cookies for the purpose of staying logged in. If you disable those cookies, you will not be able to log in. + +No advertising cookies are being currently used on the New Digital Spirit Network. + +Websites on the network may additionally set a tracking cookie, for the purpose of +attack prevention ("legitimate interest"). These cookies are set for logged out users and may not be opted out. + +## How do we collect your data + +The data collected is provided as a part of automated logging, or +explicitly logged when accessing determined resources (in that case, a +warning is usually put when accessing the resource), included but not limited +to the use of tracking pixels. + +## How will we use your data + +The stated data is collected for various reasons, including law compliance, attack prevention and providing the service. + +We take privacy, be it ours or the one of our users, very seriously. + +We see leaks of private content (including chats) or data breach, be it in our public spaces or elsewhere, +as a betrayal of our trust and the trust of our users, other than a crime and a breach of NDA. +We'll close an eye ONLY when we happen to receive a valid subpoena from an accredited authority, +and we are forced to comply at gunpoint or under threat of legal consequences. + +## How do we store your data + +The data collected is stored securely in EU servers. However, +[our hosting provider](https://www.keliweb.it/) may have random access to the data we collect. + +IPs and user agents logged explicitly are deleted after about 3 years. + +## What are your data protection rights + +* **Right to access** - You have the right to request New Digital Spirit for copies + of your personal data. +* **Right to rectification** - You have the right to request that + New Digital Spirit correct or complete any information you believe is not + accurate or incomplete. +* **Right to erasure** - You have the right to request that New Digital Spirit + erase your personal data, under certain condition. +* **Right to restrict processing** - You have the right to request that + New Digital Spirit restrict the processing of your personal data, under certain + conditions. +* **Right to object to processing** - You have the right to object to + New Digital Spirit’s processing of your personal data, under certain conditions. +* **Right to data portability** - You have the right to request that + New Digital Spirit transfer the data that we have collected to another + organization, or directly to you, under certain conditions. + +If you make a request, we have one (1) month to respond to you. +If you would like to exercise any of these rights, please contact us at our +email: \[REDACTED] + +## Minimum age + +We do not knowingly collect data from users under the age of 13, or United States residents under the age of 18. + +Data knowingly from accounts belonging to underage users will be deleted, and their accounts will be terminated. + +## Cookies + +Cookies are text files placed on your computer to collect standard Internet +log information and visitor behavior information. When you visit our websites, +we may collect information from you automatically throught cookies or similar technology. + +For further information, visit [allaboutcookies.org](https://allaboutcookies.org) + +## Privacy policies of other websites + +This privacy policy applies exclusively to the websites of the New Digital Spirit Network. Other +websites and subdomains have different privacy policies you should read. + +## Updates + +Last updated on May 13, 2025. + + {% endfilter %} +
    +{% endblock %} \ No newline at end of file diff --git a/freak/templates/register.html b/freak/templates/register.html new file mode 100644 index 0000000..35fc51a --- /dev/null +++ b/freak/templates/register.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/icon.html" import icon, callout with context %} + +{% block title %}{{ title_tag('Register', False) }}{% endblock %} + +{% block heading %} +

    Join {{ app_name }}

    +{% endblock %} + +{% block content %} +
    +
    + +
    + +
    +
    +
    + + +
    +
    + +
    + Please choose a strong password containing letters, numbers and special characters. +
    +
    + + +
    +
    + +
    + A valid email address is required to recover your account. +
    +
    + +
    + Your birthday is not shown to anyone. Some age information may be made available for transparency. + +
    + {% if not current_user.is_anonymous %} +
    + {% call callout() %}You are currently logged in. Are you sure you want to create another account?{% endcall %} + + +
    + {% endif %} + +
    + +
    +
    +
    + +

    Already have an account? Log in

    +{% endblock %} diff --git a/freak/templates/reports/report_404.html b/freak/templates/reports/report_404.html new file mode 100644 index 0000000..3205ca0 --- /dev/null +++ b/freak/templates/reports/report_404.html @@ -0,0 +1,20 @@ +{% extends "reports/report_base.html" %} + +{% block heading %} +

    Not Found

    + +

    You can't report a {% if target_type == 2 -%} +comment +{%- else -%} +post +{%- endif %} which does not exist.

    +{% endblock %} + +{% block options %} + +{% endblock %} + diff --git a/freak/templates/reports/report_base.html b/freak/templates/reports/report_base.html new file mode 100644 index 0000000..1829d4e --- /dev/null +++ b/freak/templates/reports/report_base.html @@ -0,0 +1,61 @@ + + + + Report | {{ app_name }} + + + + + + {% set selection = request.args.get('reason') if request.method != 'POST' %} + {% set selection_description = description_text(report_reasons, selection) if selection and report_reasons %} +
    +
    + {% block heading %} +

    Report

    + {% endblock %} +
    +
    + {% if selection %} +
    +

    You are about to submit a report for: {{ selection_description }}.

    +

    You hereby guarantee {{ app_name }} that your report is made in good faith and not duplicate.

    +

    {{ app_name }} removes content in violation of our Community Guidelines.

    +
    + + + {% block report_data %}{% endblock %} + +
    +
    + {% else %} +
    + {% block options %} +
      +
    • a
    • +
    + {% endblock %} +
    + {% endif %} +
    +
    + {{ app_name }} © 2025 Sakuragasaki46 +
    + + \ No newline at end of file diff --git a/freak/templates/reports/report_comment.html b/freak/templates/reports/report_comment.html new file mode 100644 index 0000000..8b8036e --- /dev/null +++ b/freak/templates/reports/report_comment.html @@ -0,0 +1,22 @@ +{% extends "reports/report_base.html" %} + +{% block heading %} +

    Report comment

    + +

    You are about to report the comment with ID {{ id |to_b32l }}

    +{% endblock %} + +{% block options %} + +{% endblock %} + +{% block report_data %} + + +{% endblock %} \ No newline at end of file diff --git a/freak/templates/reports/report_done.html b/freak/templates/reports/report_done.html new file mode 100644 index 0000000..69d9997 --- /dev/null +++ b/freak/templates/reports/report_done.html @@ -0,0 +1,19 @@ +{% extends "reports/report_base.html" %} + +{% block heading %} +

    Success!

    + +

    Your report has been received.

    +{% endblock %} + +{% block options %} + +{% endblock %} + diff --git a/freak/templates/reports/report_post.html b/freak/templates/reports/report_post.html new file mode 100644 index 0000000..9e3167c --- /dev/null +++ b/freak/templates/reports/report_post.html @@ -0,0 +1,22 @@ +{% extends "reports/report_base.html" %} + +{% block heading %} +

    Report post

    + +

    You are about to report the post with ID {{ id |to_b32l }}

    +{% endblock %} + +{% block options %} + +{% endblock %} + +{% block report_data %} + + +{% endblock %} \ No newline at end of file diff --git a/freak/templates/reports/report_self.html b/freak/templates/reports/report_self.html new file mode 100644 index 0000000..3b4fe70 --- /dev/null +++ b/freak/templates/reports/report_self.html @@ -0,0 +1,19 @@ +{% extends "reports/report_base.html" %} + +{% block heading %} +

    Are you confused?

    + +

    You can't report your own post or comment. Try editing or deleting.

    +{% endblock %} + +{% block options %} + +{% endblock %} + diff --git a/freak/templates/rules.html b/freak/templates/rules.html new file mode 100644 index 0000000..004f75c --- /dev/null +++ b/freak/templates/rules.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} + +{% block title %}{{ title_tag('Community Guidelines') }}{% endblock %} + +{% block content %} +
    + {% filter to_markdown %} +# Community Guidelines + +This is a non-authoritative copy of the New Digital Spirit General Regulation, always updated at . + +Every place has rules. +Rules define how people must behave in order to preserve the place's integrity, and are expressions of the will of whoever rules over the place. Usually, part of the rules include basic safety directives and other stuff to make people stay. +You may not participate in our spaces, except in accordance with the rules. + +_Last updated: May 5, 2025_ + +## 1. Remember the human + +Empathy, respect and mutual understanding are at the base of any lasting relationship. +Keep a positive influence, and contribute to improving our community and keeping it safe. +Any form of harassment, violence, bullying, credible threats, bigotry, discrimination, hate speech or dehumanizing is not welcome in the spaces of New Digital Spirit. + +## 2. Keep it legal + +Follow all applicable law (specifically, Italian law and the law in force on the platform), and the Terms of Service of the platform. + +> We are not reporting here the law as a whole. +> You can find out more about Italian law on these sites: +> - [Normattiva](https://www.normattiva.it/) +> - [Gazzetta Ufficiale](https://www.gazzettaufficiale.it/) +> - [Brocardi](https://www.brocardi.it/) +> +> Your interpretation of the laws is **at your own risk**; when in doubt, **contact your lawyer**. +> +> Here is a list of most severe crimes in (nearly) all countries: +> - **Child pornography** ( ) +> - **Terrorism** +> - **Piracy**/**Copyright infringement**, including downloading, hosting or torrenting copyrighted content (see also rule 10) +> - **Human trafficking** +> - **Sale of drugs** and other regulated goods +> - **Sale of firearms** and other weapons +> - **Murder** +> - **Turning against law enforcement** such as police, including violence, threats, deceit or refusal to comply with orders or identifying oneself +> - **Adultery**/**Rape** - the former in underdeveloped countries, the latter in developed ones + +## 3. Don't turn against us + +If you have trouble with us, discuss it first with the staff. +Do not put us in trouble by any means, including legal actions or threats, raiding, shitstorming, false accusations, morality trolling, intellectual property violation, and any other act in bad faith against us. +Severe violations of this kind will be met with an unappealable permanent ban. + +> You agree to _indemnify_ and _hold harmless_ us, remember. + +## 4. Don't turn against other people + +Respect other members' privacy and dignity, and make them feel safe all the time. +Inform yourself about consent and boundaries in advance, respect them, and do not engage in stalking or intimidatory conduct. Do not share personally identifiable information (PII) — such as real names, credit card numbers, SSNs, phone numbers, home or work addresses, and face pics. Do not trigger other people's feelings on purpose (i.e. flame or troll). +If you are being blocked, leave them alone and move on. + +## 5. Don't break our spaces + +Other people have the right to enjoy our spaces in safety. +Do not attempt any form of privilege escalation or disruption. +Do not manipulate the staff or other users. +Do not attempt infrastructural damage, such as security exploits, (D)DoS, nukes, account grabbing, automated raids, social engineering, spamming and flooding. Don't exploit anyone physically or psychologically. + +## 6. Enjoy your stay + +Nobody is allowed to sell or advertise any product, service or social media channel in our spaces without the staff's authorization. +Always ask other members, before sending them direct messages (DM), if they are okay with it. +Porn stuff (e.g. OnlyFans), sexting/catcalling and financial scams are NEVER welcome. +Do not steal members from our community. + +## 7. Stay on topic + +Label appropriately any content. +Mark any spoiler and content (i.e. CW) that may hurt someone else's sensibility. + +Keep the conversation on topic, and don't attempt to hijack the conversation or go off-topic. +Respect channel specific rules: NSFW and gore are prohibited unless explicitly allowed in the channel or server. + +You are encouraged to use tone tags in ambiguous situations. + +Avoid speaking or writing in languages the staff or other members can't understand and moderate. +Limited discussions in those languages is allowed as long as an accurate translation is provided along. +Excessive jargon or argot (such as TikTok brainrot) is generally not allowed. + +## 8. Be yourself + +You are allowed to remain pseudonymous, and use the nickname or pfp that better fits you. +However, you may not impersonate other users or famous people, use blank or misleading usernames, or pretend to be a mod or admin. +Do not post content you don't own without credits or attribution. +Lying about own age is strictly forbidden. + +## 9. Be sincere + +Keep our spaces authentic and trusted. +Don't spread misinformation. +Fact-check any claim, especially when sensationalistic or newsworthy, before sending or sharing it. +Do not foster conspiracy theories or pseudoscience. +Do not tell lies in order to deceive the staff or fellow members. +Always disclose usage of AI; bots posing as humans are strictly not tolerated. + +## 10. What happens here, remains here + +Except otherwise noted, anything sent in here is copyrighted. +Use outside our spaces of any conversation without authorization is forbidden, including in court and to train AI models. +Do not leak contents of private channels into public ones or elsewhere, or you'll lose access to our spaces as a whole. + +We take leaks of private chats (be it on public channels of ours or other media) very seriously. +It is betrayal of our trust and the trust of our users, other than a crime and a breach of NDA, and it is grounds for terminating your account. +(We'll close an eye ONLY when we happen to receive a valid subpoena from an accredited authority, and we are forced to comply at gunpoint or under threat of legal consequences.) [Learn more…](javascript:void(0);) + +> In legalese, you grant us a _non-exclusive, non-transferable, sublicensable, worldwide_ license to use your message content for the purpose of displaying it to other users, and allowing them to interact with you. +> +> You are prohibited from using public and private conversations: +> +> - in court, or as evidence to back rule 3 violations; +> - to train AI (LLM, GPT, ...) models; +> - as part of an investigation for the purpose of legal prosecution; +> - for targeted advertising profilation; +> - in a way that infringes upon applicable copyrights. + +## 11. Behave your age + +Be mature, and don't engage in immature behavior or lose control of yourself. +Do not gain access to age-restricted channels and spaces if you are not old enough (i.e. you can't access adult-only/NSFW channels while under 18). +In behaviors where age makes a difference, state clearly your age, and get to know the age of others. +**Lying about own age is strictly forbidden.** + +You may not engage in any sexual activity (including flirting, sexual roleplay and suggestive behavior) if you are under 18, the other person is not consentient, or outside adult-only spaces, in presence of any minor. +You have the duty to recognize whether someone is trolling you sexually ("jailbait"), and firmly refuse to engage with such behavior. +**Zero tolerance for adults hitting on minors («pedophilia»)**; see our [statement on CSAM and Minor Account Policy](https://sakux.moe/policies/u18.html) + +## 12. Keep your stuff to yourself + +Do not bring unnecessary drama to our community. +Do not spill your emotions or project your issues all over us. + +**We are not your army**. Do not engage in or ask us to engage in "wars" or feuds. +Do not ask us to do things (be them good or bad) for you, for free. +If you want us to do something, you have to pay us. +And we still have the right to refuse to do it. + +Do not blame us for things out of our control, we are not responsible for that. + +## 13. Take accountability for your actions + +Every action has a consequence. +If you break the rules, expect punishment or decay of privileges. +Your punishment is applied to every account you own alike. +Once you are banned, you are banned forever. +You may not use alts to get around moderation decisions or return after being banned. + +> Warns and time-outs are final. +> +> At administration's discretion, you may be able to appeal your permanent ban, or pay a small fee to get unbanned. You may submit only one appeal (regardless of it being granted or denied) or pay only one unban fee every 30 days. Permanent bans may be appealed only 3 months after the issue date, or later. Permanent bans for rule 3 (putting us at risk) violations, or for breaking the law, can NEVER be appealed. +> +> We don't care if you get banned from the platform. +> +> Do not use modded clients for illegal purposes, invasion of privacy or ban circumvention. +> +> We reserve the right to ban on sight users and IP addresses we deem highly dangerous for the safety of our community. Remember: **belonging to our community is a privilege, not a right**. + +## 14. Staff has the last words + +Admins and moderators are the ones in charge of building our community and keeping it clean. +It's not their job, they do it on their free time and they are not paid or rewarded for this. +Therefore, be kind and respectful with them. Staff decisions are final. +You may not ask for moderation permissions or server transfers. + +If the staff is breaking the rules and/or making you feel unsafe, report them to me. +I'll take charge and hold them accountable. + +## 15. Follow channel-specific rules + +Every community and channel is free to define additional rules to their fitness, and its members must abide by them, in addition to global rules and the law. +Channel rules that go against global rules cannot be set. + +If you feel unsafe in a community, or feel like your actions and/or presence makes someone else uncomfortable, leave it. +Nobody needs to belong to every community. + +## Final words + +The updated ruleset is always available at [https://ndspir.it/rules](https://ndspir.it/rules). + +In case of conflicts or discrepancies between translations, the English version takes precedence. + +The entire text of our General Regulation is free for everyone to use, as long as the text and its core concepts are not altered in a significant way. We encourage its adoption in order to make rules more clear, respecting them more mindless, and moderation easier. +{% endfilter %} \ No newline at end of file diff --git a/freak/templates/search.html b/freak/templates/search.html new file mode 100644 index 0000000..3831967 --- /dev/null +++ b/freak/templates/search.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/feed.html" import feed_post, stop_scrolling with context %} + +{% block title %}{{ title_tag('Search: ' + q if q else 'Search', False) }}{% endblock %} + +{% block body %} + + +{% if results %} +

    Results for {{ q }}

    + +
      + {% for p in results %} +
    • {{ feed_post(p) }}
    • + {% endfor %} + {% if results.has_next %} +
    • {{ stop_scrolling(results.page) }}
    • + {% else %} +
    • You have reached the rock bottom

    • + {% endif %} +
    +{% elif q %} +

    No results for {{ q }}

    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/freak/templates/singlepost.html b/freak/templates/singlepost.html new file mode 100644 index 0000000..7c50fa3 --- /dev/null +++ b/freak/templates/singlepost.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/feed.html" import single_comment, feed_upvote, comment_count with context %} +{% from "macros/create.html" import comment_area with context %} +{% from "macros/icon.html" import icon, callout with context %} + +{% block title %}{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}{% endblock %} + +{% block nav %} + {% if p.topic %} + {% from "macros/nav.html" import nav_topic with context %} + {{ nav_topic(p.topic) }} + {% endif %} +{% endblock %} + +{% block content %} +
    +
    +
    +

    {{ p.title }}

    +
    + Posted by @{{ p.author.username }} + {% if p.topic %} + on +{{ p.topic.name }} + {% else %} + on their user page + {% endif %} + - +
    + {% if current_user.is_administrator and p.report_count() %} + {% call callout() %} + {{ p.report_count() }} reports. Take action + {% endcall %} + {% endif %} + {% if p.is_removed %} + {% call callout('delete') %} + This post has been removed + {% endcall %} + {% endif %} +
    + {{ p.text_content | to_markdown }} +
    +
    +
    + {{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }} + {{ comment_count(p.comments | count) }} +
    +
    +
      + {% if p.author == current_user %} +
    • Edit
    • + {% else %} +
    • Report
    • + {% endif %} +
    + {{ comment_area(p.url()) }} +
    +
      + {% for comment in p.top_level_comments() %} +
    • + {{ single_comment(comment) }} + + {# if comment.children %} + {{ comment_tree(comment) }} + {% endif #} +
    • + {% endfor %} +
    +
    +
    +{% endblock %} diff --git a/freak/templates/terms.html b/freak/templates/terms.html new file mode 100644 index 0000000..5788f70 --- /dev/null +++ b/freak/templates/terms.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% from "macros/title.html" import title_tag with context %} + +{% block title %}{{ title_tag('Terms of Service') }}{% endblock %} + +{% block content %} +
    + {% filter to_markdown %} +# Terms of Service + +This is a non-authoritative copy of the actual Terms, always updated at . + +The following documents are incorporated into these Terms by reference +(i.e. an extension to these Terms in force): + +* [Privacy Policy](/privacy) +* [Community Guidelines](/rules) +* [User Generated Content Terms](https://yusur.moe/policies/ugc.html) on newdigitalspirit.com +* [Minors' Account Policy](https://yusur.moe/policies/u18.html) on newdigitalspirit.com + +## Scope and Definition + +These terms of service ("Terms") are between **New Digital Spirit**, i.e. its CEO **Sakuragasaki46**, and You, +regarding Your use of all sites and services belonging to New Digital Spirit ("New Digital Spirit Network" / "the Services"), +listed in detail in [Privacy Policy](/policies/privacy.html). + +Other websites are not covered by these Terms. + +## Age + +The whole of New Digital Spirit Network is PG-13. You may not use the Services if you are younger than 13 years old. + +Additionally, you may not directly contact New Digital Spirit if you are younger than 18 years old, for any reason besides +privacy-related requests. Any contact request knowingly from people younger than 18 will be ignored. + +United States resident under the age of 18 are **not allowed** in any way to access our network without logging in. + +New Digital Spirit reserves the right to require ID verification in case of age doubt or potential security threat. + +Minors on New Digital Spirit Network are additionally bound to the [Minor Account Policy](/policies/u18.html), +incorporated here by reference. + +Systems and plurals are considered to be minors, no matter their body age. + +## Intellectual property + +Except otherwise noted, the entirety of the content on the New Digital Spirit Network +is intellectual property of Sakuragasaki46 and New Digital Spirit. All rights reserved. + +You may not copy, modify, redistribute, mirror the contents of or create alternative Service to +yusur.moe or any other of the Services, or portions thereof, without New Digital Spirit's +prior written permission. + +## Privacy Rights + +You may not disclose any personally identifiable information (PII) in your possession +that is related to Sakuragasaki46's online persona and that may lead to Sakuragasaki46's +identification or damages to Sakuragasaki46's private life. + +Disclosure will be legally regarded as a violation of privacy and a breach of +non-disclosure agreement (NDA), and will be acted upon accordingly, regardless of +the infringer's age or any other legal protection, included but not limited to +termination of the infringer,s accounts. + +## IP Loggers + +Some sections of the New Digital Spirit Network log IP addresses. + +You agree to be logged for security and attack prevention reasons, on the basis of +legitimate interest. Logged information contains user agent strings as well. + +## User Generated Content + +Some of our Services allow user generated content. By using them, you agree to be bound +to the [User Generated Content Terms](/policies/ugc.html), incorporated here by reference. + +## No Warranty + +**Except as represented in this agreement, the New Digital Spirit Network +is provided ​“AS IS”. Other than as provided in this agreement, +New Digital Spirit makes no other warranties, express or implied, and hereby +disclaims all implied warranties, including any warranty of merchantability +and warranty of fitness for a particular purpose.** + +## Liability + +Sakuragasaki46 or New Digital Spirit **shall not be accountable** for Your damages arising from Your use +of the New Digital Spirit Network. + +## Indemnify + +You agree to [indemnify and hold harmless](https://www.upcounsel.com/difference-between-indemnify-and-hold-harmless) +Sakuragasaki46 and New Digital Spirit from any and all claims, damages, liabilities, costs and expenses, including reasonable and unreasonable +counsel and attorney’s fees, arising out of any breach of this agreement. + +## Severability + +If any of these Terms (including other Terms incorporated here by reference) shall turn out to be unenforceable, +according to the governing law, the remainder of these Terms shall remain in place. + +## Governing Law + +These terms of services are governed by, and shall be interpreted in accordance +with, the laws of Italy. You consent to the sole jurisdiction of \[REDACTED], Italy +for all disputes between You and , and You consent to the sole +application of Italian law and European Union law for all such disputes. + +## Updates + +Last updated on May 13, 2025. + + + {% endfilter %} +
    +{% endblock %} \ No newline at end of file diff --git a/freak/templates/userfeed.html b/freak/templates/userfeed.html new file mode 100644 index 0000000..c8b9714 --- /dev/null +++ b/freak/templates/userfeed.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %} +{% from "macros/title.html" import title_tag with context %} +{% from "macros/icon.html" import icon, callout with context %} + +{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %} +{% block heading %} +

    {{ user.handle() }}

    +

    {{ icon('karma') }} {{ user.karma }} karma - Joined at: - ID: {{ user.id|to_b32l }}

    +{% endblock %} + +{% block content %} + {% if l and l.pages > 0 %} + {% if not user.is_active %} + {% call callout('ban') %}The account {{ user.handle() }} is suspended{% endcall %} + {% endif %} +
      + {% for p in l %} +
    • + {{ feed_post(p) }} +
    • + {% endfor %} + {% if l.has_next %} + {{ stop_scrolling(l.page) }} + {% else %} + {{ no_more_scrolling(l.page) }} + {% endif %} +
    + {% elif not user.is_active %} +

    {{ user.handle() }} is suspended

    + {% else %} +

    {{ user.handle() }} never posted any content

    + {% endif %} +{% endblock %} + diff --git a/freak/utils.py b/freak/utils.py new file mode 100644 index 0000000..24dacd3 --- /dev/null +++ b/freak/utils.py @@ -0,0 +1,40 @@ + +import datetime +import functools +import math +import os +import time +import re +from flask import request + +def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]: + if now is None: + now = datetime.date.today() + y = now.year - date.year - ((now.month, now.day) < (date.month, date.day)) + d = (now - datetime.date(date.year + y, date.month, date.day)).days + return y, d + +def get_remote_addr(): + if request.remote_addr in ('127.0.0.1', '::1') and os.getenv('APP_IS_BEHIND_PROXY'): + return request.headers.getlist('X-Forwarded-For')[0] + return request.remote_addr + +def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False): + def decorator(func): + start_time = None + + @functools.lru_cache(maxsize, typed) + def inner_wrapper(ttl_period: int, *a, **k): + return func(*a, **k) + + @functools.wraps(func) + def wrapper(*a, **k): + nonlocal start_time + if not start_time: + start_time = int(time.time()) + return inner_wrapper(math.floor((time.time() - start_time) // ttl), *a, **k) + return wrapper + return decorator + +def is_b32l(username: str) -> bool: + return re.fullmatch(r'[a-z2-7]+', username) \ No newline at end of file diff --git a/freak/website/__init__.py b/freak/website/__init__.py new file mode 100644 index 0000000..5d19801 --- /dev/null +++ b/freak/website/__init__.py @@ -0,0 +1,27 @@ + + +blueprints = [] + +from .frontpage import bp +blueprints.append(bp) + +from .accounts import bp +blueprints.append(bp) + +from .detail import bp +blueprints.append(bp) + +from .create import bp +blueprints.append(bp) + +from .edit import bp +blueprints.append(bp) + +from .about import bp +blueprints.append(bp) + +from .reports import bp +blueprints.append(bp) + +from .admin import bp +blueprints.append(bp) \ No newline at end of file diff --git a/freak/website/about.py b/freak/website/about.py new file mode 100644 index 0000000..14c0ab2 --- /dev/null +++ b/freak/website/about.py @@ -0,0 +1,29 @@ + +import sys +from flask import Blueprint, render_template, __version__ as flask_version +from sqlalchemy import __version__ as sa_version +from .. import __version__ as app_version + +bp = Blueprint('about', __name__) + +@bp.route('/about/') +def about(): + return render_template('about.html', + flask_version=flask_version, + sa_version=sa_version, + python_version=sys.version.split()[0], + app_version=app_version + ) + +@bp.route('/terms/') +def terms(): + return render_template('terms.html') + +@bp.route('/privacy/') +def privacy(): + return render_template('privacy.html') + +@bp.route('/rules/') +def rules(): + return render_template('rules.html') + diff --git a/freak/website/accounts.py b/freak/website/accounts.py new file mode 100644 index 0000000..221729f --- /dev/null +++ b/freak/website/accounts.py @@ -0,0 +1,103 @@ +import os, sys +import re +import datetime +from typing import Mapping +from flask import Blueprint, render_template, request, redirect, flash +from flask_login import login_user, logout_user, current_user +from ..models import REPORT_REASONS, db, User +from ..utils import age_and_days +from sqlalchemy import select, insert +from werkzeug.security import generate_password_hash + +bp = Blueprint('accounts', __name__) + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST' and request.form['username']: + username = request.form['username'] + password = request.form['password'] + if '@' in username: + user = db.session.execute(select(User).where(User.email == username)).scalar() + else: + user = db.session.execute(select(User).where(User.username == username)).scalar() + + if user and '$' not in user.passhash: + flash('You need to reset your password following the procedure.') + return render_template('login.html') + elif not user or not user.check_password(password): + flash('Invalid username or password') + return render_template('login.html') + elif not user.is_active: + flash('Your account is suspended') + else: + remember_for = int(request.form.get('remember', 0)) + if remember_for > 0: + login_user(user, remember=True, duration=datetime.timedelta(days=remember_for)) + else: + login_user(user) + return redirect(request.args.get('next', '/')) + return render_template('login.html') + +@bp.route('/logout') +def logout(): + logout_user() + flash('Logged out. Come back soon~') + return redirect(request.args.get('next','/')) + +## XXX temp +def _currently_logged_in() -> bool: + return current_user and current_user.is_authenticated + +def validate_register_form() -> dict: + f = dict() + try: + f['gdpr_birthday'] = datetime.date.fromisoformat(request.form['birthday']) + + if age_and_days(f['gdpr_birthday']) < (14,): + f['banned_at'] = datetime.datetime.now() + f['banned_reason'] = REPORT_REASONS['underage'] + except ValueError: + raise ValueError('Invalid date format') + f['username'] = request.form['username'].lower() + if not re.fullmatch('[a-z0-9_-]+', f['username']): + raise ValueError('Username can contain only letters, digits, underscores and dashes.') + f['display_name'] = request.form.get('full_name') + + if request.form['password'] != request.form['confirm_password']: + raise ValueError('Passwords do not match.') + f['passhash'] = generate_password_hash(request.form['password']) + + f['email'] = request.form['email'] or None, + + if _currently_logged_in() and not request.form.get('confirm_another'): + raise ValueError('You are already logged in. Please confirm you want to create another account by checking the option.') + if not request.form.get('legal'): + raise ValueError('You must accept Terms in order to create an account.') + + return f + + +@bp.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST' and request.form['username']: + try: + user_data = validate_register_form() + except ValueError as e: + if e.args: + flash(e.args[0]) + return render_template('register.html') + + try: + db.session.execute(insert(User).values(**user_data)) + + db.session.commit() + + flash('Account created successfully. You can now log in.') + return redirect(request.args.get('next', '/')) + except Exception as e: + sys.excepthook(*sys.exc_info()) + flash('Unable to create account (possibly your username is already taken)') + return render_template('register.html') + + return render_template('register.html') + diff --git a/freak/website/admin.py b/freak/website/admin.py new file mode 100644 index 0000000..52bee0d --- /dev/null +++ b/freak/website/admin.py @@ -0,0 +1,79 @@ + + +import datetime +from functools import wraps +from typing import Callable +from flask import Blueprint, abort, redirect, render_template, request, url_for +from flask_login import current_user +from sqlalchemy import select, update + +from ..models import REPORT_REASON_STRINGS, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, db + +bp = Blueprint('admin', __name__) + +## TODO make admin interface + +def admin_required(func: Callable): + @wraps(func) + def wrapper(**ka): + user: User = current_user + if not user.is_authenticated or not user.is_administrator: + abort(403) + return func(**ka) + return wrapper + +def accept_report(target, source: PostReport): + if isinstance(target, Post): + target.removed_at = datetime.datetime.now() + target.removed_by_id = current_user.id + target.removed_reason = source.reason_code + elif isinstance(target, Comment): + target.removed_at = datetime.datetime.now() + target.removed_by_id = current_user.id + target.removed_reason = source.reason_code + db.session.add(target) + + source.update_status = REPORT_UPDATE_COMPLETE + db.session.add(source) + db.session.commit() + +def reject_report(target, source: PostReport): + source.update_status = REPORT_UPDATE_REJECTED + db.session.add(source) + db.session.commit() + +def withhold_report(target, source: PostReport): + source.update_status = REPORT_UPDATE_ON_HOLD + db.session.add(source) + db.session.commit() + +REPORT_ACTIONS = { + '1': accept_report, + '0': reject_report, + '2': withhold_report +} + +@bp.route('/admin/') +@admin_required +def homepage(): + return render_template('admin/admin_home.html') + +@bp.route('/admin/reports/') +@admin_required +def reports(): + report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc())) + return render_template('admin/admin_reports.html', + report_list=report_list, report_reasons=REPORT_REASON_STRINGS) + +@bp.route('/admin/reports/', methods=['GET', 'POST']) +@admin_required +def report_detail(id: int): + report = db.session.execute(select(PostReport).where(PostReport.id == id)).scalar() + if report is None: + abort(404) + if request.method == 'POST': + action = REPORT_ACTIONS[request.form['do']] + action(report.target(), report) + return redirect(url_for('admin.reports')) + return render_template('admin/admin_report_detail.html', report=report, + report_reasons=REPORT_REASON_STRINGS) diff --git a/freak/website/create.py b/freak/website/create.py new file mode 100644 index 0000000..42f8b61 --- /dev/null +++ b/freak/website/create.py @@ -0,0 +1,74 @@ + + +import sys +import datetime +from flask import Blueprint, abort, redirect, flash, render_template, request, url_for +from flask_login import current_user, login_required +from sqlalchemy import insert +from ..models import User, db, Topic, Post + +bp = Blueprint('create', __name__) + +@bp.route('/create/', methods=['GET', 'POST']) +@login_required +def create(): + user: User = current_user + if request.method == 'POST' and 'title' in request.form: + topic_name = request.form['to'] + if topic_name: + topic: Topic | None = db.session.execute(db.select(Topic).where(Topic.name == topic_name)).scalar() + if topic is None: + flash(f'Topic +{topic_name} not found, posting to your user page instead') + else: + topic = None + title = request.form['title'] + text = request.form['text'] + privacy = int(request.form.get('privacy', '0')) + try: + new_post: Post = db.session.execute(insert(Post).values( + author_id = user.id, + topic_id = topic.id if topic else None, + created_at = datetime.datetime.now(), + privacy = privacy, + title = title, + text_content = text + ).returning(Post.id)).fetchone() + + db.session.commit() + flash(f'Published on {'+' + topic_name if topic_name else '@' + user.username}') + return redirect(url_for('detail.post_detail', id=new_post.id)) + except Exception as e: + sys.excepthook(*sys.exc_info()) + flash('Unable to publish!') + return render_template('create.html') + + +@bp.route('/createguild/', methods=['GET', 'POST']) +@login_required +def createguild(): + if request.method == 'POST': + user: User = current_user + + if not user.can_create_community(): + flash('You are NOT allowed to create new guilds.') + abort(403) + + c_name = request.form['name'] + try: + c_id = db.session.execute(db.insert(Topic).values( + name = c_name, + display_name = request.form.get('display_name', c_name), + description = request.form['description'], + owner_id = user.id + ).returning(Topic.id)).fetchone() + + db.session.commit() + return redirect(url_for('frontpage.topic_feed', name=c_name)) + except Exception: + sys.excepthook(*sys.exc_info()) + flash('Unable to create guild. It may already exist or you could not have permission to create new communities.') + return render_template('createguild.html') + +@bp.route('/createcommunity/') +def createcommunity_redirect(): + return redirect(url_for('create.createguild')), 301 \ No newline at end of file diff --git a/freak/website/detail.py b/freak/website/detail.py new file mode 100644 index 0000000..4eeab0d --- /dev/null +++ b/freak/website/detail.py @@ -0,0 +1,100 @@ + +from flask import Blueprint, abort, flash, request, redirect, render_template, url_for +from flask_login import current_user, login_required +from sqlalchemy import select + +from ..iding import id_from_b32l +from ..utils import is_b32l +from ..models import Comment, db, User, Post, Topic +from ..algorithms import user_timeline + +bp = Blueprint('detail', __name__) + +@bp.route('/@') +def user_profile(username): + user = db.session.execute(select(User).where(User.username == username)).scalar() + + if user is None: + abort(404) + + posts = user_timeline(user.id) + + return render_template('userfeed.html', l=db.paginate(posts), user=user) + + +@bp.route('/u/') +@bp.route('/user/') +def user_profile_u(username: str): + if is_b32l(username): + userid = id_from_b32l(username) + user = db.session.execute(select(User).where(User.id == userid)).scalar() + if user is not None: + username = user.username + return redirect('/@' + username), 302 + + +@bp.route('/@/') +def user_profile_s(username): + return redirect('/@' + username), 301 + + +def single_post_post_hook(p: Post): + if 'reply_to' in request.form: + reply_to_id = request.form['reply_to'] + text = request.form['text'] + reply_to_p = db.session.execute(db.select(Post).where(Post.id == id_from_b32l(reply_to_id))).scalar() if reply_to_id else None + + db.session.execute(db.insert(Comment).values( + author_id = current_user.id, + parent_post_id = p.id, + parent_comment_id = reply_to_p, + text_content = text + )) + db.session.commit() + flash('Comment published') + return redirect(p.url()), 303 + abort(501) + +@bp.route('/comments/') +def post_detail(id: int): + post: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + + if post and post.url() != request.full_path: + return redirect(post.url()), 302 + else: + abort(404) + +@bp.route('/@/comments//', methods=['GET', 'POST']) +@bp.route('/@/comments//', methods=['GET', 'POST']) +def user_post_detail(username: str, id: int, slug: str = ''): + post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar() + + if post is None or (post.is_removed and post.author != current_user): + abort(404) + + if post.slug and not slug: + return redirect(url_for('detail.user_post_detail_slug', username=username, id=id, slug=post.slug)), 302 + + if request.method == 'POST': + single_post_post_hook(post) + + return render_template('singlepost.html', p=post) + +@bp.route('/+/comments//', methods=['GET', 'POST']) +@bp.route('/+/comments//', methods=['GET', 'POST']) +def topic_post_detail(topicname, id, slug=''): + post: Post | None = db.session.execute(select(Post).join(Topic).where(Post.id == id, Topic.name == topicname)).scalar() + + if post is None or (post.is_removed and post.author != current_user): + abort(404) + + if post.slug and not slug: + return redirect(url_for('detail.topic_post_detail_slug', topicname=topicname, id=id, slug=post.slug)), 302 + + if request.method == 'POST': + single_post_post_hook(post) + + return render_template('singlepost.html', p=post) + + + diff --git a/freak/website/edit.py b/freak/website/edit.py new file mode 100644 index 0000000..1dfecdf --- /dev/null +++ b/freak/website/edit.py @@ -0,0 +1,36 @@ + + + +import datetime +from flask import Blueprint, abort, flash, redirect, render_template, request +from flask_login import current_user, login_required + +from ..models import Post, db + + +bp = Blueprint('edit', __name__) + +@bp.route('/edit/post/', methods=['GET', 'POST']) +@login_required +def edit_post(id): + p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + + if p is None: + abort(404) + if current_user.id != p.author.id: + abort(403) + + if request.method == 'POST': + text = request.form['text'] + privacy = int(request.form.get('privacy', '0')) + + db.session.execute(db.update(Post).where(Post.id == id).values( + text_content = text, + privacy = privacy, + updated_at = datetime.datetime.now() + )) + db.session.commit() + flash('Your changes have been saved') + return redirect(p.url()), 303 + return render_template('edit.html', p=p) + diff --git a/freak/website/frontpage.py b/freak/website/frontpage.py new file mode 100644 index 0000000..637046d --- /dev/null +++ b/freak/website/frontpage.py @@ -0,0 +1,62 @@ +from flask import Blueprint, render_template, redirect, abort, request +from flask_login import current_user + +from ..search import SearchQuery +from ..models import Post, db, Topic +from ..algorithms import public_timeline, top_guilds_query, topic_timeline + +bp = Blueprint('frontpage', __name__) + +@bp.route('/') +def homepage(): + top_communities = [(x[0], x[1], 0) for x in + db.session.execute(top_guilds_query().limit(10)).fetchall()] + + if current_user and current_user.is_authenticated: + # renders user's own timeline + # TODO this is currently the public timeline. + + + return render_template('feed.html', feed_type='foryou', l=db.paginate(public_timeline()), + top_communities=top_communities) + else: + # Show a landing page to anonymous users. + return render_template('landing.html', top_communities=top_communities) + + +@bp.route('/explore/') +def explore(): + return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline())) + + +@bp.route('/+/') +def topic_feed(name): + topic: Topic = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar() + + if topic is None: + abort(404) + + posts = db.paginate(topic_timeline(name)) + + return render_template( + 'feed.html', feed_type='topic', feed_title=f'{topic.display_name} (+{topic.name})', l=posts, topic=topic) + +@bp.route('/r//') +def topic_feed_r(name): + return redirect('/+' + name + '/'), 302 + + +@bp.route("/search", methods=["GET", "POST"]) +def search(): + if request.method == "POST": + q = request.form["q"] + if q: + results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc())) + else: + results = None + return render_template( + "search.html", + results=results, + q = q + ) + return render_template("search.html") diff --git a/freak/website/reports.py b/freak/website/reports.py new file mode 100644 index 0000000..cebc1de --- /dev/null +++ b/freak/website/reports.py @@ -0,0 +1,56 @@ + + + +from flask import Blueprint, render_template, request +from flask_login import current_user, login_required +from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db + +bp = Blueprint('reports', __name__) + +def description_text(rlist: list[ReportReason], key: str) -> str: + results = [x.description for x in rlist if x.code == key] + return results[0] if results else key + +@bp.route('/report/post/', methods=['GET', 'POST']) +@login_required +def report_post(id: int): + p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar() + if p is None: + return render_template('reports/report_404.html', target_type = 1), 404 + if p.author_id == current_user.id: + return render_template('reports/report_self.html', back_to_url=p.url()), 403 + if request.method == 'POST': + reason = request.args['reason'] + db.session.execute(db.insert(PostReport).values( + author_id = current_user.id, + target_type = REPORT_TARGET_POST, + target_id = id, + reason_code = REPORT_REASONS[reason] + )) + db.session.commit() + return render_template('reports/report_done.html', back_to_url=p.url()) + return render_template('reports/report_post.html', id = id, + report_reasons = post_report_reasons, description_text=description_text) + +@bp.route('/report/comment/', methods=['GET', 'POST']) +@login_required +def report_comment(id: int): + c: Comment | None = db.session.execute(db.select(Comment).where(Comment.id == id)).scalar() + if c is None: + return render_template('reports/report_404.html', target_type = 2), 404 + if c.author_id == current_user.id: + return render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403 + if request.method == 'POST': + reason = request.args['reason'] + db.session.execute(db.insert(PostReport).values( + author_id = current_user.id, + target_type = REPORT_TARGET_COMMENT, + target_id = id, + reason_code = REPORT_REASONS[reason] + )) + db.session.commit() + return render_template('reports/report_done.html', + back_to_url=c.parent_post.url()) + return render_template('reports/report_comment.html', id = id, + report_reasons = post_report_reasons, description_text=description_text) + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7cffe5b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "sakuragasaki46_freak" +authors = [ + { name = "Sakuragasaki46" } +] +dynamic = ["version"] +dependencies = [ + "Python-Dotenv>=1.0.0", + "Flask", + "Flask-RestX", + "Python-Slugify", + "SQLAlchemy>=2.0.0", + "Flask-SQLAlchemy", + "Flask-WTF", + "Flask-Login", + "Alembic", + "Markdown>=3.0.0", + "PsycoPG2-binary", + "libsass", + "setuptools>=78.1.0", + "sakuragasaki46-suou>=0.2.3" +] +requires-python = ">=3.10" +classifiers = [ + "Private :: X" +] + + +[tool.setuptools] +packages = ["freak"] + +[tool.setuptools.dynamic] +version = { attr = "freak.__version__" } + diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..06dbb60 --- /dev/null +++ b/robots.txt @@ -0,0 +1,9 @@ +User-Agent: * +Disallow: /login +Disallow: /logout +Disallow: /create +Disallow: /register +Disallow: /createcommunity + +User-Agent: GPTBot +Disallow: / \ No newline at end of file