Compare commits
67 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2b75bf731 | |||
| 005114efe2 | |||
| 76ca5a8319 | |||
| f413bf56e8 | |||
| 7d099e8474 | |||
| 31a70671ee | |||
| dd01bd1be4 | |||
| 86e6eb1fa6 | |||
| f4918d4815 | |||
| 896a87a9bf | |||
| c0e1c2eb7e | |||
| fb730baa73 | |||
| 2f2cd5c04a | |||
| 4c86184a3e | |||
| 4effde0049 | |||
| d1f33afe09 | |||
| 41be26d484 | |||
| 0a3cfccc0d | |||
| a4144c67a9 | |||
| 6b11bf4537 | |||
| 3a77357b8e | |||
| 84ed2a5785 | |||
| 7590773710 | |||
| 213a48130f | |||
| 17bdd52253 | |||
| 79b1b574a8 | |||
| f51f488567 | |||
| 38f7e77791 | |||
| a3afecdddd | |||
| aa9adb075a | |||
| b6fa88f201 | |||
| 87d2eb6d0b | |||
| cc8858b7ac | |||
| 73b5b7993f | |||
| b97355bb89 | |||
| 1608b06356 | |||
| a40d959222 | |||
| a1dffc6a37 | |||
| 48f0582096 | |||
| 99b816562c | |||
| e7912ad88c | |||
| a88b12e844 | |||
| 71042a720c | |||
| 8361890d4a | |||
| b821f39bbf | |||
| f97e613f7a | |||
| 2214863496 | |||
| b0c815ea0a | |||
| 793c0b6612 | |||
| 66471558b9 | |||
| 6935a6ae71 | |||
| 7d8b518c85 | |||
| 299c29869c | |||
| 0311586a1b | |||
| e47103d0ee | |||
| 05dca27149 | |||
| c1c005cc4e | |||
| c451a15b1c | |||
| 690ad71328 | |||
| 803b725671 | |||
| 8e12714026 | |||
| c56594345a | |||
| 32f5346d4e | |||
| 0dc7c2f786 | |||
| 382d06961b | |||
| 1ad2aad08d | |||
| 22524c5920 |
81 changed files with 4714 additions and 1232 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -1,5 +1,44 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
- Switched to Quart framework. This implies everything is `async def` now.
|
||||||
|
- **BREAKING**: `SERVER_NAME` env variable now contains the domain name. `DOMAIN_NAME` has been removed.
|
||||||
|
- libsuou bumped to 0.6.0
|
||||||
|
- Added several REST routes. Change needed due to pending [frontend separation](https://nekode.yusur.moe/yusur/vigil).
|
||||||
|
- Deprecated the old web routes except for `/report` and `/admin`
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
- Added dependency to [SUOU](https://github.com/yusurko/suou) library
|
||||||
|
- Users can now block each other
|
||||||
|
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
|
||||||
|
- Added user strikes: a strike logs the content of a removed message for future use
|
||||||
|
- Added ✨**color themes**✨
|
||||||
|
- Posts may now be deleted by author. If it has comments, comments are not spared
|
||||||
|
- If a user for some reason can't post, their post is blocked and they can choose to post it onto another community. Previously it got posted to the user page.
|
||||||
|
- Moderators (and admins) have now access to mod tools
|
||||||
|
+ Allowed operations: change display name, description, restriction status, and exile (guild-local ban) members
|
||||||
|
+ Site administrators and guild owners can add moderators
|
||||||
|
- Guilds can have restricted posting/commenting now. Unmoderated guilds always have
|
||||||
|
- Administrators can claim ownership of abandoned guilds
|
||||||
|
- Admins can now suspend users from admin panel
|
||||||
|
- Implemented guild subscriptions (not as in $$$, yes as in the follow button)
|
||||||
|
- Minimum karma requirement for creating a guild is now configurable via env variable `FREAK_CREATE_GUILD_THRESHOLD` (previously hardcoded at 15)
|
||||||
|
- Users can now set their display name, biography and color theme in `/settings`
|
||||||
|
- Impressum can now be set in .env, e.g. `IMPRESSUM='Acme Ltd.::1 Short Island::Old York, Divided States::Responsible: ::Donald Duck'` Lines are separated by two colons. **Versions before 0.4.0 CAN'T BE RUN in German-speaking countries** as of 2025
|
||||||
|
- Several aesthetic improvements
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
- Fixed bugs in templates introduced in 0.3.2
|
||||||
|
- Improved karma management
|
||||||
|
- Fixed og: meta tags missing
|
||||||
|
|
||||||
|
## 0.3.2
|
||||||
|
|
||||||
|
- Fixed administrator users not being able to create +guilds
|
||||||
|
|
||||||
## 0.3.1
|
## 0.3.1
|
||||||
|
|
||||||
- Fixed a critical bug that prevented database initialization
|
- Fixed a critical bug that prevented database initialization
|
||||||
|
|
|
||||||
90
README.md
90
README.md
|
|
@ -1,3 +1,91 @@
|
||||||
# Freak
|
# Freak
|
||||||
|
|
||||||
(´ω\`)
|
> \~(´ω\`)\~
|
||||||
|
> (Josip Broz Tito, possibly)
|
||||||
|
|
||||||
|
**Freak** (as in extremely interested into something, NOT as in predator) is a in-development FOSS and sovereign alternative to Reddit (and an attempt to revive Ruqqus from scratch). The socio-moral reasons are beyond the scope of this README.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
* First make sure you have these requirements:
|
||||||
|
* Unix-like OS (Docker container, Linux or MacOS are all good).
|
||||||
|
* **Python** >=3.10. Recommended to use a virtualenv (unless in Docker lol).
|
||||||
|
* **PostgreSQL** at least 16.
|
||||||
|
* **Redis**/Valkey (as of 0.5.0 unused in codebase -_-).
|
||||||
|
* **Docker** and **Docker Compose**.
|
||||||
|
* A server machine with a public IP address and shell access (mandatory for production, optional for development/staging).
|
||||||
|
* First time? I recommend a VPS. The cheapest one starts at €5/month, half a Spotify subscription.
|
||||||
|
* You must have **shell access**. FTP only is not enough.
|
||||||
|
* A domain (mandatory for production).
|
||||||
|
* You must have bought it beforehand. Don't have? `.xyz` are like $2 or $3 on Namecheap[^1]
|
||||||
|
* For development, tweaking `/etc/hosts` or plain running on `localhost:5000` is usually enough.
|
||||||
|
* A reverse proxy (i.e. Caddy or nginx) listening on ports 80 and 443. Reminder to set `APP_IS_BEHIND_PROXY=1` in `.env` !!!
|
||||||
|
* Electricity.
|
||||||
|
* Will to not give up.
|
||||||
|
* Clone this repository.
|
||||||
|
* Fill in `.env` with the necessary information.
|
||||||
|
* `SERVER_NAME` (see above)
|
||||||
|
* `APP_NAME`
|
||||||
|
* `DATABASE_URL` (hint: `postgresql://username:password@localhost/dbname`)
|
||||||
|
* `SECRET_KEY` (you can generate one with the command `cat /dev/random | tr -dc A-Za-z0-9_. | head -c 56`)
|
||||||
|
* `PRIVATE_ASSETS` (you must provide the icon stylesheets here. Useful for custom CSS / scripts as well)
|
||||||
|
* `APP_IS_BEHIND_PROXY` (mandatory if behind reverse proxy or NAT)
|
||||||
|
* `IMPRESSUM` (if you host or serve your site in Germany[^2]. Lines are separated by double colons `::`)
|
||||||
|
* Adjust `docker-compose.yml` to your liking.
|
||||||
|
* Run `docker compose build`.
|
||||||
|
* Create a systemd unit file looking like this:
|
||||||
|
```systemd
|
||||||
|
[Unit]
|
||||||
|
Description=Freak
|
||||||
|
## using Caddy? replace nginx.service with Caddy.service. Yes, twice
|
||||||
|
Wants=nginx.service docker.service
|
||||||
|
After=nginx.service docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
## REPLACE it with your path
|
||||||
|
WorkingDirectory=/path/to/repository/freak
|
||||||
|
ExecStart=/usr/bin/docker compose up
|
||||||
|
ExecReload=/usr/bin/docker compose run freak bash ./docker-run.sh r
|
||||||
|
ExecStop=/usr/bin/docker compose down
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
* Copy the file to `/usr/lib/systemd/system` (with root access)
|
||||||
|
* Run `sudo systemctl enable --now freak.service`
|
||||||
|
* Expect no red text or weird error gibberish. If there is, you did not follow the tutorial: read it from the start again.
|
||||||
|
* Congratulations! Your Freak instance is up and running
|
||||||
|
|
||||||
|
|
||||||
|
[^1]: Namecheap is an American company. Don't trust American companies.
|
||||||
|
[^2]: Not legal advice.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Why another Reddit clone?
|
||||||
|
|
||||||
|
I felt like it.
|
||||||
|
|
||||||
|
### Will Freak be federated?
|
||||||
|
|
||||||
|
It's on the roadmap. However, it probably won't be fully functional if not after at least twenty feature releases. Therefore, wait patiently.
|
||||||
|
|
||||||
|
Freak is currently implementing the [SIS](https://yusur.moe/protocols/sis.html).
|
||||||
|
|
||||||
|
### What is your legal contact / Impressum?
|
||||||
|
|
||||||
|
You have to configure it yourself by setting `IMPRESSUM` in `.env`.
|
||||||
|
|
||||||
|
I only write the code. I am not accountable for Your use (see [License](#license)).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source license.
|
||||||
|
|
||||||
|
This is a hobby project, made available “AS IS”, with __no warranty__ express or implied.
|
||||||
|
|
||||||
|
I (sakuragasaki46) may NOT be held accountable for Your use of my code.
|
||||||
|
|
||||||
|
> It's pointless to file a lawsuit because you feel damaged, and it's only going to turn against you. What a waste of money you could have spent on a vacation or charity, or invested in stocks.
|
||||||
|
|
||||||
|
|
|
||||||
92
alembic/versions/29a8d663c7ce_.py
Normal file
92
alembic/versions/29a8d663c7ce_.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"""upgrade to 0.4.0
|
||||||
|
|
||||||
|
NOTICE: REVISIONS BEFORE 0.3.1 ARE LOST FOR GOOD
|
||||||
|
|
||||||
|
get over it and move on: the recommended way to upgrade is via
|
||||||
|
python3 -m freak -U
|
||||||
|
|
||||||
|
Revision ID: 29a8d663c7ce
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-06-17 21:55:16.145111
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '29a8d663c7ce'
|
||||||
|
down_revision: Union[str, None] = '7122c8715ff9'
|
||||||
|
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.create_table('freak_user_block',
|
||||||
|
sa.Column('actor_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('target_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['actor_id'], ['freak_user.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['target_id'], ['freak_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('actor_id', 'target_id')
|
||||||
|
)
|
||||||
|
op.create_table('freak_user_strike',
|
||||||
|
sa.Column('id', sa.LargeBinary(length=16), nullable=False),
|
||||||
|
sa.Column('user_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('target_type', sa.SmallInteger(), nullable=False),
|
||||||
|
sa.Column('target_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('target_content', sa.String(length=4096), nullable=True),
|
||||||
|
sa.Column('reason_code', sa.SmallInteger(), nullable=False),
|
||||||
|
sa.Column('issued_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.Column('issued_by_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['issued_by_id'], ['freak_user.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['freak_user.id'], ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('freak_member',
|
||||||
|
sa.Column('id', sa.LargeBinary(length=16), nullable=False),
|
||||||
|
sa.Column('user_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('guild_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('is_approved', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||||
|
sa.Column('is_subscribed', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||||
|
sa.Column('is_moderator', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||||
|
sa.Column('banned_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('banned_by_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('banned_reason', sa.SmallInteger(), server_default=sa.text('0'), nullable=True),
|
||||||
|
sa.Column('banned_until', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('banned_message', sa.String(length=256), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['banned_by_id'], ['freak_user.id'], name='user_banner_id'),
|
||||||
|
sa.ForeignKeyConstraint(['guild_id'], ['freak_topic.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['freak_user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'guild_id', name='member_user_topic')
|
||||||
|
)
|
||||||
|
op.add_column('freak_topic', sa.Column('is_restricted', sa.Boolean(), server_default=sa.text('false'), nullable=False))
|
||||||
|
op.add_column('freak_topic', sa.Column('is_public', sa.Boolean(), server_default=sa.text('true'), nullable=False))
|
||||||
|
op.drop_column('freak_topic', 'privacy')
|
||||||
|
op.add_column('freak_user', sa.Column('pronouns', sa.Integer(), server_default=sa.text('0'), nullable=False))
|
||||||
|
op.add_column('freak_user', sa.Column('biography', sa.String(length=1024), nullable=True))
|
||||||
|
op.add_column('freak_user', sa.Column('is_approved', sa.Boolean(), server_default=sa.text('false'), nullable=False))
|
||||||
|
op.add_column('freak_user', sa.Column('invited_by_id', sa.BigInteger(), nullable=True))
|
||||||
|
op.create_foreign_key('user_inviter_id', 'freak_user', 'freak_user', ['invited_by_id'], ['id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint('user_inviter_id', 'freak_user', type_='foreignkey')
|
||||||
|
op.drop_column('freak_user', 'invited_by_id')
|
||||||
|
op.drop_column('freak_user', 'is_approved')
|
||||||
|
op.drop_column('freak_user', 'biography')
|
||||||
|
op.drop_column('freak_user', 'pronouns')
|
||||||
|
op.add_column('freak_topic', sa.Column('privacy', sa.SMALLINT(), server_default=sa.text('0'), autoincrement=False, nullable=True))
|
||||||
|
op.drop_column('freak_topic', 'is_public')
|
||||||
|
op.drop_column('freak_topic', 'is_restricted')
|
||||||
|
op.drop_table('freak_member')
|
||||||
|
op.drop_table('freak_user_strike')
|
||||||
|
op.drop_table('freak_user_block')
|
||||||
|
# ### end Alembic commands ###
|
||||||
34
alembic/versions/6d418df3c72f_.py
Normal file
34
alembic/versions/6d418df3c72f_.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 6d418df3c72f
|
||||||
|
Revises: 90c7d0098efe
|
||||||
|
Create Date: 2025-07-07 13:37:51.667620
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6d418df3c72f'
|
||||||
|
down_revision: Union[str, None] = '90c7d0098efe'
|
||||||
|
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.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey')
|
||||||
|
op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id'], ondelete='cascade')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint('comment_parent_post_id', 'freak_comment', type_='foreignkey')
|
||||||
|
op.create_foreign_key('comment_parent_post_id', 'freak_comment', 'freak_post', ['parent_post_id'], ['id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
28
alembic/versions/7122c8715ff9_.py
Normal file
28
alembic/versions/7122c8715ff9_.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""autogenerated to allow downgrade to nothing as a bugfix
|
||||||
|
|
||||||
|
Revision ID: 7122c8715ff9
|
||||||
|
Revises: 29a8d663c7ce
|
||||||
|
Create Date: 2025-06-17 22:05:14.803669
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7122c8715ff9'
|
||||||
|
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."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
32
alembic/versions/90c7d0098efe_.py
Normal file
32
alembic/versions/90c7d0098efe_.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 90c7d0098efe
|
||||||
|
Revises: 29a8d663c7ce
|
||||||
|
Create Date: 2025-06-19 01:16:41.120290
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '90c7d0098efe'
|
||||||
|
down_revision: Union[str, None] = '29a8d663c7ce'
|
||||||
|
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_user', sa.Column('color_theme', sa.SmallInteger(), server_default=sa.text('0'), nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('freak_user', 'color_theme')
|
||||||
|
# ### end Alembic commands ###
|
||||||
14
docker-compose.yml.example
Normal file
14
docker-compose.yml.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
|
||||||
|
services:
|
||||||
|
freak:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: freak
|
||||||
|
ports:
|
||||||
|
- 5000:5000
|
||||||
|
volumes:
|
||||||
|
- .:/opt/live-app:ro
|
||||||
|
extra_hosts:
|
||||||
|
- 'postgres.docker.internal:172.17.0.1'
|
||||||
|
restart: on-failure:3
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ start-app() {
|
||||||
cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./
|
cp -rv /opt/live-app/{freak,pyproject.toml,docker-run.sh} ./
|
||||||
cp -v /opt/live-app/.env.prod .env
|
cp -v /opt/live-app/.env.prod .env
|
||||||
pip install -e .
|
pip install -e .
|
||||||
flask --app freak run --host=0.0.0.0
|
hypercorn freak:app -b 0.0.0.0:5000
|
||||||
}
|
}
|
||||||
|
|
||||||
[[ "$1" = "" ]] && start-app
|
[[ "$1" = "" ]] && start-app
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,268 @@
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from sqlite3 import ProgrammingError
|
from sqlite3 import ProgrammingError
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
import warnings
|
import warnings
|
||||||
from flask import (
|
from quart import (
|
||||||
Flask, g, redirect, render_template,
|
Quart, flash, g, jsonify, redirect, render_template,
|
||||||
request, send_from_directory, url_for
|
request, send_from_directory, url_for
|
||||||
)
|
)
|
||||||
import os
|
import os
|
||||||
import dotenv
|
import dotenv
|
||||||
from flask_login import LoginManager
|
from quart_auth import AuthUser, QuartAuth, Action as QA_Action, current_user
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from quart_wtf import CSRFProtect
|
||||||
from sqlalchemy import select
|
from sqlalchemy import inspect, select
|
||||||
|
from suou import Snowflake, ssv_list, yesno
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
from sassutils.wsgi import SassMiddleware
|
from suou.sass import SassAsyncMiddleware
|
||||||
|
from suou.quart import negotiate
|
||||||
|
from hypercorn.middleware import ProxyFixMiddleware
|
||||||
|
|
||||||
__version__ = '0.3.1'
|
from suou.configparse import ConfigOptions, ConfigValue
|
||||||
|
from suou import twocolon_list, WantsContentType
|
||||||
|
|
||||||
|
from .colors import color_themes, theme_classes
|
||||||
|
|
||||||
|
__version__ = '0.5.0-dev50'
|
||||||
|
|
||||||
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
APP_BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
if not dotenv.load_dotenv():
|
||||||
|
warnings.warn('.env not loaded; application may break!', RuntimeWarning)
|
||||||
|
|
||||||
app = Flask(__name__)
|
class AppConfig(ConfigOptions):
|
||||||
app.secret_key = os.getenv('SECRET_KEY')
|
secret_key = ConfigValue(required=True)
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
|
database_url = ConfigValue(required=True)
|
||||||
|
app_name = ConfigValue()
|
||||||
|
server_name = ConfigValue()
|
||||||
|
force_server_name = ConfigValue(cast=yesno, default=True)
|
||||||
|
private_assets = ConfigValue(cast=ssv_list)
|
||||||
|
app_is_behind_proxy = ConfigValue(cast=int, default=0)
|
||||||
|
impressum = ConfigValue(cast=twocolon_list, default='')
|
||||||
|
create_guild_threshold = ConfigValue(cast=int, default=15, prefix='freak_')
|
||||||
|
# v-- deprecated --v
|
||||||
|
jquery_url = ConfigValue(default='https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js')
|
||||||
|
# ^----------------^
|
||||||
|
|
||||||
|
app_config = AppConfig()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Quart(__name__)
|
||||||
|
app.secret_key = app_config.secret_key
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = app_config.database_url
|
||||||
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
|
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
|
||||||
|
app.config['QUART_AUTH_DURATION'] = 365 * 24 * 60 * 60
|
||||||
|
|
||||||
from .models import db, User, Post
|
if app_config.server_name and app_config.force_server_name:
|
||||||
from .iding import id_from_b32l, id_to_b32l
|
app.config['SERVER_NAME'] = app_config.server_name
|
||||||
|
|
||||||
|
|
||||||
|
## DO NOT ADD LOCAL IMPORTS BEFORE THIS LINE
|
||||||
|
|
||||||
|
from .accounts import UserLoader
|
||||||
|
from .models import Guild, db, User, Post
|
||||||
|
|
||||||
# SASS
|
# SASS
|
||||||
app.wsgi_app = SassMiddleware(app.wsgi_app, dict(
|
app.asgi_app = SassAsyncMiddleware(app.asgi_app, dict(
|
||||||
freak=('static/sass', 'static/css', '/static/css', True)
|
freak=('static/sass', 'static/css', '/static/css', True)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# proxy fix
|
||||||
|
if app_config.app_is_behind_proxy:
|
||||||
|
app.asgi_app = ProxyFixMiddleware(
|
||||||
|
app.asgi_app, trusted_hops=app_config.app_is_behind_proxy, mode='legacy'
|
||||||
|
)
|
||||||
|
|
||||||
class SlugConverter(BaseConverter):
|
class SlugConverter(BaseConverter):
|
||||||
regex = r'[a-z0-9]+(?:-[a-z0-9]+)*'
|
regex = r'[a-z0-9]+(?:-[a-z0-9]+)*'
|
||||||
|
|
||||||
class B32lConverter(BaseConverter):
|
class B32lConverter(BaseConverter):
|
||||||
regex = r'_?[a-z2-7]+'
|
regex = r'_?[a-z2-7]+'
|
||||||
def to_url(self, value):
|
def to_url(self, value):
|
||||||
return id_to_b32l(value)
|
return Snowflake(value).to_b32l()
|
||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
return id_from_b32l(value)
|
return Snowflake.from_b32l(value)
|
||||||
|
|
||||||
app.url_map.converters['slug'] = SlugConverter
|
app.url_map.converters['slug'] = SlugConverter
|
||||||
app.url_map.converters['b32l'] = B32lConverter
|
app.url_map.converters['b32l'] = B32lConverter
|
||||||
|
|
||||||
db.init_app(app)
|
db.bind(app_config.database_url)
|
||||||
|
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
login_manager = LoginManager(app)
|
|
||||||
login_manager.login_view = 'accounts.login'
|
|
||||||
|
|
||||||
|
# TODO configure quart_auth
|
||||||
|
login_manager = QuartAuth(app, user_class= UserLoader)
|
||||||
|
|
||||||
from . import filters
|
from . import filters
|
||||||
|
|
||||||
|
|
||||||
PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
|
PRIVATE_ASSETS = os.getenv('PRIVATE_ASSETS', '').split()
|
||||||
|
|
||||||
|
post_count_cache = 0
|
||||||
|
user_count_cache = 0
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def _inject_variables():
|
async def _inject_variables():
|
||||||
|
global post_count_cache, user_count_cache
|
||||||
|
try:
|
||||||
|
post_count = await Post.count()
|
||||||
|
user_count = await User.active_count()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'cannot compute post_count: {e}')
|
||||||
|
post_count = post_count_cache
|
||||||
|
user_count = user_count_cache
|
||||||
|
else:
|
||||||
|
post_count_cache = post_count
|
||||||
|
user_count_cache = user_count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'app_name': os.getenv('APP_NAME'),
|
'app_name': app_config.app_name,
|
||||||
'domain_name': os.getenv('DOMAIN_NAME'),
|
'app_version': __version__,
|
||||||
|
'server_name': app_config.server_name,
|
||||||
'url_for_css': (lambda name, **kwargs: url_for('static', filename=f'css/{name}.css', **kwargs)),
|
'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_scripts': [x for x in app_config.private_assets if x.endswith('.js')],
|
||||||
'private_styles': [x for x in PRIVATE_ASSETS if x.endswith('.css')],
|
'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',
|
'jquery_url': app_config.jquery_url,
|
||||||
'post_count': Post.count(),
|
'post_count': post_count,
|
||||||
'user_count': User.active_count()
|
'user_count': user_count,
|
||||||
|
'colors': color_themes,
|
||||||
|
'theme_classes': theme_classes,
|
||||||
|
'impressum': '\n'.join(app_config.impressum).replace('_', ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@login_manager.user_loader
|
@app.before_request
|
||||||
def _inject_user(userid):
|
async def _load_user():
|
||||||
try:
|
try:
|
||||||
return db.session.execute(select(User).where(User.id == userid)).scalar()
|
await current_user._load()
|
||||||
except Exception:
|
except RuntimeError as e:
|
||||||
warnings.warn(f'cannot retrieve user {userid} from db', RuntimeWarning)
|
logger.error(f'{e}')
|
||||||
g.no_user = True
|
g.no_user = True
|
||||||
return None
|
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
async def _unload_user(resp):
|
||||||
|
try:
|
||||||
|
await current_user._unload()
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(f'{e}')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url_password(u: str | Any) -> str | Any:
|
||||||
|
if not isinstance(u, str):
|
||||||
|
return u
|
||||||
|
return re.sub(r':[^@:/ ]+@', ':***@', u)
|
||||||
|
|
||||||
|
async def error_handler_for(status: int, message: str, template: str):
|
||||||
|
match negotiate():
|
||||||
|
case WantsContentType.JSON:
|
||||||
|
return jsonify({'error': f'{message}', 'status': status}), status
|
||||||
|
case WantsContentType.HTML:
|
||||||
|
if request.path.startswith('/admin'):
|
||||||
|
return await render_template('admin/' + template, message=f'{message}'), status
|
||||||
|
return await render_template(template, message=f'{message}'), status
|
||||||
|
case WantsContentType.PLAIN:
|
||||||
|
return f'{message} (HTTP {status})', status, {'content-type': 'text/plain; charset=UTF-8'}
|
||||||
|
|
||||||
@app.errorhandler(ProgrammingError)
|
@app.errorhandler(ProgrammingError)
|
||||||
def error_db(body):
|
async def error_db(body):
|
||||||
g.no_user = True
|
g.no_user = True
|
||||||
warnings.warn(f'No database access! (url is {app.config['SQLALCHEMY_DATABASE_URI']})', RuntimeWarning)
|
logger.error(f'No database access! (url is {redact_url_password(app.config['SQLALCHEMY_DATABASE_URI'])!r})', RuntimeWarning)
|
||||||
fix_database_url()
|
return await error_handler_for(500, body, '500.html')
|
||||||
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)
|
@app.errorhandler(400)
|
||||||
def error_400(body):
|
async def error_400(body):
|
||||||
return render_template('400.html'), 400
|
return await error_handler_for(400, body, '400.html')
|
||||||
|
|
||||||
|
@app.errorhandler(401)
|
||||||
|
async def error_401(body):
|
||||||
|
match negotiate():
|
||||||
|
case WantsContentType.HTML:
|
||||||
|
return redirect(url_for('accounts.login', next=request.path))
|
||||||
|
case _:
|
||||||
|
return await error_handler_for(401, 'Please log in.', 'login.html')
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(403)
|
@app.errorhandler(403)
|
||||||
def error_403(body):
|
async def error_403(body):
|
||||||
return render_template('403.html'), 403
|
return await error_handler_for(403, body, '403.html')
|
||||||
|
|
||||||
|
async def find_guild_or_user(name: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Used in 404 error handler.
|
||||||
|
|
||||||
|
Returns an URL to redirect or None for no redirect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if hasattr(g, 'no_user'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# do not execute for non-browsers_
|
||||||
|
if 'Mozilla/' not in request.user_agent.string:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||||
|
user = (await session.execute(select(User).where(User.username == name))).scalar()
|
||||||
|
|
||||||
|
if gu is not None:
|
||||||
|
await flash(f'There is nothing at /{name}. Luckily, a guild with name {gu.handle()} happens to exist. Next time, remember to add + before!')
|
||||||
|
return gu.url()
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
await flash(f'There is nothing at /{name}. Luckily, a user named {user.handle()} happens to exist. Next time, remember to add @ before!')
|
||||||
|
return user.url()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def error_404(body):
|
async def error_404(body):
|
||||||
return render_template('404.html'), 404
|
try:
|
||||||
|
if mo := re.match(r'/([a-z0-9_-]+)/?', request.path):
|
||||||
|
alternative = await find_guild_or_user(mo.group(1))
|
||||||
|
if alternative is not None:
|
||||||
|
return redirect(alternative), 302
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Exception in find_guild_or_user: {e}')
|
||||||
|
pass
|
||||||
|
if app_config.server_name not in (None, request.host):
|
||||||
|
logger.warning(f'request host {request.host!r} is different from configured server name {app_config.server_name!r}')
|
||||||
|
if request.referrer:
|
||||||
|
logger.warning(f'(referrer is {request.referrer!r}')
|
||||||
|
if request.host == request.referrer:
|
||||||
|
return {"error": "Loop detected"}, 508
|
||||||
|
return redirect('//' + app_config.server_name + request.full_path), 307
|
||||||
|
return await error_handler_for(404, 'Not found', '404.html')
|
||||||
|
|
||||||
@app.errorhandler(405)
|
@app.errorhandler(405)
|
||||||
def error_405(body):
|
async def error_405(body):
|
||||||
return render_template('405.html'), 405
|
return await error_handler_for(405, body, '405.html')
|
||||||
|
|
||||||
@app.errorhandler(451)
|
@app.errorhandler(451)
|
||||||
def error_451(body):
|
async def error_451(body):
|
||||||
return render_template('451.html'), 451
|
return await error_handler_for(451, body, '451.html')
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def error_500(body):
|
async def error_500(body):
|
||||||
g.no_user = True
|
g.no_user = True
|
||||||
return render_template('500.html'), 500
|
return await error_handler_for(500, body, '500.html')
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon_ico():
|
async def favicon_ico():
|
||||||
return send_from_directory(APP_BASE_DIR, 'favicon.ico')
|
return await send_from_directory(APP_BASE_DIR, 'favicon.ico')
|
||||||
|
|
||||||
@app.route('/robots.txt')
|
@app.route('/robots.txt')
|
||||||
def robots_txt():
|
async def robots_txt():
|
||||||
return send_from_directory(APP_BASE_DIR, 'robots.txt')
|
return await send_from_directory(APP_BASE_DIR, 'robots.txt')
|
||||||
|
|
||||||
|
|
||||||
from .website import blueprints
|
from .website import blueprints
|
||||||
|
|
@ -130,8 +272,8 @@ for bp in blueprints:
|
||||||
from .ajax import bp
|
from .ajax import bp
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
from .rest import rest_bp
|
from .rest import bp
|
||||||
app.register_blueprint(rest_bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from .cli import main
|
from .cli import main
|
||||||
|
|
||||||
main()
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
|
||||||
89
freak/accounts.py
Normal file
89
freak/accounts.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from suou.sqlalchemy.asyncio import AsyncSession
|
||||||
|
from .models import User, db
|
||||||
|
from quart_auth import AuthUser, Action as _Action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LoginStatus(enum.Enum):
|
||||||
|
SUCCESS = 0
|
||||||
|
ERROR = 1
|
||||||
|
SUSPENDED = 2
|
||||||
|
PASS_EXPIRED = 3
|
||||||
|
|
||||||
|
def check_login(user: User | None, password: str) -> LoginStatus:
|
||||||
|
try:
|
||||||
|
if user is None:
|
||||||
|
return LoginStatus.ERROR
|
||||||
|
if ('$' not in user.passhash) and user.email:
|
||||||
|
return LoginStatus.PASS_EXPIRED
|
||||||
|
if not user.is_active:
|
||||||
|
return LoginStatus.SUSPENDED
|
||||||
|
if user.check_password(password):
|
||||||
|
return LoginStatus.SUCCESS
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'{e}')
|
||||||
|
return LoginStatus.ERROR
|
||||||
|
|
||||||
|
|
||||||
|
class UserLoader(AuthUser):
|
||||||
|
"""
|
||||||
|
Loads user from the session.
|
||||||
|
|
||||||
|
*WARNING* requires to be awaited before request before usage!
|
||||||
|
|
||||||
|
Actual User object is at .user; other attributes are proxied.
|
||||||
|
"""
|
||||||
|
def __init__(self, auth_id: str | None, action: _Action= _Action.PASS):
|
||||||
|
self._auth_id = auth_id
|
||||||
|
self._auth_obj = None
|
||||||
|
self._auth_sess: AsyncSession | None = None
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_id(self) -> str | None:
|
||||||
|
return self._auth_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
async def is_authenticated(self) -> bool:
|
||||||
|
await self._load()
|
||||||
|
return self._auth_id is not None
|
||||||
|
|
||||||
|
async def _load(self):
|
||||||
|
if self._auth_obj is None and self._auth_id is not None:
|
||||||
|
async with db as session:
|
||||||
|
self._auth_obj = (await session.execute(select(User).where(User.id == int(self._auth_id)))).scalar()
|
||||||
|
if self._auth_obj is None:
|
||||||
|
raise RuntimeError('failed to fetch user')
|
||||||
|
|
||||||
|
def __getattr__(self, key):
|
||||||
|
if self._auth_obj is None:
|
||||||
|
raise RuntimeError('user is not loaded')
|
||||||
|
return getattr(self._auth_obj, key)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return self._auth_obj is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session(self):
|
||||||
|
return self._auth_sess
|
||||||
|
|
||||||
|
async def _unload(self):
|
||||||
|
# user is not expected to mutate
|
||||||
|
if self._auth_sess:
|
||||||
|
await self._auth_sess.rollback()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self):
|
||||||
|
return self._auth_obj
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: str
|
||||||
|
color_theme: int
|
||||||
157
freak/ajax.py
157
freak/ajax.py
|
|
@ -1,41 +1,51 @@
|
||||||
|
|
||||||
'''
|
'''
|
||||||
AJAX hooks for the website.
|
AJAX hooks for the OLD frontend.
|
||||||
|
|
||||||
2025 DEPRECATED in favor of /v1/ (REST)
|
DEPRECATED in 0.5 in favor of /v1/ (REST)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from flask import Blueprint, request
|
from quart import Blueprint, abort, flash, redirect, request
|
||||||
from .models import Topic, db, User, Post, PostUpvote
|
from sqlalchemy import delete, insert, select
|
||||||
from flask_login import current_user, login_required
|
|
||||||
|
from freak import UserLoader
|
||||||
|
from freak.utils import get_request_form
|
||||||
|
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote, username_is_legal
|
||||||
|
from quart_auth import current_user, login_required
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
bp = Blueprint('ajax', __name__)
|
bp = Blueprint('ajax', __name__)
|
||||||
|
|
||||||
@bp.route('/username_availability/<username>')
|
@bp.route('/username_availability/<username>')
|
||||||
@bp.route('/ajax/username_availability/<username>')
|
@bp.route('/ajax/username_availability/<username>')
|
||||||
def username_availability(username: str):
|
async def username_availability(username: str):
|
||||||
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None
|
is_valid = username_is_legal(username)
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
user = db.session.execute(db.select(User).where(User.username == username)).scalar()
|
async with db as session:
|
||||||
|
user = (await session.execute(select(User).where(User.username == username))).scalar()
|
||||||
|
|
||||||
is_available = user is None or user == current_user
|
is_available = user is None or user == current_user.user
|
||||||
else:
|
else:
|
||||||
is_available = False
|
is_available = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'is_valid': is_valid,
|
'is_valid': is_valid,
|
||||||
'is_available': is_available,
|
'is_available': is_available
|
||||||
}
|
}
|
||||||
|
|
||||||
@bp.route('/guild_name_availability/<username>')
|
@bp.route('/guild_name_availability/<name>')
|
||||||
def guild_name_availability(name: str):
|
async def guild_name_availability(name: str):
|
||||||
is_valid = re.fullmatch('[a-z0-9_-]+', username) is not None
|
is_valid = username_is_legal(name)
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
gd = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar()
|
async with db as session:
|
||||||
|
gd = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||||
|
|
||||||
is_available = gd is None
|
is_available = gd is None
|
||||||
else:
|
else:
|
||||||
|
|
@ -44,29 +54,118 @@ def guild_name_availability(name: str):
|
||||||
return {
|
return {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'is_valid': is_valid,
|
'is_valid': is_valid,
|
||||||
'is_available': is_available,
|
'is_available': is_available
|
||||||
}
|
}
|
||||||
|
|
||||||
@bp.route('/comments/<b32l:id>/upvote', methods=['POST'])
|
@bp.route('/comments/<b32l:id>/upvote', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def post_upvote(id):
|
async def post_upvote(id):
|
||||||
o = request.form['o']
|
form = await get_request_form()
|
||||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
o = form['o']
|
||||||
|
async with db as session:
|
||||||
|
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||||
|
|
||||||
if p is None:
|
if p is None:
|
||||||
return { 'status': 'fail', 'message': 'Post not found' }, 404
|
return { 'status': 'fail', 'message': 'Post not found' }, 404
|
||||||
|
|
||||||
if o == '1':
|
cur_score = await p.upvoted_by(current_user.user)
|
||||||
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))
|
match (o, cur_score):
|
||||||
elif o == '0':
|
case ('1', 0) | ('1', -1):
|
||||||
db.session.execute(db.delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
|
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
|
||||||
elif o == '-1':
|
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
|
||||||
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))
|
case ('0', _):
|
||||||
db.session.execute(db.insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
|
||||||
else:
|
case ('-1', 1) | ('-1', 0):
|
||||||
|
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
|
||||||
|
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
||||||
|
case ('1', 1) | ('-1', -1):
|
||||||
|
pass
|
||||||
|
case _:
|
||||||
|
await session.rollback()
|
||||||
return { 'status': 'fail', 'message': 'Invalid score' }, 400
|
return { 'status': 'fail', 'message': 'Invalid score' }, 400
|
||||||
|
|
||||||
db.session.commit()
|
await session.commit()
|
||||||
return { 'status': 'ok', 'count': p.upvotes() }
|
return { 'status': 'ok', 'count': await p.upvotes() }
|
||||||
|
|
||||||
|
@bp.route('/@<username>/block', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
async def block_user(username):
|
||||||
|
form = await get_request_form()
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
u = (await session.execute(select(User).where(User.username == username))).scalar()
|
||||||
|
|
||||||
|
if u is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
is_block = 'reverse' not in form
|
||||||
|
is_unblock = form.get('reverse') == '1'
|
||||||
|
|
||||||
|
if is_block:
|
||||||
|
if current_user.has_blocked(u):
|
||||||
|
await flash(f'{u.handle()} is already blocked')
|
||||||
|
else:
|
||||||
|
await session.execute(insert(UserBlock).values(
|
||||||
|
actor_id = current_user.id,
|
||||||
|
target_id = u.id
|
||||||
|
))
|
||||||
|
await flash(f'{u.handle()} is now blocked')
|
||||||
|
|
||||||
|
if is_unblock:
|
||||||
|
if not current_user.has_blocked(u):
|
||||||
|
await flash('You didn\'t block this user')
|
||||||
|
else:
|
||||||
|
await session.execute(delete(UserBlock).where(
|
||||||
|
UserBlock.c.actor_id == current_user.id,
|
||||||
|
UserBlock.c.target_id == u.id
|
||||||
|
))
|
||||||
|
await flash(f'Removed block on {u.handle()}')
|
||||||
|
|
||||||
|
return redirect(request.args.get('next', u.url())), 303
|
||||||
|
|
||||||
|
@bp.route('/+<name>/subscribe', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
async def subscribe_guild(name):
|
||||||
|
form = await get_request_form()
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||||
|
|
||||||
|
if gu is None:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
is_join = 'reverse' not in form
|
||||||
|
is_leave = form.get('reverse') == '1'
|
||||||
|
|
||||||
|
membership = (await session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id))).scalar()
|
||||||
|
|
||||||
|
if is_join:
|
||||||
|
if membership is None:
|
||||||
|
membership = (await session.execute(insert(Member).values(
|
||||||
|
guild_id = gu.id,
|
||||||
|
user_id = current_user.id,
|
||||||
|
is_subscribed = True
|
||||||
|
).returning(Member))).scalar()
|
||||||
|
elif membership.is_subscribed == False:
|
||||||
|
membership.is_subscribed = True
|
||||||
|
await session.add(membership)
|
||||||
|
else:
|
||||||
|
return redirect(gu.url()), 303
|
||||||
|
await flash(f"You are now subscribed to {gu.handle()}")
|
||||||
|
|
||||||
|
if is_leave:
|
||||||
|
if membership is None:
|
||||||
|
return redirect(gu.url()), 303
|
||||||
|
elif membership.is_subscribed == True:
|
||||||
|
membership.is_subscribed = False
|
||||||
|
await session.add(membership)
|
||||||
|
else:
|
||||||
|
return redirect(gu.url()), 303
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await flash(f"Unsubscribed from {gu.handle()}.")
|
||||||
|
|
||||||
|
return redirect(gu.url()), 303
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,55 @@
|
||||||
|
|
||||||
|
|
||||||
from flask_login import current_user
|
from quart_auth import current_user
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, distinct, func, select
|
||||||
from .models import db, Post, Topic, User
|
from suou import not_implemented
|
||||||
|
|
||||||
|
from .models import Comment, Member, Post, Guild, User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def cuser() -> User:
|
def cuser() -> User:
|
||||||
return current_user if current_user.is_authenticated else None
|
return current_user.user if current_user else None
|
||||||
|
|
||||||
|
def cuser_id() -> int:
|
||||||
|
return current_user.id if current_user else None
|
||||||
|
|
||||||
def public_timeline():
|
def public_timeline():
|
||||||
return select(Post).join(User, User.id == Post.author_id).where(
|
return select(Post).join(User, User.id == Post.author_id).where(
|
||||||
Post.privacy == 0, User.not_suspended(), Post.not_removed()
|
Post.privacy == 0, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||||
).order_by(Post.created_at.desc())
|
).order_by(Post.created_at.desc())
|
||||||
|
|
||||||
def topic_timeline(topic_name):
|
def topic_timeline(gname):
|
||||||
return select(Post).join(Topic).join(User, User.id == Post.author_id).where(
|
return select(Post).join(Guild, Guild.id == Post.topic_id).join(User, User.id == Post.author_id).where(
|
||||||
Post.privacy == 0, Topic.name == topic_name, User.not_suspended(), Post.not_removed()
|
Post.privacy == 0, Guild.name == gname, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||||
).order_by(Post.created_at.desc())
|
).order_by(Post.created_at.desc())
|
||||||
|
|
||||||
def user_timeline(user_id):
|
def user_timeline(user: User):
|
||||||
return select(Post).join(User, User.id == Post.author_id).where(
|
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()
|
Post.visible_by(cuser_id()), Post.author_id == user.id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||||
).order_by(Post.created_at.desc())
|
).order_by(Post.created_at.desc())
|
||||||
|
|
||||||
|
def new_comments(p: Post):
|
||||||
|
return select(Comment).join(Post, Post.id == Comment.parent_post_id).join(User, User.id == Comment.author_id
|
||||||
|
).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None, Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())
|
||||||
|
).order_by(Comment.created_at.desc())
|
||||||
|
|
||||||
def top_guilds_query():
|
def top_guilds_query():
|
||||||
q_post_count = func.count().label('post_count')
|
q_post_count = func.count(distinct(Post.id)).label('post_count')
|
||||||
qr = select(Topic, q_post_count)\
|
q_sub_count = func.count(distinct(Member.id)).label('sub_count')
|
||||||
.join(Post, Post.topic_id == Topic.id).group_by(Topic)\
|
qr = select(Guild, q_post_count, q_sub_count)\
|
||||||
.having(q_post_count > 5).order_by(q_post_count.desc())
|
.join(Post, Post.topic_id == Guild.id, isouter=True)\
|
||||||
|
.join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\
|
||||||
|
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
|
||||||
return qr
|
return qr
|
||||||
|
|
||||||
|
|
||||||
|
@not_implemented()
|
||||||
|
class Algorithms:
|
||||||
|
"""
|
||||||
|
Return SQL queries for algorithms.
|
||||||
|
"""
|
||||||
|
def __init__(self, me: User | None):
|
||||||
|
self.me = me
|
||||||
|
|
||||||
|
|
||||||
33
freak/cli.py
33
freak/cli.py
|
|
@ -4,22 +4,41 @@ import argparse
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from . import __version__ as version
|
from sqlalchemy.orm import Session
|
||||||
from .models import db
|
from . import __version__ as version, app_config
|
||||||
|
from .models import User, db
|
||||||
|
|
||||||
def make_parser():
|
def make_parser():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument('--version', '-v', action='version', version=version)
|
parser.add_argument('--version', '-v', action='version', version=version)
|
||||||
parser.add_argument('--upgrade', '-U', action='store_true', help='create or upgrade schema')
|
parser.add_argument('--upgrade', '-U', action='store_true', help='create or upgrade schema')
|
||||||
|
parser.add_argument('--flush', '-H', action='store_true', help='recompute karma for all users')
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
def main():
|
async def main():
|
||||||
args = make_parser().parse_args()
|
args = make_parser().parse_args()
|
||||||
|
|
||||||
|
engine = create_engine(os.getenv('DATABASE_URL'))
|
||||||
if args.upgrade:
|
if args.upgrade:
|
||||||
db.metadata.create_all(create_engine(os.getenv('DATABASE_URL')))
|
ret_code = subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
|
||||||
subprocess.Popen(['alembic', 'upgrade', 'head']).wait()
|
if ret_code != 0:
|
||||||
|
print(f'Schema upgrade failed (code: {ret_code})')
|
||||||
|
exit(ret_code)
|
||||||
|
# if the alembic/versions folder is empty
|
||||||
|
await db.create_all(engine)
|
||||||
print('Schema upgraded!')
|
print('Schema upgraded!')
|
||||||
|
|
||||||
print(f'Visit <https://{os.getenv("DOMAIN_NAME")}>')
|
if args.flush:
|
||||||
|
cnt = 0
|
||||||
|
async with db as session:
|
||||||
|
|
||||||
|
for u in (await session.execute(select(User))).scalars():
|
||||||
|
u.recompute_karma()
|
||||||
|
cnt += 1
|
||||||
|
session.add(u)
|
||||||
|
session.commit()
|
||||||
|
print(f'Recomputed karma of {cnt} users')
|
||||||
|
|
||||||
|
print(f'Visit <https://{app_config.server_name}>')
|
||||||
|
|
||||||
|
|
|
||||||
39
freak/colors.py
Normal file
39
freak/colors.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
|
||||||
|
ColorTheme = namedtuple('ColorTheme', 'code name')
|
||||||
|
|
||||||
|
## actual color codes are set in CSS
|
||||||
|
|
||||||
|
color_themes = [
|
||||||
|
ColorTheme(0, 'Default'),
|
||||||
|
ColorTheme(1, 'Rei'),
|
||||||
|
ColorTheme(2, 'Ai'),
|
||||||
|
ColorTheme(3, 'Aqua'),
|
||||||
|
ColorTheme(4, 'Neru'),
|
||||||
|
ColorTheme(5, 'Gumi'),
|
||||||
|
ColorTheme(6, 'Emu'),
|
||||||
|
ColorTheme(7, 'Spacegray'),
|
||||||
|
ColorTheme(8, 'Haku'),
|
||||||
|
ColorTheme(9, 'Miku'),
|
||||||
|
ColorTheme(10, 'Defoko'),
|
||||||
|
ColorTheme(11, 'Kaito'),
|
||||||
|
ColorTheme(12, 'Meiko'),
|
||||||
|
ColorTheme(13, 'WhatsApp'),
|
||||||
|
ColorTheme(14, 'Teto'),
|
||||||
|
ColorTheme(15, 'Ruby')
|
||||||
|
]
|
||||||
|
|
||||||
|
def theme_classes(color_code: int):
|
||||||
|
cl = []
|
||||||
|
sch, th = divmod(color_code, 256)
|
||||||
|
if sch == 1:
|
||||||
|
cl.append('color-scheme-light')
|
||||||
|
if sch == 2:
|
||||||
|
cl.append('color-scheme-dark')
|
||||||
|
if 1 <= th <= 15:
|
||||||
|
cl.append(f'color-theme-{th}')
|
||||||
|
|
||||||
|
return ' '.join(cl)
|
||||||
|
|
@ -1,78 +1,48 @@
|
||||||
|
|
||||||
import re, markdown
|
import markdown
|
||||||
from markdown.inlinepatterns import InlineProcessor, SimpleTagInlineProcessor
|
|
||||||
import xml.etree.ElementTree as etree
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from suou import Siq, Snowflake
|
||||||
|
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
|
||||||
|
|
||||||
from . import app
|
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
|
# make spoilers prevail over blockquotes
|
||||||
SpoilerExtension.patch_blockquote_processor()
|
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()
|
@app.template_filter()
|
||||||
def to_markdown(text, toc = False):
|
def to_markdown(text, toc = False):
|
||||||
extensions = [
|
extensions = [
|
||||||
'tables', 'footnotes', 'fenced_code', 'sane_lists',
|
'tables', 'footnotes', 'fenced_code', 'sane_lists',
|
||||||
StrikethroughExtension(), SpoilerExtension(),
|
StrikethroughExtension(), SpoilerExtension(),
|
||||||
## XXX untested
|
PingExtension({'@': '/@', '+': '/+'})
|
||||||
PingExtension()
|
|
||||||
]
|
]
|
||||||
if toc:
|
if toc:
|
||||||
extensions.append('toc')
|
extensions.append('toc')
|
||||||
return Markup(markdown.Markdown(extensions=extensions).convert(text))
|
return Markup(markdown.Markdown(extensions=extensions).convert(text))
|
||||||
|
|
||||||
|
app.template_filter('markdown')(to_markdown)
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def to_b32l(n):
|
def to_b32l(n):
|
||||||
return id_to_b32l(n)
|
return Snowflake(n).to_b32l()
|
||||||
|
|
||||||
|
app.template_filter('b32l')(to_b32l)
|
||||||
|
|
||||||
@app.template_filter()
|
@app.template_filter()
|
||||||
def append(text, l):
|
def to_cb32(n):
|
||||||
|
return '0' + Siq.from_bytes(n).to_cb32()
|
||||||
|
|
||||||
|
app.template_filter('cb32')(to_cb32)
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def append(text, l: list):
|
||||||
l.append(text)
|
l.append(text)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@app.template_filter()
|
||||||
|
def faint_paren(text: str):
|
||||||
|
if not '(' in text:
|
||||||
|
return text
|
||||||
|
t1, t2, t3 = text.partition('(')
|
||||||
|
return Markup('{0} <span class="faint">{1}</span>').format(t1, t2 + t3)
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
"""
|
"""
|
||||||
|
DEPRECATED use suou.snowflake instead.
|
||||||
|
|
||||||
PSA: this module is for the LEGACY (v2) iding.
|
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>
|
For the SIQ-based ID's, see suou.iding <https://github.com/sakuragasaki46/suou>.
|
||||||
|
|
||||||
|
The suou library also provides snowflake support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from suou.functools import deprecated
|
||||||
|
|
||||||
epoch = 1577833200000
|
epoch = 1577833200000
|
||||||
machine_id = int(os.getenv("MACHINE_ID", "0"))
|
machine_id = int(os.getenv("MACHINE_ID", "0"))
|
||||||
machine_counter = 0
|
machine_counter = 0
|
||||||
|
|
||||||
|
@deprecated('use SnowflakeGen(). Planned for removal in 0.5')
|
||||||
def new_id(*, from_date = None):
|
def new_id(*, from_date = None):
|
||||||
global machine_counter
|
global machine_counter
|
||||||
|
|
||||||
|
|
@ -28,14 +35,16 @@ def new_id(*, from_date = None):
|
||||||
((machine_counter := machine_counter + 1) % 1024)
|
((machine_counter := machine_counter + 1) % 1024)
|
||||||
)
|
)
|
||||||
|
|
||||||
def id_to_b32l(n):
|
@deprecated('use suou.Snowflake.to_b32l() instead')
|
||||||
|
def id_to_b32l(n: int) -> str:
|
||||||
return (
|
return (
|
||||||
'_' if n < 0 else ''
|
'_' if n < 0 else ''
|
||||||
) + base64.b32encode(
|
) + base64.b32encode(
|
||||||
(-n if n < 0 else n).to_bytes(10, 'big')
|
(-n if n < 0 else n).to_bytes(10, 'big')
|
||||||
).decode().lstrip('A').lower()
|
).decode().lstrip('A').lower()
|
||||||
|
|
||||||
def id_from_b32l(s, *, n_bytes=10):
|
@deprecated('use suou.Snowflake.from_b32l() instead')
|
||||||
|
def id_from_b32l(s: str) -> int:
|
||||||
return (-1 if s.startswith('_') else 1) * int.from_bytes(
|
return (-1 if s.startswith('_') else 1) * int.from_bytes(
|
||||||
base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big'
|
base64.b32decode(s.lstrip('_').upper().rjust(16, 'A').encode()), 'big'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
633
freak/models.py
633
freak/models.py
|
|
@ -2,51 +2,67 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
import datetime
|
import datetime
|
||||||
from functools import lru_cache
|
from functools import partial
|
||||||
from operator import or_
|
from operator import or_
|
||||||
|
import re
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from sqlalchemy import Column, String, ForeignKey, and_, text, \
|
from typing import Any, Callable
|
||||||
|
from quart_auth import current_user
|
||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, and_, insert, text, \
|
||||||
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
|
CheckConstraint, Date, DateTime, Boolean, func, BigInteger, \
|
||||||
SmallInteger, select, insert, update, create_engine, Table
|
SmallInteger, select, update, Table
|
||||||
from sqlalchemy.orm import Relationship, declarative_base, relationship
|
from sqlalchemy.orm import Relationship, relationship
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from suou.sqlalchemy_async import SQLAlchemy
|
||||||
from flask_login import AnonymousUserMixin
|
from suou import SiqType, Snowflake, Wanted, deprecated, makelist, not_implemented, want_isodate
|
||||||
|
from suou.sqlalchemy import create_session, declarative_base, id_column, parent_children, snowflake_column
|
||||||
from werkzeug.security import check_password_hash
|
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
|
|
||||||
|
|
||||||
|
from . import app_config
|
||||||
|
from .utils import get_remote_addr
|
||||||
|
|
||||||
|
from suou import timed_cache, age_and_days
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
## Constants and enums
|
## Constants and enums
|
||||||
|
|
||||||
|
## NOT IN USE: User has .banned_at and .is_disabled_by_user
|
||||||
USER_ACTIVE = 0
|
USER_ACTIVE = 0
|
||||||
USER_INACTIVE = 1
|
USER_INACTIVE = 1
|
||||||
USER_BANNED = 2
|
USER_BANNED = 2
|
||||||
|
|
||||||
ReportReason = namedtuple('ReportReason', 'num_code code description')
|
ReportReason = namedtuple('ReportReason', 'num_code code description extra', defaults=dict(extra=None))
|
||||||
|
|
||||||
post_report_reasons = [
|
post_report_reasons = [
|
||||||
|
## emergency
|
||||||
ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'),
|
ReportReason(110, 'hate_speech', 'Extreme hate speech / terrorism'),
|
||||||
ReportReason(121, 'csam', 'Child abuse or endangerment'),
|
ReportReason(121, 'csam', 'Child abuse or endangerment', extra=dict(suspend=True)),
|
||||||
ReportReason(142, 'revenge_sxm', 'Revenge porn'),
|
ReportReason(142, 'revenge_sxm', 'Revenge porn'),
|
||||||
ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'),
|
ReportReason(122, 'black_market', 'Sale or promotion of regulated goods (e.g. firearms)'),
|
||||||
|
## urgent
|
||||||
ReportReason(171, 'xxx', 'Pornography'),
|
ReportReason(171, 'xxx', 'Pornography'),
|
||||||
ReportReason(111, 'tasteless', 'Extreme violence / gore'),
|
ReportReason(111, 'tasteless', 'Extreme violence / gore'),
|
||||||
ReportReason(180, 'impersonation', 'Impersonation'),
|
ReportReason(180, 'impersonation', 'Impersonation'),
|
||||||
ReportReason(141, 'doxing', 'Diffusion of PII (personally identifiable information)'),
|
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'),
|
## less urgent
|
||||||
ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'),
|
|
||||||
ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'),
|
ReportReason(112, 'lgbt_hate', 'Hate speech against LGBTQ+ or women'),
|
||||||
|
ReportReason(140, 'bullying', 'Harassment, bullying, or suicide incitement'),
|
||||||
ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'),
|
ReportReason(150, 'security_exploit', 'Dangerous security exploit or violation'),
|
||||||
|
ReportReason(160, 'spam', 'Unsolicited advertising'),
|
||||||
ReportReason(190, 'false_information', 'False or deceiving information'),
|
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)')
|
ReportReason(123, 'copyviol', 'This is my creation and someone else is using it without my permission/license'),
|
||||||
|
## minor (unironically)
|
||||||
|
ReportReason(210, 'underage', 'Presence in violation of age limits (i.e. under 13, or minor in adult spaces)', extra=dict(suspend=True))
|
||||||
]
|
]
|
||||||
|
|
||||||
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_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_REASONS: dict[str, int] = {x.code: x.num_code for x in post_report_reasons}
|
||||||
|
|
||||||
REPORT_TARGET_POST = 1
|
REPORT_TARGET_POST = 1
|
||||||
REPORT_TARGET_COMMENT = 2
|
REPORT_TARGET_COMMENT = 2
|
||||||
|
|
@ -56,23 +72,56 @@ REPORT_UPDATE_COMPLETE = 1
|
||||||
REPORT_UPDATE_REJECTED = 2
|
REPORT_UPDATE_REJECTED = 2
|
||||||
REPORT_UPDATE_ON_HOLD = 3
|
REPORT_UPDATE_ON_HOLD = 3
|
||||||
|
|
||||||
|
USERNAME_RE = r'[a-z2-9_-][a-z0-9_-]+'
|
||||||
|
|
||||||
|
ILLEGAL_USERNAMES = tuple((
|
||||||
|
## masspings and administrative claims
|
||||||
|
'me everyone here room all any server app dev devel develop nil none '
|
||||||
|
'founder owner admin administrator mod modteam moderator sysop some '
|
||||||
|
## fictitious users and automations
|
||||||
|
'nobody somebody deleted suspended default bot developer undefined null '
|
||||||
|
'ai automod clanker automoderator assistant privacy anonymous removed assistance '
|
||||||
|
## law enforcement corps and slurs because yes
|
||||||
|
'pedo rape rapist nigger retard ncmec police cops 911 childsafety '
|
||||||
|
'report dmca login logout security order66 gestapo ss hitler heilhitler kgb '
|
||||||
|
'pedophile lolicon giphy tenor csam cp pedobear lolita lolice thanos '
|
||||||
|
'loli lolicon kkk pnf adl cop tranny google trustandsafety safety ice fbi nsa it '
|
||||||
|
## VVVVIP
|
||||||
|
'potus realdonaldtrump elonmusk teddysphotos mrbeast jkrowling pewdiepie '
|
||||||
|
'elizabethii elizabeth2 king queen pontifex hogwarts lumos alohomora isis daesh retards '
|
||||||
|
).split())
|
||||||
|
|
||||||
|
def username_is_legal(username: str) -> bool:
|
||||||
|
if len(username) < 2 or len(username) > 100:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if re.fullmatch(USERNAME_RE, username) is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if username in ILLEGAL_USERNAMES:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def want_User(o: User | Any | None, *, prefix: str = '', var_name: str = '') -> User | None:
|
||||||
|
if isinstance(o, User):
|
||||||
|
return o
|
||||||
|
if o is None:
|
||||||
|
return None
|
||||||
|
logger.warning(f'{prefix}: {repr(var_name) + " has " if var_name else ""}invalid type {o.__class__.__name__}, expected User')
|
||||||
|
return None
|
||||||
|
|
||||||
## END constants and enums
|
## END constants and enums
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base(app_config.server_name, app_config.secret_key,
|
||||||
|
snowflake_epoch=1577833200)
|
||||||
db = SQLAlchemy(model_class=Base)
|
db = SQLAlchemy(model_class=Base)
|
||||||
|
|
||||||
def create_session_interactively():
|
CSI = create_session_interactively = partial(create_session, app_config.database_url)
|
||||||
'''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
|
## .accounts requires db
|
||||||
class BaseModel(Base):
|
#current_user: UserLoader
|
||||||
__abstract__ = True
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, default=new_id)
|
|
||||||
|
|
||||||
## Many-to-many relationship keys for some reasons have to go
|
## Many-to-many relationship keys for some reasons have to go
|
||||||
## BEFORE other table definitions.
|
## BEFORE other table definitions.
|
||||||
|
|
@ -86,10 +135,22 @@ PostUpvote = Table(
|
||||||
Column('is_downvote', Boolean, server_default=text('false'))
|
Column('is_downvote', Boolean, server_default=text('false'))
|
||||||
)
|
)
|
||||||
|
|
||||||
class User(BaseModel):
|
UserBlock = Table(
|
||||||
__tablename__ = 'freak_user'
|
'freak_user_block',
|
||||||
|
Base.metadata,
|
||||||
|
Column('actor_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True),
|
||||||
|
Column('target_id', BigInteger, ForeignKey('freak_user.id'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'freak_user'
|
||||||
|
__table_args__ = (
|
||||||
|
## XXX this constraint (and the other three at Post, Guild and Comment) cannot be removed!!
|
||||||
|
UniqueConstraint('id', name='user_id_uniq'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = snowflake_column()
|
||||||
|
|
||||||
username = Column(String(32), CheckConstraint(text("username = lower(username) and username ~ '^[a-z0-9_-]+$'"), name="user_username_valid"), unique=True, nullable=False)
|
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)
|
display_name = Column(String(64), nullable=False)
|
||||||
|
|
@ -102,7 +163,11 @@ class User(BaseModel):
|
||||||
is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False)
|
is_disabled_by_user = Column(Boolean, server_default=text('false'), nullable=False)
|
||||||
karma = Column(BigInteger, server_default=text('0'), nullable=False)
|
karma = Column(BigInteger, server_default=text('0'), nullable=False)
|
||||||
legacy_id = Column(BigInteger, nullable=True)
|
legacy_id = Column(BigInteger, nullable=True)
|
||||||
# TODO add pronouns and biography (upcoming 0.4)
|
|
||||||
|
# pronouns must be set via suou.dei.Pronoun.from_short()
|
||||||
|
pronouns = Column(Integer, server_default=text('0'), nullable=False)
|
||||||
|
biography = Column(String(1024), nullable=True)
|
||||||
|
color_theme = Column(SmallInteger, nullable=False, server_default=text('0'))
|
||||||
|
|
||||||
# moderation
|
# moderation
|
||||||
banned_at = Column(DateTime, nullable=True)
|
banned_at = Column(DateTime, nullable=True)
|
||||||
|
|
@ -111,30 +176,43 @@ class User(BaseModel):
|
||||||
banned_until = Column(DateTime, nullable=True)
|
banned_until = Column(DateTime, nullable=True)
|
||||||
banned_message = Column(String(256), nullable=True)
|
banned_message = Column(String(256), nullable=True)
|
||||||
|
|
||||||
|
# invites
|
||||||
|
is_approved = Column(Boolean, server_default=text('false'), nullable=False)
|
||||||
|
invited_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_inviter_id'), nullable=True)
|
||||||
|
|
||||||
# utilities
|
# 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
|
## XXX posts and comments relationships are temporarily disabled because they make
|
||||||
## SQLAlchemy fail initialization of models — bricking the app.
|
## SQLAlchemy fail initialization of models — bricking the app.
|
||||||
## Posts are queried manually anyway
|
## Posts are queried manually anyway
|
||||||
|
#posts = relationship("Post", primaryjoin=lambda: #back_populates='author', pr)
|
||||||
|
upvoted_posts = relationship("Post", secondary=PostUpvote, back_populates='upvoters', lazy='selectin')
|
||||||
|
#comments = relationship("Comment", back_populates='author', lazy='selectin')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_disabled(self):
|
def is_disabled(self):
|
||||||
return self.banned_at is not None or self.is_disabled_by_user
|
now = datetime.datetime.now()
|
||||||
|
return (
|
||||||
|
# suspended
|
||||||
|
(self.banned_at is not None and (self.banned_until is None or self.banned_until >= now)) or
|
||||||
|
# self-disabled
|
||||||
|
self.is_disabled_by_user
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_active(self):
|
def is_active(self):
|
||||||
return not self.is_disabled
|
return not self.is_disabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@deprecated('shadowed by UserLoader.is_authenticated(), and always true')
|
||||||
def is_authenticated(self):
|
def is_authenticated(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@deprecated('no more in use since switch to Quart')
|
||||||
def is_anonymous(self):
|
def is_anonymous(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@deprecated('this representation uses decimal, URLs use b32l')
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
return str(self.id)
|
return str(self.id)
|
||||||
|
|
||||||
|
|
@ -145,26 +223,38 @@ class User(BaseModel):
|
||||||
def age(self):
|
def age(self):
|
||||||
return age_and_days(self.gdpr_birthday)[0]
|
return age_and_days(self.gdpr_birthday)[0]
|
||||||
|
|
||||||
def simple_info(self):
|
def simple_info(self, *, typed = False):
|
||||||
"""
|
"""
|
||||||
Return essential informations for representing a user in the REST
|
Return essential informations for representing a user in the REST
|
||||||
"""
|
"""
|
||||||
## XXX change func name?
|
## XXX change func name?
|
||||||
return dict(
|
gg = dict(
|
||||||
id = id_to_b32l(self.id),
|
id = Snowflake(self.id).to_b32l(),
|
||||||
username = self.username,
|
username = self.username,
|
||||||
display_name = self.display_name,
|
display_name = self.display_name,
|
||||||
age = self.age()
|
age = self.age(),
|
||||||
## TODO add badges?
|
badges = self.badges(),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
if typed:
|
||||||
|
gg['type'] = 'user'
|
||||||
|
return gg
|
||||||
|
|
||||||
def reward(self, points=1):
|
@deprecated('updates may be not atomic. DO NOT USE until further notice')
|
||||||
|
async def reward(self, points=1):
|
||||||
|
"""
|
||||||
|
Manipulate a user's karma on the fly
|
||||||
|
"""
|
||||||
with Lock():
|
with Lock():
|
||||||
db.session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
|
async with db as session:
|
||||||
db.session.commit()
|
await session.execute(update(User).where(User.id == self.id).values(karma = self.karma + points))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
def can_create_community(self):
|
def can_create_guild(self):
|
||||||
return self.karma > 15
|
## TODO make guild creation requirements fully configurable
|
||||||
|
return self.karma > app_config.create_guild_threshold or self.is_administrator
|
||||||
|
|
||||||
|
can_create_community = deprecated('use .can_create_guild()')(can_create_guild)
|
||||||
|
|
||||||
def handle(self):
|
def handle(self):
|
||||||
return f'@{self.username}'
|
return f'@{self.username}'
|
||||||
|
|
@ -173,10 +263,12 @@ class User(BaseModel):
|
||||||
return check_password_hash(self.passhash, password)
|
return check_password_hash(self.passhash, password)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@timed_cache(1800)
|
@timed_cache(1800, async_=True)
|
||||||
def active_count(cls) -> int:
|
async def active_count(cls) -> int:
|
||||||
active_th = datetime.datetime.now() - datetime.timedelta(days=30)
|
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()
|
async with db as session:
|
||||||
|
count = (await 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()
|
||||||
|
return count
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>'
|
return f'<{self.__class__.__name__} id:{self.id!r} username:{self.username!r}>'
|
||||||
|
|
@ -185,10 +277,125 @@ class User(BaseModel):
|
||||||
def not_suspended(cls):
|
def not_suspended(cls):
|
||||||
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
||||||
|
|
||||||
class Topic(BaseModel):
|
async def has_blocked(self, other: User | None) -> bool:
|
||||||
__tablename__ = 'freak_topic'
|
if not want_User(other, var_name='other', prefix='User.has_blocked()'):
|
||||||
|
return False
|
||||||
|
async with db as session:
|
||||||
|
block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id))).scalar()
|
||||||
|
return bool(block_exists)
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
|
async def is_blocked_by(self, other: User | None) -> bool:
|
||||||
|
if not want_User(other, var_name='other', prefix='User.is_blocked_by()'):
|
||||||
|
return False
|
||||||
|
async with db as session:
|
||||||
|
block_exists = (await session.execute(select(UserBlock).where(UserBlock.c.actor_id == other.id, UserBlock.c.target_id == self.id))).scalar()
|
||||||
|
return bool(block_exists)
|
||||||
|
|
||||||
|
def has_blocked_q(self, other_id: int):
|
||||||
|
return select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other_id).exists()
|
||||||
|
|
||||||
|
def blocked_by_q(self, other_id: int):
|
||||||
|
return select(UserBlock).where(UserBlock.c.actor_id == other_id, UserBlock.c.target_id == self.id).exists()
|
||||||
|
|
||||||
|
@not_implemented()
|
||||||
|
def end_friendship(self, other: User):
|
||||||
|
"""
|
||||||
|
Remove any relationship between two users.
|
||||||
|
Executed before block.
|
||||||
|
"""
|
||||||
|
# TODO implement in 0.5
|
||||||
|
...
|
||||||
|
|
||||||
|
def has_subscriber(self, other: User) -> bool:
|
||||||
|
# TODO implement in 0.5
|
||||||
|
return False #bool(session.execute(select(Friendship).where(...)).scalar())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_not_blocked(cls, actor: int, target: int):
|
||||||
|
"""
|
||||||
|
Filter out a content if the author has blocked current user. Returns a query.
|
||||||
|
|
||||||
|
XXX untested.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO add recognition
|
||||||
|
actor_id = actor
|
||||||
|
target_id = target
|
||||||
|
|
||||||
|
qq= ~select(UserBlock).where(UserBlock.c.actor_id == actor_id, UserBlock.c.target_id == target_id).exists()
|
||||||
|
return qq
|
||||||
|
|
||||||
|
async def recompute_karma(self):
|
||||||
|
"""
|
||||||
|
Recompute karma as of 0.4.0 karma handling
|
||||||
|
"""
|
||||||
|
async with db as session:
|
||||||
|
c = 0
|
||||||
|
c += session.execute(select(func.count('*')).select_from(Post).where(Post.author == self)).scalar()
|
||||||
|
c += session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == False)).scalar()
|
||||||
|
c -= session.execute(select(func.count('*')).select_from(PostUpvote).join(Post).where(Post.author == self, PostUpvote.c.is_downvote == True)).scalar()
|
||||||
|
self.karma = c
|
||||||
|
|
||||||
|
return c
|
||||||
|
|
||||||
|
## TODO are coroutines cacheable?
|
||||||
|
@timed_cache(60, async_=True)
|
||||||
|
async def strike_count(self) -> int:
|
||||||
|
async with db as session:
|
||||||
|
return (await session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id))).scalar()
|
||||||
|
|
||||||
|
async def moderates(self, gu: Guild) -> bool:
|
||||||
|
async with db as session:
|
||||||
|
## owner
|
||||||
|
if gu.owner_id == self.id:
|
||||||
|
return True
|
||||||
|
## admin or global mod
|
||||||
|
if self.is_administrator:
|
||||||
|
return True
|
||||||
|
memb = (await session.execute(select(Member).where(Member.user_id == self.id, Member.guild_id == gu.id))).scalar()
|
||||||
|
|
||||||
|
if memb is None:
|
||||||
|
return False
|
||||||
|
return memb.is_moderator
|
||||||
|
|
||||||
|
## TODO check banship?
|
||||||
|
|
||||||
|
@makelist
|
||||||
|
def badges(self, /):
|
||||||
|
if self.is_administrator:
|
||||||
|
yield 'administrator'
|
||||||
|
|
||||||
|
badges: Callable[[], list[str]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_by_username(cls, name: str):
|
||||||
|
"""
|
||||||
|
Get a user by its username,
|
||||||
|
"""
|
||||||
|
user_q = select(User).where(User.username == name)
|
||||||
|
try:
|
||||||
|
if current_user:
|
||||||
|
user_q = user_q.where(~select(UserBlock).where(UserBlock.c.target_id == current_user.id).exists())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'{e}')
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
user = (await session.execute(user_q)).scalar()
|
||||||
|
return user
|
||||||
|
|
||||||
|
# UserBlock table is at the top !!
|
||||||
|
|
||||||
|
## END User
|
||||||
|
|
||||||
|
ModeratorInfo = namedtuple('ModeratorInfo', 'user is_owner')
|
||||||
|
|
||||||
|
class Guild(Base):
|
||||||
|
__tablename__ = 'freak_topic'
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('id', name='topic_id_uniq'),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = snowflake_column()
|
||||||
|
|
||||||
name = Column(String(32), CheckConstraint(text("name = lower(name) AND name ~ '^[a-z0-9_-]+$'"), name='topic_name_valid'), unique=True, nullable=False)
|
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)
|
display_name = Column(String(64), nullable=False)
|
||||||
|
|
@ -196,8 +403,12 @@ class Topic(BaseModel):
|
||||||
created_at = Column(DateTime, server_default=func.current_timestamp(), index=True, nullable=False)
|
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)
|
owner_id = Column(BigInteger, ForeignKey('freak_user.id', name='topic_owner_id'), nullable=True)
|
||||||
language = Column(String(16), server_default=text("'en-US'"))
|
language = Column(String(16), server_default=text("'en-US'"))
|
||||||
privacy = Column(SmallInteger, server_default=text('0'))
|
# true: prevent non-members from participating
|
||||||
|
is_restricted = Column(Boolean, server_default=text('false'), nullable=False)
|
||||||
|
# false: make the guild invite-only
|
||||||
|
is_public = Column(Boolean, server_default=text('true'), nullable=False)
|
||||||
|
|
||||||
|
# MUST NOT be filled in on post-0.2 instances
|
||||||
legacy_id = Column(BigInteger, nullable=True)
|
legacy_id = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
def url(self):
|
def url(self):
|
||||||
|
|
@ -206,23 +417,156 @@ class Topic(BaseModel):
|
||||||
def handle(self):
|
def handle(self):
|
||||||
return f'+{self.name}'
|
return f'+{self.name}'
|
||||||
|
|
||||||
|
async def subscriber_count(self):
|
||||||
|
async with db as session:
|
||||||
|
count = (await session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True))).scalar()
|
||||||
|
return count
|
||||||
|
|
||||||
# utilities
|
# utilities
|
||||||
posts = relationship('Post', back_populates='topic')
|
owner = relationship(User, foreign_keys=owner_id, lazy='selectin')
|
||||||
|
posts = relationship('Post', back_populates='guild', lazy='selectin')
|
||||||
|
|
||||||
|
async def post_count(self):
|
||||||
|
async with db as session:
|
||||||
|
return (await session.execute(select(func.count('*')).select_from(Post).where(Post.guild == self))).scalar()
|
||||||
|
|
||||||
|
async def has_subscriber(self, other: User) -> bool:
|
||||||
|
if not want_User(other, var_name='other', prefix='Guild.has_subscriber()'):
|
||||||
|
return False
|
||||||
|
async with db as session:
|
||||||
|
sub_ex = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True))).scalar()
|
||||||
|
return bool(sub_ex)
|
||||||
|
|
||||||
|
async def has_exiled(self, other: User) -> bool:
|
||||||
|
if not want_User(other, var_name='other', prefix='Guild.has_exiled()'):
|
||||||
|
return False
|
||||||
|
async with db as session:
|
||||||
|
u = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
|
||||||
|
return u.is_banned if u else False
|
||||||
|
|
||||||
|
async def allows_posting(self, other: User) -> bool:
|
||||||
|
async with db as session:
|
||||||
|
# control owner_id instead of owner: the latter causes MissingGreenletError
|
||||||
|
if self.owner_id is None:
|
||||||
|
return False
|
||||||
|
if other.is_disabled:
|
||||||
|
return False
|
||||||
|
mem: Member | None = (await session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id))).scalar()
|
||||||
|
if mem and mem.is_banned:
|
||||||
|
return False
|
||||||
|
if await other.moderates(self):
|
||||||
|
return True
|
||||||
|
if self.is_restricted:
|
||||||
|
return (mem and mem.is_approved)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def moderators(self):
|
||||||
|
async with db as session:
|
||||||
|
if self.owner_id:
|
||||||
|
owner = (await session.execute(select(User).where(User.id == self.owner_id))).scalar()
|
||||||
|
yield ModeratorInfo(owner, True)
|
||||||
|
for mem in (await session.execute(select(Member).where(Member.guild_id == self.id, Member.is_moderator == True))).scalars():
|
||||||
|
if mem.user != self.owner and not mem.is_banned:
|
||||||
|
yield ModeratorInfo(mem.user, False)
|
||||||
|
|
||||||
|
async def update_member(self, u: User | Member, /, **values):
|
||||||
|
if isinstance(u, User):
|
||||||
|
async with db as session:
|
||||||
|
m = (await session.execute(select(Member).where(Member.user_id == u.id, Member.guild_id == self.id))).scalar()
|
||||||
|
if m is None:
|
||||||
|
m = (await session.execute(insert(Member).values(
|
||||||
|
guild_id = self.id,
|
||||||
|
user_id = u.id,
|
||||||
|
**values
|
||||||
|
).returning(Member))).scalar()
|
||||||
|
if m is None:
|
||||||
|
raise RuntimeError
|
||||||
|
return m
|
||||||
|
else:
|
||||||
|
m = u
|
||||||
|
if len(values):
|
||||||
|
async with db as session:
|
||||||
|
session.execute(update(Member).where(Member.user_id == u.id, Member.guild_id == self.id).values(**values))
|
||||||
|
return m
|
||||||
|
|
||||||
|
def simple_info(self, *, typed=False):
|
||||||
|
"""
|
||||||
|
Return essential informations for representing a guild in the REST
|
||||||
|
"""
|
||||||
|
## XXX change func name?
|
||||||
|
gg = dict(
|
||||||
|
id = Snowflake(self.id).to_b32l(),
|
||||||
|
name = self.name,
|
||||||
|
display_name = self.display_name,
|
||||||
|
badges = []
|
||||||
|
)
|
||||||
|
if typed:
|
||||||
|
gg['type'] = 'guild'
|
||||||
|
return gg
|
||||||
|
|
||||||
|
async def sub_info(self):
|
||||||
|
"""
|
||||||
|
Guild info including subscriber count.
|
||||||
|
"""
|
||||||
|
gg = self.simple_info()
|
||||||
|
gg['subscriber_count'] = await self.subscriber_count()
|
||||||
|
gg['post_count'] = await self.post_count()
|
||||||
|
return gg
|
||||||
|
|
||||||
|
|
||||||
|
Topic = deprecated('renamed to Guild')(Guild)
|
||||||
|
|
||||||
|
## END Guild
|
||||||
|
|
||||||
|
class Member(Base):
|
||||||
|
"""
|
||||||
|
User-Guild relationship. NEW in 0.4.0.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'freak_member'
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('user_id', 'guild_id', name='member_user_topic'),
|
||||||
|
)
|
||||||
|
|
||||||
|
## Newer tables use SIQ. Older tables will gradually transition to SIQ as well.
|
||||||
|
id = id_column(SiqType.MANYTOMANY)
|
||||||
|
user_id = Column(BigInteger, ForeignKey('freak_user.id'))
|
||||||
|
guild_id = Column(BigInteger, ForeignKey('freak_topic.id'))
|
||||||
|
is_approved = Column(Boolean, server_default=text('false'), nullable=False)
|
||||||
|
is_subscribed = Column(Boolean, server_default=text('false'), nullable=False)
|
||||||
|
is_moderator = Column(Boolean, server_default=text('false'), nullable=False)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
user = relationship(User, primaryjoin = lambda: User.id == Member.user_id, lazy='selectin')
|
||||||
|
guild = relationship(Guild, lazy='selectin')
|
||||||
|
banned_by = relationship(User, primaryjoin = lambda: User.id == Member.banned_by_id, lazy='selectin')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_banned(self):
|
||||||
|
return self.banned_at is not None and (self.banned_until is None or self.banned_until <= datetime.datetime.now())
|
||||||
|
|
||||||
|
|
||||||
POST_TYPE_DEFAULT = 0
|
POST_TYPE_DEFAULT = 0
|
||||||
POST_TYPE_LINK = 1
|
POST_TYPE_LINK = 1
|
||||||
|
|
||||||
class Post(BaseModel):
|
class Post(Base):
|
||||||
__tablename__ = 'freak_post'
|
__tablename__ = 'freak_post'
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('id', name='post_id_uniq'),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, default=new_id, unique=True)
|
id = snowflake_column()
|
||||||
|
|
||||||
slug = Column(String(64), CheckConstraint("slug IS NULL OR (slug = lower(slug) AND slug ~ '^[a-z0-9_-]+$')", name='post_slug_valid'), nullable=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)
|
title = Column(String(256), nullable=False)
|
||||||
post_type = Column(SmallInteger, server_default=text('0'))
|
post_type = Column(SmallInteger, server_default=text('0'))
|
||||||
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='post_author_id'), nullable=True)
|
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)
|
topic_id = Column('topic_id', BigInteger, ForeignKey('freak_topic.id', name='post_topic_id'), nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.current_timestamp())
|
created_at = Column(DateTime, server_default=func.current_timestamp())
|
||||||
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
||||||
updated_at = Column(DateTime, nullable=True)
|
updated_at = Column(DateTime, nullable=True)
|
||||||
|
|
@ -239,47 +583,60 @@ class Post(BaseModel):
|
||||||
removed_reason = Column(SmallInteger, nullable=True)
|
removed_reason = Column(SmallInteger, nullable=True)
|
||||||
|
|
||||||
# utilities
|
# utilities
|
||||||
author: Relationship[User] = relationship("User", lazy='selectin', foreign_keys=[author_id])#, back_populates="posts")
|
author: Relationship[User] = relationship("User", foreign_keys=[author_id], lazy='selectin')#, back_populates="posts")
|
||||||
topic = relationship("Topic", back_populates="posts", lazy='selectin')
|
guild: Relationship[Guild] = relationship("Guild", back_populates="posts", lazy='selectin')
|
||||||
comments = relationship("Comment", back_populates="parent_post")
|
comments = relationship("Comment", back_populates="parent_post", lazy='selectin')
|
||||||
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts')
|
upvoters = relationship("User", secondary=PostUpvote, back_populates='upvoted_posts', lazy='selectin')
|
||||||
|
|
||||||
def topic_or_user(self) -> Topic | User:
|
async def comment_count(self):
|
||||||
return self.topic or self.author
|
async with db as session:
|
||||||
|
return (await session.execute(select(func.count('*')).select_from(Comment).where(Comment.parent_post == self))).scalar()
|
||||||
|
|
||||||
|
def topic_or_user(self) -> Guild | User:
|
||||||
|
return self.guild or self.author
|
||||||
|
|
||||||
def url(self):
|
def url(self):
|
||||||
return self.topic_or_user().url() + '/comments/' + id_to_b32l(self.id) + '/' + (self.slug or '')
|
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
|
||||||
|
|
||||||
def generate_slug(self):
|
@not_implemented('slugify is not a dependency as of now')
|
||||||
return slugify.slugify(self.title, max_length=64)
|
def generate_slug(self) -> str:
|
||||||
|
return "slugify.slugify(self.title, max_length=64)"
|
||||||
|
|
||||||
def upvotes(self) -> int:
|
async 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()
|
async with db as session:
|
||||||
- db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True)).scalar())
|
upv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False))).scalar()
|
||||||
|
dwv = (await session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == True))).scalar()
|
||||||
|
return upv - dwv
|
||||||
|
|
||||||
def upvoted_by(self, user: User | AnonymousUserMixin | None):
|
async def upvoted_by(self, user: User | None):
|
||||||
if not user or not user.is_authenticated:
|
if not want_User(user, var_name='user', prefix='Post.upvoted_by()'):
|
||||||
return 0
|
return 0
|
||||||
v = db.session.execute(db.select(PostUpvote.c).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id)).fetchone()
|
async with db as session:
|
||||||
if v:
|
v = (await session.execute(select(PostUpvote.c.is_downvote).where(PostUpvote.c.voter_id == user.id, PostUpvote.c.post_id == self.id))).fetchone()
|
||||||
if v.is_downvote:
|
if v is None:
|
||||||
|
return 0
|
||||||
|
if v == (True,):
|
||||||
return -1
|
return -1
|
||||||
|
if v == (False,):
|
||||||
return 1
|
return 1
|
||||||
|
logger.warning(f'unexpected value: {v}')
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def top_level_comments(self, limit=None):
|
async 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()
|
async with db as session:
|
||||||
|
return (await 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:
|
def report_url(self) -> str:
|
||||||
return '/report/post/' + id_to_b32l(self.id)
|
return f'/report/post/{Snowflake(self.id):l}'
|
||||||
|
|
||||||
def report_count(self) -> int:
|
async 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()
|
async with db as session: return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@timed_cache(1800)
|
@timed_cache(1800, async_=True)
|
||||||
def count(cls):
|
async def count(cls):
|
||||||
return db.session.execute(select(func.count('*')).select_from(cls)).scalar()
|
async with db as session:
|
||||||
|
return (await session.execute(select(func.count('*')).select_from(cls))).scalar()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_removed(self) -> bool:
|
def is_removed(self) -> bool:
|
||||||
|
|
@ -290,19 +647,44 @@ class Post(BaseModel):
|
||||||
return Post.removed_at == None
|
return Post.removed_at == None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def visible_by(cls, user: User):
|
def visible_by(cls, user_id: int | None):
|
||||||
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
|
return or_(Post.author_id == user_id, Post.privacy == 0)
|
||||||
|
#return or_(Post.author_id == user_id, and_(Post.privacy.in_((0, 1)), ~Post.author.has_blocked_q(user_id)))
|
||||||
|
|
||||||
|
def is_text_post(self):
|
||||||
|
return self.post_type == POST_TYPE_DEFAULT
|
||||||
|
|
||||||
class Comment(BaseModel):
|
def feed_info(self):
|
||||||
|
return dict(
|
||||||
|
id=Snowflake(self.id).to_b32l(),
|
||||||
|
slug = self.slug,
|
||||||
|
title = self.title,
|
||||||
|
author = self.author.simple_info(),
|
||||||
|
to = self.topic_or_user().simple_info(),
|
||||||
|
created_at = self.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
async def feed_info_counts(self):
|
||||||
|
pj = self.feed_info()
|
||||||
|
if self.is_text_post():
|
||||||
|
pj['content'] = self.text_content[:181]
|
||||||
|
(pj['comment_count'], pj['votes'], pj['my_vote']) = await asyncio.gather(
|
||||||
|
self.comment_count(),
|
||||||
|
self.upvotes(),
|
||||||
|
self.upvoted_by(current_user.user)
|
||||||
|
)
|
||||||
|
return pj
|
||||||
|
|
||||||
|
class Comment(Base):
|
||||||
__tablename__ = 'freak_comment'
|
__tablename__ = 'freak_comment'
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('id', name='comment_id_uniq'),
|
||||||
|
)
|
||||||
|
|
||||||
# tweak to allow remote_side to work
|
id = snowflake_column()
|
||||||
## 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)
|
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_post_id = Column(BigInteger, ForeignKey('freak_post.id', name='comment_parent_post_id', ondelete='cascade'), nullable=False)
|
||||||
parent_comment_id = Column(BigInteger, ForeignKey('freak_comment.id', name='comment_parent_comment_id'), nullable=True)
|
parent_comment_id = Column(BigInteger, ForeignKey('freak_comment.id', name='comment_parent_comment_id'), nullable=True)
|
||||||
text_content = Column(String(16384), nullable=False)
|
text_content = Column(String(16384), nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.current_timestamp(), index=True)
|
created_at = Column(DateTime, server_default=func.current_timestamp(), index=True)
|
||||||
|
|
@ -310,25 +692,38 @@ class Comment(BaseModel):
|
||||||
updated_at = Column(DateTime, nullable=True)
|
updated_at = Column(DateTime, nullable=True)
|
||||||
is_locked = Column(Boolean, server_default=text('false'))
|
is_locked = Column(Boolean, server_default=text('false'))
|
||||||
|
|
||||||
|
## DO NOT FILL IN! intended for 0.2 or earlier
|
||||||
legacy_id = Column(BigInteger, nullable=True)
|
legacy_id = Column(BigInteger, nullable=True)
|
||||||
|
|
||||||
removed_at = Column(DateTime, nullable=True)
|
removed_at = Column(DateTime, nullable=True)
|
||||||
removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True)
|
removed_by_id = Column(BigInteger, ForeignKey('freak_user.id', name='user_banner_id'), nullable=True)
|
||||||
removed_reason = Column(SmallInteger, nullable=True)
|
removed_reason = Column(SmallInteger, nullable=True)
|
||||||
|
|
||||||
author = relationship('User', foreign_keys=[author_id])#, back_populates='comments')
|
author = relationship('User', foreign_keys=[author_id], lazy='selectin')#, back_populates='comments')
|
||||||
parent_post = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id])
|
parent_post: Relationship[Post] = relationship("Post", back_populates="comments", foreign_keys=[parent_post_id], lazy='selectin')
|
||||||
parent_comment = relationship("Comment", back_populates="child_comments", remote_side=[id])
|
parent_comment, child_comments = parent_children('comment', parent_remote_side=Wanted('id'))
|
||||||
child_comments = relationship("Comment", back_populates="parent_comment")
|
|
||||||
|
|
||||||
def url(self):
|
def url(self):
|
||||||
return self.parent_post.url() + '/comment/' + id_to_b32l(self.id)
|
return self.parent_post.url() + f'/comment/{Snowflake(self.id):l}'
|
||||||
|
|
||||||
|
async def is_parent_locked(self):
|
||||||
|
if self.is_locked:
|
||||||
|
return True
|
||||||
|
if self.parent_comment_id == None:
|
||||||
|
return False
|
||||||
|
async with db as session:
|
||||||
|
parent = (await session.execute(select(Comment).where(Comment.id == self.parent_comment_id))).scalar()
|
||||||
|
try:
|
||||||
|
return parent.is_parent_locked()
|
||||||
|
except RecursionError:
|
||||||
|
return True
|
||||||
|
|
||||||
def report_url(self) -> str:
|
def report_url(self) -> str:
|
||||||
return '/report/comment/' + id_to_b32l(self.id)
|
return f'/report/comment/{Snowflake(self.id):l}'
|
||||||
|
|
||||||
def report_count(self) -> int:
|
async 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()
|
async with db as session:
|
||||||
|
return (await session.execute(select(func.count('*')).select_from(PostReport).where(PostReport.target_id == self.id, ~PostReport.update_status.in_((1, 2))))).scalar()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_removed(self) -> bool:
|
def is_removed(self) -> bool:
|
||||||
|
|
@ -338,9 +733,26 @@ class Comment(BaseModel):
|
||||||
def not_removed(cls):
|
def not_removed(cls):
|
||||||
return Post.removed_at == None
|
return Post.removed_at == None
|
||||||
|
|
||||||
class PostReport(BaseModel):
|
async def section_info(self):
|
||||||
|
obj = dict(
|
||||||
|
id = Snowflake(self.id).to_b32l(),
|
||||||
|
parent = dict(id=Snowflake(self.parent_comment_id)) if self.parent_comment_id else None,
|
||||||
|
locked = await self.is_parent_locked(),
|
||||||
|
created_at = want_isodate(self.created_at)
|
||||||
|
)
|
||||||
|
if self.is_removed:
|
||||||
|
obj['removed'] = self.removed_reason
|
||||||
|
else:
|
||||||
|
obj['content'] = self.text_content
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class PostReport(Base):
|
||||||
__tablename__ = 'freak_postreport'
|
__tablename__ = 'freak_postreport'
|
||||||
|
|
||||||
|
id = snowflake_column()
|
||||||
|
|
||||||
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True)
|
author_id = Column(BigInteger, ForeignKey('freak_user.id', name='report_author_id'), nullable=True)
|
||||||
target_type = Column(SmallInteger, nullable=False)
|
target_type = Column(SmallInteger, nullable=False)
|
||||||
target_id = Column(BigInteger, nullable=False)
|
target_id = Column(BigInteger, nullable=False)
|
||||||
|
|
@ -349,16 +761,39 @@ class PostReport(BaseModel):
|
||||||
created_at = Column(DateTime, server_default=func.current_timestamp())
|
created_at = Column(DateTime, server_default=func.current_timestamp())
|
||||||
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
created_ip = Column(String(64), default=get_remote_addr, nullable=False)
|
||||||
|
|
||||||
author = relationship('User')
|
author = relationship('User', lazy='selectin')
|
||||||
|
|
||||||
def target(self):
|
async def target(self):
|
||||||
|
async with db as session:
|
||||||
if self.target_type == REPORT_TARGET_POST:
|
if self.target_type == REPORT_TARGET_POST:
|
||||||
return db.session.execute(select(Post).where(Post.id == self.target_id)).scalar()
|
return (await session.execute(select(Post).where(Post.id == self.target_id))).scalar()
|
||||||
elif self.target_type == REPORT_TARGET_COMMENT:
|
elif self.target_type == REPORT_TARGET_COMMENT:
|
||||||
return db.session.execute(select(Comment).where(Comment.id == self.target_id)).scalar()
|
return (await session.execute(select(Comment).where(Comment.id == self.target_id))).scalar()
|
||||||
else:
|
else:
|
||||||
return self.target_id
|
return self.target_id
|
||||||
|
|
||||||
|
def is_critical(self):
|
||||||
|
return self.reason_code in (
|
||||||
|
121, 142, 210
|
||||||
|
)
|
||||||
|
|
||||||
|
class UserStrike(Base):
|
||||||
|
__tablename__ = 'freak_user_strike'
|
||||||
|
|
||||||
|
id = id_column(SiqType.MULTI)
|
||||||
|
|
||||||
|
user_id = Column(BigInteger, ForeignKey('freak_user.id', ondelete='cascade'), nullable=False)
|
||||||
|
target_type = Column(SmallInteger, nullable=False)
|
||||||
|
target_id = Column(BigInteger, nullable=False)
|
||||||
|
target_content = Column(String(4096), nullable=True)
|
||||||
|
reason_code = Column(SmallInteger, nullable=False)
|
||||||
|
issued_at = Column(DateTime, server_default=func.current_timestamp())
|
||||||
|
issued_by_id = Column(BigInteger, ForeignKey('freak_user.id'), nullable=True)
|
||||||
|
|
||||||
|
user = relationship(User, primaryjoin= lambda: User.id == UserStrike.user_id, lazy='selectin')
|
||||||
|
issued_by = relationship(User, primaryjoin= lambda: User.id == UserStrike.issued_by_id, lazy='selectin')
|
||||||
|
|
||||||
# PostUpvote table is at the top !!
|
# PostUpvote table is at the top !!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,472 @@
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import datetime
|
||||||
|
import sys
|
||||||
|
from typing import Iterable, TypeVar
|
||||||
|
import logging
|
||||||
|
|
||||||
from flask import Blueprint
|
from quart import render_template, session
|
||||||
from flask_restx import Resource, Api
|
from quart import abort, Blueprint, redirect, request, url_for
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from quart_auth import current_user, login_required, login_user, logout_user
|
||||||
|
from quart_schema import validate_request
|
||||||
|
from quart_wtf.csrf import generate_csrf
|
||||||
|
from sqlalchemy import delete, insert, select
|
||||||
|
from suou import Snowflake, deprecated, makelist, not_implemented, want_isodate
|
||||||
|
|
||||||
from freak.iding import id_to_b32l
|
from suou.classtools import MISSING, MissingType
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
from suou.quart import add_rest
|
||||||
|
|
||||||
from ..models import Post, User, db
|
from freak.accounts import LoginStatus, check_login
|
||||||
|
from freak.algorithms import public_timeline, top_guilds_query, topic_timeline, user_timeline
|
||||||
|
from freak.search import SearchQuery
|
||||||
|
|
||||||
rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
|
from ..models import Comment, Guild, Post, PostUpvote, User, db
|
||||||
rest = Api(rest_bp)
|
from .. import UserLoader, app, app_config, __version__ as freak_version, csrf
|
||||||
|
|
||||||
@rest.route('/nurupo')
|
logger = logging.getLogger(__name__)
|
||||||
class Nurupo(Resource):
|
_T = TypeVar('_T')
|
||||||
def get(self):
|
|
||||||
return dict(nurupo='ga')
|
bp = Blueprint('rest', __name__, url_prefix='/v1')
|
||||||
|
rest = add_rest(app, '/v1', '/ajax')
|
||||||
|
|
||||||
|
## XXX potential security hole, but needed for REST to work
|
||||||
|
csrf.exempt(bp)
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
|
## TODO deprecate auth_required since it does not work
|
||||||
|
## will be removed in 0.6
|
||||||
|
from suou.flask_sqlalchemy import require_auth
|
||||||
|
auth_required = deprecated('use login_required() and current_user instead')(require_auth(User, db))
|
||||||
|
|
||||||
|
@not_implemented()
|
||||||
|
async def authenticated():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@bp.get('/nurupo')
|
||||||
|
async def get_nurupo():
|
||||||
|
return dict(ga=-1)
|
||||||
|
|
||||||
|
@bp.get('/health')
|
||||||
|
async def health():
|
||||||
|
async with db as session:
|
||||||
|
hi = dict(
|
||||||
|
version=freak_version,
|
||||||
|
name = app_config.app_name,
|
||||||
|
post_count = await Post.count(),
|
||||||
|
user_count = await User.active_count(),
|
||||||
|
me = Snowflake(current_user.id).to_b32l() if current_user else None,
|
||||||
|
color_theme = current_user.color_theme if current_user else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return hi
|
||||||
|
|
||||||
|
@bp.get('/oath')
|
||||||
|
async def oath():
|
||||||
|
try:
|
||||||
|
## pull csrf token from session
|
||||||
|
csrf_tok = session['csrf_token']
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
logger.warning('CSRF token regenerated!')
|
||||||
|
csrf_tok = session['csrf_token'] = generate_csrf()
|
||||||
|
except Exception as e2:
|
||||||
|
print(e, e2)
|
||||||
|
abort(503, "csrf_token is null")
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
## XXX might break any time!
|
||||||
|
csrf_token= csrf_tok
|
||||||
|
)
|
||||||
|
|
||||||
## TODO coverage of REST is still partial, but it's planned
|
## TODO coverage of REST is still partial, but it's planned
|
||||||
## to get complete sooner or later
|
## to get complete sooner or later
|
||||||
|
|
||||||
@rest.route('/user/<b32l:id>')
|
## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not
|
||||||
class UserInfo(Resource):
|
## redirect, neither is able to get user injected. It was therefore dismissed.
|
||||||
def get(self, id: int):
|
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
|
||||||
u: User | None = db.session.execute(db.select(User).where(User.id == id)).scalar()
|
|
||||||
if u is None:
|
## USERS ##
|
||||||
return dict(error='User not found'), 404
|
|
||||||
uj = dict(
|
@bp.get('/user/@me')
|
||||||
id = id_to_b32l(u.id),
|
@login_required
|
||||||
|
async def get_user_me():
|
||||||
|
return redirect(url_for(f'rest.user_get', id=current_user.id)), 302
|
||||||
|
|
||||||
|
def _user_info(u: User):
|
||||||
|
return dict(
|
||||||
|
id = f'{Snowflake(u.id):l}',
|
||||||
username = u.username,
|
username = u.username,
|
||||||
display_name = u.display_name,
|
display_name = u.display_name,
|
||||||
joined_at = u.joined_at.isoformat('T'),
|
joined_at = want_isodate(u.joined_at),
|
||||||
karma = u.karma,
|
karma = u.karma,
|
||||||
age = u.age()
|
age = u.age(),
|
||||||
|
biography=u.biography,
|
||||||
|
badges = u.badges()
|
||||||
)
|
)
|
||||||
return dict(users={id_to_b32l(id): uj})
|
|
||||||
|
|
||||||
@rest.route('/post/<b32l:id>')
|
@bp.get('/user/<b32l:id>')
|
||||||
class SinglePost(Resource):
|
async def user_get(id: int):
|
||||||
def get(self, id: int):
|
## TODO sanizize REST to make blocked users inaccessible
|
||||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
async with db as session:
|
||||||
|
u: User | None = (await session.execute(select(User).where(User.id == id))).scalar()
|
||||||
|
if u is None:
|
||||||
|
return dict(error='User not found'), 404
|
||||||
|
uj = _user_info(u)
|
||||||
|
return dict(users={f'{Snowflake(id):l}': uj})
|
||||||
|
|
||||||
|
@bp.get('/user/<b32l:id>/feed')
|
||||||
|
async def user_feed_get(id: int):
|
||||||
|
async with db as session:
|
||||||
|
u: User | None = (await session.execute(select(User).where(User.id == id))).scalar()
|
||||||
|
if u is None:
|
||||||
|
return dict(error='User not found'), 404
|
||||||
|
uj = _user_info(u)
|
||||||
|
|
||||||
|
feed = []
|
||||||
|
algo = user_timeline(u)
|
||||||
|
posts = await db.paginate(algo)
|
||||||
|
async for p in posts:
|
||||||
|
feed.append(await p.feed_info_counts())
|
||||||
|
|
||||||
|
return dict(users={f'{Snowflake(id):l}': uj}, feed=feed)
|
||||||
|
|
||||||
|
@bp.get('/user/@<username>')
|
||||||
|
async def resolve_user(username: str):
|
||||||
|
async with db as session:
|
||||||
|
uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar()
|
||||||
|
if uid is None:
|
||||||
|
abort(404, 'User not found')
|
||||||
|
return redirect(url_for('rest.user_get', id=uid)), 302
|
||||||
|
|
||||||
|
@bp.get('/user/@<username>/feed')
|
||||||
|
async def resolve_user_feed(username: str):
|
||||||
|
async with db as session:
|
||||||
|
uid: User | None = (await session.execute(select(User.id).select_from(User).where(User.username == username))).scalar()
|
||||||
|
if uid is None:
|
||||||
|
abort(404, 'User not found')
|
||||||
|
return redirect(url_for('rest.user_feed_get', id=uid)), 302
|
||||||
|
|
||||||
|
## POSTS ##
|
||||||
|
|
||||||
|
@bp.get('/post/<b32l:id>')
|
||||||
|
async def get_post(id: int):
|
||||||
|
async with db as session:
|
||||||
|
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||||
if p is None:
|
if p is None:
|
||||||
return dict(error='Not found'), 404
|
return dict(error='Not found'), 404
|
||||||
pj = dict(
|
pj = dict(
|
||||||
id = id_to_b32l(p.id),
|
id = f'{Snowflake(p.id):l}',
|
||||||
title = p.title,
|
title = p.title,
|
||||||
author = p.author.simple_info(),
|
author = p.author.simple_info(),
|
||||||
to = p.topic_or_user().handle(),
|
to = p.topic_or_user().simple_info(typed=True),
|
||||||
created_at = p.created_at.isoformat('T')
|
created_at = p.created_at.isoformat('T')
|
||||||
)
|
)
|
||||||
|
|
||||||
return dict(posts={id_to_b32l(id): pj})
|
if p.is_text_post():
|
||||||
|
pj['content'] = p.text_content
|
||||||
|
|
||||||
|
pj['comment_count'] = await p.comment_count()
|
||||||
|
pj['votes'] = await p.upvotes()
|
||||||
|
pj['my_vote'] = await p.upvoted_by(current_user.user)
|
||||||
|
|
||||||
|
return dict(posts={f'{Snowflake(id):l}': pj})
|
||||||
|
|
||||||
|
class VoteIn(BaseModel):
|
||||||
|
vote: int
|
||||||
|
|
||||||
|
@bp.post('/post/<b32l:id>/upvote')
|
||||||
|
@validate_request(VoteIn)
|
||||||
|
async def upvote_post(id: int, data: VoteIn):
|
||||||
|
async with db as session:
|
||||||
|
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||||
|
|
||||||
|
if p is None:
|
||||||
|
return { 'status': 404, 'error': 'Post not found' }, 404
|
||||||
|
|
||||||
|
cur_score = await p.upvoted_by(current_user.user)
|
||||||
|
|
||||||
|
match (data.vote, cur_score):
|
||||||
|
case (1, 0) | (1, -1):
|
||||||
|
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == True))
|
||||||
|
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = False))
|
||||||
|
case (0, _):
|
||||||
|
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id))
|
||||||
|
case (-1, 1) | (-1, 0):
|
||||||
|
await session.execute(delete(PostUpvote).where(PostUpvote.c.post_id == p.id, PostUpvote.c.voter_id == current_user.id, PostUpvote.c.is_downvote == False))
|
||||||
|
await session.execute(insert(PostUpvote).values(post_id = p.id, voter_id = current_user.id, is_downvote = True))
|
||||||
|
case (1, 1) | (1, -1):
|
||||||
|
pass
|
||||||
|
case _:
|
||||||
|
await session.rollback()
|
||||||
|
return { 'status': 400, 'error': 'Invalid score' }, 400
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return { 'votes': await p.upvotes() }
|
||||||
|
|
||||||
|
## COMMENTS ##
|
||||||
|
|
||||||
|
@bp.get('/post/<b32l:id>/comments')
|
||||||
|
async def post_comments (id: int):
|
||||||
|
async with db as session:
|
||||||
|
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||||
|
|
||||||
|
if p is None:
|
||||||
|
return { 'status': 404, 'error': 'Post not found' }, 404
|
||||||
|
|
||||||
|
l = []
|
||||||
|
for com in await p.top_level_comments():
|
||||||
|
com: Comment
|
||||||
|
l.append(await com.section_info())
|
||||||
|
|
||||||
|
return dict(has=l)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## GUILDS ##
|
||||||
|
|
||||||
|
async def _guild_info(gu: Guild):
|
||||||
|
return dict(
|
||||||
|
id = f'{Snowflake(gu.id):l}',
|
||||||
|
name = gu.name,
|
||||||
|
display_name = gu.display_name,
|
||||||
|
description = gu.description,
|
||||||
|
created_at = want_isodate(gu.created_at),
|
||||||
|
badges = []
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.get('/guild/<b32l:gid>')
|
||||||
|
async def guild_info_id(gid: int):
|
||||||
|
async with db as session:
|
||||||
|
gu: Guild | None = (await session.execute(select(Guild).where(Guild.id == gid))).scalar()
|
||||||
|
|
||||||
|
if gu is None:
|
||||||
|
return dict(error='Not found'), 404
|
||||||
|
gj = await _guild_info(gu)
|
||||||
|
|
||||||
|
return dict(guilds={f'{Snowflake(gu.id):l}': gj})
|
||||||
|
|
||||||
|
@bp.get('/guild/@<gname>')
|
||||||
|
async def guild_info_only(gname: str):
|
||||||
|
async with db as session:
|
||||||
|
gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
|
||||||
|
|
||||||
|
if gu is None:
|
||||||
|
return dict(error='Not found'), 404
|
||||||
|
gj = await _guild_info(gu)
|
||||||
|
|
||||||
|
return dict(guilds={f'{Snowflake(gu.id):l}': gj})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/guild/@<gname>/feed')
|
||||||
|
async def guild_feed(gname: str):
|
||||||
|
async with db as session:
|
||||||
|
gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
|
||||||
|
|
||||||
|
if gu is None:
|
||||||
|
return dict(error='Not found'), 404
|
||||||
|
gj = await _guild_info(gu)
|
||||||
|
feed = []
|
||||||
|
algo = topic_timeline(gname)
|
||||||
|
posts = await db.paginate(algo)
|
||||||
|
async for p in posts:
|
||||||
|
feed.append(await p.feed_info_counts())
|
||||||
|
|
||||||
|
return dict(guilds={f'{Snowflake(gu.id):l}': gj}, feed=feed)
|
||||||
|
|
||||||
|
|
||||||
|
## CREATE ##
|
||||||
|
|
||||||
|
class CreateIn(BaseModel):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
privacy: int = Field(default=0, ge=0, lt=4)
|
||||||
|
|
||||||
|
@bp.post('/guild/@<gname>')
|
||||||
|
@login_required
|
||||||
|
@validate_request(CreateIn)
|
||||||
|
async def guild_post(data: CreateIn, gname: str):
|
||||||
|
async with db as session:
|
||||||
|
user = current_user.user
|
||||||
|
gu: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
|
||||||
|
|
||||||
|
if gu is None:
|
||||||
|
return dict(error='Not found'), 404
|
||||||
|
if await gu.has_exiled(current_user.user):
|
||||||
|
return dict(error=f'You are banned from +{gname}'), 403
|
||||||
|
if not await gu.allows_posting(current_user.user):
|
||||||
|
return dict(error=f'You can\'t post on +{gname}'), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_post_id: int = (await session.execute(insert(Post).values(
|
||||||
|
author_id = user.id,
|
||||||
|
topic_id = gu.id,
|
||||||
|
privacy = data.privacy,
|
||||||
|
title = data.title,
|
||||||
|
text_content = data.text
|
||||||
|
).returning(Post.id))).scalar()
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return dict(id=Snowflake(new_post_id).to_b32l()), 200
|
||||||
|
except Exception:
|
||||||
|
sys.excepthook(*sys.exc_info())
|
||||||
|
return {'error': 'Internal Server Error'}, 500
|
||||||
|
|
||||||
|
## LOGIN/OUT ##
|
||||||
|
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
remember: bool = False
|
||||||
|
|
||||||
|
@bp.post('/login')
|
||||||
|
@validate_request(LoginIn)
|
||||||
|
async def login(data: LoginIn):
|
||||||
|
async with db as session:
|
||||||
|
u = (await session.execute(select(User).where(User.username == data.username))).scalar()
|
||||||
|
match check_login(u, data.password):
|
||||||
|
case LoginStatus.SUCCESS:
|
||||||
|
remember_for = int(data.remember)
|
||||||
|
if remember_for > 0:
|
||||||
|
login_user(UserLoader(u.get_id()), remember=True)
|
||||||
|
else:
|
||||||
|
login_user(UserLoader(u.get_id()))
|
||||||
|
return {'id': f'{Snowflake(u.id):l}'}, 200
|
||||||
|
case LoginStatus.ERROR:
|
||||||
|
abort(404, 'Invalid username or password')
|
||||||
|
case LoginStatus.SUSPENDED:
|
||||||
|
abort(403, 'Your account is suspended')
|
||||||
|
case LoginStatus.PASS_EXPIRED:
|
||||||
|
abort(403, 'You need to reset your password following the procedure.')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/logout')
|
||||||
|
@login_required
|
||||||
|
async def logout():
|
||||||
|
logout_user()
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
|
||||||
|
## HOME ##
|
||||||
|
|
||||||
|
@bp.get('/home/feed')
|
||||||
|
@login_required
|
||||||
|
async def home_feed():
|
||||||
|
async with db as session:
|
||||||
|
me = current_user.user
|
||||||
|
posts = await db.paginate(public_timeline())
|
||||||
|
feed = []
|
||||||
|
async for post in posts:
|
||||||
|
feed.append(await post.feed_info_counts())
|
||||||
|
|
||||||
|
return dict(feed=feed)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/top/guilds')
|
||||||
|
async def top_guilds():
|
||||||
|
async with db as session:
|
||||||
|
top_g = [await x.sub_info() for x in
|
||||||
|
(await session.execute(top_guilds_query().limit(10))).scalars()]
|
||||||
|
|
||||||
|
return dict(has=top_g)
|
||||||
|
|
||||||
|
## SEARCH ##
|
||||||
|
|
||||||
|
class QueryIn(BaseModel):
|
||||||
|
query: str
|
||||||
|
|
||||||
|
@bp.post('/search/top')
|
||||||
|
@validate_request(QueryIn)
|
||||||
|
async def search_top(data: QueryIn):
|
||||||
|
async with db as session:
|
||||||
|
sq = SearchQuery(data.query)
|
||||||
|
|
||||||
|
result = (await session.execute(sq.select(Post, [Post.title]).limit(20))).scalars()
|
||||||
|
|
||||||
|
return dict(has = [p.feed_info() for p in result])
|
||||||
|
|
||||||
|
|
||||||
|
## SUGGEST
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/suggest/guild")
|
||||||
|
@validate_request(QueryIn)
|
||||||
|
async def suggest_guild(data: QueryIn):
|
||||||
|
if not data.query.isidentifier():
|
||||||
|
return dict(has=[])
|
||||||
|
async with db as session:
|
||||||
|
sq = select(Guild).where(Guild.name.like(data.query + "%"))
|
||||||
|
|
||||||
|
result: Iterable[Guild] = (await session.execute(sq.limit(10))).scalars()
|
||||||
|
|
||||||
|
return dict(has = [g.simple_info() for g in result if await g.allows_posting(current_user.user)])
|
||||||
|
|
||||||
|
|
||||||
|
## SETTINGS
|
||||||
|
|
||||||
|
@bp.get("/settings/appearance")
|
||||||
|
@login_required
|
||||||
|
async def get_settings_appearance():
|
||||||
|
return dict(
|
||||||
|
color_theme = current_user.user.color_theme
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsAppearanceIn(BaseModel):
|
||||||
|
color_theme : int | None = None
|
||||||
|
color_scheme : int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_or(obj: _T | MissingType, obj2: _T) -> _T:
|
||||||
|
if obj is None:
|
||||||
|
return obj2
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@bp.patch("/settings/appearance")
|
||||||
|
@login_required
|
||||||
|
@validate_request(SettingsAppearanceIn)
|
||||||
|
async def patch_settings_appearance(data: SettingsIn):
|
||||||
|
u = current_user.user
|
||||||
|
if u is None:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
|
u.color_theme = (
|
||||||
|
_missing_or(data.color_theme, u.color_theme % (1 << 8)) % 256 +
|
||||||
|
_missing_or(data.color_scheme, u.color_theme >> 8) << 8
|
||||||
|
)
|
||||||
|
current_user.session.add(u)
|
||||||
|
await current_user.session.commit()
|
||||||
|
|
||||||
|
return '', 204
|
||||||
|
|
||||||
|
## TERMS
|
||||||
|
|
||||||
|
@bp.get('/about/about')
|
||||||
|
async def about_about():
|
||||||
|
return dict(
|
||||||
|
content=await render_template("about.md",
|
||||||
|
quart_version=quart_version,
|
||||||
|
sa_version=sa_version,
|
||||||
|
python_version=sys.version.split()[0]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.get('/about/terms')
|
||||||
|
async def terms():
|
||||||
|
return dict(
|
||||||
|
content=await render_template("terms.md")
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.get('/about/privacy')
|
||||||
|
async def privacy():
|
||||||
|
return dict(
|
||||||
|
content=await render_template("privacy.md")
|
||||||
|
)
|
||||||
|
|
||||||
|
@bp.get('/about/rules')
|
||||||
|
async def rules():
|
||||||
|
return dict(
|
||||||
|
content=await render_template("rules.md")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from sqlalchemy import Column, Select, select, or_
|
from sqlalchemy import Column, Select, select, or_
|
||||||
|
|
||||||
|
|
||||||
class SearchQuery:
|
class SearchQuery:
|
||||||
keywords: Iterable[str]
|
keywords: Iterable[str]
|
||||||
|
|
||||||
|
|
@ -23,3 +22,4 @@ class SearchQuery:
|
||||||
sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0])
|
sq = sq.where(or_(*or_cond) if len(or_cond) > 1 else or_cond[0])
|
||||||
return sq
|
return sq
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
652
freak/static/admin/style.css
Normal file
652
freak/static/admin/style.css
Normal file
|
|
@ -0,0 +1,652 @@
|
||||||
|
/**
|
||||||
|
Static version of style.css from v0.4.0
|
||||||
|
expressly for admin pages, skimmed
|
||||||
|
*/
|
||||||
|
|
||||||
|
@charset "UTF-8";
|
||||||
|
* {
|
||||||
|
box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--c0-accent: #ff7300;
|
||||||
|
--c1-accent: #ff7300;
|
||||||
|
--c2-accent: #f837ce;
|
||||||
|
--c3-accent: #38b8ff;
|
||||||
|
--c4-accent: #ffe338;
|
||||||
|
--c5-accent: #78f038;
|
||||||
|
--c6-accent: #ff9aae;
|
||||||
|
--c7-accent: #606080;
|
||||||
|
--c8-accent: #aeaac0;
|
||||||
|
--c9-accent: #3ae0b8;
|
||||||
|
--c10-accent: #8828ea;
|
||||||
|
--c11-accent: #1871d8;
|
||||||
|
--c12-accent: #885a18;
|
||||||
|
--c13-accent: #38a856;
|
||||||
|
--c14-accent: #ff3018;
|
||||||
|
--c15-accent: #ff1668;
|
||||||
|
--light-text-primary: #181818;
|
||||||
|
--light-text-alt: #444;
|
||||||
|
--light-border: #999;
|
||||||
|
--light-success: #73af00;
|
||||||
|
--light-error: #e04830;
|
||||||
|
--light-warning: #dea800;
|
||||||
|
--light-canvas: #eaecee;
|
||||||
|
--light-background: #f9f9f9;
|
||||||
|
--light-bg-sharp: #fdfdff;
|
||||||
|
--dark-text-primary: #e8e8e8;
|
||||||
|
--dark-text-alt: #c0cad3;
|
||||||
|
--dark-border: #777;
|
||||||
|
--dark-success: #93cf00;
|
||||||
|
--dark-error: #e04830;
|
||||||
|
--dark-warning: #dea800;
|
||||||
|
--dark-canvas: #0a0a0e;
|
||||||
|
--dark-background: #181a21;
|
||||||
|
--dark-bg-sharp: #080808;
|
||||||
|
--accent: var(--c0-accent);
|
||||||
|
--light-accent: var(--accent);
|
||||||
|
--dark-accent: var(--accent);
|
||||||
|
--text-primary: var(--light-text-primary);
|
||||||
|
--text-alt: var(--light-text-alt);
|
||||||
|
--border: var(--light-border);
|
||||||
|
--success: var(--light-success);
|
||||||
|
--error: var(--light-error);
|
||||||
|
--warning: var(--light-warning);
|
||||||
|
--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);
|
||||||
|
--success: var(--dark-success);
|
||||||
|
--error: var(--dark-error);
|
||||||
|
--warning: var(--dark-warning);
|
||||||
|
--canvas: var(--dark-canvas);
|
||||||
|
--background: var(--dark-background);
|
||||||
|
--bg-sharp: var(--dark-bg-sharp); } }
|
||||||
|
|
||||||
|
.color-scheme-light {
|
||||||
|
--text-primary: var(--light-text-primary);
|
||||||
|
--text-alt: var(--light-text-alt);
|
||||||
|
--border: var(--light-border);
|
||||||
|
--success: var(--light-success);
|
||||||
|
--error: var(--light-error);
|
||||||
|
--warning: var(--light-warning);
|
||||||
|
--canvas: var(--light-canvas);
|
||||||
|
--background: var(--light-background);
|
||||||
|
--bg-sharp: var(--light-bg-sharp); }
|
||||||
|
|
||||||
|
.color-scheme-dark {
|
||||||
|
--text-primary: var(--dark-text-primary);
|
||||||
|
--text-alt: var(--dark-text-alt);
|
||||||
|
--border: var(--dark-border);
|
||||||
|
--success: var(--dark-success);
|
||||||
|
--error: var(--dark-error);
|
||||||
|
--warning: var(--dark-warning);
|
||||||
|
--canvas: var(--dark-canvas);
|
||||||
|
--background: var(--dark-background);
|
||||||
|
--bg-sharp: var(--dark-bg-sharp); }
|
||||||
|
|
||||||
|
.color-theme-1 {
|
||||||
|
--accent: var(--c1-accent); }
|
||||||
|
|
||||||
|
.color-theme-2 {
|
||||||
|
--accent: var(--c2-accent); }
|
||||||
|
|
||||||
|
.color-theme-3 {
|
||||||
|
--accent: var(--c3-accent); }
|
||||||
|
|
||||||
|
.color-theme-4 {
|
||||||
|
--accent: var(--c4-accent); }
|
||||||
|
|
||||||
|
.color-theme-5 {
|
||||||
|
--accent: var(--c5-accent); }
|
||||||
|
|
||||||
|
.color-theme-6 {
|
||||||
|
--accent: var(--c6-accent); }
|
||||||
|
|
||||||
|
.color-theme-7 {
|
||||||
|
--accent: var(--c7-accent); }
|
||||||
|
|
||||||
|
.color-theme-8 {
|
||||||
|
--accent: var(--c8-accent); }
|
||||||
|
|
||||||
|
.color-theme-9 {
|
||||||
|
--accent: var(--c9-accent); }
|
||||||
|
|
||||||
|
.color-theme-10 {
|
||||||
|
--accent: var(--c10-accent); }
|
||||||
|
|
||||||
|
.color-theme-11 {
|
||||||
|
--accent: var(--c11-accent); }
|
||||||
|
|
||||||
|
.color-theme-12 {
|
||||||
|
--accent: var(--c12-accent); }
|
||||||
|
|
||||||
|
.color-theme-13 {
|
||||||
|
--accent: var(--c13-accent); }
|
||||||
|
|
||||||
|
.color-theme-14 {
|
||||||
|
--accent: var(--c14-accent); }
|
||||||
|
|
||||||
|
.color-theme-15 {
|
||||||
|
--accent: var(--c15-accent); }
|
||||||
|
|
||||||
|
body, input, select, button {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Noto Sans", sans-serif; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 18px; }
|
||||||
|
|
||||||
|
input, button, select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit; }
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
font-family: monospace; }
|
||||||
|
|
||||||
|
input:not([type="submit"], [type="button"], [type="reset"]), textarea {
|
||||||
|
background: var(--bg-sharp);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: var(--border);
|
||||||
|
border-radius: 9px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--canvas); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--background);
|
||||||
|
border: var(--canvas) 1px solid;
|
||||||
|
border-radius: 9px;
|
||||||
|
margin: 12px auto;
|
||||||
|
padding: 12px;
|
||||||
|
max-width: 960px; }
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 110%; }
|
||||||
|
|
||||||
|
a:link, a:visited {
|
||||||
|
color: var(--accent);
|
||||||
|
transition: ease 5s; }
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100vh; }
|
||||||
|
|
||||||
|
.faint {
|
||||||
|
opacity: .75; }
|
||||||
|
strong .faint {
|
||||||
|
font-weight: 400; }
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
color: var(--text-alt); }
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success); }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error); }
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: var(--warning); }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0; }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
margin: 12px auto; }
|
||||||
|
|
||||||
|
header.header {
|
||||||
|
background-color: var(--background);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 3em;
|
||||||
|
padding: .75em 1.5em;
|
||||||
|
line-height: 1; }
|
||||||
|
header.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.5em; }
|
||||||
|
header.header .metanav {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: auto;
|
||||||
|
margin-inline-start: 2em; }
|
||||||
|
header.header .metanav ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0; }
|
||||||
|
header.header .metanav ul > li {
|
||||||
|
margin: 0 6px; }
|
||||||
|
header.header .metanav ul, header.header .metanav ul > li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end; }
|
||||||
|
header.header .metanav, header.header .metanav > ul, header.header .metanav > ul > li:has(.mini-search-bar) {
|
||||||
|
flex: 1; }
|
||||||
|
header.header .metanav ul > li span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: .6em; }
|
||||||
|
header.header .header-username > * {
|
||||||
|
display: block;
|
||||||
|
font-size: .5em;
|
||||||
|
line-height: 1.25; }
|
||||||
|
header.header .header-username .icon {
|
||||||
|
font-size: inherit; }
|
||||||
|
header.header a {
|
||||||
|
text-decoration: none; }
|
||||||
|
header.header .mini-search-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.2rem; }
|
||||||
|
header.header .mini-search-bar [type="search"] {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
background-color: inherit;
|
||||||
|
focus-background-color: var(--bg-sharp);
|
||||||
|
focus-border-color: var(--accent); }
|
||||||
|
header.header .mini-search-bar [type="submit"] {
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden; }
|
||||||
|
header.header .mini-search-bar + a {
|
||||||
|
display: none; }
|
||||||
|
|
||||||
|
aside.card {
|
||||||
|
overflow: hidden; }
|
||||||
|
aside.card > :is(h1, h2, h3, h4, h5, h6):first-child {
|
||||||
|
background-color: var(--accent);
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin: -12px -12px 0 -12px;
|
||||||
|
position: relative; }
|
||||||
|
aside.card > :is(h1, h2, h3, h4, h5, h6):first-child a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline; }
|
||||||
|
aside.card > ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0; }
|
||||||
|
aside.card > ul > li {
|
||||||
|
border-bottom: 1px solid var(--canvas);
|
||||||
|
padding: 12px; }
|
||||||
|
aside.card > ul > li:last-child {
|
||||||
|
border-bottom: none; }
|
||||||
|
aside.card > p {
|
||||||
|
padding: 12px;
|
||||||
|
margin: 0; }
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
border-color: yellow;
|
||||||
|
background-color: #fff00040; }
|
||||||
|
|
||||||
|
ul.timeline {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0 1em; }
|
||||||
|
ul.timeline > li {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 6px; }
|
||||||
|
ul.timeline > li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
margin-bottom: 0; }
|
||||||
|
|
||||||
|
ul.inline {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: inline; }
|
||||||
|
ul.inline > li {
|
||||||
|
display: inline; }
|
||||||
|
ul.inline > li::before {
|
||||||
|
content: ' · ';
|
||||||
|
margin: 0 .5em; }
|
||||||
|
ul.inline > li:first-child::before {
|
||||||
|
content: '';
|
||||||
|
margin: 0; }
|
||||||
|
|
||||||
|
ul.grid {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
grid-template-rows: auto; }
|
||||||
|
ul.grid > li {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: .5em;
|
||||||
|
padding: .5em;
|
||||||
|
margin: 1em .5em;
|
||||||
|
text-align: center; }
|
||||||
|
ul.grid > li small {
|
||||||
|
display: block; }
|
||||||
|
|
||||||
|
ul.message-options {
|
||||||
|
color: var(--text-alt);
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: smaller; }
|
||||||
|
|
||||||
|
.post-frame {
|
||||||
|
margin-left: 3em;
|
||||||
|
position: relative;
|
||||||
|
min-height: 6em;
|
||||||
|
clear: right; }
|
||||||
|
[dir="rtl"] .post-frame {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 3em; }
|
||||||
|
.post-frame .message-options {
|
||||||
|
margin-bottom: 1em; }
|
||||||
|
.post-frame .message-stats {
|
||||||
|
position: absolute;
|
||||||
|
left: -3em;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 2em;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.0; }
|
||||||
|
[dir="rtl"] .post-frame .message-stats {
|
||||||
|
right: -3em;
|
||||||
|
left: unset; }
|
||||||
|
.post-frame .message-stats > * {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; }
|
||||||
|
.post-frame .message-stats strong {
|
||||||
|
font-size: smaller; }
|
||||||
|
.post-frame .message-stats 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; }
|
||||||
|
.shorten::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); }
|
||||||
|
form.boundaryless dd {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0; }
|
||||||
|
form.boundaryless textarea, form.boundaryless input[type="text"] {
|
||||||
|
width: 100%; }
|
||||||
|
form.boundaryless textarea {
|
||||||
|
min-height: 4em; }
|
||||||
|
form.boundaryless p input[type="text"] {
|
||||||
|
width: unset; }
|
||||||
|
|
||||||
|
.big-search-bar form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
font-size: 1.6em;
|
||||||
|
width: 80%;
|
||||||
|
margin: auto; }
|
||||||
|
.big-search-bar form > [type="search"] {
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 2px solid var(--border); }
|
||||||
|
|
||||||
|
footer.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: smaller; }
|
||||||
|
footer.footer ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0; }
|
||||||
|
footer.footer ul > 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; }
|
||||||
|
button.primary, [type="submit"].primary, [type="reset"].primary, [type="button"].primary {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--background); }
|
||||||
|
button[disabled], [type="submit"][disabled], [type="reset"][disabled], [type="button"][disabled] {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border: var(--border);
|
||||||
|
color: var(--border); }
|
||||||
|
button.primary[disabled], [type="submit"].primary[disabled], [type="reset"].primary[disabled], [type="button"].primary[disabled] {
|
||||||
|
color: var(--background);
|
||||||
|
background-color: var(--border); }
|
||||||
|
button:first-child, [type="submit"]:first-child, [type="reset"]:first-child, [type="button"]:first-child {
|
||||||
|
margin-inline-start: 0; }
|
||||||
|
button:last-child, [type="submit"]:last-child, [type="reset"]:last-child, [type="button"]: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);
|
||||||
|
background: var(--background);
|
||||||
|
padding: 12px 12px 6px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border-start-start-radius: 0;
|
||||||
|
min-width: 50%;
|
||||||
|
width: 0;
|
||||||
|
margin-inline-end: auto;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative; }
|
||||||
|
.comment-frame::before {
|
||||||
|
content: '';
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-inline-end: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
background: var(--background);
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
position: absolute;
|
||||||
|
left: calc(-1px - .5em);
|
||||||
|
top: -1px;
|
||||||
|
transform: skewX(45deg); }
|
||||||
|
li:has(> .comment-frame) {
|
||||||
|
list-style: none; }
|
||||||
|
|
||||||
|
.border-accent {
|
||||||
|
border: var(--accent) 1px solid;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 4px; }
|
||||||
|
|
||||||
|
.round {
|
||||||
|
border-radius: 1em; }
|
||||||
|
|
||||||
|
.done {
|
||||||
|
opacity: .5; }
|
||||||
|
|
||||||
|
button.card {
|
||||||
|
width: 100%;
|
||||||
|
padding: .5em 1em;
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 1em; }
|
||||||
|
button.card.primary {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--background); }
|
||||||
|
|
||||||
|
.big_icon {
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto;
|
||||||
|
font-size: 36px;
|
||||||
|
text-align: center; }
|
||||||
|
|
||||||
|
textarea.create_text {
|
||||||
|
min-height: 8em; }
|
||||||
|
form.boundaryless textarea.create_text {
|
||||||
|
min-height: 8em; }
|
||||||
|
|
||||||
|
:is(input, select, textarea).fullwidth {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0; }
|
||||||
|
|
||||||
|
label:has([type="checkbox"]:not(:checked)) {
|
||||||
|
opacity: .75; }
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin: 2em auto;
|
||||||
|
max-width: 1280px; }
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 4px solid var(--border);
|
||||||
|
margin-left: 0; }
|
||||||
|
[dir="rtl"] blockquote {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: 0;
|
||||||
|
padding-right: 1em;
|
||||||
|
border-right: 4px solid var(--border); }
|
||||||
|
|
||||||
|
.message-content p {
|
||||||
|
margin: 4px 0; }
|
||||||
|
|
||||||
|
.message-content ul {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 0;
|
||||||
|
padding-inline-start: 1.5em; }
|
||||||
|
.message-content ul > li {
|
||||||
|
margin: 0; }
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 500; }
|
||||||
|
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
.content-container {
|
||||||
|
display: block; }
|
||||||
|
.content-nav, .content-main {
|
||||||
|
width: 100%; }
|
||||||
|
ul.grid {
|
||||||
|
grid-template-columns: 1fr 1fr; }
|
||||||
|
.nomobile {
|
||||||
|
display: none !important; }
|
||||||
|
body {
|
||||||
|
position: relative; }
|
||||||
|
footer.mobile-nav {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--background);
|
||||||
|
box-shadow: 0 0 6px var(--border);
|
||||||
|
z-index: 150; }
|
||||||
|
footer.mobile-nav > ul {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch; }
|
||||||
|
footer.mobile-nav > ul > li {
|
||||||
|
flex: 1;
|
||||||
|
padding: .5em;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center; }
|
||||||
|
footer.mobile-nav > ul > li a {
|
||||||
|
text-decoration: none; }
|
||||||
|
footer.mobile-nav > ul > li .icon {
|
||||||
|
font-size: 2rem; }
|
||||||
|
.content-nav {
|
||||||
|
margin: 1em;
|
||||||
|
width: unset; }
|
||||||
|
header.header h1 {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-left: 6px; }
|
||||||
|
.content-header {
|
||||||
|
text-align: center; }
|
||||||
|
.big-search-bar form {
|
||||||
|
flex-direction: column; }
|
||||||
|
.big-search-bar form [type="submit"] {
|
||||||
|
width: unset;
|
||||||
|
margin: 12px auto; } }
|
||||||
|
|
||||||
|
@media screen and (max-width: 960px) {
|
||||||
|
.header-username {
|
||||||
|
display: none; }
|
||||||
|
header.header {
|
||||||
|
padding: .5em .5em; }
|
||||||
|
header.header .mini-search-bar {
|
||||||
|
display: none; }
|
||||||
|
header.header .mini-search-bar + a {
|
||||||
|
display: inline-block; }
|
||||||
|
header.header ul > li:has(.mini-search-bar) {
|
||||||
|
flex: unset; } }
|
||||||
|
|
||||||
|
@media screen and (min-width: 801px) {
|
||||||
|
.mobileonly {
|
||||||
|
display: none !important; } }
|
||||||
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
(function(){
|
(function(){
|
||||||
// UNUSED! Period is disallowed regardless now
|
"use strict";
|
||||||
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(){
|
function attachUsernameInput(){
|
||||||
|
|
@ -35,21 +27,30 @@
|
||||||
usernameInputMessage.className = 'username-input-message error';
|
usernameInputMessage.className = 'username-input-message error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (value.length >= 100) {
|
||||||
|
usernameInputMessage.innerHTML = 'Your username must be shorter.';
|
||||||
|
usernameInputMessage.className = 'username-input-message error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if(/^[01]/.test(value)) {
|
if(/^[01]/.test(value)) {
|
||||||
usernameInputMessage.innerHTML = 'Your username cannot start with 0 or 1.';
|
usernameInputMessage.innerHTML = 'Your username cannot start with 0 or 1.';
|
||||||
usernameInputMessage.className = 'username-input-message error';
|
usernameInputMessage.className = 'username-input-message error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
usernameInputMessage.innerHTML = 'Checking username...';
|
usernameInputMessage.innerHTML = 'Checking username...';
|
||||||
usernameInputMessage.className = 'username-input-message checking';
|
usernameInputMessage.className = 'username-input-message checking faint';
|
||||||
requestUsernameAvailability(value, endpoint).then((resp) => {
|
requestUsernameAvailability(value, endpoint).then((resp) => {
|
||||||
if (['ok', void 0].indexOf(resp.status) < 0){
|
if (['ok', void 0].indexOf(resp.status) < 0){
|
||||||
usernameInputMessage.innerHTML = 'Sorry, there was an unknown error.';
|
usernameInputMessage.innerHTML = 'Sorry, there was an unknown error.';
|
||||||
usernameInputMessage.className = 'username-input-message error';
|
usernameInputMessage.className = 'username-input-message error';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (resp.is_available){
|
if (resp.is_valid === false) {
|
||||||
usernameInputMessage.innerHTML = "The username @" + value + " is available!";
|
usernameInputMessage.innerHTML = "You can't use this username.";
|
||||||
|
usernameInputMessage.className = 'username-input-message error';
|
||||||
|
return;
|
||||||
|
} else if (resp.is_available){
|
||||||
|
usernameInputMessage.innerHTML = `The username @${value} is available!`;
|
||||||
usernameInputMessage.className = 'username-input-message success';
|
usernameInputMessage.className = 'username-input-message success';
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -140,9 +141,36 @@
|
||||||
}).then(e => e.json());
|
}).then(e => e.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableThemeChange() {
|
||||||
|
let schemeItems = document.querySelectorAll('.apply-theme [name="color_scheme"]');
|
||||||
|
|
||||||
|
for (let ii of schemeItems) {
|
||||||
|
ii.addEventListener('change', function(e) {
|
||||||
|
let removed_classes = Array.from(document.body.classList).filter((x) => /^color-scheme-/.test(x));
|
||||||
|
document.body.classList.remove(...removed_classes);
|
||||||
|
if (e.target.value !== 'unset') {
|
||||||
|
document.body.classList.add(`color-scheme-${e.target.value}`);
|
||||||
|
}
|
||||||
|
console.log(`Color scheme changed to ${e.target.value}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeItems = document.querySelectorAll('.apply-theme [name="color_theme"]');
|
||||||
|
|
||||||
|
for (let ii of themeItems) {
|
||||||
|
ii.addEventListener('change', function(e) {
|
||||||
|
let removed_classes = Array.from(document.body.classList).filter((x) => /^color-theme-/.test(x));
|
||||||
|
document.body.classList.remove(...removed_classes);
|
||||||
|
document.body.classList.add(`color-theme-${e.target.value}`);
|
||||||
|
console.log(`Color theme changed to ${e.target.value}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
attachUsernameInput();
|
attachUsernameInput();
|
||||||
enablePostVotes();
|
enablePostVotes();
|
||||||
|
enableThemeChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,29 @@
|
||||||
box-sizing: border-box
|
box-sizing: border-box
|
||||||
|
|
||||||
\:root
|
\:root
|
||||||
|
--c0-accent: #ff7300
|
||||||
|
--c1-accent: #ff7300
|
||||||
|
--c2-accent: #f837ce
|
||||||
|
--c3-accent: #38b8ff
|
||||||
|
--c4-accent: #ffe338
|
||||||
|
--c5-accent: #78f038
|
||||||
|
--c6-accent: #ff9aae
|
||||||
|
--c7-accent: #606080
|
||||||
|
--c8-accent: #aeaac0
|
||||||
|
--c9-accent: #3ae0b8
|
||||||
|
--c10-accent: #8828ea
|
||||||
|
--c11-accent: #1871d8
|
||||||
|
--c12-accent: #885a18
|
||||||
|
--c13-accent: #38a856
|
||||||
|
--c14-accent: #ff3018
|
||||||
|
--c15-accent: #ff1668
|
||||||
|
|
||||||
--light-text-primary: #181818
|
--light-text-primary: #181818
|
||||||
--light-text-alt: #444
|
--light-text-alt: #444
|
||||||
--light-border: #999
|
--light-border: #999
|
||||||
--light-accent: #ff7300
|
|
||||||
--light-success: #73af00
|
--light-success: #73af00
|
||||||
--light-error: #e04433
|
--light-error: #e04830
|
||||||
|
--light-warning: #dea800
|
||||||
--light-canvas: #eaecee
|
--light-canvas: #eaecee
|
||||||
--light-background: #f9f9f9
|
--light-background: #f9f9f9
|
||||||
--light-bg-sharp: #fdfdff
|
--light-bg-sharp: #fdfdff
|
||||||
|
|
@ -18,19 +35,25 @@
|
||||||
--dark-text-primary: #e8e8e8
|
--dark-text-primary: #e8e8e8
|
||||||
--dark-text-alt: #c0cad3
|
--dark-text-alt: #c0cad3
|
||||||
--dark-border: #777
|
--dark-border: #777
|
||||||
--dark-accent: #ff7300
|
|
||||||
--dark-success: #93cf00
|
--dark-success: #93cf00
|
||||||
--dark-error: #e04433
|
--dark-error: #e04830
|
||||||
|
--dark-warning: #dea800
|
||||||
--dark-canvas: #0a0a0e
|
--dark-canvas: #0a0a0e
|
||||||
--dark-background: #181a21
|
--dark-background: #181a21
|
||||||
--dark-bg-sharp: #080808
|
--dark-bg-sharp: #080808
|
||||||
|
|
||||||
|
--accent: var(--c0-accent)
|
||||||
|
|
||||||
|
// the following are DEPRECATED //
|
||||||
|
--light-accent: var(--accent)
|
||||||
|
--dark-accent: var(--accent)
|
||||||
|
|
||||||
--text-primary: var(--light-text-primary)
|
--text-primary: var(--light-text-primary)
|
||||||
--text-alt: var(--light-text-alt)
|
--text-alt: var(--light-text-alt)
|
||||||
--border: var(--light-border)
|
--border: var(--light-border)
|
||||||
--accent: var(--light-accent)
|
|
||||||
--success: var(--light-success)
|
--success: var(--light-success)
|
||||||
--error: var(--light-error)
|
--error: var(--light-error)
|
||||||
|
--warning: var(--light-warning)
|
||||||
--canvas: var(--light-canvas)
|
--canvas: var(--light-canvas)
|
||||||
--background: var(--light-background)
|
--background: var(--light-background)
|
||||||
--bg-sharp: var(--light-bg-sharp)
|
--bg-sharp: var(--light-bg-sharp)
|
||||||
|
|
@ -40,35 +63,80 @@
|
||||||
--text-primary: var(--dark-text-primary)
|
--text-primary: var(--dark-text-primary)
|
||||||
--text-alt: var(--dark-text-alt)
|
--text-alt: var(--dark-text-alt)
|
||||||
--border: var(--dark-border)
|
--border: var(--dark-border)
|
||||||
--accent: var(--dark-accent)
|
|
||||||
--success: var(--dark-success)
|
--success: var(--dark-success)
|
||||||
--error: var(--dark-error)
|
--error: var(--dark-error)
|
||||||
|
--warning: var(--dark-warning)
|
||||||
--canvas: var(--dark-canvas)
|
--canvas: var(--dark-canvas)
|
||||||
--background: var(--dark-background)
|
--background: var(--dark-background)
|
||||||
--bg-sharp: var(--dark-bg-sharp)
|
--bg-sharp: var(--dark-bg-sharp)
|
||||||
|
|
||||||
body.color-scheme-light
|
.color-scheme-light
|
||||||
--text-primary: var(--light-text-primary)
|
--text-primary: var(--light-text-primary)
|
||||||
--text-alt: var(--light-text-alt)
|
--text-alt: var(--light-text-alt)
|
||||||
--border: var(--light-border)
|
--border: var(--light-border)
|
||||||
--accent: var(--light-accent)
|
|
||||||
--success: var(--light-success)
|
--success: var(--light-success)
|
||||||
--error: var(--light-error)
|
--error: var(--light-error)
|
||||||
|
--warning: var(--light-warning)
|
||||||
--canvas: var(--light-canvas)
|
--canvas: var(--light-canvas)
|
||||||
--background: var(--light-background)
|
--background: var(--light-background)
|
||||||
--bg-sharp: var(--light-bg-sharp)
|
--bg-sharp: var(--light-bg-sharp)
|
||||||
|
|
||||||
body.color-scheme-dark
|
.color-scheme-dark
|
||||||
--text-primary: var(--dark-text-primary)
|
--text-primary: var(--dark-text-primary)
|
||||||
--text-alt: var(--dark-text-alt)
|
--text-alt: var(--dark-text-alt)
|
||||||
--border: var(--dark-border)
|
--border: var(--dark-border)
|
||||||
--accent: var(--dark-accent)
|
|
||||||
--success: var(--dark-success)
|
--success: var(--dark-success)
|
||||||
--error: var(--dark-error)
|
--error: var(--dark-error)
|
||||||
|
--warning: var(--dark-warning)
|
||||||
--canvas: var(--dark-canvas)
|
--canvas: var(--dark-canvas)
|
||||||
--background: var(--dark-background)
|
--background: var(--dark-background)
|
||||||
--bg-sharp: var(--dark-bg-sharp)
|
--bg-sharp: var(--dark-bg-sharp)
|
||||||
|
|
||||||
|
.color-theme-1
|
||||||
|
--accent: var(--c1-accent)
|
||||||
|
|
||||||
|
.color-theme-2
|
||||||
|
--accent: var(--c2-accent)
|
||||||
|
|
||||||
|
.color-theme-3
|
||||||
|
--accent: var(--c3-accent)
|
||||||
|
|
||||||
|
.color-theme-4
|
||||||
|
--accent: var(--c4-accent)
|
||||||
|
|
||||||
|
.color-theme-5
|
||||||
|
--accent: var(--c5-accent)
|
||||||
|
|
||||||
|
.color-theme-6
|
||||||
|
--accent: var(--c6-accent)
|
||||||
|
|
||||||
|
.color-theme-7
|
||||||
|
--accent: var(--c7-accent)
|
||||||
|
|
||||||
|
.color-theme-8
|
||||||
|
--accent: var(--c8-accent)
|
||||||
|
|
||||||
|
.color-theme-9
|
||||||
|
--accent: var(--c9-accent)
|
||||||
|
|
||||||
|
.color-theme-10
|
||||||
|
--accent: var(--c10-accent)
|
||||||
|
|
||||||
|
.color-theme-11
|
||||||
|
--accent: var(--c11-accent)
|
||||||
|
|
||||||
|
.color-theme-12
|
||||||
|
--accent: var(--c12-accent)
|
||||||
|
|
||||||
|
.color-theme-13
|
||||||
|
--accent: var(--c13-accent)
|
||||||
|
|
||||||
|
.color-theme-14
|
||||||
|
--accent: var(--c14-accent)
|
||||||
|
|
||||||
|
.color-theme-15
|
||||||
|
--accent: var(--c15-accent)
|
||||||
|
|
||||||
|
|
||||||
body, input, select, button
|
body, input, select, button
|
||||||
font-family: $ui-fonts
|
font-family: $ui-fonts
|
||||||
|
|
@ -103,12 +171,6 @@ body
|
||||||
padding: 12px
|
padding: 12px
|
||||||
max-width: 960px
|
max-width: 960px
|
||||||
|
|
||||||
.a11y
|
|
||||||
overflow: hidden
|
|
||||||
width: 0
|
|
||||||
height: 0
|
|
||||||
display: inline-block
|
|
||||||
|
|
||||||
.centered
|
.centered
|
||||||
text-align: center
|
text-align: center
|
||||||
font-size: 110%
|
font-size: 110%
|
||||||
|
|
@ -124,3 +186,17 @@ img
|
||||||
|
|
||||||
.faint
|
.faint
|
||||||
opacity: .75
|
opacity: .75
|
||||||
|
strong &
|
||||||
|
font-weight: 400
|
||||||
|
|
||||||
|
.callout
|
||||||
|
color: var(--text-alt)
|
||||||
|
|
||||||
|
.success
|
||||||
|
color: var(--success)
|
||||||
|
|
||||||
|
.error
|
||||||
|
color: var(--error)
|
||||||
|
|
||||||
|
.warning
|
||||||
|
color: var(--warning)
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,13 @@ blockquote
|
||||||
padding-right: 1em
|
padding-right: 1em
|
||||||
border-right: 4px solid var(--border)
|
border-right: 4px solid var(--border)
|
||||||
|
|
||||||
.success
|
|
||||||
color: var(--success)
|
|
||||||
|
|
||||||
.error
|
|
||||||
color: var(--error)
|
|
||||||
|
|
||||||
.callout
|
|
||||||
color: var(--text-alt)
|
|
||||||
|
|
||||||
.message-content
|
.message-content
|
||||||
p
|
p
|
||||||
margin: 4px 0
|
margin: 4px 0
|
||||||
ul
|
ul
|
||||||
margin: 4px 0
|
margin: 4px 0
|
||||||
padding: 0
|
padding: 0
|
||||||
|
padding-inline-start: 1.5em
|
||||||
> li
|
> li
|
||||||
margin: 0
|
margin: 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
|
||||||
@import "constants.sass"
|
@import "constants.sass"
|
||||||
|
|
||||||
|
body
|
||||||
|
margin: 0
|
||||||
|
|
||||||
.content-container
|
.content-container
|
||||||
display: flex
|
display: flex
|
||||||
|
|
@ -18,6 +20,7 @@
|
||||||
|
|
||||||
main
|
main
|
||||||
min-height: 70vh
|
min-height: 70vh
|
||||||
|
margin: 12px auto
|
||||||
|
|
||||||
|
|
||||||
// __ header styles __ //
|
// __ header styles __ //
|
||||||
|
|
@ -28,7 +31,6 @@ header.header
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
height: 3em
|
height: 3em
|
||||||
padding: .75em 1.5em
|
padding: .75em 1.5em
|
||||||
margin: -12px
|
|
||||||
line-height: 1
|
line-height: 1
|
||||||
h1
|
h1
|
||||||
margin: 0
|
margin: 0
|
||||||
|
|
@ -54,6 +56,11 @@ header.header
|
||||||
|
|
||||||
&, > ul, > ul > li:has(.mini-search-bar)
|
&, > ul, > ul > li:has(.mini-search-bar)
|
||||||
flex: 1
|
flex: 1
|
||||||
|
|
||||||
|
ul > li span
|
||||||
|
color: var(--text-primary)
|
||||||
|
font-size: .6em
|
||||||
|
|
||||||
.header-username
|
.header-username
|
||||||
> *
|
> *
|
||||||
display: block
|
display: block
|
||||||
|
|
@ -98,11 +105,14 @@ header.header
|
||||||
// __ aside styles __ //
|
// __ aside styles __ //
|
||||||
aside.card
|
aside.card
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
> :first-child
|
> :is(h1, h2, h3, h4, h5, h6):first-child
|
||||||
background-color: var(--accent)
|
background-color: var(--accent)
|
||||||
padding: 12px
|
padding: 6px 12px
|
||||||
margin: -12px -12px 0 -12px
|
margin: -12px -12px 0 -12px
|
||||||
position: relative
|
position: relative
|
||||||
|
a
|
||||||
|
color: inherit
|
||||||
|
text-decoration: underline
|
||||||
> ul
|
> ul
|
||||||
list-style: none
|
list-style: none
|
||||||
margin: 0
|
margin: 0
|
||||||
|
|
@ -112,6 +122,9 @@ aside.card
|
||||||
padding: 12px
|
padding: 12px
|
||||||
&:last-child
|
&:last-child
|
||||||
border-bottom: none
|
border-bottom: none
|
||||||
|
> p
|
||||||
|
padding: 12px
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
|
||||||
.flash
|
.flash
|
||||||
|
|
@ -132,6 +145,7 @@ ul.inline
|
||||||
list-style: none
|
list-style: none
|
||||||
padding: 0
|
padding: 0
|
||||||
margin: 0
|
margin: 0
|
||||||
|
display: inline
|
||||||
> li
|
> li
|
||||||
display: inline
|
display: inline
|
||||||
&::before
|
&::before
|
||||||
|
|
@ -139,13 +153,29 @@ ul.inline
|
||||||
margin: 0 .5em
|
margin: 0 .5em
|
||||||
&:first-child::before
|
&:first-child::before
|
||||||
content: ''
|
content: ''
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
ul.grid
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr
|
||||||
|
grid-template-rows: auto
|
||||||
|
> li
|
||||||
|
border: 1px solid var(--border)
|
||||||
|
border-radius: .5em
|
||||||
|
padding: .5em
|
||||||
|
margin: 1em .5em
|
||||||
|
text-align: center
|
||||||
|
small
|
||||||
|
display: block
|
||||||
|
|
||||||
|
|
||||||
ul.message-options
|
ul.message-options
|
||||||
color: var(--text-alt)
|
color: var(--text-alt)
|
||||||
list-style: none
|
list-style: none
|
||||||
padding: 0
|
padding: 0
|
||||||
font-size: smaller
|
font-size: smaller
|
||||||
margin-bottom: -4px
|
|
||||||
|
|
||||||
.post-frame
|
.post-frame
|
||||||
margin-left: 3em
|
margin-left: 3em
|
||||||
|
|
@ -156,6 +186,9 @@ ul.message-options
|
||||||
margin-left: 0
|
margin-left: 0
|
||||||
margin-right: 3em
|
margin-right: 3em
|
||||||
|
|
||||||
|
.message-options
|
||||||
|
margin-bottom: 1em
|
||||||
|
|
||||||
.message-stats
|
.message-stats
|
||||||
position: absolute
|
position: absolute
|
||||||
left: -3em
|
left: -3em
|
||||||
|
|
@ -272,11 +305,17 @@ button, [type="submit"], [type="reset"], [type="button"]
|
||||||
|
|
||||||
&.primary
|
&.primary
|
||||||
background-color: var(--accent)
|
background-color: var(--accent)
|
||||||
color: var(--bg-main)
|
color: var(--background)
|
||||||
|
|
||||||
&[disabled]
|
&[disabled]
|
||||||
opacity: .5
|
opacity: .5
|
||||||
cursor: not-allowed
|
cursor: not-allowed
|
||||||
|
border: var(--border)
|
||||||
|
color: var(--border)
|
||||||
|
|
||||||
|
&.primary[disabled]
|
||||||
|
color: var(--background)
|
||||||
|
background-color: var(--border)
|
||||||
|
|
||||||
&:first-child
|
&:first-child
|
||||||
margin-inline-start: 0
|
margin-inline-start: 0
|
||||||
|
|
@ -291,12 +330,74 @@ button, [type="submit"], [type="reset"], [type="button"]
|
||||||
|
|
||||||
.comment-frame
|
.comment-frame
|
||||||
border: 1px solid var(--border)
|
border: 1px solid var(--border)
|
||||||
padding: 12px
|
background: var(--background)
|
||||||
|
padding: 12px 12px 6px
|
||||||
border-radius: 24px
|
border-radius: 24px
|
||||||
border-start-start-radius: 0
|
border-start-start-radius: 0
|
||||||
min-width: 50%
|
min-width: 50%
|
||||||
width: 0
|
width: 0
|
||||||
margin-right: auto
|
margin-inline-end: auto
|
||||||
|
margin-bottom: 12px
|
||||||
|
position: relative
|
||||||
|
&::before
|
||||||
|
content: ''
|
||||||
|
border: 1px solid var(--border)
|
||||||
|
border-inline-end: 0
|
||||||
|
border-bottom: 0
|
||||||
|
background: var(--background)
|
||||||
|
height: 1em
|
||||||
|
width: 1em
|
||||||
|
position: absolute
|
||||||
|
left: calc(-1px - .5em)
|
||||||
|
top: -1px
|
||||||
|
transform: skewX(45deg)
|
||||||
|
li:has(> &)
|
||||||
|
list-style: none
|
||||||
|
|
||||||
|
|
||||||
|
.border-accent
|
||||||
|
border: var(--accent) 1px solid
|
||||||
|
display: inline-flex
|
||||||
|
align-items: center
|
||||||
|
padding: 0 4px
|
||||||
|
|
||||||
|
.round
|
||||||
|
border-radius: 1em
|
||||||
|
|
||||||
|
.done
|
||||||
|
opacity: .5
|
||||||
|
|
||||||
|
button.card
|
||||||
|
width: 100%
|
||||||
|
padding: .5em 1em
|
||||||
|
background-color: transparent
|
||||||
|
border-color: var(--accent)
|
||||||
|
color: var(--accent)
|
||||||
|
border-radius: 1em
|
||||||
|
|
||||||
|
&.primary
|
||||||
|
background-color: var(--accent)
|
||||||
|
color: var(--background)
|
||||||
|
|
||||||
|
.big_icon
|
||||||
|
display: block
|
||||||
|
margin: 12px auto
|
||||||
|
font-size: 36px
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
|
||||||
|
textarea.create_text
|
||||||
|
min-height: 8em
|
||||||
|
|
||||||
|
// specificity ew //
|
||||||
|
form.boundaryless &
|
||||||
|
min-height: 8em
|
||||||
|
|
||||||
|
\:is(input, select, textarea).fullwidth
|
||||||
|
width: 100%
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
|
||||||
|
label:has([type="checkbox"]:not(:checked))
|
||||||
|
opacity: .75
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,70 @@
|
||||||
.content-nav, .content-main
|
.content-nav, .content-main
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
|
ul.grid
|
||||||
|
grid-template-columns: 1fr 1fr
|
||||||
|
|
||||||
|
.nomobile
|
||||||
|
display: none !important
|
||||||
|
|
||||||
|
body
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
footer.mobile-nav
|
||||||
|
position: sticky
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
overflow: hidden
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
background-color: var(--background)
|
||||||
|
box-shadow: 0 0 6px var(--border)
|
||||||
|
z-index: 150
|
||||||
|
|
||||||
|
> ul
|
||||||
|
display: flex
|
||||||
|
list-style: none
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
flex-direction: row
|
||||||
|
align-items: stretch
|
||||||
|
justify-content: stretch
|
||||||
|
> li
|
||||||
|
flex: 1
|
||||||
|
padding: .5em
|
||||||
|
margin: 0
|
||||||
|
text-align: center
|
||||||
|
a
|
||||||
|
text-decoration: none
|
||||||
|
.icon
|
||||||
|
font-size: 2rem
|
||||||
|
|
||||||
|
.content-nav
|
||||||
|
margin: 1em
|
||||||
|
width: unset
|
||||||
|
|
||||||
|
header.header h1
|
||||||
|
margin-top: 4px
|
||||||
|
margin-left: 6px
|
||||||
|
|
||||||
|
.content-header
|
||||||
|
text-align: center
|
||||||
|
|
||||||
|
.big-search-bar form
|
||||||
|
flex-direction: column
|
||||||
|
|
||||||
|
[type="submit"]
|
||||||
|
width: unset
|
||||||
|
margin: 12px auto
|
||||||
|
|
||||||
@media screen and (max-width: 960px)
|
@media screen and (max-width: 960px)
|
||||||
.header-username
|
.header-username
|
||||||
display: none
|
display: none
|
||||||
|
|
||||||
header.header
|
header.header
|
||||||
|
padding: .5em .5em
|
||||||
|
|
||||||
.mini-search-bar
|
.mini-search-bar
|
||||||
display: none
|
display: none
|
||||||
|
|
||||||
|
|
@ -19,3 +78,10 @@
|
||||||
|
|
||||||
ul > li:has(.mini-search-bar)
|
ul > li:has(.mini-search-bar)
|
||||||
flex: unset
|
flex: unset
|
||||||
|
|
||||||
|
|
||||||
|
// not mobile: //
|
||||||
|
|
||||||
|
@media screen and (min-width: 801px)
|
||||||
|
.mobileonly
|
||||||
|
display: none !important
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
<title>X _ X; on {{ app_name }}</title>
|
{{ title_tag('X _ X') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
<title>O _ O; on {{ app_name }}</title>
|
{{ title_tag('O _ O') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
<title>O _ O; on {{ app_name }}</title>
|
{{ title_tag('O _ O') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,9 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>Stats</h2>
|
{% filter to_markdown %}
|
||||||
<ul>
|
{% include "about.md" %}
|
||||||
<li>No. of posts: <strong>{{ post_count }}</strong></li>
|
{% endfilter %}
|
||||||
<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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
25
freak/templates/about.md
Normal file
25
freak/templates/about.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
* \# of posts: **{{ post_count }}**
|
||||||
|
* \# of active users (posters in the last 30 days): **{{ user_count }}**
|
||||||
|
|
||||||
|
## Software versions
|
||||||
|
|
||||||
|
* **Python**: {{ python_version }}
|
||||||
|
* **SQLAlchemy**: {{ sa_version }}
|
||||||
|
* **Quart**: {{ quart_version }}
|
||||||
|
* **{{ app_name }}**: {{ app_version }}
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Source code is available at: <https://nekode.yusur.moe/yusur/freak>
|
||||||
|
|
||||||
|
{% if impressum %}
|
||||||
|
## Legal Contacts
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ impressum }}
|
||||||
|
```
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
10
freak/templates/admin/400.html
Normal file
10
freak/templates/admin/400.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="centered">
|
||||||
|
<h2>Bad Request</h2>
|
||||||
|
|
||||||
|
<p><a href="/">Back to homepage.</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
freak/templates/admin/403.html
Normal file
12
freak/templates/admin/403.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="centered">
|
||||||
|
<h2>Access Denied</h2>
|
||||||
|
|
||||||
|
<p><a href="/">Back to homepage.</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
12
freak/templates/admin/404.html
Normal file
12
freak/templates/admin/404.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="centered">
|
||||||
|
<h2>Not Found</h2>
|
||||||
|
|
||||||
|
<p><a href="/admin/">Back</a></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
11
freak/templates/admin/500.html
Normal file
11
freak/templates/admin/500.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<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 %}
|
||||||
|
|
||||||
|
|
@ -5,22 +5,24 @@
|
||||||
{{ title_tag("Admin") }}
|
{{ title_tag("Admin") }}
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/style.css">
|
<link rel="stylesheet" type="text/css" href="/admin/style.css">
|
||||||
<style>.done{opacity:.5}</style>
|
{% for private_style in private_styles %}
|
||||||
|
<link rel="stylesheet" href="{{ private_style }}" />
|
||||||
|
{% endfor %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="admin {{ theme_classes(current_user.color_theme) }}">
|
||||||
<div class="header">
|
<header class="header">
|
||||||
<h1><a href="{{ url_for('admin.homepage') }}">{{ site_name }} Admin</a></h1>
|
<h1><span class="faint">{{ app_name }}:</span> <a href="{{ url_for('admin.homepage') }}">Admin</a></h1>
|
||||||
</div>
|
</header>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% for message in get_flashed_messages() %}
|
{% for message in get_flashed_messages() %}
|
||||||
<div class="flash">{{ message }}</div>
|
<div class="flash">{{ message }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<footer class="footer">
|
||||||
<p><a href="/">Back to {{ app_name }}</a>.</p>
|
<p><a href="/">Back to {{ app_name }}</a>.</p>
|
||||||
</div>
|
</footer>
|
||||||
<script src="/static/lib.js"></script>
|
<script src="/static/js/lib.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
{% extends "admin/admin_base.html" %}
|
{% extends "admin/admin_base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<ul>
|
<ul class="grid">
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('admin.reports') }}">Reports</a>
|
<h2><a href="{{ url_for('admin.reports') }}">Reports</a></h2>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h2><a href="{{ url_for('admin.strikes') }}">Strikes</a></h2>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h2><a href="{{ url_for('admin.users') }}">Users</a></h2>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{% extends "admin/admin_base.html" %}
|
{% extends "admin/admin_base.html" %}
|
||||||
{% from "macros/embed.html" import embed_post with context %}
|
{% from "macros/embed.html" import embed_post with context %}
|
||||||
|
{% from "macros/icon.html" import icon, callout with context %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Report detail #{{ report.id }}</h2>
|
<h2>Report detail #{{ report.id }}</h2>
|
||||||
|
|
@ -14,10 +15,20 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<p><i>Unknown media type</i></p>
|
<p><i>Unknown media type</i></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if report.is_critical() %}
|
||||||
|
{% call callout('nsfw_language') %}
|
||||||
|
This is a critical offense. “Strike” will immediately suspend the offender's account.
|
||||||
|
{% endcall %}
|
||||||
|
{% endif %}
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<button type="submit" name="do" value="0">Reject</button>
|
<button type="submit" name="do" value="0">Reject</button>
|
||||||
|
{% if report.is_critical() %}
|
||||||
|
<button type="submit" name="do" value="2" class="primary">Strike</button>
|
||||||
|
{% else %}
|
||||||
<button type="submit" name="do" value="1" class="primary">Remove</button>
|
<button type="submit" name="do" value="1" class="primary">Remove</button>
|
||||||
<button type="submit" name="do" value="2">Put on hold</button>
|
<button type="submit" name="do" value="2">Strike</button>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" name="do" value="3">Put on hold</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
20
freak/templates/admin/admin_strikes.html
Normal file
20
freak/templates/admin/admin_strikes.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<ul>
|
||||||
|
{% for strike in strike_list %}
|
||||||
|
<li>
|
||||||
|
<p><strong>#{{ strike.id | to_cb32 }}</strong> to {{ strike.user.handle() }}</p>
|
||||||
|
<ul class="inline">
|
||||||
|
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
|
||||||
|
<!-- you might not want to see why -->
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if strike_list.has_next %}
|
||||||
|
{{ stop_scrolling(strike_list.page) }}
|
||||||
|
{% else %}
|
||||||
|
{{ no_more_scrolling(strike_list.page) }}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
62
freak/templates/admin/admin_user_detail.html
Normal file
62
freak/templates/admin/admin_user_detail.html
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
{% from "macros/icon.html" import callout with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2><span class="faint">User:</span> {{ u.handle() }}</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})</li>
|
||||||
|
<li>Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}</li>
|
||||||
|
<li>Registered from IP address: {{ u.joined_ip }}</li>
|
||||||
|
<li>Status: {{ account_status_string(u) }}</li>
|
||||||
|
<li>Karma: {{ u.karma }}</li>
|
||||||
|
{% if u.email %}
|
||||||
|
<li>E-mail: {{ u.email }}</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if u.banned_at %}
|
||||||
|
{% call callout('spoiler', 'error') %}
|
||||||
|
{{ u.handle() }} is suspended
|
||||||
|
{% if u.banned_until %}until {{ u.banned_until.strftime("%B %d, %Y %H:%M %z") }}{% else %}indefinitely{% endif %}.
|
||||||
|
{% if u.banned_message %}<br />Ban message: “{{ u.banned_message }}”{% endif %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- quick actions -->
|
||||||
|
<h3>Quick Actions</h3>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<select name="reason">
|
||||||
|
<option selected value="0">(Select a reason)</option>
|
||||||
|
<option value="100">Multiple violations</option>
|
||||||
|
{% for k, v in report_reasons.items() %}
|
||||||
|
<option value="{{ k }}">{{ v }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<br />
|
||||||
|
{% if u.banned_at %}
|
||||||
|
<button type="submit" name="do" value="unsuspend">Remove suspension</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="do" value="suspend">Suspend</button>
|
||||||
|
<button type="submit" name="do" value="to_3d">Time-out (3 days)</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Strikes</h3>
|
||||||
|
|
||||||
|
{% if strikes %}
|
||||||
|
<ul>
|
||||||
|
{% for strike in strikes %}
|
||||||
|
<li>
|
||||||
|
<p><strong>#{{ strike.id | to_cb32 }}</strong></p>
|
||||||
|
<ul class="inline">
|
||||||
|
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
|
||||||
|
<li><span class="spoiler">{{ strike.text_content }}</span></li>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="centered success">{{ u.handle() }} is all good!</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
30
freak/templates/admin/admin_users.html
Normal file
30
freak/templates/admin/admin_users.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends "admin/admin_base.html" %}
|
||||||
|
{% from "macros/feed.html" import stop_scrolling, no_more_scrolling with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<ul>
|
||||||
|
{% for u in user_list %}
|
||||||
|
<li>
|
||||||
|
<p><a href="/admin/users/{{ u.id | to_b32l }}">{{ u.handle() }}</a> (#{{ u.id | to_b32l }})
|
||||||
|
{%- if u.is_administrator %}
|
||||||
|
<span>(Admin)</span>
|
||||||
|
{% endif -%}
|
||||||
|
{% if u == current_user.user %}
|
||||||
|
<span>(You)</span>
|
||||||
|
{% endif -%}
|
||||||
|
</p>
|
||||||
|
<ul class="inline">
|
||||||
|
<li>Age: {{ u.age() }} years old ({{ u.gdpr_birthday.strftime("%B %d, %Y") }})</li>
|
||||||
|
<li>Registered at: {{ u.joined_at.strftime("%B %d, %Y %H:%M %z") }}</li>
|
||||||
|
<li>Registered from IP address: {{ u.joined_ip }}</li>
|
||||||
|
<li>Status: {{ account_status_string(u) }}</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if user_list.has_next %}
|
||||||
|
{{ stop_scrolling(user_list.page) }}
|
||||||
|
{% else %}
|
||||||
|
{{ no_more_scrolling(user_list.page) }}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
{% from "macros/icon.html" import icon with context %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
<title>{{ app_name }}</title>
|
<title>{{ app_name }}</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
This Service is available "AS IS", with NO WARRANTY, explicit or implied.
|
This Service is available "AS IS", with NO WARRANTY, explicit or implied.
|
||||||
Sakuragasaki46 is NOT legally liable for Your use of the Service.
|
Sakuragasaki46 is NOT legally liable for Your use of the Service.
|
||||||
This service is age-restricted; do not access if underage.
|
This service is age-restricted; do not access if underage.
|
||||||
More info: https://{{ domain_name }}/terms
|
More info: https://{{ server_name }}/terms
|
||||||
-->
|
-->
|
||||||
|
<meta name="og:site_name" content="{{ app_name }}" />
|
||||||
|
<meta name="generator" content="{{ app_name }} {{ app_version }}" />
|
||||||
<meta name="csrf_token" content="{{ csrf_token() }}">
|
<meta name="csrf_token" content="{{ csrf_token() }}">
|
||||||
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
|
<link rel="stylesheet" href="{{ url_for_css('style') }}" />
|
||||||
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
|
{# psa: icons url MUST be supplied by .env via PRIVATE_ASSETS= #}
|
||||||
|
|
@ -23,7 +25,7 @@
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<script src="{{ jquery_url }}"></script>
|
<script src="{{ jquery_url }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body {% if current_user and current_user.color_theme %} class="{{ theme_classes(current_user.color_theme) }}"{% endif %}>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1><a href="/">{{ app_name }}</a></h1>
|
<h1><a href="/">{{ app_name }}</a></h1>
|
||||||
<div class="metanav">
|
<div class="metanav">
|
||||||
|
|
@ -35,37 +37,41 @@
|
||||||
<input type="search" name="q" placeholder="Search among {{ post_count }} posts…">
|
<input type="search" name="q" placeholder="Search among {{ post_count }} posts…">
|
||||||
<input type="submit" value="Search">
|
<input type="submit" value="Search">
|
||||||
</form>
|
</form>
|
||||||
<a href="/search">
|
<a href="/search" aria-label="Search" title="Search">
|
||||||
<i class="icon icon-search"></i><span class="a11y">search</span>
|
{{ icon('search') }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.no_user %}
|
{% if g.no_user %}
|
||||||
<!-- no user -->
|
<!-- no user -->
|
||||||
{% elif current_user.is_authenticated %}
|
{% elif current_user %}
|
||||||
<li><a href="/create" title="Create a post">
|
<li class="nomobile">
|
||||||
<i class="icon icon-add"></i>
|
<a class="round border-accent" href="{{ url_for('create.create', on=current_guild.name) if current_guild and current_guild.allows_posting(current_user.user) else '/create/' }}" title="Create a post" aria-label="Create a post">
|
||||||
<span class="a11y">create</span>
|
{{ icon('add') }}
|
||||||
</a></li><li><a href="{{ current_user.url() }}"
|
<span>New post</span>
|
||||||
title="@{{ current_user.username }}'s profile">
|
</a>
|
||||||
<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>
|
||||||
<li><a href="/logout" title="Log out">
|
{% if current_user.is_administrator %}
|
||||||
<i class="icon icon-logout"></i><span class="a11y">log out</span>
|
<li class="nomobile">
|
||||||
|
<a href="/admin" title="Admin Tools" aria-label="Admin Tools">
|
||||||
|
{{ icon('mod') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a href="{{ current_user.url() }}" title="{{ current_user.handle() }}'s profile" aria-label="User profile">{{ icon('profile') }}</a>
|
||||||
|
<div class="header-username">
|
||||||
|
<strong class="header-username-name">{{ current_user.handle() }}</strong>
|
||||||
|
<span class="header-username-karma">{{ icon('karma') }} {{ current_user.karma }} karma</span>
|
||||||
|
</div></li>
|
||||||
|
<li><a href="/logout" title="Log out" aria-label="Log out">
|
||||||
|
{{ icon('logout') }}
|
||||||
</a></li>
|
</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="/login" title="Log in">
|
<li><a href="/login" title="Log in" aria-label="Log in">
|
||||||
<i class="icon icon-logout"></i>
|
{{ icon('logout') }}
|
||||||
<span class="a11y">log in</span>
|
</a></li>
|
||||||
</a></li><li><a href="/register" title="Register">
|
<li><a href="/register" title="Create account" aria-label="Create account">
|
||||||
<i class="icon icon-join"></i>
|
{{ icon('join') }}
|
||||||
<span class="a11y">register</span>
|
|
||||||
</a></li>
|
</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -75,6 +81,7 @@
|
||||||
{% for message in get_flashed_messages() %}
|
{% for message in get_flashed_messages() %}
|
||||||
<div class="flash card">{{ message }}</div>
|
<div class="flash card">{{ message }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<script>document.write('<div class="flash card">The old HTTP-only frontend is deprecated. Please use the new Svelte frontend.</div>');</script>
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
{% block heading %}{% endblock %}
|
{% block heading %}{% endblock %}
|
||||||
|
|
@ -98,13 +105,24 @@
|
||||||
<li><a href="https://github.com/sakuragasaki46/freak">GitHub</a></li>
|
<li><a href="https://github.com/sakuragasaki46/freak">GitHub</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% if current_user %}
|
||||||
|
<footer class="mobile-nav mobileonly">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" title="Homepage">{{ icon('home') }}</a></li>
|
||||||
|
<li><a href="/search" title="Search">{{ icon('search') }}</a></li>
|
||||||
|
<li><a href="/create" title="Create">{{ icon('add') }}</a></li>
|
||||||
|
<li><a href="{{ current_user.url() }}" title="Messages">{{ icon('message') }}</a></li>
|
||||||
|
<li><a href="https://trollface.dk" title="Notifications">{{ icon('notification') }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
function changeAccentColorTime() {
|
function changeAccentColorTime() {
|
||||||
let hours = (new Date).getHours();
|
let hours = (new Date).getHours();
|
||||||
if (hours < 6 || hours >= 19) {
|
if (hours < 6 || hours >= 19) {
|
||||||
document.body.style.setProperty('--accent', '#1871d8');
|
document.body.classList.add('night');
|
||||||
} else {
|
} else {
|
||||||
document.body.style.removeProperty('--accent');
|
document.body.classList.remove('night');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changeAccentColorTime();
|
changeAccentColorTime();
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,21 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div>
|
<div>
|
||||||
<p>Posting as <strong>{{ current_user.handle() }}</strong></p>
|
<p>Posting as <strong>{{ current_user.handle() }}</strong></p>
|
||||||
<p>Post to: <input type="text" name="to" placeholder="{{ current_user.handle() }}"></p>
|
<p>Post to: <input type="text" name="to" placeholder="{{ current_user.handle() }}" value="{{ sv_target }}"></p>
|
||||||
<div>
|
<div>
|
||||||
<span class="a11y">Title:</span><input type="text" name="title" placeholder="An interesting title" maxlength="256">
|
<input type="text" name="title" placeholder="An interesting title" maxlength="256" class="fullwidth" value="{{ sv_title }}" />
|
||||||
</div>
|
</div>
|
||||||
|
<hr />
|
||||||
<div>
|
<div>
|
||||||
<span class="a11y">Text:</span>
|
<textarea name="text" placeholder="What's happening ~" class="create_text fullwidth">{{ sv_content }}</textarea>
|
||||||
<textarea name="text" placeholder="What's happening?" class="create_text">{{ request.args['preload'] }}</textarea>
|
|
||||||
</div>
|
</div>
|
||||||
{#<dd id="fileInputContainer"><a href="javascript:attachFileInput();">Add a file...</a>#}
|
{#<dd id="fileInputContainer"><a href="javascript:attachFileInput();">Add a file...</a>#}
|
||||||
<div>{{ privacy_select() }}</div>
|
<div>{{ privacy_select(sv_privacy) }}</div>
|
||||||
<div><button type="submit" class="primary">Create</button></div>
|
<div><button type="submit" class="primary">Create</button></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<form action="{{ url_for('create.createguild') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
<form action="{{ url_for('create.createguild') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div>
|
<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>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>
|
<p><small class="faint">Must be alphanumeric and unique. <strong>May not be changed later</strong>: choose wisely!</small></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros/title.html" import title_tag with context %}
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
{% from "macros/create.html" import privacy_select with context %}
|
{% from "macros/create.html" import privacy_select with context %}
|
||||||
|
{% from "macros/icon.html" import icon, callout with context %}
|
||||||
|
|
||||||
{% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %}
|
{% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %}
|
||||||
|
|
||||||
|
|
@ -13,13 +14,14 @@
|
||||||
<form action="{{ url_for('edit.edit_post', id=p.id) }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
<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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div>
|
<div>
|
||||||
<span class="a11y">Text:</span>
|
<textarea name="text" placeholder="What's happening?" class="create_text fullwidth">{{ p.text_content }}</textarea></dd>
|
||||||
<textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>{{ privacy_select(p.privacy) }}</div>
|
<div>{{ privacy_select(p.privacy) }}</div>
|
||||||
<div><input type="submit" value="Save" /></dd>
|
<div>
|
||||||
|
<input type="submit" value="Save" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="error">{{ icon('delete') }} <a href="/delete/post/{{ p.id | to_b32l }}">Delete post</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
{% 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/title.html" import title_tag with context %}
|
||||||
|
{% from "macros/nav.html" import nav_guild, nav_top_communities 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 %}
|
{% block title %}
|
||||||
{{ title_tag(feed_title) }}
|
{{ title_tag(feed_title) }}
|
||||||
|
|
@ -16,16 +13,14 @@
|
||||||
|
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
{% if top_communities %}
|
{% if top_communities %}
|
||||||
{% from "macros/nav.html" import nav_top_communities with context %}
|
|
||||||
{{ nav_top_communities(top_communities) }}
|
{{ nav_top_communities(top_communities) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if feed_type == 'topic' %}
|
{% if feed_type == 'guild' %}
|
||||||
{% from "macros/nav.html" import nav_topic with context %}
|
{{ nav_guild(guild) }}
|
||||||
{{ nav_topic(topic) }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<aside class="card">
|
<aside class="card nomobile">
|
||||||
<h3>Don’t miss a post!</h3>
|
<h3>Don’t miss a post!</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong><a id="notificationEnabler" href="#">Enable notifications</a></strong> to continue staying with us 😉</li>
|
<li><strong><a id="notificationEnabler" href="#">Enable notifications</a></strong> to continue staying with us 😉</li>
|
||||||
|
|
@ -48,8 +43,6 @@
|
||||||
{{ no_more_scrolling(l.page) }}
|
{{ no_more_scrolling(l.page) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{# TODO: pagination #}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|
|
||||||
95
freak/templates/guildsettings.html
Normal file
95
freak/templates/guildsettings.html
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/icon.html" import icon with context %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
{% from "macros/create.html" import checked_if with context %}
|
||||||
|
|
||||||
|
{% block title %}{{ title_tag('Settings for ' + gu.handle()) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
<h1><span class="faint">Settings:</span> <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if gu.owner == None and current_user.is_administrator %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="transfer_owner" value="{{ current_user.username }}" />
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="error">{{ icon('spoiler') }} {{ gu.handle() }} is <u>unmoderated</u></h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="primary">Claim ownership</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<section class="card">
|
||||||
|
<h2>Community Identity</h2>
|
||||||
|
<div>
|
||||||
|
<label>Display name:
|
||||||
|
<input type="text" name="display_name" value="{{ gu.display_name or '' }}" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Description:
|
||||||
|
<textarea name="description">{{ gu.description or '' }}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Safety</h2>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="restricted" value="1" {{ checked_if(gu.is_restricted) }} />
|
||||||
|
Allow only approved members to post and comment
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
Ban user from participating in {{ gu.handle() }}:
|
||||||
|
<input type="text" name="exile_name" placeholder="username" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="exile_reverse" value="1" />
|
||||||
|
Remove ban on given user
|
||||||
|
</label>
|
||||||
|
<small class="faint">
|
||||||
|
Bans (aka: exiles) are permanent and reversible.<br />
|
||||||
|
Banned (exiled) users are not allowed to post or comment on {{ gu.handle() }}.<br />
|
||||||
|
Reverse the ban by checking “Remove ban on given user”.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Management</h2>
|
||||||
|
<!-- TODO: make moderation consensual -->
|
||||||
|
{% if gu.owner == current_user.user or current_user.is_administrator %}
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
Add user as moderator:
|
||||||
|
<input type="text" name="moderator_name" placeholder="username" />
|
||||||
|
</label><br />
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="moderator_consent" value="1" autocomplete="off" />
|
||||||
|
<u>I understand that new moderators may damage my community and the above user is trusted</u>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
27
freak/templates/macros/button.html
Normal file
27
freak/templates/macros/button.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
|
||||||
|
|
||||||
|
{% from "macros/icon.html" import icon with context%}
|
||||||
|
|
||||||
|
{% macro block_button(target, blocked = False) %}
|
||||||
|
<form method="POST" action="{{ target.url() }}/block">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
{% if blocked %}
|
||||||
|
<input type="hidden" name="reverse" value="1" />
|
||||||
|
<button type="submit" class="card">{{ icon('self') }} Remove block</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="card">{{ icon('block') }} Block</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro subscribe_button(target, subbed = False) %}
|
||||||
|
<form method="POST" action="{{ target.url() }}/subscribe">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
{% if subbed %}
|
||||||
|
<input type="hidden" name="reverse" value="1" />
|
||||||
|
<button type="submit" class="card">{{ icon('leave') }} Unsubscribe</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="card">{{ icon('join') }} Subscribe</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
|
||||||
|
{% from "macros/icon.html" import icon with context %}
|
||||||
|
|
||||||
{% macro checked_if(cond) %}
|
{% macro checked_if(cond) %}
|
||||||
{% if cond -%}
|
{% if cond -%}
|
||||||
checked=""
|
checked=""
|
||||||
|
|
@ -12,16 +14,24 @@ disabled=""
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro privacy_select(value = 0) %}
|
{% macro privacy_select(value = 0) %}
|
||||||
<ul>
|
<ul class="grid">
|
||||||
<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="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >{{ icon('topic_travel') }} 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="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >{{ icon('link_post') }} 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="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >{{ icon('custom_feed') }} 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>
|
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >{{ icon('lock') }} Only you <small class="faint">(nobody else)</small></label></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro comment_area(url) %}
|
{% macro comment_area(p) %}
|
||||||
<form id="comment-area" class="boundaryless" action="{{ url }}" method="POST" enctype="multipart/form-data">
|
{% if current_user.is_authenticated %}
|
||||||
|
{% if current_user.is_disabled %}
|
||||||
|
<div class="centered">Your account is suspended</div>
|
||||||
|
{% elif current_guild and not current_guild.allows_posting(current_user.user) %}
|
||||||
|
<div class="centered">This community allows only its members to post and comment</div>
|
||||||
|
{% elif p.is_locked %}
|
||||||
|
<div class="centered">Comments are closed</div>
|
||||||
|
{% else %}
|
||||||
|
<form id="comment-area" class="boundaryless" action="{{ p.url() }}" method="POST" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<input type="hidden" name="reply_to" value="" />
|
<input type="hidden" name="reply_to" value="" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -32,4 +42,8 @@ disabled=""
|
||||||
<button type="submit" class="primary">Publish</button>
|
<button type="submit" class="primary">Publish</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="centered"><a href="/login">Log in</a> to leave a comment</div>
|
||||||
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{% macro embed_post(p) %}
|
{% macro embed_post(p) %}
|
||||||
<div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}">
|
<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>
|
<h3 class="message-title"><a href="/={{ p.id | to_b32l }}">{{ p.title }}</a></h3>
|
||||||
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||||
{% if p.parent_post %}
|
{% if p.parent_post %}
|
||||||
as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a>
|
as a comment on <a href="{{ p.parent_post.url() }}">post “{{ p.parent_post.title }}”</a>
|
||||||
{% elif p.topic %}
|
{% elif p.guild %}
|
||||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
on their user page
|
on their user page
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,16 @@
|
||||||
<div id="post-{{ p.id | to_b32l }}" class="post-frame" data-endpoint="{{ p.id | to_b32l }}">
|
<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>
|
<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>
|
<div class="message-meta">Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||||
{% if p.topic %}
|
{% if p.guild %}
|
||||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
on <a href="{{ p.guild.url() }}">{{ p.guild.handle() }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
on their user page
|
on their user page
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-stats">
|
<div class="message-stats">
|
||||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }}
|
||||||
{{ comment_count(p.comments | count) }}
|
{{ comment_count(p.comment_count()) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-content shorten">
|
<div class="message-content shorten">
|
||||||
|
|
@ -26,22 +26,22 @@
|
||||||
{% macro feed_upvote(postid, count, uservote=0) %}
|
{% macro feed_upvote(postid, count, uservote=0) %}
|
||||||
<div class="upvote-button" data-endpoint="{{ postid|to_b32l }}">
|
<div class="upvote-button" data-endpoint="{{ postid|to_b32l }}">
|
||||||
{% if uservote > 0 %}
|
{% if uservote > 0 %}
|
||||||
<a href="javascript:void 0" class="upvote-button-up active">
|
<a href="javascript:void 0" class="upvote-button-up active" aria-label="upvoted">
|
||||||
<i class="icon icon-upvote_fill"></i><span class="a11y">upvoted</span>
|
{{ icon('upvote', True) }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="javascript:void 0" class="upvote-button-up">
|
<a href="javascript:void 0" class="upvote-button-up" aria-label="upvote">
|
||||||
<i class="icon icon-upvote"></i><span class="a11y">upvote</span>
|
{{ icon('upvote') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<strong class="upvote-count">{{ count }}</strong>
|
<strong class="upvote-count">{{ count }}</strong>
|
||||||
{% if uservote < 0 %}
|
{% if uservote < 0 %}
|
||||||
<a href="javascript:void 0" class="upvote-button-down active">
|
<a href="javascript:void 0" class="upvote-button-down active" aria-label="downvoted">
|
||||||
<i class="icon icon-downvote_fill"></i><span class="a11y">downvoted</span>
|
{{ icon('downvote', True) }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="javascript:void 0" class="upvote-button-down">
|
<a href="javascript:void 0" class="upvote-button-down" aria-label="downvote">
|
||||||
<i class="icon icon-downvote"></i><span class="a11y">downvote</span>
|
{{ icon('downvote') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -53,12 +53,12 @@
|
||||||
{% call callout('delete') %}<i>Removed comment</i>{% endcall %}
|
{% call callout('delete') %}<i>Removed comment</i>{% endcall %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="message-meta">
|
<div class="message-meta">
|
||||||
{% if comment.author %}
|
{% if comment.author_id %}
|
||||||
<a href="{{ comment.author.url() }}">{{ comment.author.handle() }}</a>
|
<a href="{{ comment.author.url() }}">{{ comment.author.handle() }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i>deleted account</i>
|
<i>deleted account</i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if comment.author and comment.author == comment.parent_post.author %}
|
{% if comment.author and comment.author_id == comment.parent_post.author_id %}
|
||||||
<span class="faint">(OP)</span>
|
<span class="faint">(OP)</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# TODO add is_distinguished i.e. official comment #}
|
{# TODO add is_distinguished i.e. official comment #}
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
{{ comment.text_content | to_markdown }}
|
{{ comment.text_content | to_markdown }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="message-options inline">
|
<ul class="message-options inline">
|
||||||
{% if comment.author == current_user %}
|
{% if comment.author_id == current_user.id %}
|
||||||
{# TODO add comment edit link #}
|
{# TODO add comment edit link #}
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li>
|
<li><a href="{{ comment.report_url() }}">{{ icon('report') }} Report</a></li>
|
||||||
|
|
@ -86,9 +86,8 @@
|
||||||
|
|
||||||
{% macro comment_count(c) %}
|
{% macro comment_count(c) %}
|
||||||
<div class="comment-count">
|
<div class="comment-count">
|
||||||
<a><i class="icon icon-comment"></i></a>
|
<a aria-label="Comments">{{ icon('comment') }}</a>
|
||||||
<strong>{{ c }}</strong>
|
<strong>{{ c }}</strong>
|
||||||
<span class="a11y">comments</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
|
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro callout(useicon = "spoiler") %}
|
{% macro callout(useicon = "spoiler", classes = "") %}
|
||||||
<div class="callout">
|
<div class="callout {{ classes }}">
|
||||||
{{ icon(useicon) }}
|
{{ icon(useicon) }}
|
||||||
{{ caller() }}
|
{{ caller() }}
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro big_icon(name, fill = False) %}
|
||||||
|
<div class="big_icon">{{ icon(name, fill) }}</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -1,24 +1,73 @@
|
||||||
|
|
||||||
{% macro nav_topic(topic) %}
|
{% from "macros/icon.html" import icon with context %}
|
||||||
|
{% from "macros/button.html" import block_button, subscribe_button with context %}
|
||||||
|
|
||||||
|
{% macro nav_guild(gu) %}
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
<h3>About {{ topic.handle() }}</h3>
|
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ topic.description }}</li>
|
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{{ topic.posts | count }}</strong> posts -
|
<strong>{{ gu.post_count() }}</strong> posts -
|
||||||
<strong>-</strong> subscribers
|
<strong>{{ gu.subscriber_count() }}</strong> subscribers
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{% if current_user.moderates(gu) %}
|
||||||
|
<a href="{{ gu.url() }}/settings"><button class="card">{{ icon('settings') }} Mod Tools</button></a>
|
||||||
|
{% endif %}
|
||||||
|
{{ subscribe_button(gu, gu.has_subscriber(current_user.user)) }}
|
||||||
|
{% if not gu.owner_id %}
|
||||||
|
<aside class="card">
|
||||||
|
<p class="centered">{{ gu.handle() }} is currently unmoderated</p>
|
||||||
|
</aside>
|
||||||
|
{% elif gu.has_exiled(current_user.user) %}
|
||||||
|
<aside class="card">
|
||||||
|
<p class="centered">Moderator list is hidden because you are banned.</p>
|
||||||
|
<!-- TODO appeal button -->
|
||||||
|
</aside>
|
||||||
|
{% else %}
|
||||||
|
<aside class="card">
|
||||||
|
<h3>Moderators of {{ gu.handle() }}</h3>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
{% for moder in gu.moderators() %}
|
||||||
|
<li><a href="{{ moder.user.url() }}">{{ moder.user.handle() }}</a>
|
||||||
|
{% if moder.is_owner %}
|
||||||
|
<span>{{ icon('mod_mode') }} <small>Owner</small></span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro nav_user(user) %}
|
{% macro nav_user(user) %}
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
<h3>About {{ user.handle() }}</h3>
|
<h3>About <a href="{{ user.url() }}">{{ user.display_name or user.handle() }}</a></h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>{# user.biography #}</li>
|
{% if user.biography %}
|
||||||
|
<li>{{ icon('info') }} {{ user.biography }}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated and current_user.age() >= 18 and user.age() < 18 %}
|
||||||
|
<li class="error">{{ icon('spoiler') }} MINOR</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
{% if user == current_user.user %}
|
||||||
|
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
|
||||||
|
{% elif current_user.is_authenticated %}
|
||||||
|
{{ block_button(user, current_user.has_blocked(user)) }}
|
||||||
|
{{ subscribe_button(user, user.has_subscriber(current_user.user)) }}
|
||||||
|
{% else %}
|
||||||
|
<aside class="card">
|
||||||
|
<p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro nav_top_communities(top_communities) %}
|
{% macro nav_top_communities(top_communities) %}
|
||||||
|
|
@ -26,9 +75,9 @@
|
||||||
<h3>Top Communities</h3>
|
<h3>Top Communities</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{% for comm, pcnt, scnt in top_communities %}
|
{% 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>
|
<li><strong><a href="/+{{ comm }}">+{{ comm }}</a></strong> - <strong>{{ pcnt }}</strong> posts - <strong>{{ scnt }}</strong> subscribers</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if current_user and current_user.is_authenticated and current_user.can_create_community() %}
|
{% if current_user and current_user.can_create_community() %}
|
||||||
<li>Can’t find your community? <a href="/createcommunity">Create a new one.</a></li>
|
<li>Can’t find your community? <a href="/createcommunity">Create a new one.</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -6,130 +6,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% filter to_markdown %}
|
{% filter to_markdown %}
|
||||||
# Privacy Policy
|
{% include "privacy.md" %}
|
||||||
|
|
||||||
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 %}
|
{% endfilter %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
124
freak/templates/privacy.md
Normal file
124
freak/templates/privacy.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
<label>{{ icon('calendar') }} Date of birth:</label>
|
<label>{{ icon('calendar') }} Date of birth:</label>
|
||||||
<input type="date" name="birthday"><br>
|
<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>
|
<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 }}. -->
|
<!-- You must be 14 years old or older to register on {{ app_name }}. You can try to evade the limits, but fuck around and find out -->
|
||||||
</div>
|
</div>
|
||||||
{% if not current_user.is_anonymous %}
|
{% if not current_user.is_anonymous %}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
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{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.primary{background-color:var(--ac);color:var(--fg)}
|
||||||
button:hover{opacity:1;transition:2s ease;}
|
button:hover{opacity:1;transition:2s ease;}
|
||||||
@media (prefers-color-scheme:dark){body{color:var(--fg);background-color:var(--bg)}}
|
@media (prefers-color-scheme:dark){body{color:var(--fg);background-color:var(--bg)} }
|
||||||
footer{font-size:smaller;text-align:center;}
|
footer{font-size:smaller;text-align:center;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
|
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<li class="faint">
|
||||||
|
<a href="{{ back_to_url or 'javascript:history.go(-1);' }}">I clicked "Report" by mistake</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
|
<a href="?reason={{ opt.code |urlencode }}">{{ opt.description }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<li class="faint">
|
||||||
|
<a href="{{ back_to_url or 'javascript:history.go(-1);' }}">I clicked "Report" by mistake</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,187 +6,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% filter to_markdown %}
|
{% filter to_markdown %}
|
||||||
# Community Guidelines
|
{% include "rules.md" %}
|
||||||
|
|
||||||
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 %}
|
{% endfilter %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
183
freak/templates/rules.md
Normal file
183
freak/templates/rules.md
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
# 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.
|
||||||
27
freak/templates/singledelete.html
Normal file
27
freak/templates/singledelete.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% 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('Confirm deletion: ' + p.title, False) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
<h2><span class="faint">Confirm deletion:</span> {{ p.title }}</h2>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card">
|
||||||
|
<form action="/delete/post/{{ p.id | to_b32l }}" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p>You are about to delete <u>permanently</u> <a href="{{ p.url() }}">your post on {{ p.topic_or_user().handle() }}</a>.</p>
|
||||||
|
{% call callout('spoiler', 'error') %}This action <u><b>cannot be undone</b></u>.{% endcall %}
|
||||||
|
{% if (p.comment_count()) %}
|
||||||
|
{% call callout('spoiler', 'warning') %}Your post has <strong>{{ (p.comment_count()) }} comments</strong>. Your post will be deleted <u>along with ALL the comments</u>.{% endcall %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="primary">{{ icon('delete') }} Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -3,13 +3,22 @@
|
||||||
{% from "macros/feed.html" import single_comment, feed_upvote, comment_count 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/create.html" import comment_area with context %}
|
||||||
{% from "macros/icon.html" import icon, callout with context %}
|
{% from "macros/icon.html" import icon, callout with context %}
|
||||||
|
{% from "macros/nav.html" import nav_guild, nav_user with context %}
|
||||||
|
|
||||||
{% block title %}{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}{% endblock %}
|
{% block title %}
|
||||||
|
{{ title_tag(p.title + '; from ' + p.topic_or_user().handle()) }}
|
||||||
|
<meta name="og:title" content="{{ p.title }}" />
|
||||||
|
{# meta name="og:description" coming in 0.4 #}
|
||||||
|
{% if p.author %}
|
||||||
|
<meta name="author" content="{{ p.author.display_name or p.author.username }}" />
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
{% if p.topic %}
|
{% if p.guild %}
|
||||||
{% from "macros/nav.html" import nav_topic with context %}
|
{{ nav_guild(p.guild) }}
|
||||||
{{ nav_topic(p.topic) }}
|
{% elif p.author %}
|
||||||
|
{{ nav_user(p.author) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -20,20 +29,23 @@
|
||||||
<h1 class="message-title">{{ p.title }}</h1>
|
<h1 class="message-title">{{ p.title }}</h1>
|
||||||
<div class="message-meta">
|
<div class="message-meta">
|
||||||
Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
Posted by <a href="{{ p.author.url() }}">@{{ p.author.username }}</a>
|
||||||
{% if p.topic %}
|
{% if p.guild %}
|
||||||
on <a href="{{ p.topic.url() }}">+{{ p.topic.name }}</a>
|
on <a href="{{ p.guild.url() }}">+{{ p.guild.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
on their user page
|
on their user page
|
||||||
{% endif %}
|
{% endif %}
|
||||||
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
- <time datetime="{{ p.created_at.isoformat('T') }}">{{ p.created_at.strftime('%B %-d, %Y at %H:%M') }}</time>
|
||||||
|
{% if p.privacy == 1 %}
|
||||||
|
- {{ icon('link_post') }} Unlisted
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_administrator and p.report_count() %}
|
{% if current_user.is_administrator and p.report_count() %}
|
||||||
{% call callout() %}
|
{% call callout('spoiler', 'error') %}
|
||||||
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
|
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if p.is_removed %}
|
{% if p.is_removed %}
|
||||||
{% call callout('delete') %}
|
{% call callout('delete', 'error') %}
|
||||||
This post has been removed
|
This post has been removed
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -42,21 +54,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-stats">
|
<div class="message-stats">
|
||||||
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user)) }}
|
{{ feed_upvote(p.id, p.upvotes(), p.upvoted_by(current_user.user)) }}
|
||||||
{{ comment_count(p.comments | count) }}
|
{{ comment_count(p.comment_count()) }}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ul class="message-options inline">
|
<ul class="message-options inline">
|
||||||
{% if p.author == current_user %}
|
{% if p.author_id == current_user.id %}
|
||||||
<li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li>
|
<li><a href="/edit/post/{{ p.id|to_b32l }}"><i class="icon icon-edit"></i> Edit</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
<li><a href="{{ p.report_url() }}"><i class="icon icon-report"></i> Report</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{{ comment_area(p.url()) }}
|
</div>
|
||||||
|
|
||||||
|
{{ comment_area(p) }}
|
||||||
<div class="comment-section">
|
<div class="comment-section">
|
||||||
<ul>
|
<ul>
|
||||||
{% for comment in p.top_level_comments() %}
|
{% for comment in comments %}
|
||||||
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
|
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
|
||||||
{{ single_comment(comment) }}
|
{{ single_comment(comment) }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,111 +5,9 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
{# If you host your own instance, rememmber to change Terms to fit your own purposes! #}
|
||||||
{% filter to_markdown %}
|
{% filter to_markdown %}
|
||||||
# Terms of Service
|
{% include "terms.md" %}
|
||||||
|
|
||||||
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 %}
|
{% endfilter %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
105
freak/templates/terms.md
Normal file
105
freak/templates/terms.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
|
||||||
|
# Terms of Service
|
||||||
|
|
||||||
|
This is a non-authoritative copy of the actual Terms, always updated at <https://ndspir.it/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://ndspir.it/ugc.html) on newdigitalspirit.com
|
||||||
|
* [Minors' Account Policy](https://ndspir.it/u18.html) on newdigitalspirit.com
|
||||||
|
|
||||||
|
## Scope and Definition
|
||||||
|
|
||||||
|
These terms of service ("Terms") are between **{{ app_name }}** 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 {{ app_name }} is PG-13. You may not use the Services if you are younger than 13 years old.
|
||||||
|
|
||||||
|
Additionally, you may not directly contact {{ app_name }} 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.
|
||||||
|
|
||||||
|
Australian and Danish users under the age of 16 are not authorized to use their accounts.
|
||||||
|
|
||||||
|
New Digital Spirit reserves the right to require ID verification in case of age doubt or suspected security threat.
|
||||||
|
|
||||||
|
Minors on New Digital Spirit Network are additionally bound to the [Minor Account Policy](https://ndspir.it/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 {{ app_name }}
|
||||||
|
is intellectual property of {{ app_name }}. All rights reserved.
|
||||||
|
|
||||||
|
You may not copy, modify, redistribute, mirror the contents of or create alternative Service to
|
||||||
|
{{ server_name }} or any other of the Services, or portions thereof, without {{ app_name }}'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](https://ndspir.it/ugc.html), incorporated here by reference.
|
||||||
|
|
||||||
|
## No Warranty
|
||||||
|
|
||||||
|
**Except as represented in this agreement, {{ app_name }}
|
||||||
|
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
|
||||||
|
|
||||||
|
{{ app_name }} **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)
|
||||||
|
{{ app_name }} 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 {{ app_name }}, 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.
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
{% 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/title.html" import title_tag with context %}
|
||||||
{% from "macros/icon.html" import icon, callout with context %}
|
{% from "macros/icon.html" import icon, big_icon, callout with context %}
|
||||||
|
{% from "macros/nav.html" import nav_user with context %}
|
||||||
|
|
||||||
{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
<h2>{{ user.handle() }}</h2>
|
<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>
|
<ul class="inline">
|
||||||
|
<li>{{ icon('karma') }} <strong>{{ user.karma }}</strong> karma</li>
|
||||||
|
<li>Joined at: <time datetime="{{ user.joined_at.isoformat('T') }}">{{ user.joined_at.strftime('%B %-d, %Y %H:%M') }}</time></li>
|
||||||
|
<li>ID: {{ user.id|to_b32l }}</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block nav %}
|
||||||
|
{% if user.is_active and not user.has_blocked(current_user.user) %}
|
||||||
|
{{ nav_user(user) }}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -27,9 +39,10 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% elif not user.is_active %}
|
{% elif not user.is_active %}
|
||||||
|
{{ big_icon('ban') }}
|
||||||
<p class="centered">{{ user.handle() }} is suspended</p>
|
<p class="centered">{{ user.handle() }} is suspended</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="centered">{{ user.handle() }} never posted any content</p>
|
<p class="centered">{{ user.handle() }} has never posted any content</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
50
freak/templates/usersettings.html
Normal file
50
freak/templates/usersettings.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
|
{% from "macros/create.html" import checked_if with context %}
|
||||||
|
|
||||||
|
{% block title %}{{ title_tag('User Settings') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block heading %}
|
||||||
|
<h1>Settings for {{ current_user.handle() }}</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<section class="card">
|
||||||
|
<h2>Identification</h2>
|
||||||
|
<div><label for="US__display_name">Full name:</label>
|
||||||
|
<input type="text" name="display_name" id="US__display_name" value="{{ current_user.display_name or '' }}" />
|
||||||
|
</div>
|
||||||
|
<div><label for="US__biography">Bio:</label>
|
||||||
|
<textarea name="biography" id="US__biography">{{ current_user.biography or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<div>
|
||||||
|
<label>Color scheme</label>
|
||||||
|
<ul class="apply-theme grid">
|
||||||
|
<li><input type="radio" id="US__color_scheme_dark" name="color_scheme" value="dark" {{ checked_if((current_user.color_theme // 256) == 2) }}><label for="US__color_scheme_dark">Dark</label></li>
|
||||||
|
<li><input type="radio" id="US__color_scheme_light" name="color_scheme" value="light" {{ checked_if((current_user.color_theme // 256) == 1) }}><label for="US__color_scheme_light">Light</label></li>
|
||||||
|
<li><input type="radio" id="US__color_scheme_unset" name="color_scheme" value="unset" {{ checked_if((current_user.color_theme // 256) == 0) }}><label for="US__color_scheme_unset">System</label></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Color theme</label>
|
||||||
|
<ul class="apply-theme grid">
|
||||||
|
{% for color in colors %}
|
||||||
|
<li><input type="radio" id="US__color_theme_{{ color.code }}" name="color_theme" value="{{ color.code }}" {{ checked_if((current_user.color_theme % 256) == color.code) }}><label for="US__color_theme_{{ color.code }}">{{ color.name }}</label></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p><small class="faint">Don't forget to save your changes to apply the theme!</small></p>
|
||||||
|
</div><div>
|
||||||
|
<button type="submit" class="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -5,8 +5,10 @@ import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
from flask import request
|
from quart import request
|
||||||
|
from suou import deprecated, twocolon_list as _twocolon_list
|
||||||
|
|
||||||
|
@deprecated('replaced by suou.age_and_days()')
|
||||||
def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]:
|
def age_and_days(date: datetime.datetime, now: datetime.datetime | None = None) -> tuple[int, int]:
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.date.today()
|
now = datetime.date.today()
|
||||||
|
|
@ -19,6 +21,7 @@ def get_remote_addr():
|
||||||
return request.headers.getlist('X-Forwarded-For')[0]
|
return request.headers.getlist('X-Forwarded-For')[0]
|
||||||
return request.remote_addr
|
return request.remote_addr
|
||||||
|
|
||||||
|
@deprecated('replaced by suou.timed_cache()')
|
||||||
def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
|
def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
start_time = None
|
start_time = None
|
||||||
|
|
@ -38,3 +41,14 @@ def timed_cache(ttl: int, maxsize: int = 128, typed: bool = False):
|
||||||
|
|
||||||
def is_b32l(username: str) -> bool:
|
def is_b32l(username: str) -> bool:
|
||||||
return re.fullmatch(r'[a-z2-7]+', username)
|
return re.fullmatch(r'[a-z2-7]+', username)
|
||||||
|
|
||||||
|
twocolon_list = deprecated('import from suou instead')(_twocolon_list)
|
||||||
|
|
||||||
|
async def get_request_form() -> dict:
|
||||||
|
"""
|
||||||
|
Get the request form as HTTP x-www-form-urlencoded dict
|
||||||
|
|
||||||
|
NEW 0.5.0
|
||||||
|
"""
|
||||||
|
return dict(await request.form)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ blueprints.append(bp)
|
||||||
from .edit import bp
|
from .edit import bp
|
||||||
blueprints.append(bp)
|
blueprints.append(bp)
|
||||||
|
|
||||||
|
from .delete import bp
|
||||||
|
blueprints.append(bp)
|
||||||
|
|
||||||
|
from .moderation import bp
|
||||||
|
blueprints.append(bp)
|
||||||
|
|
||||||
from .about import bp
|
from .about import bp
|
||||||
blueprints.append(bp)
|
blueprints.append(bp)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,32 @@
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from flask import Blueprint, render_template, __version__ as flask_version
|
from quart import Blueprint, render_template
|
||||||
|
import importlib.metadata
|
||||||
|
try:
|
||||||
|
from quart import __version__ as quart_version
|
||||||
|
except Exception:
|
||||||
|
quart_version = importlib.metadata.version('quart')
|
||||||
from sqlalchemy import __version__ as sa_version
|
from sqlalchemy import __version__ as sa_version
|
||||||
from .. import __version__ as app_version
|
|
||||||
|
|
||||||
bp = Blueprint('about', __name__)
|
bp = Blueprint('about', __name__)
|
||||||
|
|
||||||
@bp.route('/about/')
|
@bp.route('/about/')
|
||||||
def about():
|
async def about():
|
||||||
return render_template('about.html',
|
return await render_template('about.html',
|
||||||
flask_version=flask_version,
|
quart_version=quart_version,
|
||||||
sa_version=sa_version,
|
sa_version=sa_version,
|
||||||
python_version=sys.version.split()[0],
|
python_version=sys.version.split()[0]
|
||||||
app_version=app_version
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@bp.route('/terms/')
|
@bp.route('/terms/')
|
||||||
def terms():
|
async def terms():
|
||||||
return render_template('terms.html')
|
return await render_template('terms.html')
|
||||||
|
|
||||||
@bp.route('/privacy/')
|
@bp.route('/privacy/')
|
||||||
def privacy():
|
async def privacy():
|
||||||
return render_template('privacy.html')
|
return await render_template('privacy.html')
|
||||||
|
|
||||||
@bp.route('/rules/')
|
@bp.route('/rules/')
|
||||||
def rules():
|
async def rules():
|
||||||
return render_template('rules.html')
|
return await render_template('rules.html')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,182 @@
|
||||||
import os, sys
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
import re
|
import re
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
from flask import Blueprint, render_template, request, redirect, flash
|
from quart import Blueprint, render_template, request, redirect, flash
|
||||||
from flask_login import login_user, logout_user, current_user
|
from quart_auth import AuthUser, login_required, login_user, logout_user, current_user
|
||||||
|
from suou.functools import deprecated
|
||||||
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
|
from .. import UserLoader
|
||||||
from ..models import REPORT_REASONS, db, User
|
from ..models import REPORT_REASONS, db, User
|
||||||
from ..utils import age_and_days
|
from ..utils import age_and_days, get_request_form
|
||||||
from sqlalchemy import select, insert
|
from sqlalchemy import select, insert
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
bp = Blueprint('accounts', __name__)
|
bp = Blueprint('accounts', __name__)
|
||||||
|
|
||||||
@bp.route('/login', methods=['GET', 'POST'])
|
from ..accounts import LoginStatus, check_login
|
||||||
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.')
|
@bp.get('/login')
|
||||||
return render_template('login.html')
|
async def login():
|
||||||
elif not user or not user.check_password(password):
|
return await render_template('login.html')
|
||||||
flash('Invalid username or password')
|
|
||||||
return render_template('login.html')
|
@bp.post('/login')
|
||||||
elif not user.is_active:
|
async def post_login():
|
||||||
flash('Your account is suspended')
|
form = await get_request_form()
|
||||||
|
# TODO schema validator
|
||||||
|
username: str = form['username']
|
||||||
|
password: str = form['password']
|
||||||
|
if '@' in username:
|
||||||
|
user_q = select(User).where(User.email == username)
|
||||||
else:
|
else:
|
||||||
remember_for = int(request.form.get('remember', 0))
|
user_q = select(User).where(User.username == username)
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
user = (await session.execute(user_q)).scalar()
|
||||||
|
|
||||||
|
match check_login(user, password):
|
||||||
|
case LoginStatus.SUCCESS:
|
||||||
|
remember_for = int(form.get('remember', 0))
|
||||||
if remember_for > 0:
|
if remember_for > 0:
|
||||||
login_user(user, remember=True, duration=datetime.timedelta(days=remember_for))
|
login_user(UserLoader(user.get_id()), remember=True)
|
||||||
else:
|
else:
|
||||||
login_user(user)
|
login_user(UserLoader(user.get_id()))
|
||||||
return redirect(request.args.get('next', '/'))
|
return redirect(request.args.get('next', '/'))
|
||||||
return render_template('login.html')
|
case LoginStatus.ERROR:
|
||||||
|
await flash('Invalid username or password')
|
||||||
|
case LoginStatus.SUSPENDED:
|
||||||
|
await flash('Your account is suspended')
|
||||||
|
case LoginStatus.PASS_EXPIRED:
|
||||||
|
await flash('You need to reset your password following the procedure.')
|
||||||
|
return await render_template('login.html')
|
||||||
|
|
||||||
@bp.route('/logout')
|
@bp.route('/logout')
|
||||||
def logout():
|
async def logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
flash('Logged out. Come back soon~')
|
await flash('Logged out. Come back soon~')
|
||||||
return redirect(request.args.get('next','/'))
|
return redirect(request.args.get('next','/'))
|
||||||
|
|
||||||
## XXX temp
|
## XXX temp
|
||||||
|
@deprecated('no good use')
|
||||||
def _currently_logged_in() -> bool:
|
def _currently_logged_in() -> bool:
|
||||||
return current_user and current_user.is_authenticated
|
return bool(current_user)
|
||||||
|
|
||||||
def validate_register_form() -> dict:
|
|
||||||
|
# XXX temp
|
||||||
|
@deprecated('please implement IpBan table')
|
||||||
|
def _check_ip_bans(ip) -> bool:
|
||||||
|
if ip in ('127.0.0.1', '::1', '::'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def validate_register_form() -> dict:
|
||||||
|
form = await get_request_form()
|
||||||
f = dict()
|
f = dict()
|
||||||
try:
|
try:
|
||||||
f['gdpr_birthday'] = datetime.date.fromisoformat(request.form['birthday'])
|
f['gdpr_birthday'] = datetime.date.fromisoformat(form['birthday'])
|
||||||
|
|
||||||
|
if age_and_days(f['gdpr_birthday']) == (0, 0):
|
||||||
|
# block bot attempt to register
|
||||||
|
raise Forbidden
|
||||||
if age_and_days(f['gdpr_birthday']) < (14,):
|
if age_and_days(f['gdpr_birthday']) < (14,):
|
||||||
f['banned_at'] = datetime.datetime.now()
|
f['banned_at'] = datetime.datetime.now()
|
||||||
f['banned_reason'] = REPORT_REASONS['underage']
|
f['banned_reason'] = REPORT_REASONS['underage']
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError('Invalid date format')
|
raise ValueError('Invalid date format')
|
||||||
f['username'] = request.form['username'].lower()
|
f['username'] = form['username'].lower()
|
||||||
if not re.fullmatch('[a-z0-9_-]+', f['username']):
|
if not re.fullmatch('[a-z0-9_-]+', f['username']):
|
||||||
raise ValueError('Username can contain only letters, digits, underscores and dashes.')
|
raise ValueError('Username can contain only letters, digits, underscores and dashes.')
|
||||||
f['display_name'] = request.form.get('full_name')
|
f['display_name'] = form.get('full_name')
|
||||||
|
|
||||||
if request.form['password'] != request.form['confirm_password']:
|
if form['password'] != form['confirm_password']:
|
||||||
raise ValueError('Passwords do not match.')
|
raise ValueError('Passwords do not match.')
|
||||||
f['passhash'] = generate_password_hash(request.form['password'])
|
f['passhash'] = generate_password_hash(form['password'])
|
||||||
|
|
||||||
f['email'] = request.form['email'] or None,
|
f['email'] = form['email'] or None
|
||||||
|
|
||||||
if _currently_logged_in() and not request.form.get('confirm_another'):
|
is_ip_banned: bool = await _check_ip_bans()
|
||||||
|
|
||||||
|
if is_ip_banned:
|
||||||
|
raise ValueError('Your IP address is banned.')
|
||||||
|
|
||||||
|
if _currently_logged_in() and not form.get('confirm_another'):
|
||||||
raise ValueError('You are already logged in. Please confirm you want to create another account by checking the option.')
|
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'):
|
if not form.get('legal'):
|
||||||
raise ValueError('You must accept Terms in order to create an account.')
|
raise ValueError('You must accept Terms in order to create an account.')
|
||||||
|
|
||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/register', methods=['GET', 'POST'])
|
class RegisterStatus(enum.Enum):
|
||||||
def register():
|
SUCCESS = 0
|
||||||
if request.method == 'POST' and request.form['username']:
|
ERROR = 1
|
||||||
|
USERNAME_TAKEN = 2
|
||||||
|
IP_BANNED = 3
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/register')
|
||||||
|
async def register_post():
|
||||||
try:
|
try:
|
||||||
user_data = validate_register_form()
|
user_data = await validate_register_form()
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
if e.args:
|
if e.args:
|
||||||
flash(e.args[0])
|
await flash(e.args[0])
|
||||||
return render_template('register.html')
|
return await render_template('register.html')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.session.execute(insert(User).values(**user_data))
|
async with db as session:
|
||||||
|
await session.execute(insert(User).values(**user_data))
|
||||||
|
|
||||||
db.session.commit()
|
await flash('Account created successfully. You can now log in.')
|
||||||
|
|
||||||
flash('Account created successfully. You can now log in.')
|
|
||||||
return redirect(request.args.get('next', '/'))
|
return redirect(request.args.get('next', '/'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.excepthook(*sys.exc_info())
|
sys.excepthook(*sys.exc_info())
|
||||||
flash('Unable to create account (possibly your username is already taken)')
|
await flash('Unable to create account (possibly your username is already taken)')
|
||||||
return render_template('register.html')
|
return await render_template('register.html')
|
||||||
|
|
||||||
return render_template('register.html')
|
@bp.get('/register')
|
||||||
|
async def register_get():
|
||||||
|
return await render_template('register.html')
|
||||||
|
|
||||||
|
COLOR_SCHEMES = {'dark': 2, 'light': 1, 'system': 0, 'unset': 0}
|
||||||
|
|
||||||
|
@bp.route('/settings', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
async def settings():
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = await get_request_form()
|
||||||
|
async with db as session:
|
||||||
|
changes = False
|
||||||
|
user = current_user.user
|
||||||
|
color_scheme = COLOR_SCHEMES[form.get('color_scheme')] if 'color_scheme' in form else None
|
||||||
|
color_theme: int = int(form.get('color_theme')) if 'color_theme' in form else None
|
||||||
|
biography: str = form.get('biography')
|
||||||
|
display_name: str = form.get('display_name')
|
||||||
|
|
||||||
|
if display_name and display_name != user.display_name:
|
||||||
|
changes, user.display_name = True, display_name.strip()
|
||||||
|
if biography and biography != user.biography:
|
||||||
|
changes, user.biography = True, biography.strip()
|
||||||
|
if color_scheme is not None and color_theme is not None:
|
||||||
|
comp_color_theme = 256 * color_scheme + color_theme
|
||||||
|
if comp_color_theme != user.color_theme:
|
||||||
|
changes, user.color_theme = True, comp_color_theme
|
||||||
|
if changes:
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
await flash('Changes saved!')
|
||||||
|
|
||||||
|
return await render_template('usersettings.html')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,78 +2,236 @@
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
import os
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from flask import Blueprint, abort, redirect, render_template, request, url_for
|
import warnings
|
||||||
from flask_login import current_user
|
from quart import Blueprint, abort, redirect, render_template, request, send_from_directory, url_for
|
||||||
from sqlalchemy import select, update
|
from quart_auth import current_user
|
||||||
|
from markupsafe import Markup
|
||||||
|
from sqlalchemy import insert, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from suou import additem, not_implemented
|
||||||
|
import logging
|
||||||
|
|
||||||
from ..models import REPORT_REASON_STRINGS, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, db
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from freak import UserLoader, app_config
|
||||||
|
from freak.utils import get_request_form
|
||||||
|
|
||||||
|
from ..models import REPORT_REASON_STRINGS, REPORT_REASONS, REPORT_TARGET_COMMENT, REPORT_TARGET_POST, REPORT_UPDATE_COMPLETE, REPORT_UPDATE_ON_HOLD, REPORT_UPDATE_REJECTED, Comment, Post, PostReport, User, UserStrike, db
|
||||||
|
|
||||||
bp = Blueprint('admin', __name__)
|
bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
## TODO make admin interface
|
## TODO make admin interface
|
||||||
|
|
||||||
def admin_required(func: Callable):
|
def admin_required(func: Callable):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(**ka):
|
async def wrapper(*a, **ka):
|
||||||
user: User = current_user
|
user: User = current_user.user
|
||||||
if not user.is_authenticated or not user.is_administrator:
|
if not user or not user.is_administrator:
|
||||||
abort(403)
|
abort(403)
|
||||||
return func(**ka)
|
return await func(*a, **ka)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def accept_report(target, source: PostReport):
|
|
||||||
|
TARGET_TYPES = {
|
||||||
|
Post: REPORT_TARGET_POST,
|
||||||
|
Comment: REPORT_TARGET_COMMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
def account_status_string(u: User):
|
||||||
|
if u.is_active:
|
||||||
|
return 'Active'
|
||||||
|
elif u.banned_at:
|
||||||
|
s = 'Suspended'
|
||||||
|
if u.banned_until:
|
||||||
|
s += f' (until {u.banned_until:%b %d, %Y %H:%M})'
|
||||||
|
if u.banned_reason in REPORT_REASON_STRINGS:
|
||||||
|
s += f' ({REPORT_REASON_STRINGS[u.banned_reason]})'
|
||||||
|
return s
|
||||||
|
elif u.is_disabled_by_user:
|
||||||
|
return 'Paused'
|
||||||
|
else:
|
||||||
|
return 'Inactive'
|
||||||
|
|
||||||
|
def colorized_account_status_string(u: User):
|
||||||
|
textc = account_status_string(u)
|
||||||
|
t1, t2, t3 = textc.partition('(')
|
||||||
|
if u.is_active:
|
||||||
|
base = '<span class="success">{0}</span>'
|
||||||
|
elif u.banned_at:
|
||||||
|
base = '<span class="error">{0}</span>'
|
||||||
|
else:
|
||||||
|
base = '<span class="warning">{0}</span>'
|
||||||
|
if t2:
|
||||||
|
base += ' <span class="faint">{1}</span>'
|
||||||
|
return Markup(base).format(t1, t2 + t3)
|
||||||
|
|
||||||
|
async def remove_content(target, reason_code: int):
|
||||||
|
async with db as session:
|
||||||
if isinstance(target, Post):
|
if isinstance(target, Post):
|
||||||
target.removed_at = datetime.datetime.now()
|
target.removed_at = datetime.datetime.now()
|
||||||
target.removed_by_id = current_user.id
|
target.removed_by_id = current_user.id
|
||||||
target.removed_reason = source.reason_code
|
target.removed_reason = reason_code
|
||||||
elif isinstance(target, Comment):
|
elif isinstance(target, Comment):
|
||||||
target.removed_at = datetime.datetime.now()
|
target.removed_at = datetime.datetime.now()
|
||||||
target.removed_by_id = current_user.id
|
target.removed_by_id = current_user.id
|
||||||
target.removed_reason = source.reason_code
|
target.removed_reason = reason_code
|
||||||
db.session.add(target)
|
session.add(target)
|
||||||
|
|
||||||
|
def get_author(target) -> User | None:
|
||||||
|
if isinstance(target, (Post, Comment)):
|
||||||
|
return target.author
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_content(target) -> str | None:
|
||||||
|
if isinstance(target, Post):
|
||||||
|
return target.title + '\n\n' + target.text_content
|
||||||
|
elif isinstance(target, Comment):
|
||||||
|
return target.text_content
|
||||||
|
return None
|
||||||
|
|
||||||
|
## REPORT ACTIONS ##
|
||||||
|
|
||||||
|
REPORT_ACTIONS = {}
|
||||||
|
|
||||||
|
@additem(REPORT_ACTIONS, '1')
|
||||||
|
async def accept_report(target, source: PostReport, session: AsyncSession):
|
||||||
|
if source.is_critical():
|
||||||
|
warnings.warn('attempted remove on a critical report case, striking instead', UserWarning)
|
||||||
|
return await strike_report(target, source)
|
||||||
|
|
||||||
|
await remove_content(target, source.reason_code)
|
||||||
|
|
||||||
source.update_status = REPORT_UPDATE_COMPLETE
|
source.update_status = REPORT_UPDATE_COMPLETE
|
||||||
db.session.add(source)
|
session.add(source)
|
||||||
db.session.commit()
|
await session.commit()
|
||||||
|
|
||||||
def reject_report(target, source: PostReport):
|
|
||||||
|
@additem(REPORT_ACTIONS, '2')
|
||||||
|
async def strike_report(target, source: PostReport, session: AsyncSession):
|
||||||
|
await remove_content(target, source.reason_code)
|
||||||
|
|
||||||
|
author = get_author(target)
|
||||||
|
if author:
|
||||||
|
await session.execute(insert(UserStrike).values(
|
||||||
|
user_id = author.id,
|
||||||
|
target_type = TARGET_TYPES[type(target)],
|
||||||
|
target_id = target.id,
|
||||||
|
target_content = get_content(target),
|
||||||
|
reason_code = source.reason_code,
|
||||||
|
issued_by_id = current_user.id
|
||||||
|
))
|
||||||
|
|
||||||
|
if source.is_critical():
|
||||||
|
author.banned_at = datetime.datetime.now()
|
||||||
|
author.banned_reason = source.reason_code
|
||||||
|
|
||||||
|
source.update_status = REPORT_UPDATE_COMPLETE
|
||||||
|
session.add(source)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@additem(REPORT_ACTIONS, '0')
|
||||||
|
async def reject_report(target, source: PostReport, session: AsyncSession):
|
||||||
source.update_status = REPORT_UPDATE_REJECTED
|
source.update_status = REPORT_UPDATE_REJECTED
|
||||||
db.session.add(source)
|
session.add(source)
|
||||||
db.session.commit()
|
await session.commit()
|
||||||
|
|
||||||
def withhold_report(target, source: PostReport):
|
|
||||||
|
@additem(REPORT_ACTIONS, '3')
|
||||||
|
async def withhold_report(target, source: PostReport, session: AsyncSession):
|
||||||
source.update_status = REPORT_UPDATE_ON_HOLD
|
source.update_status = REPORT_UPDATE_ON_HOLD
|
||||||
db.session.add(source)
|
session.add(source)
|
||||||
db.session.commit()
|
await session.commit()
|
||||||
|
|
||||||
REPORT_ACTIONS = {
|
|
||||||
'1': accept_report,
|
@additem(REPORT_ACTIONS, '4')
|
||||||
'0': reject_report,
|
@not_implemented()
|
||||||
'2': withhold_report
|
def escalate_report(target, source: PostReport):
|
||||||
}
|
...
|
||||||
|
|
||||||
|
## END report actions
|
||||||
|
|
||||||
@bp.route('/admin/')
|
@bp.route('/admin/')
|
||||||
@admin_required
|
@admin_required
|
||||||
def homepage():
|
async def homepage():
|
||||||
return render_template('admin/admin_home.html')
|
return await render_template('admin/admin_home.html')
|
||||||
|
|
||||||
|
@bp.route('/admin/style.css')
|
||||||
|
async def style_css():
|
||||||
|
return redirect(f'//{app_config.server_name}/static/admin/style.css'), 303
|
||||||
|
|
||||||
@bp.route('/admin/reports/')
|
@bp.route('/admin/reports/')
|
||||||
@admin_required
|
@admin_required
|
||||||
def reports():
|
async def reports():
|
||||||
report_list = db.paginate(select(PostReport).order_by(PostReport.id.desc()))
|
report_list = await db.paginate(select(PostReport).order_by(PostReport.id.desc()))
|
||||||
return render_template('admin/admin_reports.html',
|
return await render_template('admin/admin_reports.html',
|
||||||
report_list=report_list, report_reasons=REPORT_REASON_STRINGS)
|
report_list=report_list, report_reasons=REPORT_REASON_STRINGS)
|
||||||
|
|
||||||
@bp.route('/admin/reports/<b32l:id>', methods=['GET', 'POST'])
|
@bp.route('/admin/reports/<b32l:id>', methods=['GET', 'POST'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def report_detail(id: int):
|
async def report_detail(id: int):
|
||||||
report = db.session.execute(select(PostReport).where(PostReport.id == id)).scalar()
|
async with db as session:
|
||||||
|
report = (await session.execute(select(PostReport).where(PostReport.id == id))).scalar()
|
||||||
if report is None:
|
if report is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
target = await report.target()
|
||||||
|
if target is None:
|
||||||
|
abort(404)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
action = REPORT_ACTIONS[request.form['do']]
|
form = await get_request_form()
|
||||||
action(report.target(), report)
|
action = REPORT_ACTIONS[form['do']]
|
||||||
|
await action(target, report, session)
|
||||||
return redirect(url_for('admin.reports'))
|
return redirect(url_for('admin.reports'))
|
||||||
return render_template('admin/admin_report_detail.html', report=report,
|
return await render_template('admin/admin_report_detail.html', report=report,
|
||||||
report_reasons=REPORT_REASON_STRINGS)
|
report_reasons=REPORT_REASON_STRINGS)
|
||||||
|
|
||||||
|
@bp.route('/admin/strikes/')
|
||||||
|
@admin_required
|
||||||
|
async def strikes():
|
||||||
|
strike_list = await db.paginate(select(UserStrike).order_by(UserStrike.id.desc()))
|
||||||
|
return await render_template('admin/admin_strikes.html',
|
||||||
|
strike_list=strike_list, report_reasons=REPORT_REASON_STRINGS)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/users/')
|
||||||
|
@admin_required
|
||||||
|
async def users():
|
||||||
|
user_list = await db.paginate(select(User).order_by(User.joined_at.desc()), page=int(request.args.get('page', 1)))
|
||||||
|
return await render_template('admin/admin_users.html',
|
||||||
|
user_list=user_list, account_status_string=colorized_account_status_string)
|
||||||
|
|
||||||
|
@bp.route('/admin/users/<b32l:id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
async def user_detail(id: int):
|
||||||
|
async with db as session:
|
||||||
|
u = (await session.execute(select(User).where(User.id == id))).scalar()
|
||||||
|
if u is None:
|
||||||
|
abort(404)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = await get_request_form()
|
||||||
|
action = form['do']
|
||||||
|
if action == 'suspend':
|
||||||
|
u.banned_at = datetime.datetime.now()
|
||||||
|
u.banned_by_id = current_user.id
|
||||||
|
u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0)
|
||||||
|
elif action == 'unsuspend':
|
||||||
|
u.banned_at = None
|
||||||
|
u.banned_by_id = None
|
||||||
|
u.banned_until = None
|
||||||
|
u.banned_reason = None
|
||||||
|
elif action == 'to_3d':
|
||||||
|
u.banned_at = datetime.datetime.now()
|
||||||
|
u.banned_until = datetime.datetime.now() + datetime.timedelta(days=3)
|
||||||
|
u.banned_by_id = current_user.id
|
||||||
|
u.banned_reason = REPORT_REASONS.get(form.get('reason'), 0)
|
||||||
|
else:
|
||||||
|
abort(400)
|
||||||
|
strikes = (await session.execute(select(UserStrike).where(UserStrike.user_id == id).order_by(UserStrike.id.desc()))).scalars()
|
||||||
|
return await render_template('admin/admin_user_detail.html', u=u,
|
||||||
|
report_reasons=REPORT_REASON_STRINGS, account_status_string=colorized_account_status_string, strikes=strikes)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,73 +2,103 @@
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import datetime
|
import datetime
|
||||||
from flask import Blueprint, abort, redirect, flash, render_template, request, url_for
|
from quart import Blueprint, abort, redirect, flash, render_template, request, url_for
|
||||||
from flask_login import current_user, login_required
|
from quart_auth import current_user, login_required
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import insert, select
|
||||||
from ..models import User, db, Topic, Post
|
|
||||||
|
from freak import UserLoader
|
||||||
|
from freak.utils import get_request_form
|
||||||
|
from ..models import User, db, Guild, Post
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
bp = Blueprint('create', __name__)
|
bp = Blueprint('create', __name__)
|
||||||
|
|
||||||
|
async def create_savepoint(
|
||||||
|
target = '', title = '', content = '',
|
||||||
|
privacy = 0
|
||||||
|
):
|
||||||
|
return await render_template('create.html',
|
||||||
|
sv_target = target,
|
||||||
|
sv_title = title,
|
||||||
|
sv_content = content,
|
||||||
|
sv_privacy = privacy
|
||||||
|
)
|
||||||
|
|
||||||
@bp.route('/create/', methods=['GET', 'POST'])
|
@bp.route('/create/', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create():
|
async def create():
|
||||||
user: User = current_user
|
user: User = current_user.user
|
||||||
if request.method == 'POST' and 'title' in request.form:
|
form = await get_request_form()
|
||||||
topic_name = request.form['to']
|
if request.method == 'POST' and 'title' in form:
|
||||||
if topic_name:
|
gname = form['to']
|
||||||
topic: Topic | None = db.session.execute(db.select(Topic).where(Topic.name == topic_name)).scalar()
|
title = form['title']
|
||||||
if topic is None:
|
text = form['text']
|
||||||
flash(f'Topic +{topic_name} not found, posting to your user page instead')
|
privacy = int(form.get('privacy', '0'))
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
if gname:
|
||||||
|
guild: Guild | None = (await session.execute(select(Guild).where(Guild.name == gname))).scalar()
|
||||||
|
if guild is None:
|
||||||
|
await flash(f'Guild +{gname} not found or inaccessible')
|
||||||
|
return await create_savepoint('', title, text, privacy)
|
||||||
|
if guild.has_exiled(user):
|
||||||
|
await flash(f'You are banned from +{gname}')
|
||||||
|
return await create_savepoint('', title, text, privacy)
|
||||||
|
if not guild.allows_posting(user):
|
||||||
|
await flash(f'You can\'t post on +{gname}')
|
||||||
|
return await create_savepoint('', title, text, privacy)
|
||||||
else:
|
else:
|
||||||
topic = None
|
guild = None
|
||||||
title = request.form['title']
|
|
||||||
text = request.form['text']
|
|
||||||
privacy = int(request.form.get('privacy', '0'))
|
|
||||||
try:
|
try:
|
||||||
new_post: Post = db.session.execute(insert(Post).values(
|
new_post_id: int = (await session.execute(insert(Post).values(
|
||||||
author_id = user.id,
|
author_id = user.id,
|
||||||
topic_id = topic.id if topic else None,
|
topic_id = guild.id if guild else None,
|
||||||
created_at = datetime.datetime.now(),
|
created_at = datetime.datetime.now(),
|
||||||
privacy = privacy,
|
privacy = privacy,
|
||||||
title = title,
|
title = title,
|
||||||
text_content = text
|
text_content = text
|
||||||
).returning(Post.id)).fetchone()
|
).returning(Post.id))).scalar()
|
||||||
|
|
||||||
db.session.commit()
|
session.commit()
|
||||||
flash(f'Published on {'+' + topic_name if topic_name else '@' + user.username}')
|
await flash(f'Published on {guild.handle() if guild else user.handle()}')
|
||||||
return redirect(url_for('detail.post_detail', id=new_post.id))
|
return redirect(url_for('detail.post_detail', id=new_post_id))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.excepthook(*sys.exc_info())
|
sys.excepthook(*sys.exc_info())
|
||||||
flash('Unable to publish!')
|
await flash('Unable to publish!')
|
||||||
return render_template('create.html')
|
return await create_savepoint(target=request.args.get('on',''))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/createguild/', methods=['GET', 'POST'])
|
@bp.route('/createguild/', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def createguild():
|
async def createguild():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
user: User = current_user
|
if not current_user.user.can_create_community():
|
||||||
|
await flash('You are NOT allowed to create new guilds.')
|
||||||
if not user.can_create_community():
|
|
||||||
flash('You are NOT allowed to create new guilds.')
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
c_name = request.form['name']
|
form = await get_request_form()
|
||||||
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()
|
c_name = form['name']
|
||||||
return redirect(url_for('frontpage.topic_feed', name=c_name))
|
try:
|
||||||
|
async with db as session:
|
||||||
|
new_guild = (await session.execute(insert(Guild).values(
|
||||||
|
name = c_name,
|
||||||
|
display_name = form.get('display_name', c_name),
|
||||||
|
description = form['description'],
|
||||||
|
owner_id = current_user.id
|
||||||
|
).returning(Guild))).scalar()
|
||||||
|
|
||||||
|
if new_guild is None:
|
||||||
|
raise RuntimeError('no returning')
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return redirect(new_guild.url())
|
||||||
except Exception:
|
except Exception:
|
||||||
sys.excepthook(*sys.exc_info())
|
sys.excepthook(*sys.exc_info())
|
||||||
flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
|
await flash('Unable to create guild. It may already exist or you could not have permission to create new communities.')
|
||||||
return render_template('createguild.html')
|
return await render_template('createguild.html')
|
||||||
|
|
||||||
@bp.route('/createcommunity/')
|
@bp.route('/createcommunity/')
|
||||||
def createcommunity_redirect():
|
async def createcommunity_redirect():
|
||||||
return redirect(url_for('create.createguild')), 301
|
return redirect(url_for('create.createguild')), 301
|
||||||
35
freak/website/delete.py
Normal file
35
freak/website/delete.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, abort, flash, redirect, render_template, request
|
||||||
|
from quart_auth import current_user, login_required
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from freak import UserLoader
|
||||||
|
|
||||||
|
|
||||||
|
from ..models import Post, db, User
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
|
bp = Blueprint('delete', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/delete/post/<b32l:id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
async def delete_post(id: int):
|
||||||
|
async with db as session:
|
||||||
|
p = (await session.execute(select(Post).where(Post.id == id, Post.author_id == current_user.id))).scalar()
|
||||||
|
|
||||||
|
if p is None:
|
||||||
|
abort(404)
|
||||||
|
if p.author != current_user.user:
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
pt = p.topic_or_user()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
session.execute(delete(Post).where(Post.id == id, Post.author_id == current_user.id))
|
||||||
|
await flash('Your post has been deleted')
|
||||||
|
return redirect(pt.url()), 303
|
||||||
|
|
||||||
|
return await render_template('singledelete.html', p=p)
|
||||||
|
|
@ -1,100 +1,138 @@
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
|
from __future__ import annotations
|
||||||
from flask_login import current_user, login_required
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from ..iding import id_from_b32l
|
from typing import Iterable
|
||||||
from ..utils import is_b32l
|
from quart import Blueprint, abort, flash, request, redirect, render_template, url_for
|
||||||
from ..models import Comment, db, User, Post, Topic
|
from quart_auth import current_user
|
||||||
from ..algorithms import user_timeline
|
from sqlalchemy import insert, select
|
||||||
|
from suou import Snowflake
|
||||||
|
|
||||||
|
from freak import UserLoader
|
||||||
|
|
||||||
|
from ..utils import get_request_form, is_b32l
|
||||||
|
from ..models import Comment, Guild, db, User, Post
|
||||||
|
from ..algorithms import new_comments, user_timeline
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
bp = Blueprint('detail', __name__)
|
bp = Blueprint('detail', __name__)
|
||||||
|
|
||||||
@bp.route('/@<username>')
|
@bp.route('/@<username>')
|
||||||
def user_profile(username):
|
async def user_profile(username):
|
||||||
user = db.session.execute(select(User).where(User.username == username)).scalar()
|
async with db as session:
|
||||||
|
user = (await session.execute(select(User).where(User.username == username))).scalar()
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
posts = user_timeline(user.id)
|
posts = await db.paginate(user_timeline(user))
|
||||||
|
print(posts.pages)
|
||||||
|
|
||||||
return render_template('userfeed.html', l=db.paginate(posts), user=user)
|
return await render_template('userfeed.html', l=posts, user=user)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/u/<username>')
|
@bp.route('/u/<username>')
|
||||||
@bp.route('/user/<username>')
|
@bp.route('/user/<username>')
|
||||||
def user_profile_u(username: str):
|
async def user_profile_u(username: str):
|
||||||
if is_b32l(username):
|
if is_b32l(username):
|
||||||
userid = id_from_b32l(username)
|
userid = int(Snowflake.from_b32l(username))
|
||||||
user = db.session.execute(select(User).where(User.id == userid)).scalar()
|
async with db as session:
|
||||||
|
user = (await session.execute(select(User).where(User.id == userid))).scalar()
|
||||||
if user is not None:
|
if user is not None:
|
||||||
username = user.username
|
username = user.username
|
||||||
return redirect('/@' + username), 302
|
return redirect('/@' + username), 302
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/@<username>/')
|
|
||||||
def user_profile_s(username):
|
|
||||||
return redirect('/@' + username), 301
|
return redirect('/@' + username), 301
|
||||||
|
|
||||||
|
|
||||||
def single_post_post_hook(p: Post):
|
@bp.route('/@<username>/')
|
||||||
if 'reply_to' in request.form:
|
async def user_profile_s(username):
|
||||||
reply_to_id = request.form['reply_to']
|
return redirect('/@' + username), 301
|
||||||
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(
|
|
||||||
|
async def single_post_post_hook(p: Post):
|
||||||
|
if p.guild is not None:
|
||||||
|
gu = p.guild
|
||||||
|
if gu.has_exiled(current_user.user):
|
||||||
|
await flash(f'You have been banned from {gu.handle()}')
|
||||||
|
return
|
||||||
|
|
||||||
|
if not gu.allows_posting(current_user.user):
|
||||||
|
await flash(f'You can\'t post in {gu.handle()}')
|
||||||
|
return
|
||||||
|
|
||||||
|
if p.is_locked:
|
||||||
|
await flash(f'You can\'t comment on locked posts')
|
||||||
|
return
|
||||||
|
|
||||||
|
form = await get_request_form()
|
||||||
|
if 'reply_to' in form:
|
||||||
|
reply_to_id = form['reply_to']
|
||||||
|
text = form['text']
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
reply_to_p = (await session.execute(select(Post).where(Post.id == int(Snowflake.from_b32l(reply_to_id))))).scalar() if reply_to_id else None
|
||||||
|
|
||||||
|
session.execute(insert(Comment).values(
|
||||||
author_id = current_user.id,
|
author_id = current_user.id,
|
||||||
parent_post_id = p.id,
|
parent_post_id = p.id,
|
||||||
parent_comment_id = reply_to_p,
|
parent_comment_id = reply_to_p,
|
||||||
text_content = text
|
text_content = text
|
||||||
))
|
))
|
||||||
db.session.commit()
|
session.commit()
|
||||||
flash('Comment published')
|
await flash('Comment published')
|
||||||
return redirect(p.url()), 303
|
return redirect(p.url()), 303
|
||||||
abort(501)
|
abort(501)
|
||||||
|
|
||||||
@bp.route('/comments/<b32l:id>')
|
@bp.route('/comments/<b32l:id>')
|
||||||
def post_detail(id: int):
|
async def post_detail(id: int):
|
||||||
post: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
async with db as session:
|
||||||
|
post: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||||
|
|
||||||
if post and post.url() != request.full_path:
|
if post and post.url() != request.full_path:
|
||||||
return redirect(post.url()), 302
|
return redirect(post.url()), 302
|
||||||
else:
|
else:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
async def comments_of(p: Post) -> Iterable[Comment]:
|
||||||
|
## TODO add sort argument
|
||||||
|
pp = await db.paginate(new_comments(p))
|
||||||
|
print(pp.pages)
|
||||||
|
return pp
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||||
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||||
def user_post_detail(username: str, id: int, slug: str = ''):
|
async 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()
|
async with db as session:
|
||||||
|
post: Post | None = (await 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):
|
if post is None or (post.author and await post.author.has_blocked(current_user.user)) or (post.is_removed and post.author != current_user.user):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if post.slug and not slug:
|
if post.slug and slug != post.slug:
|
||||||
return redirect(url_for('detail.user_post_detail_slug', username=username, id=id, slug=post.slug)), 302
|
return redirect(post.url()), 302
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
single_post_post_hook(post)
|
single_post_post_hook(post)
|
||||||
|
|
||||||
return render_template('singlepost.html', p=post)
|
return await render_template('singlepost.html', p=post, comments=await comments_of(post))
|
||||||
|
|
||||||
@bp.route('/+<topicname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
@bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||||
@bp.route('/+<topicname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||||
def topic_post_detail(topicname, id, slug=''):
|
async def guild_post_detail(gname, id, slug=''):
|
||||||
post: Post | None = db.session.execute(select(Post).join(Topic).where(Post.id == id, Topic.name == topicname)).scalar()
|
async with db as session:
|
||||||
|
post: Post | None = (await session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname))).scalar()
|
||||||
|
|
||||||
if post is None or (post.is_removed and post.author != current_user):
|
if post is None or (post.author and await post.author.has_blocked(current_user.user)) or (post.is_removed and post.author != current_user.user):
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if post.slug and not slug:
|
if post.slug and slug != post.slug:
|
||||||
return redirect(url_for('detail.topic_post_detail_slug', topicname=topicname, id=id, slug=post.slug)), 302
|
return redirect(post.url()), 302
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
single_post_post_hook(post)
|
single_post_post_hook(post)
|
||||||
|
|
||||||
return render_template('singlepost.html', p=post)
|
return await render_template('singlepost.html', p=post, comments=await comments_of(post), current_guild = post.guild)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,21 @@
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request
|
from quart import Blueprint, abort, flash, redirect, render_template, request
|
||||||
from flask_login import current_user, login_required
|
from quart_auth import current_user, login_required
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from freak.utils import get_request_form
|
||||||
|
|
||||||
from ..models import Post, db
|
from ..models import Post, db
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint('edit', __name__)
|
bp = Blueprint('edit', __name__)
|
||||||
|
|
||||||
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
|
@bp.route('/edit/post/<b32l:id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_post(id):
|
async def edit_post(id):
|
||||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
async with db as session:
|
||||||
|
p: Post | None = (await session.execute(select(Post).where(Post.id == id, Post.author == current_user.user))).scalar()
|
||||||
|
|
||||||
if p is None:
|
if p is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -21,16 +24,17 @@ def edit_post(id):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
text = request.form['text']
|
form = await get_request_form()
|
||||||
privacy = int(request.form.get('privacy', '0'))
|
text = form['text']
|
||||||
|
privacy = int(form.get('privacy', '0'))
|
||||||
|
|
||||||
db.session.execute(db.update(Post).where(Post.id == id).values(
|
await session.execute(update(Post).where(Post.id == id).values(
|
||||||
text_content = text,
|
text_content = text,
|
||||||
privacy = privacy,
|
privacy = privacy,
|
||||||
updated_at = datetime.datetime.now()
|
updated_at = datetime.datetime.now()
|
||||||
))
|
))
|
||||||
db.session.commit()
|
await session.commit()
|
||||||
flash('Your changes have been saved')
|
await flash('Your changes have been saved')
|
||||||
return redirect(p.url()), 303
|
return redirect(p.url()), 303
|
||||||
return render_template('edit.html', p=p)
|
return await render_template('edit.html', p=p)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,77 @@
|
||||||
from flask import Blueprint, render_template, redirect, abort, request
|
|
||||||
from flask_login import current_user
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, render_template, redirect, abort, request
|
||||||
|
from quart_auth import current_user
|
||||||
|
from sqlalchemy import and_, distinct, func, select
|
||||||
|
|
||||||
|
from freak import UserLoader
|
||||||
|
from freak.utils import get_request_form
|
||||||
|
|
||||||
from ..search import SearchQuery
|
from ..search import SearchQuery
|
||||||
from ..models import Post, db, Topic
|
from ..models import Guild, Member, Post, User, db
|
||||||
from ..algorithms import public_timeline, top_guilds_query, topic_timeline
|
from ..algorithms import public_timeline, top_guilds_query, topic_timeline
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
bp = Blueprint('frontpage', __name__)
|
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:
|
|
||||||
|
@bp.route('/')
|
||||||
|
async def homepage():
|
||||||
|
async with db as session:
|
||||||
|
top_communities = [(x[0], x[1], x[2]) for x in
|
||||||
|
(await session.execute(top_guilds_query().limit(10))).fetchall()]
|
||||||
|
|
||||||
|
if current_user:
|
||||||
# renders user's own timeline
|
# renders user's own timeline
|
||||||
# TODO this is currently the public timeline.
|
# TODO this is currently the public timeline.
|
||||||
|
|
||||||
|
return await render_template('feed.html', feed_type='foryou', l=await db.paginate(public_timeline()),
|
||||||
return render_template('feed.html', feed_type='foryou', l=db.paginate(public_timeline()),
|
|
||||||
top_communities=top_communities)
|
top_communities=top_communities)
|
||||||
else:
|
else:
|
||||||
# Show a landing page to anonymous users.
|
# Show a landing page to anonymous users.
|
||||||
return render_template('landing.html', top_communities=top_communities)
|
return await render_template('landing.html', top_communities=top_communities)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/explore/')
|
@bp.route('/explore/')
|
||||||
def explore():
|
async def explore():
|
||||||
return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline()))
|
return render_template('feed.html', feed_type='explore', l=db.paginate(public_timeline()))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/+<name>/')
|
@bp.route('/+<name>/')
|
||||||
def topic_feed(name):
|
async def guild_feed(name):
|
||||||
topic: Topic = db.session.execute(db.select(Topic).where(Topic.name == name)).scalar()
|
async with db as session:
|
||||||
|
guild: Guild | None = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||||
|
|
||||||
if topic is None:
|
if guild is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
posts = db.paginate(topic_timeline(name))
|
posts = await db.paginate(topic_timeline(name))
|
||||||
|
|
||||||
return render_template(
|
return await render_template(
|
||||||
'feed.html', feed_type='topic', feed_title=f'{topic.display_name} (+{topic.name})', l=posts, topic=topic)
|
'feed.html', feed_type='guild', feed_title=f'{guild.display_name} (+{guild.name})', l=posts, guild=guild,
|
||||||
|
current_guild=guild)
|
||||||
|
|
||||||
@bp.route('/r/<name>/')
|
@bp.route('/r/<name>/')
|
||||||
def topic_feed_r(name):
|
async def guild_feed_r(name):
|
||||||
return redirect('/+' + name + '/'), 302
|
return redirect('/+' + name + '/'), 302
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/search", methods=["GET", "POST"])
|
@bp.route("/search", methods=["GET", "POST"])
|
||||||
def search():
|
async def search():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
q = request.form["q"]
|
form = await get_request_form()
|
||||||
|
q = form["q"]
|
||||||
if q:
|
if q:
|
||||||
results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc()))
|
results = db.paginate(SearchQuery(q).select(Post, [Post.title]).order_by(Post.created_at.desc()))
|
||||||
else:
|
else:
|
||||||
results = None
|
results = None
|
||||||
return render_template(
|
return await render_template(
|
||||||
"search.html",
|
"search.html",
|
||||||
results=results,
|
results=results,
|
||||||
q = q
|
q = q
|
||||||
)
|
)
|
||||||
return render_template("search.html")
|
return await render_template("search.html")
|
||||||
|
|
|
||||||
92
freak/website/moderation.py
Normal file
92
freak/website/moderation.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from quart import Blueprint, abort, flash, render_template, request
|
||||||
|
from quart_auth import current_user, login_required
|
||||||
|
from sqlalchemy import select
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from .. import UserLoader
|
||||||
|
from ..utils import get_request_form
|
||||||
|
|
||||||
|
from ..models import db, User, Guild
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
|
bp = Blueprint('moderation', __name__)
|
||||||
|
|
||||||
|
@bp.route('/+<name>/settings', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
async def guild_settings(name: str):
|
||||||
|
form = await get_request_form()
|
||||||
|
|
||||||
|
async with db as session:
|
||||||
|
gu = (await session.execute(select(Guild).where(Guild.name == name))).scalar()
|
||||||
|
|
||||||
|
if not current_user.moderates(gu):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if current_user.is_administrator and form.get('transfer_owner') == current_user.username:
|
||||||
|
gu.owner_id = current_user.id
|
||||||
|
await session.add(gu)
|
||||||
|
await session.commit()
|
||||||
|
await flash(f'Claimed ownership of {gu.handle()}')
|
||||||
|
return await render_template('guildsettings.html', gu=gu)
|
||||||
|
|
||||||
|
changes = False
|
||||||
|
display_name: str = form.get('display_name')
|
||||||
|
description: str = form.get('description')
|
||||||
|
exile_name: str = form.get('exile_name')
|
||||||
|
exile_reverse = 'exile_reverse' in form
|
||||||
|
restricted = 'restricted' in form
|
||||||
|
moderator_name: str = form.get('moderator_name')
|
||||||
|
moderator_consent = 'moderator_consent' in form
|
||||||
|
|
||||||
|
if description and description != gu.description:
|
||||||
|
changes, gu.description = True, description.strip()
|
||||||
|
if display_name and display_name != gu.display_name:
|
||||||
|
changes, gu.display_name = True, display_name.strip()
|
||||||
|
if exile_name:
|
||||||
|
exile_user = (await session.execute(select(User).where(User.username == exile_name))).scalar()
|
||||||
|
if exile_user:
|
||||||
|
if exile_reverse:
|
||||||
|
mem = await gu.update_member(exile_user, banned_at = None, banned_by_id = None)
|
||||||
|
if mem.banned_at == None:
|
||||||
|
await flash(f'Removed ban on {exile_user.handle()}')
|
||||||
|
changes = True
|
||||||
|
else:
|
||||||
|
mem = await gu.update_member(exile_user, banned_at = datetime.datetime.now(), banned_by_id = current_user.id)
|
||||||
|
if mem.banned_at != None:
|
||||||
|
await flash(f'{exile_user.handle()} has been exiled')
|
||||||
|
changes = True
|
||||||
|
else:
|
||||||
|
await flash(f'User \'{exile_name}\' not found, can\'t exile')
|
||||||
|
if restricted and restricted != gu.is_restricted:
|
||||||
|
changes, gu.is_restricted = True, restricted
|
||||||
|
if moderator_consent and moderator_name:
|
||||||
|
mu = (await session.execute(select(User).where(User.username == moderator_name))).scalar()
|
||||||
|
if mu is None:
|
||||||
|
await flash(f'User \'{moderator_name}\' not found')
|
||||||
|
elif mu.is_disabled:
|
||||||
|
await flash('Suspended users can\'t be moderators')
|
||||||
|
elif mu.has_blocked(current_user.user):
|
||||||
|
await flash(f'User \'{moderator_name}\' not found')
|
||||||
|
else:
|
||||||
|
mm = await gu.update_member(mu)
|
||||||
|
if mm.is_moderator:
|
||||||
|
await flash(f'{mu.handle()} is already a moderator')
|
||||||
|
elif mm.is_banned:
|
||||||
|
await flash('Exiled users can\'t be moderators')
|
||||||
|
else:
|
||||||
|
mm.is_moderator = True
|
||||||
|
await session.add(mm)
|
||||||
|
changes = True
|
||||||
|
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
session.add(gu)
|
||||||
|
session.commit()
|
||||||
|
await flash('Changes saved!')
|
||||||
|
|
||||||
|
return render_template('guildsettings.html', gu=gu)
|
||||||
|
|
||||||
|
|
@ -1,56 +1,65 @@
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import Blueprint, render_template, request
|
from quart import Blueprint, render_template, request
|
||||||
from flask_login import current_user, login_required
|
from quart_auth import current_user, login_required
|
||||||
from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db
|
from sqlalchemy import insert, select
|
||||||
|
from suou import Snowflake
|
||||||
|
|
||||||
|
from freak import UserLoader
|
||||||
|
from ..models import REPORT_TARGET_COMMENT, REPORT_TARGET_POST, ReportReason, User, post_report_reasons, Comment, Post, PostReport, REPORT_REASONS, db
|
||||||
|
|
||||||
bp = Blueprint('reports', __name__)
|
bp = Blueprint('reports', __name__)
|
||||||
|
|
||||||
|
current_user: UserLoader
|
||||||
|
|
||||||
def description_text(rlist: list[ReportReason], key: str) -> str:
|
def description_text(rlist: list[ReportReason], key: str) -> str:
|
||||||
results = [x.description for x in rlist if x.code == key]
|
results = [x.description for x in rlist if x.code == key]
|
||||||
return results[0] if results else key
|
return results[0] if results else key
|
||||||
|
|
||||||
@bp.route('/report/post/<b32l:id>', methods=['GET', 'POST'])
|
@bp.route('/report/post/<b32l:id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def report_post(id: int):
|
async def report_post(id: int):
|
||||||
p: Post | None = db.session.execute(db.select(Post).where(Post.id == id)).scalar()
|
async with db as session:
|
||||||
|
p: Post | None = (await session.execute(select(Post).where(Post.id == id))).scalar()
|
||||||
if p is None:
|
if p is None:
|
||||||
return render_template('reports/report_404.html', target_type = 1), 404
|
return await render_template('reports/report_404.html', target_type = 1), 404
|
||||||
if p.author_id == current_user.id:
|
if p.author_id == current_user.id:
|
||||||
return render_template('reports/report_self.html', back_to_url=p.url()), 403
|
return await render_template('reports/report_self.html', back_to_url=p.url()), 403
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
reason = request.args['reason']
|
reason = request.args['reason']
|
||||||
db.session.execute(db.insert(PostReport).values(
|
await session.execute(insert(PostReport).values(
|
||||||
author_id = current_user.id,
|
author_id = current_user.id,
|
||||||
target_type = REPORT_TARGET_POST,
|
target_type = REPORT_TARGET_POST,
|
||||||
target_id = id,
|
target_id = id,
|
||||||
reason_code = REPORT_REASONS[reason]
|
reason_code = REPORT_REASONS[reason]
|
||||||
))
|
))
|
||||||
db.session.commit()
|
session.commit()
|
||||||
return render_template('reports/report_done.html', back_to_url=p.url())
|
return await render_template('reports/report_done.html', back_to_url='/=' + Snowflake(p.id).to_b32l())
|
||||||
return render_template('reports/report_post.html', id = id,
|
return await render_template('reports/report_post.html', id = id,
|
||||||
report_reasons = post_report_reasons, description_text=description_text)
|
report_reasons = post_report_reasons, description_text=description_text)
|
||||||
|
|
||||||
@bp.route('/report/comment/<b32l:id>', methods=['GET', 'POST'])
|
@bp.route('/report/comment/<b32l:id>', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def report_comment(id: int):
|
async def report_comment(id: int):
|
||||||
c: Comment | None = db.session.execute(db.select(Comment).where(Comment.id == id)).scalar()
|
async with db as session:
|
||||||
|
c: Comment | None = (await session.execute(select(Comment).where(Comment.id == id))).scalar()
|
||||||
if c is None:
|
if c is None:
|
||||||
return render_template('reports/report_404.html', target_type = 2), 404
|
return await render_template('reports/report_404.html', target_type = 2), 404
|
||||||
if c.author_id == current_user.id:
|
if c.author_id == current_user.id:
|
||||||
return render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
|
return await render_template('reports/report_self.html', back_to_url=c.parent_post.url()), 403
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
reason = request.args['reason']
|
reason = request.args['reason']
|
||||||
db.session.execute(db.insert(PostReport).values(
|
session.execute(insert(PostReport).values(
|
||||||
author_id = current_user.id,
|
author_id = current_user.id,
|
||||||
target_type = REPORT_TARGET_COMMENT,
|
target_type = REPORT_TARGET_COMMENT,
|
||||||
target_id = id,
|
target_id = id,
|
||||||
reason_code = REPORT_REASONS[reason]
|
reason_code = REPORT_REASONS[reason]
|
||||||
))
|
))
|
||||||
db.session.commit()
|
session.commit()
|
||||||
return render_template('reports/report_done.html',
|
return await render_template('reports/report_done.html',
|
||||||
back_to_url=c.parent_post.url())
|
back_to_url=c.parent_post.url())
|
||||||
return render_template('reports/report_comment.html', id = id,
|
return await render_template('reports/report_comment.html', id = id,
|
||||||
report_reasons = post_report_reasons, description_text=description_text)
|
report_reasons = post_report_reasons, description_text=description_text)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,20 @@ authors = [
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Python-Dotenv>=1.0.0",
|
"Python-Dotenv>=1.0.0",
|
||||||
"Flask",
|
"Quart",
|
||||||
"Flask-RestX",
|
"Quart-Schema",
|
||||||
"Python-Slugify",
|
"Python-Slugify",
|
||||||
"SQLAlchemy>=2.0.0",
|
"SQLAlchemy>=2.0.0",
|
||||||
"Flask-SQLAlchemy",
|
# XXX it's Quart-wtFORMS not Quart-wtf see: https://github.com/Quart-Addons/quart-wtf/issues/20
|
||||||
"Flask-WTF",
|
"Quart-WTForms>=1.0.3",
|
||||||
"Flask-Login",
|
"Quart-Auth",
|
||||||
"Alembic",
|
"Alembic",
|
||||||
"Markdown>=3.0.0",
|
"Markdown>=3.0",
|
||||||
"PsycoPG2-binary",
|
"PsycoPG>=3.0",
|
||||||
"libsass",
|
"libsass",
|
||||||
"setuptools>=78.1.0",
|
"setuptools>=78.1.0",
|
||||||
"sakuragasaki46-suou>=0.2.3"
|
"Hypercorn",
|
||||||
|
"suou[sqlalchemy]>=0.11.2"
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Disallow: /login
|
||||||
Disallow: /logout
|
Disallow: /logout
|
||||||
Disallow: /create
|
Disallow: /create
|
||||||
Disallow: /register
|
Disallow: /register
|
||||||
Disallow: /createcommunity
|
Disallow: /createguild
|
||||||
|
|
||||||
User-Agent: GPTBot
|
User-Agent: GPTBot
|
||||||
Disallow: /
|
Disallow: /
|
||||||
Loading…
Add table
Add a link
Reference in a new issue