diff --git a/.gitignore b/.gitignore
index 8a5dbec..62f74a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,29 @@
-# application content
-media/
-**.sqlite
-database/
-site.conf
-run_8180.py
-
-# automatically generated garbage
-**/__pycache__/
+node_modules/
+__pycache__/
**.pyc
**~
-**/.\#*
-**/\#*\#
-ig_api_settings/
+.*.swp
+\#*\#
+.\#*
+alembic.ini
+.env
+.env.*
+.venv
+env
+venv
+venv-*/
+config/
+conf/
+config.json
+data/
+site-*.conf
+site.conf
+.err
+.vscode
+/run.sh
+/target
+dist/
+build/
+**.egg-info
+ROADMAP.md
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39a9ca6..2f11a4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,109 @@
# What’s New
+## 1.1.0
+
++ **Deprecated** several configuration values ~
++ **Schema changes**: several columns changed format. Update your schema.
++ Removed permanently the remains of extensions.
++ I18n improvements.
+
+## 1.0.0
+
++ **BREAKING CHANGES AHEAD**!
++ **SECURITY ADVISORY**: versions from `0.7` up to `0.9` are **VULNERABLE to XSS** due to `.jinja2` not getting autoescaped.
++ No more a monolith: `app.py` got split into several files into `salvi` package
++ Switched from peewee to **SQLAlchemy**; Schema as of 1.0 is the same as 0.9
++ Added dependency on [libsuou](https://github.com/yusurko/suou)
++ `site.conf` deprecated, but still supported for the time being
++ Switched to `pyproject.toml`. `requirements.txt` has been sunset.
++ Switched to the Apache License; the old license text is moved to `LICENSE.0_9`
++ Added color themes! This is a breaking (but trivial) aesthetic change. Default theme is 'Miku' (aquamarine green).
++ Extensions **have been removed**. They never had a clear, usable, public API in the first place.
+
+## 0.9
+
++ Removed `markdown_katex` dependency, and therefore support for math.
+ It is bloat; moreover, it ships executables with it, negatively impacting the lightweightness of the app.
++ Added support for `.env` (dotenv) file.
++ Now a database URL is required. For example, `[database]directory = /path/to/data/` becomes
+ `[database]url = sqlite:////path/to/data/data.sqlite` (site.conf) or
+ `DATABASE_URL=sqlite:////path/to/data/data.sqlite` (.env).
+
+## 0.8
+
++ Schema changes:
+ + New tables `UserGroup`, `UserGroupMembership` and `PagePermission`.
+ + Added flag `is_cw` to `Page`.
+ + Added `restrictions` field to `User`.
++ Pages now can have a Content Warning. It prevents them to show up in previews, and adds a
+ caution message when viewing them.
++ SEO improvement: added `keywords` and `description` meta tags to viewing pages.
++ Added Terms, Privacy Policy and Rules.
++ Changed user page URLs (contributions page) from `/u/user` to `/@user`.
++ `/manage/` is now a list of all managing options, including export/import and the brand new
+ `/manage/accounts`.
++ Users can now be disabled (and re-enabled) by administrator.
++ TOC is now shown in pages when screen width is greater than 960 pixels.
++ Style changes: added a top bar with the site title. It replaces the floating menu on the top right.
++ Now logged-in users have an “Edit” button below the first heading. All users can access page history
+ by clicking the last modified time.
++ Added a built-in installer (`app_init.py`). You still need to manually create `site.conf`.
+
+## 0.7.1
+
++ Improved calendar view. Now `/calendar` shows a list of years and months.
+
+## 0.7
+
++ Schema changes:
+ + Removed `PagePolicy` and `PagePolicyKey` tables altogether. They were never useful.
+ + Added `calendar` field to `Page`.
+ + Added `User` table.
++ Added `Flask-Login` and `Flask-WTF` dependencies in order to implement user logins.
++ Added `python-i18n` as a dependency. Therefore, i18n changed format, using JSON files now.
++ Login is now required for creating and editing.
++ Now you can leave a comment while changing a page’s text. Moreover, a new revision is created now
+ only in case of an effective text change.
++ Now a page can be dated in the calendar.
++ Now you can export and import pages in a JSON format. Importing can be done by admin users only.
++ Improved page history view, and added user contributions page.
++ Updated Markdown extensions to work under latest version.
++ Like it or not, now gzip library is required.
++ Added CSS variables in the site style.
++ Templates are now with `.jinja2` extension.
+
+## 0.6
+
++ Added support for database URLs: you can now specify the URL of the database
+ in `site.conf` by setting `[database]url`, be it MySQL, PostgreSQL or SQLite.
++ Added experimental math support, with `markdown_katex` library. The math
+ parsing can be opted out in many ways.
++ Backlinks can now be accessed for each page.
++ Spoiler tags at beginning of line now work. Just for now.
++ Removed `Upload` table.
++ Added `PageLink` table.
+
+## 0.5
+
++ Removed support for uploads. The `/upload/` endpoint now points to an info
+ page, and the “Upload image” button and gallery from home page are now gone.
++ `markdown_strikethrough` extension is no more needed. Now there are two new
+ built-in extensions: `StrikethroughExtension` and `SpoilerExtension` (the
+ last one is buggy tho).
++ Removed support for magic words (the commands between `{{` `}}`). These
+ features are now lost: `backto`, `media` and `gallery` (easily replaceable
+ with simple Markdown).
++ Added app version to site footer.
++ Added client-side drafts (they require JS enabled).
+
+## 0.4
+
+
+
+## 0.3
+
+
+
## 0.2
+ Some code refactoring.
diff --git a/LICENSE b/LICENSE
index a2d4b30..df8a219 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,19 +1,55 @@
-Copyright (C) 2020-2021 Sakuragasaki46
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+Copyright (c) 2020-2025 Sakuragasaki46
-The above copyright notice and this permission notice shall be included
-in all copies or substantial portions of the Software.
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+ You must give any other recipients of the Work or Derivative Works a copy of this License; and
+ You must cause any modified files to carry prominent notices stating that You changed the files; and
+ You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+ If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
diff --git a/LICENSE.0_9 b/LICENSE.0_9
new file mode 100644
index 0000000..a513535
--- /dev/null
+++ b/LICENSE.0_9
@@ -0,0 +1,20 @@
+
+Copyright (C) 2020-2021 Sakuragasaki46
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 9cb1ad9..56a8ff2 100644
--- a/README.md
+++ b/README.md
@@ -11,22 +11,53 @@ suitable as a community or team knowledge base.
+ Write notes on the go, using Markdown syntax
+ Any note can have its own URL
+ Revision history
-+ Stored in SQLite databases
++ Stored in SQLite/MySQL databases
+ Material Icons
+ Light/dark theme
++ Calendar
+ Works fine even with JavaScript disabled.
-## Requirements
+## Requirements & Dependencies
-+ **Python** 3.6+.
-+ **Flask** web framework.
-+ **Peewee** ORM.
++ **Python** 3.10+.
++ **Flask** web framework (and Flask-Login / Flask-WTF / Flask-Arrest extensions).
++ **SQLAlchemy** ORM.
++ **Markdown** for page rendering.
++ **[SUOU](https://github.com/yusurko/suou)**. (since 1.0)
+* **Python-Dotenv**.
++ The database drivers needed for the type of database.
+
+To install the dependencies, run (virtualenv advised):
+> `pip install -e .`
+
+## Usage
+
++ Clone this repository: `git clone https://github.com/yusurko/salvi`
++ Edit `.env` with the needed parameters. An example `.env` with MySQL:
+
+```
+APP_NAME=Salvi
+DATABASE_URL=mysql://root:root@localhost/salvi
+```
+
++ Run `python3 -m app_init` to initialize the database and create the administrator user. (The installer is unstable)
++ Run `flask run`.
++ You can now access Salvi in your browser at port 5000.
## Caveats
-+ All pages created are, as of now, viewable and editable by anyone, with no
- trace of users and/or passwords.
++ The whole application is free of unit tests. Ergo untested. No coverage.
++ If you forget the password, there is currently no way to reset it. E-mail is not supported as of 1.0.
++ This app comes with no content. It means, you have to write it yourself.
## License
-[MIT License](./LICENSE).
+Since 1.0, Salvi is licensed under the [Apache License, Version 2.0](LICENSE), a non-copyleft free and open source license.
+
+Salvi`<=0.9` is under **[MIT license](LICENSE.0_9)**, available at `LICENSE.0_9`.
+
+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.
diff --git a/alembic/README b/alembic/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/alembic/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/alembic/env.py b/alembic/env.py
new file mode 100644
index 0000000..a283d9d
--- /dev/null
+++ b/alembic/env.py
@@ -0,0 +1,79 @@
+from logging.config import fileConfig
+
+from sqlalchemy import engine_from_config
+from sqlalchemy import pool
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from salvi.models import Base
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection, target_metadata=target_metadata
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/alembic/script.py.mako b/alembic/script.py.mako
new file mode 100644
index 0000000..fbc4b07
--- /dev/null
+++ b/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/alembic/versions/ae0587e14725_.py b/alembic/versions/ae0587e14725_.py
new file mode 100644
index 0000000..a9c6a70
--- /dev/null
+++ b/alembic/versions/ae0587e14725_.py
@@ -0,0 +1,86 @@
+"""empty message
+
+Sorry, due to unattended changes, upgrade from 1.0.0 is not reliable and untested. Sorry.
+
+Revision ID: ae0587e14725
+Revises:
+Create Date: 2025-10-04 09:43:41.158057
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision: str = 'ae0587e14725'
+down_revision: Union[str, None] = 'ebde30d24167'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ #op.drop_index('pagepolicykey_passphrase_sec_code', table_name='pagepolicykey')
+ #op.drop_index('page_owner', table_name='page')
+ op.create_index(op.f('ix_page_calendar'), 'page', ['calendar'], unique=False)
+ op.create_index('page_calendar', 'page', ['calendar'], unique=False)
+ op.drop_index('user_id', table_name='usergroupmembership')
+ op.drop_index('user_id_2', table_name='usergroupmembership')
+ #op.drop_index('usergroupmembership_group_id', table_name='usergroupmembership')
+ #op.drop_index('usergroupmembership_user_id', table_name='usergroupmembership')
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_index('usergroupmembership_user_id', 'usergroupmembership', ['user_id'], unique=False)
+ op.create_index('usergroupmembership_group_id', 'usergroupmembership', ['group_id'], unique=False)
+ op.create_index('user_id_2', 'usergroupmembership', ['user_id', 'group_id'], unique=True)
+ op.create_index('user_id', 'usergroupmembership', ['user_id', 'group_id'], unique=True)
+ op.drop_index('page_calendar', table_name='page')
+ op.drop_index(op.f('ix_page_calendar'), table_name='page')
+ op.create_index('page_owner', 'page', ['owner_id'], unique=False)
+ op.create_table('upload',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
+ sa.Column('url_name', mysql.VARCHAR(length=256), nullable=True),
+ sa.Column('filetype', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('filesize', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('upload_date', mysql.DATETIME(), nullable=False),
+ sa.Column('md5', mysql.VARCHAR(length=32), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mariadb_collate='utf8mb4_general_ci',
+ mariadb_default_charset='utf8mb4',
+ mariadb_engine='InnoDB'
+ )
+ op.create_index('upload_upload_date', 'upload', ['upload_date'], unique=False)
+ op.create_index('upload_md5', 'upload', ['md5'], unique=False)
+ op.create_table('pagepolicy',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('page_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
+ sa.Column('type', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('key_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('sitewide', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.ForeignKeyConstraint(['key_id'], ['pagepolicykey.id'], name='pagepolicy_FK_0_0'),
+ sa.ForeignKeyConstraint(['page_id'], ['page.id'], name='pagepolicy_FK_1_0'),
+ sa.PrimaryKeyConstraint('id'),
+ mariadb_collate='utf8mb4_general_ci',
+ mariadb_default_charset='utf8mb4',
+ mariadb_engine='InnoDB'
+ )
+ op.create_index('pagepolicy_page_id_key_id', 'pagepolicy', ['page_id', 'key_id'], unique=True)
+ op.create_index('pagepolicy_page_id', 'pagepolicy', ['page_id'], unique=False)
+ op.create_index('pagepolicy_key_id', 'pagepolicy', ['key_id'], unique=False)
+ op.create_table('pagepolicykey',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('passphrase', mysql.VARCHAR(length=255), nullable=False),
+ sa.Column('sec_code', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mariadb_collate='utf8mb4_general_ci',
+ mariadb_default_charset='utf8mb4',
+ mariadb_engine='InnoDB'
+ )
+ op.create_index('pagepolicykey_passphrase_sec_code', 'pagepolicykey', ['passphrase', 'sec_code'], unique=True)
+ # ### end Alembic commands ###
diff --git a/alembic/versions/ebde30d24167_.py b/alembic/versions/ebde30d24167_.py
new file mode 100644
index 0000000..222417e
--- /dev/null
+++ b/alembic/versions/ebde30d24167_.py
@@ -0,0 +1,312 @@
+"""empty message
+
+Revision ID: ebde30d24167
+Revises: ae0587e14725
+Create Date: 2025-10-10 19:01:04.309127
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision: str = 'ebde30d24167'
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('pagepolicy',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('page_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
+ sa.Column('type', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('key_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('sitewide', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.ForeignKeyConstraint(['key_id'], ['pagepolicykey.id'], name='pagepolicy_FK_0_0'),
+ sa.ForeignKeyConstraint(['page_id'], ['page.id'], name='pagepolicy_FK_1_0'),
+ sa.PrimaryKeyConstraint('id'),
+ mariadb_collate='utf8mb4_general_ci',
+ mariadb_default_charset='utf8mb4',
+ mariadb_engine='InnoDB'
+ )
+ op.create_index('pagepolicy_page_id_key_id', 'pagepolicy', ['page_id', 'key_id'], unique=True)
+ op.create_index('pagepolicy_page_id', 'pagepolicy', ['page_id'], unique=False)
+ op.create_index('pagepolicy_key_id', 'pagepolicy', ['key_id'], unique=False)
+ op.create_table('pagepolicykey',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('passphrase', mysql.VARCHAR(length=255), nullable=False),
+ sa.Column('sec_code', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mariadb_collate='utf8mb4_general_ci',
+ mariadb_default_charset='utf8mb4',
+ mariadb_engine='InnoDB'
+ )
+ op.alter_column('page', 'title',
+ existing_type=mysql.VARCHAR(length=256),
+ nullable=True)
+ op.alter_column('page', 'touched',
+ existing_type=mysql.DATETIME(),
+ nullable=True)
+ op.alter_column('page', 'flags',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=True)
+ op.drop_index('page_calendar', table_name='page')
+ op.drop_index('page_owner', table_name='page')
+ op.drop_index('page_title', table_name='page')
+ op.drop_index('page_touched', table_name='page')
+ op.create_index(op.f('ix_page_title'), 'page', ['title'], unique=False)
+ op.create_index(op.f('ix_page_touched'), 'page', ['touched'], unique=False)
+ op.alter_column('pagelink', 'from_page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pagelink', 'to_page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.drop_constraint('pagelink_ibfk_2', 'pagelink', type_='foreignkey')
+ op.drop_constraint('pagelink_ibfk_1', 'pagelink', type_='foreignkey')
+ op.create_foreign_key(None, 'pagelink', 'page', ['from_page_id'], ['id'])
+ op.create_foreign_key(None, 'pagelink', 'page', ['to_page_id'], ['id'])
+ op.alter_column('pagepermission', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pagepermission', 'group_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pagepermission', 'permissions',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=True)
+ op.drop_constraint('pagepermission_ibfk_4', 'pagepermission', type_='foreignkey')
+ op.drop_constraint('pagepermission_ibfk_5', 'pagepermission', type_='foreignkey')
+ op.create_foreign_key(None, 'pagepermission', 'usergroup', ['group_id'], ['id'])
+ op.create_foreign_key(None, 'pagepermission', 'page', ['page_id'], ['id'])
+ op.alter_column('pageproperty', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pageproperty', 'key',
+ existing_type=mysql.VARCHAR(length=64),
+ nullable=True)
+ op.alter_column('pageproperty', 'value',
+ existing_type=mysql.VARCHAR(length=8000),
+ nullable=True)
+ op.alter_column('pagerevision', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pagerevision', 'comment',
+ existing_type=mysql.VARCHAR(length=1024),
+ nullable=True)
+ op.alter_column('pagerevision', 'textref_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pagerevision', 'pub_date',
+ existing_type=mysql.DATETIME(),
+ nullable=True)
+ op.alter_column('pagerevision', 'length',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.drop_constraint('pagerevision_ibfk_1', 'pagerevision', type_='foreignkey')
+ op.drop_constraint('pagerevision_ibfk_2', 'pagerevision', type_='foreignkey')
+ op.drop_constraint('pagerevision_ibfk_3', 'pagerevision', type_='foreignkey')
+ op.create_foreign_key(None, 'pagerevision', 'pagetext', ['textref_id'], ['id'])
+ op.create_foreign_key(None, 'pagerevision', 'page', ['page_id'], ['id'])
+ op.create_foreign_key(None, 'pagerevision', 'user', ['user_id'], ['id'])
+ op.alter_column('pagetag', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('pagetag', 'name',
+ existing_type=mysql.VARCHAR(length=64),
+ nullable=True)
+ op.drop_constraint('pagetag_ibfk_1', 'pagetag', type_='foreignkey')
+ op.create_foreign_key(None, 'pagetag', 'page', ['page_id'], ['id'])
+ op.alter_column('pagetext', 'content',
+ existing_type=sa.BLOB(),
+ nullable=True)
+ op.alter_column('pagetext', 'flags',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=True)
+ op.add_column('user', sa.Column('privileges', sa.BigInteger(), nullable=True))
+ op.add_column('user', sa.Column('restrictions', sa.BigInteger(), nullable=True))
+ op.alter_column('user', 'username',
+ existing_type=mysql.VARCHAR(length=32),
+ nullable=True)
+ op.alter_column('user', 'password',
+ existing_type=mysql.VARCHAR(length=255),
+ nullable=True)
+ op.alter_column('user', 'join_date',
+ existing_type=mysql.DATETIME(),
+ nullable=True)
+ op.alter_column('user', 'karma',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.drop_column('user', 'is_disabled')
+ op.drop_column('user', 'is_admin')
+ op.drop_column('user', 'color_theme')
+ op.alter_column('usergroup', 'name',
+ existing_type=mysql.VARCHAR(length=32),
+ nullable=True)
+ op.alter_column('usergroup', 'permissions',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=True)
+ op.alter_column('usergroupmembership', 'user_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('usergroupmembership', 'group_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=True)
+ op.alter_column('usergroupmembership', 'since',
+ existing_type=mysql.DATETIME(),
+ nullable=True)
+ op.drop_index('usergroupmembership_group_id', table_name='usergroupmembership')
+ op.drop_index('usergroupmembership_user_id', table_name='usergroupmembership')
+ # ### end Alembic commands ###
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_index('usergroupmembership_user_id', 'usergroupmembership', ['user_id'], unique=False)
+ op.create_index('usergroupmembership_group_id', 'usergroupmembership', ['group_id'], unique=False)
+ op.alter_column('usergroupmembership', 'since',
+ existing_type=mysql.DATETIME(),
+ nullable=False)
+ op.alter_column('usergroupmembership', 'group_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('usergroupmembership', 'user_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('usergroup', 'permissions',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=False)
+ op.alter_column('usergroup', 'name',
+ existing_type=mysql.VARCHAR(length=32),
+ nullable=False)
+ op.add_column('user', sa.Column('color_theme', mysql.SMALLINT(display_width=6), server_default=sa.text('0'), autoincrement=False, nullable=False))
+ op.add_column('user', sa.Column('is_admin', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
+ op.add_column('user', sa.Column('is_disabled', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
+ op.alter_column('user', 'karma',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('user', 'join_date',
+ existing_type=mysql.DATETIME(),
+ nullable=False)
+ op.alter_column('user', 'password',
+ existing_type=mysql.VARCHAR(length=255),
+ nullable=False)
+ op.alter_column('user', 'username',
+ existing_type=mysql.VARCHAR(length=32),
+ nullable=False)
+ op.drop_column('user', 'restrictions')
+ op.drop_column('user', 'privileges')
+ op.alter_column('pagetext', 'flags',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=False)
+ op.alter_column('pagetext', 'content',
+ existing_type=sa.BLOB(),
+ nullable=False)
+ op.drop_constraint(None, 'pagetag', type_='foreignkey')
+ op.create_foreign_key('pagetag_ibfk_1', 'pagetag', 'page', ['page_id'], ['id'], ondelete='CASCADE')
+ op.alter_column('pagetag', 'name',
+ existing_type=mysql.VARCHAR(length=64),
+ nullable=False)
+ op.alter_column('pagetag', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.drop_constraint(None, 'pagerevision', type_='foreignkey')
+ op.drop_constraint(None, 'pagerevision', type_='foreignkey')
+ op.drop_constraint(None, 'pagerevision', type_='foreignkey')
+ op.create_foreign_key('pagerevision_ibfk_3', 'pagerevision', 'user', ['user_id'], ['id'], ondelete='SET NULL')
+ op.create_foreign_key('pagerevision_ibfk_2', 'pagerevision', 'page', ['page_id'], ['id'], ondelete='CASCADE')
+ op.create_foreign_key('pagerevision_ibfk_1', 'pagerevision', 'pagetext', ['textref_id'], ['id'], ondelete='CASCADE')
+ op.alter_column('pagerevision', 'length',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('pagerevision', 'pub_date',
+ existing_type=mysql.DATETIME(),
+ nullable=False)
+ op.alter_column('pagerevision', 'textref_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('pagerevision', 'comment',
+ existing_type=mysql.VARCHAR(length=1024),
+ nullable=False)
+ op.alter_column('pagerevision', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('pageproperty', 'value',
+ existing_type=mysql.VARCHAR(length=8000),
+ nullable=False)
+ op.alter_column('pageproperty', 'key',
+ existing_type=mysql.VARCHAR(length=64),
+ nullable=False)
+ op.alter_column('pageproperty', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.drop_constraint(None, 'pagepermission', type_='foreignkey')
+ op.drop_constraint(None, 'pagepermission', type_='foreignkey')
+ op.create_foreign_key('pagepermission_ibfk_5', 'pagepermission', 'usergroup', ['group_id'], ['id'], ondelete='CASCADE')
+ op.create_foreign_key('pagepermission_ibfk_4', 'pagepermission', 'page', ['page_id'], ['id'], ondelete='CASCADE')
+ op.alter_column('pagepermission', 'permissions',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=False)
+ op.alter_column('pagepermission', 'group_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('pagepermission', 'page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.drop_constraint(None, 'pagelink', type_='foreignkey')
+ op.drop_constraint(None, 'pagelink', type_='foreignkey')
+ op.create_foreign_key('pagelink_ibfk_1', 'pagelink', 'page', ['to_page_id'], ['id'], ondelete='CASCADE')
+ op.create_foreign_key('pagelink_ibfk_2', 'pagelink', 'page', ['from_page_id'], ['id'], ondelete='CASCADE')
+ op.alter_column('pagelink', 'to_page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.alter_column('pagelink', 'from_page_id',
+ existing_type=mysql.INTEGER(display_width=11),
+ nullable=False)
+ op.drop_index(op.f('ix_page_touched'), table_name='page')
+ op.drop_index(op.f('ix_page_title'), table_name='page')
+ op.create_index('page_touched', 'page', ['touched'], unique=False)
+ op.create_index('page_title', 'page', ['title'], unique=False)
+ op.create_index('page_owner', 'page', ['owner_id'], unique=False)
+ op.create_index('page_calendar', 'page', ['calendar'], unique=False)
+ op.alter_column('page', 'flags',
+ existing_type=mysql.BIGINT(display_width=20),
+ nullable=False)
+ op.alter_column('page', 'touched',
+ existing_type=mysql.DATETIME(),
+ nullable=False)
+ op.alter_column('page', 'title',
+ existing_type=mysql.VARCHAR(length=256),
+ nullable=False)
+ op.create_table('upload',
+ sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
+ sa.Column('name', mysql.VARCHAR(length=256), nullable=False),
+ sa.Column('url_name', mysql.VARCHAR(length=256), nullable=True),
+ sa.Column('filetype', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('filesize', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
+ sa.Column('upload_date', mysql.DATETIME(), nullable=False),
+ sa.Column('md5', mysql.VARCHAR(length=32), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ mariadb_collate='utf8mb4_general_ci',
+ mariadb_default_charset='utf8mb4',
+ mariadb_engine='InnoDB'
+ )
+ op.create_index('upload_upload_date', 'upload', ['upload_date'], unique=False)
+ op.create_index('upload_md5', 'upload', ['md5'], unique=False)
+ try:
+ ## drop tables removed in 0.x
+ op.drop_table('pagepolicykey')
+ op.drop_index('pagepolicy_key_id', table_name='pagepolicy')
+ op.drop_index('pagepolicy_page_id', table_name='pagepolicy')
+ op.drop_index('pagepolicy_page_id_key_id', table_name='pagepolicy')
+ op.drop_table('pagepolicy')
+ op.drop_index('upload_md5', table_name='upload')
+ op.drop_index('upload_upload_date', table_name='upload')
+ op.drop_table('upload')
+ except Exception:
+ pass
+ # ### end Alembic commands ###
diff --git a/app.py b/app.py
deleted file mode 100644
index 960e63a..0000000
--- a/app.py
+++ /dev/null
@@ -1,893 +0,0 @@
-# (C) 2020-2021 Sakuragasaki46.
-# See LICENSE for copying info.
-
-'''
-A simple wiki-like note webapp.
-
-Pages are stored in SQLite databases.
-Markdown is used for text formatting.
-
-Application is kept compact, with all its core in a single file.
-Extensions are supported (?), kept in extensions/ folder.
-'''
-
-#### IMPORTS ####
-
-from flask import (
- Flask, Markup, abort, flash, g, jsonify, make_response, redirect, request,
- render_template, send_from_directory)
-from werkzeug.routing import BaseConverter
-from peewee import *
-import csv, datetime, hashlib, html, importlib, json, markdown, os, random, \
- re, sys, uuid, warnings
-from functools import lru_cache, partial
-from urllib.parse import quote
-from configparser import ConfigParser
-try:
- import gzip
-except ImportError:
- gzip = None
-try:
- from slugify import slugify
-except ImportError:
- slugify = None
-try:
- import markdown_strikethrough
-except Exception:
- markdown_strikethrough = None
-
-__version__ = '0.4'
-
-#### CONSTANTS ####
-
-APP_BASE_DIR = os.path.dirname(__file__)
-
-FK = ForeignKeyField
-
-SLUG_RE = r'[a-z0-9]+(?:-[a-z0-9]+)*'
-MAGIC_RE = r'\{\{\s*(' + SLUG_RE + ')\s*:\s*(.*?)\s*\}\}'
-REDIRECT_RE = r'\{\{\s*redirect\s*:\s*(\d+)\s*\}\}'
-
-upload_types = {'jpeg': 1, 'jpg': 1, 'png': 2}
-upload_types_rev = {1: 'jpg', 2: 'png'}
-
-UPLOAD_DIR = APP_BASE_DIR + '/media'
-DATABASE_DIR = APP_BASE_DIR + "/database"
-
-#### GENERAL CONFIG ####
-
-DEFAULT_CONF = {
- ('site', 'title'): 'Salvi',
- ('config', 'media_dir'): APP_BASE_DIR + '/media',
- ('config', 'database_dir'): APP_BASE_DIR + "/database",
-}
-
-_cfp = ConfigParser()
-if _cfp.read([APP_BASE_DIR + '/site.conf']):
- @lru_cache(maxsize=50)
- def _getconf(k1, k2, fallback=None, cast=None):
- if fallback is None:
- fallback = DEFAULT_CONF.get((k1, k2))
- v = _cfp.get(k1, k2, fallback=fallback)
- if cast in (int, float, str):
- try:
- v = cast(v)
- except ValueError:
- v = fallback
- return v
-else:
- def _getconf(k1, k2, fallback=None, cast=None):
- if fallback is None:
- fallback = DEFAULT_CONF.get((k1, k2))
- return fallback
-
-#### misc. helpers ####
-
-def _makelist(l):
- if isinstance(l, (str, bytes, bytearray)):
- return [l]
- elif hasattr(l, '__iter__'):
- return list(l)
- elif l:
- return [l]
- else:
- return []
-
-#### DATABASE SCHEMA ####
-
-database = SqliteDatabase(_getconf("config", "database_dir") + '/data.sqlite')
-
-class BaseModel(Model):
- class Meta:
- database = database
-
-# Used for PagePolicy
-def _passphrase_hash(pp):
- pp_bin = pp.encode('utf-8')
- h = str(len(pp_bin)) + ':' + hashlib.sha256(pp_bin).hexdigest()
- return h
-
-class Page(BaseModel):
- url = CharField(64, unique=True, null=True)
- title = CharField(256, index=True)
- touched = DateTimeField(index=True)
- flags = BitField()
- is_redirect = flags.flag(1)
- is_sync = flags.flag(2)
- @property
- def latest(self):
- if self.revisions:
- return self.revisions.order_by(PageRevision.pub_date.desc())[0]
- def get_url(self):
- return '/' + self.url + '/' if self.url else '/p/{}/'.format(self.id)
- def short_desc(self):
- text = remove_tags(self.latest.text)
- return text[:200] + ('\u2026' if len(text) > 200 else '')
- def change_tags(self, new_tags):
- old_tags = set(x.name for x in self.tags)
- new_tags = set(new_tags)
- PageTag.delete().where((PageTag.page == self) &
- (PageTag.name << (old_tags - new_tags))).execute()
- for tag in (new_tags - old_tags):
- PageTag.create(page=self, name=tag)
- def js_info(self):
- latest = self.latest
- return dict(
- id=self.id,
- url=self.url,
- title=self.title,
- is_redirect=self.is_redirect,
- touched=self.touched.timestamp(),
- is_editable=self.is_editable(),
- latest=dict(
- id=latest.id if latest else None,
- length=latest.length,
- pub_date=latest.pub_date.timestamp() if latest and latest.pub_date else None
- ),
- tags=[x.name for x in self.tags]
- )
- @property
- def prop(self):
- return PagePropertyDict(self)
- def unlock(self, perm, pp, sec):
- ## XX complete later!
- policies = self.policies.where(PagePolicy.type << _makelist(perm))
- if not policies.exists():
- return True
- for policy in policies:
- if policy.verify(pp, sec):
- return True
- return False
- def is_locked(self, perm):
- policies = self.policies.where(PagePolicy.type << _makelist(perm))
- return policies.exists()
- def is_classified(self):
- return self.is_locked(POLICY_CLASSIFY)
- def is_editable(self):
- return not self.is_locked(POLICY_EDIT)
-
-
-class PageText(BaseModel):
- content = BlobField()
- flags = BitField()
- is_utf8 = flags.flag(1)
- is_gzipped = flags.flag(2)
- def get_content(self):
- c = self.content
- if self.is_gzipped:
- c = gzip.decompress(c)
- if self.is_utf8:
- return c.decode('utf-8')
- else:
- return c.decode('latin-1')
- @classmethod
- def create_content(cls, text, treshold=600, search_dup=True):
- c = text.encode('utf-8')
- use_gzip = len(c) > treshold
- if use_gzip and gzip:
- c = gzip.compress(c)
- if search_dup:
- item = cls.get_or_none((cls.content == c) & (cls.is_gzipped == use_gzip))
- if item:
- return item
- return cls.create(
- content=c,
- is_utf8=True,
- is_gzipped=use_gzip
- )
-
-class PageRevision(BaseModel):
- page = FK(Page, backref='revisions', index=True)
- user_id = IntegerField(default=0)
- comment = CharField(1024, default='')
- textref = FK(PageText)
- pub_date = DateTimeField(index=True)
- length = IntegerField()
- @property
- def text(self):
- return self.textref.get_content()
- def html(self):
- return md(self.text)
- def human_pub_date(self):
- delta = datetime.datetime.now() - self.pub_date
- T = partial(get_string, g.lang)
- if delta < datetime.timedelta(seconds=60):
- return T('just-now')
- elif delta < datetime.timedelta(seconds=3600):
- return T('n-minutes-ago').format(delta.seconds // 60)
-
- elif delta < datetime.timedelta(days=1):
- return T('n-hours-ago').format(delta.seconds // 3600)
- elif delta < datetime.timedelta(days=15):
- return T('n-days-ago').format(delta.days)
- else:
- return self.pub_date.strftime('%B %-d, %Y')
-
-class PageTag(BaseModel):
- page = FK(Page, backref='tags', index=True)
- name = CharField(64, index=True)
- class Meta:
- indexes = (
- (('page', 'name'), True),
- )
- def popularity(self):
- return PageTag.select().where(PageTag.name == self.name).count()
-
-class PageProperty(BaseModel):
- page = ForeignKeyField(Page, backref='page_meta', index=True)
- key = CharField(64)
- value = CharField(8000)
- class Meta:
- indexes = (
- (('page', 'key'), True),
- )
-
-# currently experimental
-class PagePropertyDict(object):
- def __init__(self, page):
- self._page = page
- def items(self):
- for kv in self._page.page_meta:
- yield kv.key, kv.value
- def __len__(self):
- return self._page.page_meta.count()
- def keys(self):
- for kv in self._page.page_meta:
- yield kv.key
- __iter__ = keys
- def __getitem__(self, key):
- try:
- return self._page.page_meta.get(PageProperty.key == key).value
- except PageProperty.DoesNotExist:
- raise KeyError(key)
- def get(self, key, default=None):
- try:
- return self._page.page_meta.get(PageProperty.key == key).value
- except PageProperty.DoesNotExist:
- return default
- def setdefault(self, key, default):
- try:
- return self._page.page_meta.get(PageProperty.key == key).value
- except PageProperty.DoesNotExist:
- self[key] = default
- return default
- def __setitem__(self, key, value):
- if key in self:
- pp = self._page.page_meta.get(PageProperty.key == key)
- pp.value = value
- pp.save()
- else:
- PageProperty.create(page=self._page, key=key, value=value)
- def __delitem__(self, key):
- PageProperty.delete().where((PageProperty.page == self._page) &
- (PageProperty.key == key)).execute()
- def __contains__(self, key):
- return PageProperty.select().where((PageProperty.page == self._page) &
- (PageProperty.key == key)).exists()
-
-# Store keys for PagePolicy.
-# Experimental.
-class PagePolicyKey(BaseModel):
- passphrase = CharField()
- sec_code = IntegerField()
- class Meta:
- indexes = (
- (('passphrase','sec_code'), True),
- )
-
- @classmethod
- def create_from_plain(cls, pp, sec):
- PagePolicyKey.create(passphrase=_passphrase_hash(pp), sec_code=sec)
- def verify(self, pp, sec):
- h = _passphrase_hash(pp)
- return self.passphrase == h and self.sec_code == sec
-
-POLICY_ADMIN = 1
-POLICY_READ = 2
-POLICY_EDIT = 3
-POLICY_META = 4
-POLICY_CLASSIFY = 5
-
-# Manage policies for pages (e.g., reading or editing).
-# Experimental.
-class PagePolicy(BaseModel):
- page = FK(Page, backref='policies', index=True, null=True)
- type = IntegerField()
- key = FK(PagePolicyKey, backref='applied_to')
- sitewide = IntegerField(default=0)
-
- class Meta:
- indexes = (
- (('page', 'key'), True),
- )
-
-class Upload(BaseModel):
- name = CharField(256)
- url_name = CharField(256, null=True)
- filetype = SmallIntegerField()
- filesize = IntegerField()
- upload_date = DateTimeField(index=True)
- md5 = CharField(32, index=True)
- @property
- def filepath(self):
- return '{0}/{1}/{2}{3}.{4}'.format(self.md5[:1], self.md5[:2], self.id,
- '-' + self.url_name if self.url_name else '', upload_types_rev[self.filetype])
- @property
- def url(self):
- return '/media/' + self.filepath
- def get_content(self, check=True):
- with open(os.path.join(UPLOAD_DIR, self.filepath)) as f:
- content = f.read()
- if check:
- if len(content) != self.filesize:
- raise AssertionError('file is corrupted')
- if hashlib.md5(content).hexdigest() != self.md5:
- raise AssertionError('file is corrupted')
- return content
- @classmethod
- def create_content(cls, name, ext, content):
- ext = ext.lstrip('.')
- if ext not in upload_types:
- raise ValueError('invalid file type')
- filetype = upload_types[ext]
- name = name[:256]
- if slugify:
- url_name = slugify(name)[:256]
- else:
- url_name = None
- filemd5 = hashlib.md5(content).hexdigest()
- basepath = os.path.join(UPLOAD_DIR, filemd5[:1], filemd5[:2])
- if not os.path.exists(basepath):
- os.makedirs(basepath)
- obj = cls.create(
- name=name,
- url_name=url_name,
- filetype=filetype,
- filesize=len(content),
- upload_date=datetime.datetime.now(),
- md5=filemd5
- )
- try:
- with open(os.path.join(basepath, '{0}{1}.{2}'.format(obj.id,
- '-' + url_name if url_name else '', upload_types_rev[filetype]
- )), 'wb') as f:
- f.write(content)
- except OSError:
- cls.delete_by_id(obj.id)
- raise
- return obj
-
-def init_db():
- database.create_tables([Page, PageText, PageRevision, PageTag, PageProperty, PagePolicyKey, PagePolicy, Upload])
-
-#### WIKI SYNTAX ####
-
-magic_word_filters = {}
-
-def _replace_magic_word(match):
- name = match.group(1)
- if name not in magic_word_filters:
- return match.group()
- f = magic_word_filters[name]
- try:
- return f(*(x.strip() for x in match.group(2).split('|')))
- except Exception:
- return ''
-
-def expand_magic_words(text):
- '''
- Replace the special markups in double curly brackets.
-
- Unknown keywords are not replaced. Valid keywords with invalid arguments are replaced with nothing.
- '''
- return re.sub(MAGIC_RE, _replace_magic_word, text)
-
-def md(text, expand_magic=False, toc=True):
- if expand_magic:
- # DEPRECATED seeking for a better solution.
- warnings.warn('Magic words are no more supported.', DeprecationWarning)
- text = expand_magic_words(text)
- extensions = ['tables', 'footnotes', 'fenced_code', 'sane_lists']
- if markdown_strikethrough:
- extensions.append("markdown_strikethrough.extension")
- if toc:
- extensions.append('toc')
- return markdown.Markdown(extensions=extensions).convert(text)
-
-def remove_tags(text, convert=True, headings=True):
- if headings:
- text = re.sub(r'\#[^\n]*', '', text)
- if convert:
- text = md(text, expand_magic=False, toc=False)
- return re.sub(r'<.*?>|\{\{.*?\}\}', '', text)
-
-
-### Magic words (deprecated!) ###
-
-def expand_backto(pageid):
- p = Page[pageid]
- return '*« Main article: [{}]({}).*'.format(html.escape(p.title), p.get_url())
-
-magic_word_filters['backto'] = expand_backto
-
-def expand_upload(id, *opt):
- try:
- upload = Upload[id]
- except Upload.DoesNotExist:
- return ''
- if opt:
- desc = opt[-1]
- else:
- desc = None
- classname = 'fig-right'
- return '{3}'.format(
- classname, html.escape(upload.name), upload.url,
- '{0}'.format(md(desc, expand_magic=False)) if desc else '',
- upload.id)
-
-magic_word_filters['media'] = expand_upload
-
-def make_gallery(items):
- result = []
- for upload, desc in items:
- result.append('{3}'.format(
- upload.id, html.escape(upload.name), upload.url,
- '{0}'.format(md(desc, expand_magic=False)) if desc else ''))
- return '
' + ''.join(result) + '
'
-
-def expand_gallery(*ids):
- items = []
- for i in ids:
- if ' ' in i:
- id, desc = i.split(' ', 1)
- else:
- id, desc = i, ''
- try:
- upload = Upload[id]
- except Upload.DoesNotExist:
- continue
- items.append((upload, desc))
- return make_gallery(items)
-
-magic_word_filters['gallery'] = expand_gallery
-
-#### I18N ####
-
-lang_poses = {'en': 1, 'en-US': 1, 'it': 2, 'it-IT': 2}
-
-def read_strings():
- with open(APP_BASE_DIR + '/strings.csv', encoding='utf-8') as f:
- return csv.reader(f)
-
-@lru_cache(maxsize=1000)
-def get_string(lang, name):
- with open(APP_BASE_DIR + '/strings.csv', encoding='utf-8') as f:
- for line in csv.reader(f):
- if not line[0] or line[0].startswith('#'):
- continue
- if line[0] == name:
- ln = lang_poses[lang]
- if len(line) > ln and line[ln]:
- return line[ln]
- elif len(line) > 1:
- return line[1]
- return '(' + name + ')'
-
-
-#### APPLICATION CONFIG ####
-
-class SlugConverter(BaseConverter):
- regex = SLUG_RE
-
-def is_valid_url(url):
- return re.fullmatch(SLUG_RE, url)
-
-def is_url_available(url):
- return url not in forbidden_urls and not Page.select().where(Page.url == url).exists()
-
-forbidden_urls = [
- 'create', 'edit', 'p', 'ajax', 'history', 'manage', 'static', 'media',
- 'accounts', 'tags', 'init-config', 'upload', 'upload-info', 'about',
- 'stats', 'terms', 'privacy', 'easter', 'search', 'help', 'circles',
- 'protect', 'kt', 'embed'
-]
-
-app = Flask(__name__)
-app.secret_key = 'qrdldCcvamtdcnidmtasegasdsedrdqvtautar'
-app.url_map.converters['slug'] = SlugConverter
-
-
-#### ROUTES ####
-
-@app.before_request
-def _before_request():
- for l in request.headers.get('accept-language', 'it,en').split(','):
- if ';' in l:
- l, _ = l.split(';')
- if l in lang_poses:
- lang = l
- break
- else:
- lang = 'en'
- g.lang = lang
-
-@app.context_processor
-def _inject_variables():
- return {
- 'T': partial(get_string, g.lang),
- 'app_name': _getconf('site', 'title'),
- 'strong': lambda x:Markup('{0}').format(x),
- }
-
-@app.template_filter()
-def linebreaks(text):
- text = html.escape(text)
- text = text.replace("\n\n", '
').replace('\n', ' ')
- return Markup(text)
-
-@app.route('/')
-def homepage():
- page_limit = _getconf("appearance","items_per_page",20,cast=int)
- return render_template('home.html', new_notes=Page.select()
- .order_by(Page.touched.desc()).limit(page_limit),
- gallery=make_gallery((x, '') for x in Upload.select().order_by(Upload.upload_date.desc()).limit(3)))
-
-@app.route('/robots.txt')
-def robots():
- return send_from_directory(APP_BASE_DIR, 'robots.txt')
-
-@app.route('/favicon.ico')
-def favicon():
- return send_from_directory(APP_BASE_DIR, 'favicon.ico')
-
-## error handlers ##
-
-@app.errorhandler(404)
-def error_404(body):
- return render_template('notfound.html'), 404
-
-@app.errorhandler(403)
-def error_403(body):
- return render_template('forbidden.html'), 403
-
-@app.errorhandler(500)
-def error_400(body):
- return render_template('badrequest.html'), 400
-
-# Middle point during page editing.
-def savepoint(form, is_preview=False):
- if is_preview:
- preview = md(form['text'])
- else:
- preview = None
- pl_js_info = dict()
- pl_js_info['editing'] = dict(
- original_text = None, # TODO
- preview_text = form['text'],
- )
- return render_template('edit.html', pl_url=form['url'], pl_title=form['title'], pl_text=form['text'], pl_tags=form['tags'], preview=preview, pl_js_info=pl_js_info)
-
-@app.route('/create/', methods=['GET', 'POST'])
-def create():
- if request.method == 'POST':
- if request.form.get('preview'):
- return savepoint(request.form, is_preview=True)
- p_url = request.form['url'] or None
- if p_url:
- if not is_valid_url(p_url):
- flash('Invalid URL. Valid URLs contain only letters, numbers and hyphens.')
- return savepoint(request.form)
- elif not is_url_available(p_url):
- flash('This URL is not available.')
- return savepoint(request.form)
- p_tags = [x.strip().lower().replace(' ', '-').replace('_', '-').lstrip('#')
- for x in request.form.get('tags', '').split(',') if x]
- if any(not re.fullmatch(SLUG_RE, x) for x in p_tags):
- flash('Invalid tags text. Tags contain only letters, numbers and hyphens, and are separated by comma.')
- return savepoint(request.form)
- try:
- p = Page.create(
- url=p_url,
- title=request.form['title'],
- is_redirect=False,
- touched=datetime.datetime.now(),
- )
- p.change_tags(p_tags)
- except IntegrityError as e:
- flash('An error occurred while saving this revision: {e}'.format(e=e))
- return savepoint(request.form)
- pr = PageRevision.create(
- page=p,
- user_id=0,
- comment='',
- textref=PageText.create_content(request.form['text']),
- pub_date=datetime.datetime.now(),
- length=len(request.form['text'])
- )
- return redirect(p.get_url())
- return render_template('edit.html', pl_url=request.args.get('url'))
-
-@app.route('/edit//', methods=['GET', 'POST'])
-def edit(id):
- p = Page[id]
- if request.method == 'POST':
- if request.form.get('preview'):
- return savepoint(request.form, is_preview=True)
- p_url = request.form['url'] or None
- if p_url:
- if not is_valid_url(p_url):
- flash('Invalid URL. Valid URLs contain only letters, numbers and hyphens.')
- return savepoint(request.form)
- elif not is_url_available(p_url) and p_url != p.url:
- flash('This URL is not available.')
- return savepoint(request.form)
- p_tags = [x.strip().lower().replace(' ', '-').replace('_', '-').lstrip('#')
- for x in request.form.get('tags', '').split(',')]
- p_tags = [x for x in p_tags if x]
- if any(not re.fullmatch(SLUG_RE, x) for x in p_tags):
- flash('Invalid tags text. Tags contain only letters, numbers and hyphens, and are separated by comma.')
- return savepoint(request.form)
- p.url = p_url
- p.title = request.form['title']
- p.touched = datetime.datetime.now()
- p.save()
- p.change_tags(p_tags)
- pr = PageRevision.create(
- page=p,
- user_id=0,
- comment='',
- textref=PageText.create_content(request.form['text']),
- pub_date=datetime.datetime.now(),
- length=len(request.form['text'])
- )
- return redirect(p.get_url())
- return render_template('edit.html', pl_url=p.url, pl_title=p.title, pl_text=p.latest.text, pl_tags=','.join(x.name for x in p.tags))
-
-@app.route("/__sync_start")
-def __sync_start():
- if _getconf("sync", "master", "this") == "this":
- abort(403)
- from app_sync import main
- main()
- flash("Successfully synced messages.")
- return redirect("/")
-
-@app.route('/_jsoninfo/', methods=['GET', 'POST'])
-def page_jsoninfo(id):
- try:
- p = Page[id]
- except Page.DoesNotExist:
- return jsonify({'status':'fail'}), 404
- j = p.js_info()
- j["status"] = "ok"
- if request.method == "POST":
- j["text"] = p.latest.text
- return jsonify(j)
-
-@app.route("/_jsoninfo/changed/")
-def jsoninfo_changed(ts):
- tse = str(datetime.datetime.fromtimestamp(ts).isoformat(" "))
- ps = Page.select().where(Page.touched >= tse)
- return jsonify({
- "ids": [i.id for i in ps],
- "status": "ok"
- })
-
-
-@app.route('/p//')
-def view_unnamed(id):
- try:
- p = Page[id]
- except Page.DoesNotExist:
- abort(404)
- if p.url:
- if p.url not in forbidden_urls:
- return redirect(p.get_url())
- else:
- flash('The URL of this page is a reserved URL. Please change it.')
- return render_template('view.html', p=p, rev=p.latest)
-
-@app.route('/embed//')
-def embed_view(id):
- try:
- p = Page[id]
- except Page.DoesNotExist:
- return "", 404
- rev = p.latest
- return "
You can export how many pages you want, that will be downloaded in JSON format and can be imported in another {{ app_name }} instance.
+
+
In order to add page to export list, please enter exact title, /url, #tag or +id. Entering a tag will add all pages with that tag to list. Each page or tag is separated by a newline.
+
+{% endblock %}
\ No newline at end of file
diff --git a/extensions/__init__.py b/salvi/templates/macros/icon.html
similarity index 100%
rename from extensions/__init__.py
rename to salvi/templates/macros/icon.html
diff --git a/salvi/templates/macros/nl.html b/salvi/templates/macros/nl.html
new file mode 100644
index 0000000..a4355fd
--- /dev/null
+++ b/salvi/templates/macros/nl.html
@@ -0,0 +1,70 @@
+
+
+
+{#
+ Recommendations: Always import this macro with context,
+ otherwise it fails. It depends on a couple context-defined functions.
+#}
+
+{# TODO rewrite to fit! #}
+{# TODO rewrite to fit! #}
+
+{% macro nl_list(l, hl_tags=(), hl_calendar=None, other_url='p/most_recent', is_main = False) %}
+{% set page_n = l.page %}
+{% set total_count = l.total %}
+
+{% if not is_main %}
+
+
+{% endblock %}
+
+{% block toc %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/salvi/templates/privacy.html b/salvi/templates/privacy.html
new file mode 100644
index 0000000..545fd66
--- /dev/null
+++ b/salvi/templates/privacy.html
@@ -0,0 +1,45 @@
+{% extends "base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+{% block title %}{{ title_tag('Privacy Policy') }}{% endblock %}
+
+{% block content %}
+
Privacy Policy
+
+
+{% filter markdown %}
+# Privacy
+
+## Cookies
+
+{{ app_name }} uses one cryptographically signed cookie to maintain secure authenticated sessions. This cookie is randomized with each new session.
+
+This cookie is used for the following purposes:
+
+* Authentication
+* Abuse mitigation
+* Per-device settings like light/dark theme selection.
+
+This cookie is mandatory and cannot be opted out. Manual alteration or removal of this cookie will result in deauthentication and reversion to default per-device settings.
+
+## Your information
+
+We collect basic information in order to operate the platform, and to mitigate abuse. This includes:
+
+* Your email address, if you choose to provide it.
+* The IP address from which content is submitted.
+* Whether or not you have indicated that you are 18 years of age.
+* Other saved personal preferances, which you can edit from your account settings.
+
+We use - and then promptly forget - the following information about you from third parties:
+
+* Verification from our captcha provider that you aren't a bot, during account registration
+
+{{ app_name }} will not release your information to third parties, except:
+
+* As required by Italian law
+* At our discretion, {{ app_name }} may share your information with Italian law enforcement in the event of an emergency, if we have good cause to believe that doing so would avert or mitigate the emergency.
+{% endfilter %}
+
+
+{% endblock %}
diff --git a/salvi/templates/register.html b/salvi/templates/register.html
new file mode 100644
index 0000000..c1a1c57
--- /dev/null
+++ b/salvi/templates/register.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+{% block title %}{{ title_tag(T('sign-up')) }}{% endblock %}
+
+{% block content %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/salvi/templates/rules.html b/salvi/templates/rules.html
new file mode 100644
index 0000000..a2aa57d
--- /dev/null
+++ b/salvi/templates/rules.html
@@ -0,0 +1,81 @@
+{% extends "base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+{% block title %}{{ title_tag('Content Policy') }}{% endblock %}
+
+{% block content %}
+
Content Policy
+
+
+{% filter markdown %}
+__THIS POLICY IS OUTDATED AND SUPERSEDED. SEE __
+
+These are the Rules of {{ app_name }}.
+
+{{ app_name }} is a Free Speech environment. However, in order to ensure
+the safety of our users as well as the longevity of the platform, there are a
+few things you are not permitted to do with the platform.
+
+By using {{ app_name }}, you agree to follow these rules.
+Violations may lead to the suspension or terminaton of your {{ app_name }} account.
+
+## 1. Intellectual Property
+
+You may not upload to, embed within, or link out from {{ app_name }}:
+
+1. Copyrighted material that you are not authorized to distribute
+2. Anything not legal to publish within, or export from, intentionally
+
+## 2. Digital Safety
+
+You may not upload to, embed within, or link out from {{ app_name }}:
+
+1. IP or token grabbers
+2. Viruses or exploits
+3. URL shorteners
+4. Anything with the intent of breaking {{ app_name }}
+
+## 3. User Safety
+
+You may not use {{ app_name }} to do any of the following:
+
+1. Threaten, intimidate, or harass other users
+2. Impersonate other users, real life people, or {{ app_name }} staff
+3. Solicit, collect, or publish personally identifiable information (PII), be it yours or the one
+ of another individual
+4. Spam (the definition of “spam” is at {{ app_name }}’s own discretion)
+
+## 4. Sexual Content
+
+You may not upload to, embed within, or link out from {{ app_name }}:
+
+1. Sexual or sexually suggestive material not marked "NSFW"
+2. Sexual or sexually suggestive material involving individuals under the age of 18, including fictitious content. Solicitation of such material is also prohibited.
+3. Sexual or sexually suggestive material involving individuals who did not consent to its creation and distribution (commonly called "revenge pornography" or "involuntary pornography"). Solicitation of such material is also prohibited.
+
+## 5. IRL Safety
+
+You may not use {{ app_name }} to do any of the following:
+
+1. Incite, plan, or execute unlawful or violent activity
+2. Engage in fraud
+
+## 6. Evil
+
+While it would be nice to be able to entertain all viewpoints, certain ideologies are ontologically evil. We have a zero tolerance policy on advocacy, propaganda, recruitment, and any other forms of promotion of evil.
+
+Evil ideologies prohibited from {{ app_name }} include, but are not limited to:
+
+* Ethnic, racial, or sex-based supremecism
+* Pedophile acceptance/normalization
+* Terrorism
+
+Discussion of these topics as they relate to current events or other subject matter is permitted; advocacy or promotion of them is not.
+
+## 7. Additional Rules
+
+Additional rules may be put in place by the {{ app_name }} administrator.
+
+{% endfilter %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/salvi/templates/search.html b/salvi/templates/search.html
new file mode 100644
index 0000000..4a40c59
--- /dev/null
+++ b/salvi/templates/search.html
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+{% block title %}{{ title_tag(T('search-results', "“" + q + "”") if q else T('search'), False) }}{% endblock %}
+
+{% block content %}
+
+
+
+{% block content %}
+
+
diff --git a/salvi/templates/terms.html b/salvi/templates/terms.html
new file mode 100644
index 0000000..b262dc5
--- /dev/null
+++ b/salvi/templates/terms.html
@@ -0,0 +1,147 @@
+{% extends "base.html" %}
+{% from "macros/title.html" import title_tag with context %}
+
+{% block title %}{{ title_tag('Terms of Service') }}{% endblock %}
+
+{% block content %}
+
Terms of Service
+
+
+{% filter markdown %}
+## Scope and Definitions
+
+These terms of service ("Terms") are between {{ app_name }}
+("{{ request.headers['Host'] }}") and You, regarding Your use of the social platform
+{{ app_name }}, and any other services or test servers which may exist
+now or in the future (collectively "{{ app_name }}").
+
+Content on this page displayed in blockquotes is provided for convenience
+and readability only. Blockquote text is not part of the terms of service.
+
+## Conduct and content
+
+You agree to follow the Content Policy [here](/rules/).
+The Content Policy is incorporated into these Terms by this reference.
+
+## Age
+
+You warrant that You are at least 13 years old.
+You warrant that You are at least 18 years old,
+if You indicate as such in Your user settings.
+
+
+## Account Access
+
+Your {{ app_name }} account is nontransferable. You will not buy, sell, gift, loan, or otherwise grant a third party access to Your {{ app_name }} account. You will not provide a third party with Your {{ app_name }} password or two-factor authentication codes.
+
+In the event that Your {{ app_name }} account is accessed by a third party anyways, You remain accountable for all actions taken by Your account.
+
+## Liability
+
+{{ app_name }} shall not be liable for Your damages arising from any of the following:
+
+* Use of the {{ app_name }} platform
+* Technical failure of {{ app_name }}
+* Administrative actions performed by {{ app_name }}, including both automatic and manual actions
+* Unauthorized access of a third party to Your {{ app_name }} account
+* Loss or breach of {{ app_name }} data
+* Force majeur
+
+You accept full liability for all content You upload to, or embed within, {{ app_name }}, and indemnify {{ app_name }} from all such liability.
+
+> You are responsible for everything You do, and everything that happens with, with Your {{ app_name }} account. Don't use {{ app_name }} to store important data or sensitive data.
+
+## Intellectual Property
+
+You grant {{ app_name }} a permanent, worldwide, and irrevocable license to store, modify, copy, and distribute all content that You submit to or upload to {{ app_name }}, including any creative works. Additionally, You grant {{ app_name }} a permanent, worldwide, and irrevocable license to sublicense these same rights to {{ app_name }}'s service providers. Additionally, You warrant that You are authorized to grant {{ app_name }} these rights.
+
+You retain all other intellectual property rights, including ownership.
+
+> We need to be able to store, copy, and distribute Your content, because that's just how websites like {{ app_name }} work. We also need to be able to modify it for security and formatting purposes.
+
+## Acceptable Use
+
+You will not use {{ app_name }} as a means to support any other website or service without written permission by {{ app_name }}.
+
+You will not attempt to gain access to a {{ app_name }} account that does not belong to You.
+
+You will not use {{ app_name }} to engage in child sexual abuse activities, as defined in the [CSAM Policy](/help/csam). The CSAM Policy is incorporated into these terms by this reference.
+
+## Multiple Accounts
+
+You are permitted to create and use multiple {{ app_name }} accounts.
+
+You will not use multiple accounts to circumvent per-user limits.
+
+Additionally, these terms apply to all of Your accounts, which may exist now or in the future. {{ app_name }} reserves the right to take administrative action against all of Your accounts in the event of a terms of service violation on any of Your accounts.
+
+## Your Privacy
+
+{{ app_name }} will never intentionally share Your personal information with third parties, except:
+
+* As required by United States law.
+* We will share Your data with our own service providers that are necessary for {{ app_name }} operations.
+* At our discretion, {{ app_name }} may share Your information with United States law enforcement in the event of an emergency, if we have good cause to believe that doing so would avert or mitigate the emergency.
+
+{{ app_name }} will make all reasonable efforts to ensure that user information is kept confidential.
+
+## Legal Forms
+
+{{ app_name }} grants You the ability to use Your {{ app_name }} account to send us certain types of legal forms through our help portal. These include, but are not limited to:
+
+* DMCA takedown request
+* DMCA takedown counter-request
+* Subpoenas and court orders
+
+You acknowledge that these forms are provided for the sake of convenience and speed, and that {{ app_name }} does not waive any of its rights regarding legal process, including the right to object for lack of proper service.
+
+You agree to refrain from making abusive, frivolous, or otherwise improper submissions to these forms. This includes, but is not limited to:
+
+* Nonsensical or incomplete requests
+* Duplicate submissions
+* DMCA takedown requests not made in good faith
+* DMCA takedown requests for content protected by Fair Use
+* DMCA takedown requests for content that you are not authorized to claim.
+* Legal demands not based in fact and/or not backed by appropriate law.
+* Legal documents from courts or law enforcement that lack jurisdiction inside the United States
+* Legal documents that are not in English and are not accompanied by an official English translation.
+
+## Enforcement
+
+{{ app_name }} retains sole discretion in all determinations regarding content policy and terms of service violations.
+
+{{ app_name }} reserves the right to remove content that we deem to violate content policy or the terms of service, and to deactivate accounts responsible for such violations.
+
+## Severability
+
+If one clause of these Terms or the Content Policy is determined by a court to be unenforceable, the remainder of the Terms and Content Policy shall remain in force.
+
+## No Implied Waiver
+
+A failure by {{ app_name }} in one or more instances to insist upon Your strict adherence to these terms or the Content Policy, shall not be construed as a waiver of any continuing or subsequent violations of these terms or the Content Policy.
+
+## Completeness
+
+These Terms, together with the other policies incorporated into them by reference, contain all the terms and conditions agreed upon by You and {{ app_name }} regarding Your use of the {{ app_name }} service. No other agreement, oral or otherwise, will be deemed to exist or to bind either of the parties to this Agreement.
+
+## Governing Law
+
+These terms of service are governed by, and shall be interpreted in accordance with, the laws of Italy. You consent to the sole jurisdiction of Italy for all disputes between You and {{ app_name }}, and You consent to the sole application of Italian and E.U. law for all such disputes.
+
+## Updates
+
+{{ app_name }} may periodically update these terms of service and/or the Content Policy. When this happens, {{ app_name }} will make reasonable efforts to notify You of such changes.
+
+Whenever {{ app_name }} updates these terms of service or the Content Policy, Your continued use of the {{ app_name }} platform constitutes Your agreement to the updated terms of service.
+
+## Translations
+
+If there is any inconsistency between these terms and any translation into other languages, the English language version takes precedence.
+
+
+
+{% endfilter %}
+
+