0.3.0: initial commit + Dockerfile + rewrite
This commit is contained in:
commit
e679de5991
77 changed files with 4147 additions and 0 deletions
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
17
CHANGELOG.md
Normal file
17
CHANGELOG.md
Normal file
|
|
@ -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.*
|
||||
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -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"]
|
||||
54
LICENSE
Normal file
54
LICENSE
Normal file
|
|
@ -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
|
||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Freak
|
||||
|
||||
(´ω\`)
|
||||
79
alembic/env.py
Normal file
79
alembic/env.py
Normal file
|
|
@ -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()
|
||||
28
alembic/script.py.mako
Normal file
28
alembic/script.py.mako
Normal file
|
|
@ -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"}
|
||||
50
alembic/versions/c7c2d5b8f71c_.py
Normal file
50
alembic/versions/c7c2d5b8f71c_.py
Normal file
|
|
@ -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 ###
|
||||
132
alembic/versions/fc9d1a0dc94e_.py
Normal file
132
alembic/versions/fc9d1a0dc94e_.py
Normal file
|
|
@ -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 ###
|
||||
12
docker-run.sh
Normal file
12
docker-run.sh
Normal file
|
|
@ -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
|
||||
|
||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
145
freak/__init__.py
Normal file
145
freak/__init__.py
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
4
freak/__main__.py
Normal file
4
freak/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
72
freak/ajax.py
Normal file
72
freak/ajax.py
Normal file
|
|
@ -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/<username>')
|
||||
@bp.route('/ajax/username_availability/<username>')
|
||||
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/<username>')
|
||||
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/<b32l:id>/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() }
|
||||
|
||||
31
freak/algorithms.py
Normal file
31
freak/algorithms.py
Normal file
|
|
@ -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
|
||||
|
||||
17
freak/cli.py
Normal file
17
freak/cli.py
Normal file
|
|
@ -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 <https://{os.getenv("DOMAIN_NAME")}>')
|
||||
|
||||
78
freak/filters.py
Normal file
78
freak/filters.py
Normal file
|
|
@ -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 '<del>' + match.group(1) + '</del>'
|
||||
|
||||
|
||||
### 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
|
||||
42
freak/iding.py
Normal file
42
freak/iding.py
Normal file
|
|
@ -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 <https://github.com/sakuragasaki46/suou>
|
||||
"""
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
364
freak/models.py
Normal file
364
freak/models.py
Normal file
|
|
@ -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 !!
|
||||
|
||||
|
||||
51
freak/rest/__init__.py
Normal file
51
freak/rest/__init__.py
Normal file
|
|
@ -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/<b32l:id>')
|
||||
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/<b32l:id>')
|
||||
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})
|
||||
25
freak/search.py
Normal file
25
freak/search.py
Normal file
|
|
@ -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
|
||||
|
||||
150
freak/static/js/lib.js
Normal file
150
freak/static/js/lib.js
Normal file
|
|
@ -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<usernameInputs.length;i++)(function(usernameInput){
|
||||
let lastValue = '';
|
||||
const endpoint = usernameInput.getAttribute('data-endpoint');
|
||||
let usernameInputMessage = document.createElement('small');
|
||||
usernameInput.oninput = function(event){
|
||||
let value = usernameInput.value;
|
||||
if (value != lastValue){
|
||||
if(!/^[a-z0-9_ ]*$/i.test(value)){
|
||||
usernameInputMessage.innerHTML = 'Usernames can only contain letters, numbers, and underscores.';
|
||||
usernameInputMessage.className = 'username-input-message error';
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if(/ /.test(value)){
|
||||
value = value.replace(/ /g,'_');
|
||||
}
|
||||
usernameInput.value = lastValue = value.toLowerCase();
|
||||
if(!value){
|
||||
usernameInputMessage.innerHTML = 'You cannot have an empty username.';
|
||||
usernameInputMessage.className = 'username-input-message error';
|
||||
return;
|
||||
}
|
||||
if(/^[01]/.test(value)) {
|
||||
usernameInputMessage.innerHTML = 'Your username cannot start with 0 or 1.';
|
||||
usernameInputMessage.className = 'username-input-message error';
|
||||
return;
|
||||
}
|
||||
usernameInputMessage.innerHTML = 'Checking username...';
|
||||
usernameInputMessage.className = 'username-input-message checking';
|
||||
requestUsernameAvailability(value, endpoint).then((resp) => {
|
||||
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();
|
||||
|
||||
})();
|
||||
126
freak/static/sass/base.sass
Normal file
126
freak/static/sass/base.sass
Normal file
|
|
@ -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
|
||||
19
freak/static/sass/constants.sass
Normal file
19
freak/static/sass/constants.sass
Normal file
|
|
@ -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
|
||||
38
freak/static/sass/content.sass
Normal file
38
freak/static/sass/content.sass
Normal file
|
|
@ -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
|
||||
|
||||
302
freak/static/sass/layout.sass
Normal file
302
freak/static/sass/layout.sass
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
21
freak/static/sass/mobile.sass
Normal file
21
freak/static/sass/mobile.sass
Normal file
|
|
@ -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
|
||||
4
freak/static/sass/style.sass
Normal file
4
freak/static/sass/style.sass
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
@import "base.sass"
|
||||
@import "layout.sass"
|
||||
@import "content.sass"
|
||||
@import "mobile.sass"
|
||||
13
freak/templates/400.html
Normal file
13
freak/templates/400.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<title>U _ U; on {{ app_name }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="centered">
|
||||
<h2>Bad Request</h2>
|
||||
|
||||
<p><a href="/">Back to homepage.</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
freak/templates/403.html
Normal file
13
freak/templates/403.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<title>X _ X; on {{ app_name }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="centered">
|
||||
<h2>Access Denied</h2>
|
||||
|
||||
<p><a href="/">Back to homepage.</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
freak/templates/404.html
Normal file
13
freak/templates/404.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<title>O _ O; on {{ app_name }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="centered">
|
||||
<h2>Not Found</h2>
|
||||
|
||||
<p><a href="/">Back to homepage.</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
freak/templates/405.html
Normal file
13
freak/templates/405.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<title>O _ O; on {{ app_name }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="centered">
|
||||
<h2>Method Not Allowed</h2>
|
||||
|
||||
<p><a href="/">Back to homepage.</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
15
freak/templates/451.html
Normal file
15
freak/templates/451.html
Normal file
|
|
@ -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 %}
|
||||
<div class="centered">
|
||||
<h2>Unavailable for Legal Reasons</h2>
|
||||
|
||||
<p>This content is not available in your region because making it available would get us in trouble with the law.</p>
|
||||
<p><a href="/">Back to homepage.</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
freak/templates/500.html
Normal file
16
freak/templates/500.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
|
||||
{% block title %}
|
||||
<title>% _ %</title>
|
||||
<meta http-equiv="Refresh" content="10" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="centered">
|
||||
<h2>Internal Server Error</h2>
|
||||
|
||||
<p>It's on us. <a href="javascript:history.go(0)">Refresh the page</a>.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
30
freak/templates/about.html
Normal file
30
freak/templates/about.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
|
||||
{% block title %}{{ title_tag('About') }}{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
<h2>About</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
<h2>Stats</h2>
|
||||
<ul>
|
||||
<li>No. of posts: <strong>{{ post_count }}</strong></li>
|
||||
<li>No. of active users (posters in the last 30 days): <strong>{{ user_count }}</strong></li>
|
||||
</ul>
|
||||
|
||||
<h2>Software versions</h2>
|
||||
<ul>
|
||||
<li><strong>Python</strong>: {{ python_version }}</strong></li>
|
||||
<li><strong>SQLAlchemy</strong>: {{ sa_version }}</li>
|
||||
<li><strong>Flask</strong>: {{ flask_version }}</li>
|
||||
<li><strong>{{ app_name }}</strong>: {{ app_version }}</li>
|
||||
</ul>
|
||||
|
||||
<h2>License</h2>
|
||||
<p>Source code is available at: <a href="https://github.com/sakuragasaki46/freak">https://github.com/sakuragasaki46/freak</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
26
freak/templates/admin/admin_base.html
Normal file
26
freak/templates/admin/admin_base.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
{{ title_tag("Admin") }}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/style.css">
|
||||
<style>.done{opacity:.5}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1><a href="{{ url_for('admin.homepage') }}">{{ site_name }} Admin</a></h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% for message in get_flashed_messages() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p><a href="/">Back to {{ app_name }}</a>.</p>
|
||||
</div>
|
||||
<script src="/static/lib.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
freak/templates/admin/admin_home.html
Normal file
9
freak/templates/admin/admin_home.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "admin/admin_base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ url_for('admin.reports') }}">Reports</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
23
freak/templates/admin/admin_report_detail.html
Normal file
23
freak/templates/admin/admin_report_detail.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends "admin/admin_base.html" %}
|
||||
{% from "macros/embed.html" import embed_post with context %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Report detail #{{ report.id }}</h2>
|
||||
<ul>
|
||||
<li>Reason: <strong>{{ report_reasons[report.reason_code] }}</strong></li>
|
||||
<li>Status: <strong>{{ ['Unreviewed', 'Complete', 'Rejected', 'On hold'][report.update_status] }}</strong></li>
|
||||
</ul>
|
||||
|
||||
<h3>Detail</h3>
|
||||
{% if report.target_type in (1, 2) %}
|
||||
{{ embed_post(report.target()) }}
|
||||
{% else %}
|
||||
<p><i>Unknown media type</i></p>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<button type="submit" name="do" value="0">Reject</button>
|
||||
<button type="submit" name="do" value="1" class="primary">Remove</button>
|
||||
<button type="submit" name="do" value="2">Put on hold</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
21
freak/templates/admin/admin_reports.html
Normal file
21
freak/templates/admin/admin_reports.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{% extends "admin/admin_base.html" %}
|
||||
{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %}
|
||||
|
||||
{% block content %}
|
||||
<ul>
|
||||
{% for report in report_list %}
|
||||
<li {% if report.update_status in (1, 2) %} class="done"{% endif %}>
|
||||
|
||||
<p><strong>#{{ report.id }}</strong> (<a href="{{ url_for('admin.report_detail', id=report.id) }}">detail</a>)</p>
|
||||
<ul class="inline"><li>Reason: <strong>{{ report_reasons[report.reason_code] }}</strong></li>
|
||||
<li>Status: <strong>{{ ['Unreviewed', 'Complete', 'Rejected', 'On hold'][report.update_status] }}</strong></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if report_list.has_next %}
|
||||
{{ stop_scrolling(report_list.page) }}
|
||||
{% else %}
|
||||
{{ no_more_scrolling(report_list.page) }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
119
freak/templates/base.html
Normal file
119
freak/templates/base.html
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{% block title %}
|
||||
<title>{{ app_name }}</title>
|
||||
{% endblock %}
|
||||
<!--
|
||||
Copyright (c) 2025 Sakuragasaki46.
|
||||
This Service is available "AS IS", with NO WARRANTY, explicit or implied.
|
||||
Sakuragasaki46 is NOT legally liable for Your use of the Service.
|
||||
This service is age-restricted; do not access if underage.
|
||||
More info: https://{{ domain_name }}/terms
|
||||
-->
|
||||
<meta name="csrf_token" content="{{ csrf_token() }}">
|
||||
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
|
||||
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
|
||||
{% for private_style in private_styles %}
|
||||
<link rel="stylesheet" href="{{ private_style }}" />
|
||||
{% endfor %}
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script src="{{ jquery_url }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1><a href="/">{{ app_name }}</a></h1>
|
||||
<div class="metanav">
|
||||
<ul>
|
||||
{% if not g.no_user %}
|
||||
<li>
|
||||
<form action="/search" method="POST" class="mini-search-bar">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="search" name="q" placeholder="Search among {{ post_count }} posts…">
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
<a href="/search">
|
||||
<i class="icon icon-search"></i><span class="a11y">search</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if g.no_user %}
|
||||
<!-- no user -->
|
||||
{% elif current_user.is_authenticated %}
|
||||
<li><a href="/create" title="Create a post">
|
||||
<i class="icon icon-add"></i>
|
||||
<span class="a11y">create</span>
|
||||
</a></li><li><a href="{{ current_user.url() }}"
|
||||
title="@{{ current_user.username }}'s profile">
|
||||
<i class="icon icon-profile"></i>
|
||||
<span class="a11y">profile</span>
|
||||
</a></li><li>
|
||||
<div class="header-username">
|
||||
<strong class="header-username-name">@{{ current_user.username }}</strong>
|
||||
<span class="header-username-karma"><i class="icon icon-karma"></i> {{ current_user.karma }} karma</span>
|
||||
</div>
|
||||
</li>
|
||||
<li><a href="/logout" title="Log out">
|
||||
<i class="icon icon-logout"></i><span class="a11y">log out</span>
|
||||
</a></li>
|
||||
{% else %}
|
||||
<li><a href="/login" title="Log in">
|
||||
<i class="icon icon-logout"></i>
|
||||
<span class="a11y">log in</span>
|
||||
</a></li><li><a href="/register" title="Register">
|
||||
<i class="icon icon-join"></i>
|
||||
<span class="a11y">register</span>
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div><!-- .metanav -->
|
||||
</header>
|
||||
<main class="content">
|
||||
{% for message in get_flashed_messages() %}
|
||||
<div class="flash card">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% block body %}
|
||||
<div class="content-header">
|
||||
{% block heading %}{% endblock %}
|
||||
</div>
|
||||
<div class="content-container">
|
||||
<div class="content-nav">
|
||||
{% block nav %}{% endblock %}
|
||||
</div>
|
||||
<div class="content-main">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<p class="copyright">© 2021-2025 Sakuragasaki46.</p>
|
||||
<ul class="copyright-about">
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="/terms">Terms</a></li>
|
||||
<li><a href="/privacy">Privacy</a></li>
|
||||
<li><a href="https://github.com/sakuragasaki46/freak">GitHub</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
<script>
|
||||
function changeAccentColorTime() {
|
||||
let hours = (new Date).getHours();
|
||||
if (hours < 6 || hours >= 19) {
|
||||
document.body.style.setProperty('--accent', '#1871d8');
|
||||
} else {
|
||||
document.body.style.removeProperty('--accent');
|
||||
}
|
||||
}
|
||||
changeAccentColorTime();
|
||||
setInterval(changeAccentColorTime, 300000);
|
||||
</script>
|
||||
<script src="/static/js/lib.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
{% for private_script in private_scripts %}
|
||||
<script src="{{ private_script }}"></script>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
32
freak/templates/create.html
Normal file
32
freak/templates/create.html
Normal file
|
|
@ -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 %}
|
||||
<h2>Create</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<p>Posting as <strong>{{ current_user.handle() }}</strong></p>
|
||||
<p>Post to: <input type="text" name="to" placeholder="{{ current_user.handle() }}"></p>
|
||||
<div>
|
||||
<span class="a11y">Title:</span><input type="text" name="title" placeholder="An interesting title" maxlength="256">
|
||||
</div>
|
||||
<div>
|
||||
<span class="a11y">Text:</span>
|
||||
<textarea name="text" placeholder="What's happening?" class="create_text">{{ request.args['preload'] }}</textarea>
|
||||
</div>
|
||||
{#<dd id="fileInputContainer"><a href="javascript:attachFileInput();">Add a file...</a>#}
|
||||
<div>{{ privacy_select() }}</div>
|
||||
<div><button type="submit" class="primary">Create</button></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
34
freak/templates/createguild.html
Normal file
34
freak/templates/createguild.html
Normal file
|
|
@ -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 %}
|
||||
<h2>Create a guild</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<form action="{{ url_for('create.createguild') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<p>URL of the guild: <strong>+</strong><input type="text" class="username-input" name="name" required="true" data-endpoint="guild_name_availability/$1" /></p>
|
||||
<p><small class="faint">Must be alphanumeric and unique. <strong>May not be changed later</strong>: choose wisely!</small></p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Display name: <input type="text" name="display_name" placeholder="Guild Name" required="true" /></p>
|
||||
<p><small class="faint">Will be shown in title bar and search engines.</small></p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Description: <small class="faint">(will be shown in sidebar)</small></p>
|
||||
<textarea name="description" placeholder="We do cool stuff"></textarea>
|
||||
</div>
|
||||
<div><button type="submit" class="primary" {{ disabled_if(not current_user.can_create_community()) }}>Create guild</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
25
freak/templates/edit.html
Normal file
25
freak/templates/edit.html
Normal file
|
|
@ -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 %}
|
||||
<h2><span class="faint">Editing:</span> {{ p.title }}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<form action="{{ url_for('edit.edit_post', id=p.id) }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<span class="a11y">Text:</span>
|
||||
<textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd>
|
||||
</div>
|
||||
<div>{{ privacy_select(p.privacy) }}</div>
|
||||
<div><input type="submit" value="Save" /></dd>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
63
freak/templates/feed.html
Normal file
63
freak/templates/feed.html
Normal file
|
|
@ -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 %}
|
||||
<h2>{{ feed_title }}</h2>
|
||||
{% 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 %}
|
||||
|
||||
<aside class="card">
|
||||
<h3>Don’t miss a post!</h3>
|
||||
<ul>
|
||||
<li><strong><a id="notificationEnabler" href="#">Enable notifications</a></strong> to continue staying with us 😉</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="timeline card">
|
||||
{% for p in l %}
|
||||
<li id="p_{{ p.id }}">
|
||||
{{ feed_post(p) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if l.has_next %}
|
||||
{{ stop_scrolling(l.page) }}
|
||||
{% else %}
|
||||
{{ no_more_scrolling(l.page) }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
{# TODO: pagination #}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('notificationEnabler').onclick = function() {
|
||||
Notification.requestPermission(function(status) {
|
||||
console.log('Notification permission status:', status);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
25
freak/templates/landing.html
Normal file
25
freak/templates/landing.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
|
||||
{% block title %}{{ title_tag(None) }}{% endblock %}
|
||||
|
||||
{% block heading %}
|
||||
<h2>Welcome to {{ app_name }}!</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card centered">
|
||||
<p>
|
||||
{{ app_name }} is a social media platform made by people like you.<br />
|
||||
<a href="/login">Log in</a> or <a href="/register">sign up</a> to see {{ post_count }} posts
|
||||
and talk with {{ user_count }} users right now!
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
{% if top_communities %}
|
||||
{% from "macros/nav.html" import nav_top_communities with context %}
|
||||
{{ nav_top_communities(top_communities) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
40
freak/templates/login.html
Normal file
40
freak/templates/login.html
Normal file
|
|
@ -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 %}
|
||||
<h2>Log in</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if error %}
|
||||
<p class="error"><strong>Error:</strong> {{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label>{{ icon('user') }} Username / Email:</label>
|
||||
<input type="text" name="username">
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('privacy') }} Password:</label>
|
||||
<input type="password" name="password">
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('pin') }} Remember me:</label>
|
||||
<input type="checkbox" name="remember" value="60">
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="primary">{{ icon('logout') }} Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p>Don’t have an account? <a href="/register">Register</a>.</p>
|
||||
{% endblock %}
|
||||
35
freak/templates/macros/create.html
Normal file
35
freak/templates/macros/create.html
Normal file
|
|
@ -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) %}
|
||||
<ul>
|
||||
<li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >Public <small class="faint">(everyone in your profile or public timeline)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >Friends <small class="faint">(only people you follow each other)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >Only you <small class="faint">(nobody else)</small></label></li>
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro comment_area(url) %}
|
||||
<form id="comment-area" class="boundaryless" action="{{ url }}" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="reply_to" value="" />
|
||||
<div>
|
||||
<textarea name="text" placeholder="Comment as {{ current_user.handle() }} ~"></textarea>
|
||||
</div>
|
||||
<div class="button-row-right">
|
||||
<button type="reset">Cancel</button>
|
||||
<button type="submit" class="primary">Publish</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
19
freak/templates/macros/embed.html
Normal file
19
freak/templates/macros/embed.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% macro embed_post(p) %}
|
||||
<div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}">
|
||||
<h3 class="message-title"><a href="{{ p.url() }}">{{ p.title }}</a></h3>
|
||||
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||
{% if p.parent_post %}
|
||||
as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a>
|
||||
{% elif p.topic %}
|
||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||
{% else %}
|
||||
on their user page
|
||||
{% endif %}
|
||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
{{ p.text_content | to_markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
133
freak/templates/macros/feed.html
Normal file
133
freak/templates/macros/feed.html
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
|
||||
{% from "macros/icon.html" import icon, callout with context %}
|
||||
|
||||
{% macro feed_post(p) %}
|
||||
<div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}">
|
||||
<h3 class="message-title"><a href="{{ p.url() }}">{{ p.title }}</a></h3>
|
||||
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||
{% if p.topic %}
|
||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||
{% else %}
|
||||
on their user page
|
||||
{% endif %}
|
||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||
</div>
|
||||
<div class="message-stats">
|
||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
||||
{{ comment_count(p.comments | count) }}
|
||||
</div>
|
||||
|
||||
<div class="message-content shorten">
|
||||
{{ p.text_content | to_markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro feed_upvote(postid, count, uservote=0) %}
|
||||
<div class="upvote-button" data-endpoint="{{ postid|to_b32l }}">
|
||||
{% if uservote > 0 %}
|
||||
<a href="javascript:void 0" class="upvote-button-up active">
|
||||
<i class="icon icon-upvote_fill"></i><span class="a11y">upvoted</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="javascript:void 0" class="upvote-button-up">
|
||||
<i class="icon icon-upvote"></i><span class="a11y">upvote</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<strong class="upvote-count">{{ count }}</strong>
|
||||
{% if uservote < 0 %}
|
||||
<a href="javascript:void 0" class="upvote-button-down active">
|
||||
<i class="icon icon-downvote_fill"></i><span class="a11y">downvoted</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="javascript:void 0" class="upvote-button-down">
|
||||
<i class="icon icon-downvote"></i><span class="a11y">downvote</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro single_comment(comment) %}
|
||||
<div class="comment-frame">
|
||||
{% if comment.is_removed %}
|
||||
{% call callout('delete') %}<i>Removed comment</i>{% endcall %}
|
||||
{% else %}
|
||||
<div class="message-meta">
|
||||
{% if comment.author %}
|
||||
<a href="{{ comment.author.url() }}">{{ comment.author.handle() }}</a>
|
||||
{% else %}
|
||||
<i>deleted account</i>
|
||||
{% endif %}
|
||||
{% if comment.author and comment.author == comment.parent_post.author %}
|
||||
<span class="faint">(OP)</span>
|
||||
{% endif %}
|
||||
{# TODO add is_distinguished i.e. official comment #}
|
||||
-
|
||||
<time datetime="{{ comment.created_at.isoformat('T') }}">{{ comment.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||
</div>
|
||||
|
||||
<div class="message-content">
|
||||
{{ comment.text_content | to_markdown }}
|
||||
</div>
|
||||
<ul class="message-options inline">
|
||||
{% if comment.author == current_user %}
|
||||
{# TODO add comment edit link #}
|
||||
{% else %}
|
||||
<li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li>
|
||||
{% endif %}
|
||||
{% if current_user.is_administrator and comment.report_count() %}
|
||||
<li><a href="/admin/reports"><strong>{{ comment.report_count() }}</strong> reports</a></li>
|
||||
{% endif %}
|
||||
<li>ID #{{ comment.id|to_b32l }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro comment_count(c) %}
|
||||
<div class="comment-count">
|
||||
<a><i class="icon icon-comment"></i></a>
|
||||
<strong>{{ c }}</strong>
|
||||
<span class="a11y">comments</span>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="centered">
|
||||
<p><strong class="error">{{ choice1 }}</strong></p>
|
||||
<p><small><a href="?page={{ page_n + 1 }}">{{ choice2 }}</a></small></p>
|
||||
</div>
|
||||
{% 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'
|
||||
] %}
|
||||
<li>
|
||||
<p class="centered">You have reached the rock bottom
|
||||
{%- if page_n > 10 or page_n + (range(10) | random) > 10 -%}
|
||||
. {{ choices1 | random }}
|
||||
{% endif %}
|
||||
</p></li>
|
||||
{% endmacro %}
|
||||
11
freak/templates/macros/icon.html
Normal file
11
freak/templates/macros/icon.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
{% macro icon(name, fill = False) %}
|
||||
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro callout(useicon = "spoiler") %}
|
||||
<div class="callout">
|
||||
{{ icon(useicon) }}
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
36
freak/templates/macros/nav.html
Normal file
36
freak/templates/macros/nav.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
{% macro nav_topic(topic) %}
|
||||
<aside class="card">
|
||||
<h3>About {{ topic.handle() }}</h3>
|
||||
<ul>
|
||||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ topic.description }}</li>
|
||||
<li>
|
||||
<strong>{{ topic.posts | count }}</strong> posts -
|
||||
<strong>-</strong> subscribers
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro nav_user(user) %}
|
||||
<aside class="card">
|
||||
<h3>About {{ user.handle() }}</h3>
|
||||
<ul>
|
||||
<li>{# user.biography #}</li>
|
||||
</ul>
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro nav_top_communities(top_communities) %}
|
||||
<aside class="card">
|
||||
<h3>Top Communities</h3>
|
||||
<ul>
|
||||
{% for comm, pcnt, scnt in top_communities %}
|
||||
<li><strong><a href="{{ comm.url() }}">{{ comm.handle() }}</a></strong> - <strong>{{ pcnt }}</strong> posts - <strong>{{ scnt }}</strong> subscribers</li>
|
||||
{% endfor %}
|
||||
{% if current_user and current_user.is_authenticated and current_user.can_create_community() %}
|
||||
<li>Can’t find your community? <a href="/createcommunity">Create a new one.</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% endmacro %}
|
||||
16
freak/templates/macros/title.html
Normal file
16
freak/templates/macros/title.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
{% macro title_tag(name, robots=True) %}
|
||||
<title>
|
||||
{%- if name -%}
|
||||
{{ name }}; on {{ app_name }}
|
||||
{%- else -%}
|
||||
{{ app_name }}
|
||||
{%- endif -%}
|
||||
</title>
|
||||
{% if robots %}
|
||||
<meta name="robots" content="noai,noimageai" />
|
||||
{% else %}
|
||||
<meta name="robots" content="noindex,nofollow,noai,noimageai" />
|
||||
{% endif %}
|
||||
|
||||
{% endmacro %}
|
||||
136
freak/templates/privacy.html
Normal file
136
freak/templates/privacy.html
Normal file
|
|
@ -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 %}
|
||||
<div class="content">
|
||||
{% filter to_markdown %}
|
||||
# Privacy Policy
|
||||
|
||||
This is a non-authoritative copy of the actual Privacy Policy, always updated at <https://yusur.moe/policies/privacy.html>.
|
||||
|
||||
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 <https://ndspir.it>.
|
||||
|
||||
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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
61
freak/templates/register.html
Normal file
61
freak/templates/register.html
Normal file
|
|
@ -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 %}
|
||||
<h2>Join {{ app_name }}</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<form action="/register" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div>
|
||||
<label>{{ icon('user') }} Username:</label>
|
||||
<input type="text" class="username-input" name="username" autocomplete="off" data-endpoint="/username_availability/$1"><br />
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('user') }} Display name (optional):</label>
|
||||
<input type="text" name="full_name">
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('privacy') }} Password:</label>
|
||||
<input type="password" name="password" oncopy="return false"><br />
|
||||
<small class="faint field_desc">Please choose a strong password containing letters, numbers and special characters.</small>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('privacy') }} Confirm password:</label>
|
||||
<input type="password" name="confirm_password" onpaste="return false">
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('message') }} Email (optional):</label>
|
||||
<input type="text" name="email"><br />
|
||||
<small class="faint field_desc">A valid email address is required to recover your account.</small>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ icon('calendar') }} Date of birth:</label>
|
||||
<input type="date" name="birthday"><br>
|
||||
<small class="faint field_desc">Your birthday is not shown to anyone. Some age information may be made available for transparency.</small>
|
||||
<!-- You must be 14 years old or older to register on {{ app_name }}. -->
|
||||
</div>
|
||||
{% if not current_user.is_anonymous %}
|
||||
<div>
|
||||
{% call callout() %}You are currently logged in. Are you sure you want to create another account?{% endcall %}
|
||||
<input type="checkbox" name="confirm_another" id="cb__confirm_another" value="1">
|
||||
<label for="cb__confirm_another">Yes, I want to create another account</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<input type="checkbox" name="legal" id="cb__legal" value="1">
|
||||
<label for="cb__legal">I agree to the <a href="/terms">Terms of Service</a>, <a href="/privacy">Privacy Policy</a> and <a href="/rules">Community Guidelines</a></label>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="primary">{{ icon('add') }} Register</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p>Already have an account? <a href="/login">Log in</a></p>
|
||||
{% endblock %}
|
||||
20
freak/templates/reports/report_404.html
Normal file
20
freak/templates/reports/report_404.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "reports/report_base.html" %}
|
||||
|
||||
{% block heading %}
|
||||
<h1>Not Found</h1>
|
||||
|
||||
<p>You can't report a {% if target_type == 2 -%}
|
||||
comment
|
||||
{%- else -%}
|
||||
post
|
||||
{%- endif %} which does not exist.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block options %}
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">Back to homepage</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
61
freak/templates/reports/report_base.html
Normal file
61
freak/templates/reports/report_base.html
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Report | {{ app_name }}</title>
|
||||
<meta name="robots" content="noindex,noai,noimageai" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
*{box-sizing:border-box;}
|
||||
:root{--ac:#f00040;--bo:grey;--bg:#181818;--fg:#f4f4f4;}
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;background-color:var(--fg);color:var(--bg);}
|
||||
main{max-width:960px;margin:1em auto;}
|
||||
a:link,a:visited{font-weight:600;text-decoration:none;color:inherit;}
|
||||
a:hover{text-decoration:underline var(--bo);color:var(--ac)}
|
||||
h1,h2,h3,h4,h5,h6{font-weight:500;}
|
||||
ul{list-style:none;margin:.5em 0;padding:0;border-collapse:collapse;}
|
||||
ul > li{border:1px solid var(--bo);padding:.5em 1em;}
|
||||
hr{border:1px solid var(--bo);}
|
||||
button{border:1px solid var(--ac);border-radius:6px;color:var(--ac);background-color:transparent;opacity:.8;margin:6px 12px;padding:6px 12px;font:inherit}
|
||||
button.primary{background-color:var(--ac);color:var(--fg)}
|
||||
button:hover{opacity:1;transition:2s ease;}
|
||||
@media (prefers-color-scheme:dark){body{color:var(--fg);background-color:var(--bg)}}
|
||||
footer{font-size:smaller;text-align:center;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% set selection = request.args.get('reason') if request.method != 'POST' %}
|
||||
{% set selection_description = description_text(report_reasons, selection) if selection and report_reasons %}
|
||||
<main>
|
||||
<section>
|
||||
{% block heading %}
|
||||
<h1>Report</h1>
|
||||
{% endblock %}
|
||||
</section>
|
||||
<hr />
|
||||
{% if selection %}
|
||||
<section>
|
||||
<p>You are about to submit a report for: <strong>{{ selection_description }}</strong>.</p>
|
||||
<p>You hereby guarantee {{ app_name }} that your report is made in good faith and not duplicate.</p>
|
||||
<p>{{ app_name }} removes content in violation of our <a href="/rules">Community Guidelines</a>.</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="rp_reason" value="{{ selection }}" />
|
||||
{% block report_data %}{% endblock %}
|
||||
<button type="submit" class="primary">Send report</button>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section>
|
||||
{% block options %}
|
||||
<ul>
|
||||
<li>a</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
{{ app_name }} © 2025 Sakuragasaki46
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
22
freak/templates/reports/report_comment.html
Normal file
22
freak/templates/reports/report_comment.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "reports/report_base.html" %}
|
||||
|
||||
{% block heading %}
|
||||
<h1>Report comment</h1>
|
||||
|
||||
<p>You are about to report the comment with ID {{ id |to_b32l }}</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block options %}
|
||||
<ul>
|
||||
{% for opt in report_reasons %}
|
||||
<li>
|
||||
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
{% block report_data %}
|
||||
<input type="hidden" name="rp_target" value="{{ id }}" />
|
||||
<input type="hidden" name="rp_type" value="comment" />
|
||||
{% endblock %}
|
||||
19
freak/templates/reports/report_done.html
Normal file
19
freak/templates/reports/report_done.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "reports/report_base.html" %}
|
||||
|
||||
{% block heading %}
|
||||
<h1>Success!</h1>
|
||||
|
||||
<p>Your report has been received.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block options %}
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ back_to_url }}">Back to post</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/">Back to homepage</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
22
freak/templates/reports/report_post.html
Normal file
22
freak/templates/reports/report_post.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "reports/report_base.html" %}
|
||||
|
||||
{% block heading %}
|
||||
<h1>Report post</h1>
|
||||
|
||||
<p>You are about to report the post with ID {{ id |to_b32l }}</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block options %}
|
||||
<ul>
|
||||
{% for opt in report_reasons %}
|
||||
<li>
|
||||
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
{% block report_data %}
|
||||
<input type="hidden" name="rp_target" value="{{ id }}" />
|
||||
<input type="hidden" name="rp_type" value="post" />
|
||||
{% endblock %}
|
||||
19
freak/templates/reports/report_self.html
Normal file
19
freak/templates/reports/report_self.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "reports/report_base.html" %}
|
||||
|
||||
{% block heading %}
|
||||
<h1>Are you confused?</h1>
|
||||
|
||||
<p>You can't report your own post or comment. Try editing or deleting.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block options %}
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ back_to_url }}">Back to post</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/">Back to homepage</a>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
192
freak/templates/rules.html
Normal file
192
freak/templates/rules.html
Normal file
|
|
@ -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 %}
|
||||
<div class="content">
|
||||
{% filter to_markdown %}
|
||||
# Community Guidelines
|
||||
|
||||
This is a non-authoritative copy of the New Digital Spirit General Regulation, always updated at <https://ndspir.it/rules>.
|
||||
|
||||
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 %}
|
||||
32
freak/templates/search.html
Normal file
32
freak/templates/search.html
Normal file
|
|
@ -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 %}
|
||||
<div class="big-search-bar">
|
||||
<form action="/search" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="search" name="q" placeholder="Search among {{ post_count }} posts…" value="{{ q }}">
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<h2 class="placeholder"><span class="faint">Results for</span> <q>{{ q }}</q></h2>
|
||||
|
||||
<ul class="timeline card">
|
||||
{% for p in results %}
|
||||
<li>{{ feed_post(p) }}</li>
|
||||
{% endfor %}
|
||||
{% if results.has_next %}
|
||||
<li>{{ stop_scrolling(results.page) }}</li>
|
||||
{% else %}
|
||||
<li><p class="centered">You have reached the rock bottom</p></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% elif q %}
|
||||
<p class="placeholder">No results for <q>{{ q }}</q></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
71
freak/templates/singlepost.html
Normal file
71
freak/templates/singlepost.html
Normal file
|
|
@ -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 %}
|
||||
<article class="card">
|
||||
<div class="post-frame" id="post-{{ p.id | to_b32l }}">
|
||||
<div class="post-body">
|
||||
<h1 class="message-title">{{ p.title }}</h1>
|
||||
<div class="message-meta">
|
||||
Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||
{% if p.topic %}
|
||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
||||
{% else %}
|
||||
on their user page
|
||||
{% endif %}
|
||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||
</div>
|
||||
{% if current_user.is_administrator and p.report_count() %}
|
||||
{% call callout() %}
|
||||
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if p.is_removed %}
|
||||
{% call callout('delete') %}
|
||||
This post has been removed
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
<div class="message-content">
|
||||
{{ p.text_content | to_markdown }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-stats">
|
||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
||||
{{ comment_count(p.comments | count) }}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="message-options inline">
|
||||
{% if p.author == current_user %}
|
||||
<li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{{ comment_area(p.url()) }}
|
||||
<div class="comment-section">
|
||||
<ul>
|
||||
{% for comment in p.top_level_comments() %}
|
||||
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
|
||||
{{ single_comment(comment) }}
|
||||
|
||||
{# if comment.children %}
|
||||
{{ comment_tree(comment) }}
|
||||
{% endif #}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
115
freak/templates/terms.html
Normal file
115
freak/templates/terms.html
Normal file
|
|
@ -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 %}
|
||||
<div class="content">
|
||||
{% filter to_markdown %}
|
||||
# Terms of Service
|
||||
|
||||
This is a non-authoritative copy of the actual Terms, always updated at <https://yusur.moe/policies/terms.html>.
|
||||
|
||||
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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
35
freak/templates/userfeed.html
Normal file
35
freak/templates/userfeed.html
Normal file
|
|
@ -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 %}
|
||||
<h2>{{ user.handle() }}</h2>
|
||||
<p>{{ icon('karma') }} <strong>{{ user.karma }}</strong> karma - Joined at: <time datetime="{{ user.joined_at.isoformat('T') }}">{{ user.joined_at.strftime('%B %-d, %Y %H:%M') }}</time> - ID: {{ user.id|to_b32l }}</p>
|
||||
{% 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 %}
|
||||
<ul class="timeline card">
|
||||
{% for p in l %}
|
||||
<li id="p_{{ p.id }}">
|
||||
{{ feed_post(p) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if l.has_next %}
|
||||
{{ stop_scrolling(l.page) }}
|
||||
{% else %}
|
||||
{{ no_more_scrolling(l.page) }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% elif not user.is_active %}
|
||||
<p class="centered">{{ user.handle() }} is suspended</p>
|
||||
{% else %}
|
||||
<p class="centered">{{ user.handle() }} never posted any content</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
40
freak/utils.py
Normal file
40
freak/utils.py
Normal file
|
|
@ -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)
|
||||
27
freak/website/__init__.py
Normal file
27
freak/website/__init__.py
Normal file
|
|
@ -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)
|
||||
29
freak/website/about.py
Normal file
29
freak/website/about.py
Normal file
|
|
@ -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')
|
||||
|
||||
103
freak/website/accounts.py
Normal file
103
freak/website/accounts.py
Normal file
|
|
@ -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')
|
||||
|
||||
79
freak/website/admin.py
Normal file
79
freak/website/admin.py
Normal file
|
|
@ -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/<b32l:id>', 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)
|
||||
74
freak/website/create.py
Normal file
74
freak/website/create.py
Normal file
|
|
@ -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
|
||||
100
freak/website/detail.py
Normal file
100
freak/website/detail.py
Normal file
|
|
@ -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('/@<username>')
|
||||
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/<username>')
|
||||
@bp.route('/user/<username>')
|
||||
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('/@<username>/')
|
||||
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/<b32l:id>')
|
||||
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('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', 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('/+<topicname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||
@bp.route('/+<topicname>/comments/<b32l:id>/<slug:slug>', 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)
|
||||
|
||||
|
||||
|
||||
36
freak/website/edit.py
Normal file
36
freak/website/edit.py
Normal file
|
|
@ -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/<b32l:id>', 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)
|
||||
|
||||
62
freak/website/frontpage.py
Normal file
62
freak/website/frontpage.py
Normal file
|
|
@ -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('/+<name>/')
|
||||
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/<name>/')
|
||||
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")
|
||||
56
freak/website/reports.py
Normal file
56
freak/website/reports.py
Normal file
|
|
@ -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/<b32l:id>', 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/<b32l:id>', 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)
|
||||
|
||||
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
|
|
@ -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__" }
|
||||
|
||||
9
robots.txt
Normal file
9
robots.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
User-Agent: *
|
||||
Disallow: /login
|
||||
Disallow: /logout
|
||||
Disallow: /create
|
||||
Disallow: /register
|
||||
Disallow: /createcommunity
|
||||
|
||||
User-Agent: GPTBot
|
||||
Disallow: /
|
||||
Loading…
Add table
Add a link
Reference in a new issue