From 5ba7a3bb2c29cd291267a333fb52901d38708f67 Mon Sep 17 00:00:00 2001 From: n Date: Thu, 30 Jan 2025 22:43:32 -0600 Subject: [PATCH] Import --- .editorconfig | 11 ++ .gitignore | 170 ++++++++++++++++++++++ README.md | 64 +++++++++ UNLICENSE | 26 ++++ doc/api.md | 20 +++ doc/nginx.conf | 79 +++++++++++ doc/views.md | 16 +++ migrations/1.0.0-score-table.sql | 6 + migrations/3.0.0-ocr.py | 48 +++++++ migrations/3.1.0-delete-role.sql | 13 ++ migrations/3.1.1-case-sensitive-ocr.py | 46 ++++++ pyproject.toml | 21 +++ requirements.txt | 7 + scripts/run.sh | 11 ++ src/imag/__init__.py | 86 ++++++++++++ src/imag/api.py | 105 ++++++++++++++ src/imag/const.py | 12 ++ src/imag/models.py | 163 +++++++++++++++++++++ src/imag/py.typed | 0 src/imag/routing.py | 29 ++++ src/imag/static/index.css | 38 +++++ src/imag/static/index.js | 50 +++++++ src/imag/templates/index.j2 | 187 +++++++++++++++++++++++++ src/imag/util.py | 90 ++++++++++++ src/imag/views.py | 145 +++++++++++++++++++ src/main.py | 25 ++++ tox.ini | 8 ++ 27 files changed, 1476 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 UNLICENSE create mode 100644 doc/api.md create mode 100644 doc/nginx.conf create mode 100644 doc/views.md create mode 100644 migrations/1.0.0-score-table.sql create mode 100644 migrations/3.0.0-ocr.py create mode 100644 migrations/3.1.0-delete-role.sql create mode 100644 migrations/3.1.1-case-sensitive-ocr.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100755 scripts/run.sh create mode 100644 src/imag/__init__.py create mode 100644 src/imag/api.py create mode 100644 src/imag/const.py create mode 100644 src/imag/models.py create mode 100644 src/imag/py.typed create mode 100644 src/imag/routing.py create mode 100644 src/imag/static/index.css create mode 100644 src/imag/static/index.js create mode 100644 src/imag/templates/index.j2 create mode 100644 src/imag/util.py create mode 100644 src/imag/views.py create mode 100644 src/main.py create mode 100644 tox.ini diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6c54233 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +tab_width = 2 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40261de --- /dev/null +++ b/.gitignore @@ -0,0 +1,170 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Databases +*.db + +# Other +.ccls-cache/ +!py.typed + +instance/ +images/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..720c054 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# imag + +> simple and hackable suckless image board + +the imag image board is made to be simple, though separated, so you +could easily add or remove features, update them, etc + +this is not sole software, it is suckless ( ? ) software, which you are meant +to hack yourself,, you can, of course, use the default settings and whatnot, +but it is highly encouraged to make forks and hack it yourself :) + +example instance : https://quotes.ari.lt/ + +~~i hate github versioning so much~~ + +# licensing + +you can distribute, modify, share, redistribute, etc etc etc with no credit or anything, +this project is released under unlicense and i give away all my rights to this project :) + +license : unlicense + +# prerequisites + +- tesseract : +- tesseract english data ( or whatever other languages ) : + +# bot + +i made a matrix bot to integrate well with this, it is open source : , mainly for purpose of posting quotes + +# docs & running + +see the [doc directory](/dov) for documentation, it also has an example nginx config, +and you can also run the app using [./scripts/run.sh](./scripts/run.sh) to match that config :) - but don't run it using +the run.sh as the first run if you ever want to post on it lol + +running with gunicorn ( run.sh ) is for production use, for master key generation ( first run ), please +run it in dev mode : + +```sh +python3 src/main.py +``` + +and only then with gunicorn :) + +if you already ran it in production and don't know where the key is, run the following command : + +```sh +rm -rf src/images src/instance +``` + +and then run it in debug + +### step-by-step + +this comes from an email i got from a user : + +1. clone the repository : `git clone https://ari.lt/gh/imag && cd imag` +2. make sure you have virtualenv installed ( either through python-virtualenv / python3-virtualenv / py3-virtualenv packages, or by pip - `python3 -m pip install --user --break-system-packages --upgrade virtualenv` +3. ensure you have sqlite3 and memcached installed : `apt install sqlite3 memcached` +4. create a new virtual environment : `python3 -m virtualenv venv && source venv/bin/activate` +5. install the dependencies in the environment : `pip install -r requirements.txt` +6. run the app by either running `scripts/run.sh` or by manually starting memcached and running `src/main.py` with gunicorn ( i assume you're reverse proxying it anyway ) diff --git a/UNLICENSE b/UNLICENSE new file mode 100644 index 0000000..51129ad --- /dev/null +++ b/UNLICENSE @@ -0,0 +1,26 @@ +UNLICENSE + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +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 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. + +For more information, please refer to diff --git a/doc/api.md b/doc/api.md new file mode 100644 index 0000000..7258da9 --- /dev/null +++ b/doc/api.md @@ -0,0 +1,20 @@ +# developer API + +the API is meant for developers to access and use, it doesn't return human +readable data unlike the human interface + +the POST APIs are based on form data requests and JSON responses + +- `GET /api/image/` - get image information +- `GET /api/search/?q=...` - search in image descriptions, creation dates, and editing dates +- `POST /api/key` - check if you can access the key api ( admin access only ) + - `POST /api/key/new` - generate a new access key, following form arguments are required : + - `perm` - the permission level, usually either `write` or `admin` + - `POST /api/key/revoke` - revoke a key + - `rev` form argument is required with the value of the key being revoked + - `GET /api/key/keys` - list all keys and their permission levels + - `GET /api/key/info` - shows information about a specific key + - `target` - the target key + +keep in mind, this is **form data**, so in for example JavaScript you'd use `FormData`, or `-F'field=value'` in curl +and whatnot diff --git a/doc/nginx.conf b/doc/nginx.conf new file mode 100644 index 0000000..a743aa1 --- /dev/null +++ b/doc/nginx.conf @@ -0,0 +1,79 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +worker_rlimit_nofile 16384; # 1024 * + +events { + use epoll; + multi_accept on; + worker_connections 6144; # 1024 * +} + +http { + include mime.types; + default_type application/octet-stream; + + access_log off; + + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 120; + types_hash_max_size 2048; + server_names_hash_bucket_size 256; + + sendfile on; + + server { + listen 80; + listen [::]:80; + + server_name imag.example.com; # or whatever domain you use + + access_log off; + + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + server_name imag.example.com; + + access_log off; + + # letsencrypt ssl certs - you don't have to use LE, but this is just an example + ssl_certificate /etc/letsencrypt/live/imag.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/imag.example.com/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/imag.example.com/chain.pem; + + ssl_stapling on; + ssl_stapling_verify on; + ssl_session_timeout 1d; + ssl_session_cache shared:MozSSL:10m; + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256'; + ssl_prefer_server_ciphers on; + + location / { + access_log off; + + proxy_pass http://127.0.0.1:19721; # or wherever your app is running + + proxy_cache_bypass $http_upgrade; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + client_max_body_size 512M; + proxy_max_temp_file_size 0; + proxy_buffering off; + } + } +} diff --git a/doc/views.md b/doc/views.md new file mode 100644 index 0000000..078b5a1 --- /dev/null +++ b/doc/views.md @@ -0,0 +1,16 @@ +# views ( user access endpoints ) + +most of the following routes are meant mainly for user access, so they are restricted by CORS and return human readable +data, for developer access see the developer api docs + +- `GET /` - the index page, lazy-loads all images, renders `src/imag/templates/index.j2` - original origin only +- `POST /` - posts an image to the instance, redirects to the image once uploaded, POST the following form arguments + - `key` - an access key with at least `write` permissions + - by default a master admin key is generated, see the developer api docs for more info + - the key length is usually going to be 128 bytes + - `desc` ( optional ) - description of the image + - the description length is usually limited to 1024 bytes + - `image` - the image file, only images allowed ( `image/*` mimetypes ) +- `GET /search?q=...` - search the description, edit, and creation dates of images, argument `q` required, else redirects back to `/` - original origin only +- `GET /image/` or `/image/.png` or `/image/.jpg` ( like `/image/69` or `/image/69.png` ) - returns the image file of the associated ID - all origins +- `POST /edit/` - edit the image data, supports all the same arguments as `POST /`, but updates the `edited` date diff --git a/migrations/1.0.0-score-table.sql b/migrations/1.0.0-score-table.sql new file mode 100644 index 0000000..05e0cf4 --- /dev/null +++ b/migrations/1.0.0-score-table.sql @@ -0,0 +1,6 @@ +-- Migration for version 1.0.0: Score support + +BEGIN TRANSACTION; +ALTER TABLE image ADD COLUMN score INTEGER; +UPDATE image SET score=0; +COMMIT; diff --git a/migrations/3.0.0-ocr.py b/migrations/3.0.0-ocr.py new file mode 100644 index 0000000..6a08a06 --- /dev/null +++ b/migrations/3.0.0-ocr.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ocr migration""" + +import os +import sqlite3 +import sys +from warnings import filterwarnings as filter_warnings + +import PIL +import pytesseract # type: ignore + + +def main() -> int: + """entry / main function""" + + if len(sys.argv) < 3: + print( + f"Usage: {sys.argv[0]} ", file=sys.stderr + ) + return 1 + + s: int = int(sys.argv[2]) + + print("-- Migration for version 3.0.0: OCR support") + + print("BEGIN TRANSACTION;") + + print(f"ALTER TABLE image ADD COLUMN ocr VARCHAR({s}) NOT NULL DEFAULT '';") + + conn: sqlite3.Connection = sqlite3.connect(":memory:") + + for image in os.listdir(sys.argv[1]): + with PIL.Image.open(os.path.join(sys.argv[1], image)) as img: # type: ignore + ocr: str = str(pytesseract.image_to_string(img)).lower().strip()[:s].strip() # type: ignore + ocre: str = conn.execute("SELECT quote(?);", (ocr,)).fetchone()[0] + print(f"UPDATE image SET ocr={ocre} WHERE iid={os.path.basename(image)};") + + print("COMMIT;") + + return 0 + + +if __name__ == "__main__": + assert main.__annotations__.get("return") is int, "main() should return an integer" + + filter_warnings("error", category=Warning) + raise SystemExit(main()) diff --git a/migrations/3.1.0-delete-role.sql b/migrations/3.1.0-delete-role.sql new file mode 100644 index 0000000..3e3b9d2 --- /dev/null +++ b/migrations/3.1.0-delete-role.sql @@ -0,0 +1,13 @@ +-- Migration for version 3.1.0: Added "delete" role + +BEGIN TRANSACTION; +ALTER TABLE access_key RENAME TO old_access_key; +CREATE TABLE access_key ( + "key" VARCHAR(128) NOT NULL, + access_level VARCHAR(6), + PRIMARY KEY ("key"), + UNIQUE ("key") +); +INSERT INTO access_key SELECT * FROM old_access_key; +DROP TABLE old_access_key; +COMMIT; diff --git a/migrations/3.1.1-case-sensitive-ocr.py b/migrations/3.1.1-case-sensitive-ocr.py new file mode 100644 index 0000000..98d0d2b --- /dev/null +++ b/migrations/3.1.1-case-sensitive-ocr.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ocr case-sensitivity migration""" + +import os +import sqlite3 +import sys +from warnings import filterwarnings as filter_warnings + +import PIL +import pytesseract # type: ignore + + +def main() -> int: + """entry / main function""" + + if len(sys.argv) < 3: + print( + f"Usage: {sys.argv[0]} ", file=sys.stderr + ) + return 1 + + s: int = int(sys.argv[2]) + + print("-- Migration for version 3.1.1: OCR case sensitivity") + + print("BEGIN TRANSACTION;") + + conn: sqlite3.Connection = sqlite3.connect(":memory:") + + for image in os.listdir(sys.argv[1]): + with PIL.Image.open(os.path.join(sys.argv[1], image)) as img: # type: ignore + ocr: str = str(pytesseract.image_to_string(img)).strip()[:s].strip() # type: ignore + ocre: str = conn.execute("SELECT quote(?);", (ocr,)).fetchone()[0] + print(f"UPDATE image SET ocr={ocre} WHERE iid={os.path.basename(image)};") + + print("COMMIT;") + + return 0 + + +if __name__ == "__main__": + assert main.__annotations__.get("return") is int, "main() should return an integer" + + filter_warnings("error", category=Warning) + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..53053d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.pyright] +pythonVersion = "3.10" +exclude = [ + "venv", + "**/node_modules", + "**/__pycache__", + ".git" +] +include = ["src", "scripts"] +venv = "venv" +stubPath = "src/stubs" +typeCheckingMode = "strict" +useLibraryCodeForTypes = true +reportMissingTypeStubs = true + +[tool.mypy] +exclude = [ + "^venv/.*", + "^node_modules/.*", + "^__pycache__/.*", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4109e2f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask +flask-sqlalchemy +python-magic +flask-limiter +pymemcache +pytesseract +Pillow diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..1799bbe --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +set -eu + +main() { + cd src + memcached --port=18391 --daemon --memory-limit=1024 --enable-largepages + python3 -m gunicorn -b 127.0.0.1:19721 -w "$(nproc --all)" main:app & +} + +main "$@" diff --git a/src/imag/__init__.py b/src/imag/__init__.py new file mode 100644 index 0000000..c9977bf --- /dev/null +++ b/src/imag/__init__.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""the imag image board""" + +import secrets +import typing as t +from os import makedirs + +import flask +from werkzeug.middleware.proxy_fix import ProxyFix + +from . import const + +__version__: str = "3.1.1" + + +def create_app(db: str = "sqlite:///imag.db") -> t.Tuple[flask.Flask, t.Optional[str]]: + """creates an imag app, returns a tuple of the app and the admin key if it was created""" + + app: flask.Flask = flask.Flask(__name__) + + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # type: ignore + + app.config["SQLALCHEMY_DATABASE_URI"] = db + app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True} + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + app.config["PREFERRED_URL_SCHEME"] = "https" + + app.config["SECRET_KEY"] = secrets.SystemRandom().randbytes(1024 * 16) + + from . import models + + models.limiter.init_app(app) + models.db.init_app(app) + + admin_key: t.Optional[str] = None + + with app.app_context(): + models.db.create_all() + + if models.AccessKey.query.first() is None: + key: models.AccessKey = models.AccessKey(models.AccessLevel.admin) + models.db.session.add(key) + models.db.session.commit() + admin_key = key.key + + makedirs(const.IMAGE_DIR, exist_ok=True) + + @app.context_processor # type: ignore + def _() -> t.Dict[str, t.Any]: + """expose custom stuff""" + + return { + "desc_len": const.DESC_LEN, + "key_len": const.KEY_LEN, + "imagv": __version__, + } + + @app.after_request + def _(response: flask.Response) -> flask.Response: + """update headers, allow all origins, hsts""" + + response.headers.extend(getattr(flask.g, "headers", {})) + + if not app.debug: + response.headers["Content-Security-Policy"] = "upgrade-insecure-requests" + response.headers["Strict-Transport-Security"] = ( + "max-age=63072000; includeSubDomains; preload" + ) + + response.headers["X-Frame-Options"] = "ALLOWALL" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Permitted-Cross-Domain-Policies"] = "all" + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, HEAD" + + return response + + from .api import api + from .views import views + + app.register_blueprint(api, url_prefix="/api/") + app.register_blueprint(views, url_prefix="/") + + return app, admin_key diff --git a/src/imag/api.py b/src/imag/api.py new file mode 100644 index 0000000..fe9deb6 --- /dev/null +++ b/src/imag/api.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""the API for imag""" + +import typing as t + +import flask +from werkzeug.wrappers import Response + +from . import models, util +from .routing import Bp + +api: Bp = Bp("api", __name__).set_api() + + +@api.get("/latest") +def latest_image() -> flask.Response: + """get latest image id""" + return flask.Response(str(models.Image.query.order_by((models.Image.created).desc()).first().iid), 200, content_type="text/plain") # type: ignore + + +@api.get("/all") +def all_mages() -> flask.Response: + """get latest image id""" + return flask.jsonify( + [ + img.json() # type: ignore + for img in models.Image.query.order_by((models.Image.created if "newest" == flask.request.args.get("s") else models.Image.score).desc()).all() # type: ignore + ] + ) + + +@api.get("/image/") +def image(iid: int) -> flask.Response: + """get image data""" + return flask.jsonify(models.Image.query.filter_by(iid=iid).first_or_404().json()) + + +@api.get("/search") +def search() -> flask.Response: + """get image data""" + + query: t.Optional[str] = flask.request.args.get("q") + + if not query: + flask.abort(400) + + return flask.jsonify([image.json() for image in models.Image.by_search(query, flask.request.args.get("s") != "newest")]) # type: ignore + + +@api.post("/key") +@util.with_access(models.AccessLevel.admin) +def key() -> str: + """only admins can access this""" + return "Congrats! You can access the admin-only key api." + + +@api.post("/key/new") +@util.with_access(models.AccessLevel.admin) +@util.require_args("perm") +def new_key() -> str: + """generate a new key""" + + try: + access: models.AccessLevel = models.AccessLevel[flask.request.form["perm"]] + except KeyError: + flask.abort(400) + + key: models.AccessKey = models.AccessKey(access) + + models.db.session.add(key) + models.db.session.commit() + + return key.key + + +@api.post("/key/revoke") +@util.with_access(models.AccessLevel.admin) +@util.require_args("rev") +def revoke_key() -> str: + """revoke an access key""" + + key: models.AccessKey = models.AccessKey.query.filter_by( + key=flask.request.form["rev"] + ).first_or_404() + + models.db.session.delete(key) + models.db.session.commit() + + return key.key + + +@api.post("/key/keys") +@util.with_access(models.AccessLevel.admin) +def list_keys() -> Response: + """show all access keys""" + return flask.jsonify([key.json() for key in models.AccessKey.query.all()]) # type: ignore + + +@api.post("/key/info") +@util.with_access(models.AccessLevel.admin) +@util.require_args("target") +def key_info() -> Response: + """show info about a key""" + return flask.jsonify(models.AccessKey.query.filter_by(key=flask.request.form["target"]).first_or_404().json()) # type: ignore diff --git a/src/imag/const.py b/src/imag/const.py new file mode 100644 index 0000000..1398a90 --- /dev/null +++ b/src/imag/const.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""constants""" + +from typing import Final + +KEY_LEN: Final[int] = 128 +DESC_LEN: Final[int] = 1024 + +IMAGE_DIR: Final[str] = "images" + +MAX_OCR: Final[int] = 1024 diff --git a/src/imag/models.py b/src/imag/models.py new file mode 100644 index 0000000..bfe5c13 --- /dev/null +++ b/src/imag/models.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""database models""" + +import base64 +import typing as t +from datetime import datetime +from enum import Enum, auto +from io import BytesIO +from secrets import SystemRandom + +import PIL +import pytesseract # type: ignore +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import DateTime +from sqlalchemy import Enum as StorageEnum +from sqlalchemy import Unicode, or_ + +from . import const + +db: SQLAlchemy = SQLAlchemy() + +rand: SystemRandom = SystemRandom() + +limiter: Limiter = Limiter( + get_remote_address, + storage_uri="memcached://127.0.0.1:18391", + strategy="fixed-window", +) + + +class AccessLevel(Enum): + """access level of an access token""" + + write = auto() + delete = auto() + admin = auto() + + +class AccessKey(db.Model): + """access key""" + + key: str = db.Column( + db.String(const.KEY_LEN), + unique=True, + nullable=False, + primary_key=True, + ) + access_level: AccessLevel = db.Column( + StorageEnum( + AccessLevel, + default=AccessLevel.write, + ) + ) + + def __init__(self, acceess_level: AccessLevel = AccessLevel.write) -> None: + """create a new access key""" + + self.access_level: AccessLevel = acceess_level + + while True: + key: str = base64.urlsafe_b64encode( + rand.randbytes(const.KEY_LEN * 2) + ).decode()[: const.KEY_LEN] + + if self.query.filter_by(key=key).first() is None: + self.key = key + break + + def json(self) -> t.Dict[str, t.Any]: + """return json""" + + return { + "key": self.key, + "access": self.access_level.name, + } + + +class Image(db.Model): + """image""" + + iid: int = db.Column( + db.Integer, + primary_key=True, + unique=True, + ) + desc: t.Optional[str] = db.Column(Unicode(const.DESC_LEN)) + created: datetime = db.Column( + DateTime, + default=datetime.utcnow, + nullable=False, + ) + edited: datetime = db.Column( + DateTime, + default=datetime.utcnow, + nullable=False, + ) + score: int = db.Column( + db.Integer, + default=0, + ) + ocr: str = db.Column( + Unicode(const.MAX_OCR), + nullable=False, + ) + + def __init__(self, desc: t.Optional[str], file: bytes) -> None: + """create a new image""" + self.set_desc(desc) + self.set_ocr(file) + + # i just dislike properties for db stuff + + def set_desc(self, desc: t.Optional[str]) -> "Image": + """set `desc` description""" + + if desc: + assert len(desc) <= const.DESC_LEN, "description too long" + + self.desc: t.Optional[str] = desc + return self + + def set_ocr(self, file: bytes) -> "Image": + """set `ocr`""" + + with PIL.Image.open(BytesIO(file)) as img: # type: ignore + self.ocr: str = str(pytesseract.image_to_string(img)).strip()[: const.MAX_OCR].strip() # type: ignore + + return self + + def json(self) -> t.Dict[str, t.Any]: + """return json""" + + return { + "iid": self.iid, + "desc": self.desc, + "created": self.created.timestamp(), + "edited": self.edited.timestamp(), + "score": self.score, + "ocr": self.ocr, + } + + @classmethod + def by_search(cls, query: str, score: bool = True) -> t.Tuple["Image", ...]: + """search for images""" + + results: t.Any = cls.query.filter( + or_( + cls.desc.ilike(f"%{query}%"), # type: ignore + cls.created.cast(db.String).ilike(f"%{query}%"), # type: ignore + cls.edited.cast(db.String).ilike(f"%{query}%"), # type: ignore + cls.ocr.cast(db.String).ilike(f"%{query}%"), # type: ignore + ) + ) + + if score: + results = results.order_by(cls.score.desc(), cls.created.desc()) # type: ignore + else: + results = results.order_by(cls.created.desc()) # type: ignore + + return tuple(results.all()) # type: ignore diff --git a/src/imag/py.typed b/src/imag/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/imag/routing.py b/src/imag/routing.py new file mode 100644 index 0000000..72f1848 --- /dev/null +++ b/src/imag/routing.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""app routing helper""" + +from typing import Any + +from flask import Blueprint, Response + +from .util import make_api + + +class Bp(Blueprint): + def get(self, rule: str, **kwargs: Any) -> Any: + """wrapper for GET""" + return self.route(rule=rule, methods=("GET",), **kwargs) + + def post(self, rule: str, **kwargs: Any) -> Any: + """wrapper for POST""" + return self.route(rule=rule, methods=("POST",), **kwargs) + + def set_api(self) -> "Bp": + """disable cors and cache""" + + @self.after_request # type: ignore + def _(response: Response) -> Response: + """disable cache""" + return make_api(response) + + return self diff --git a/src/imag/static/index.css b/src/imag/static/index.css new file mode 100644 index 0000000..a881006 --- /dev/null +++ b/src/imag/static/index.css @@ -0,0 +1,38 @@ +*, +*::before, +*::after { + -webkit-box-sizing: border-box; + box-sizing: border-box; + word-wrap: break-word; + font-family: sans-serif; +} + +* { + color: whitesmoke; + background-color: #000; +} + +body { + margin: auto; + max-width: 1100px; + padding: 2em; +} + +h1 { + text-align: center; +} + +img { + max-width: 256px; +} + +button { + border: none; + padding: 0.5em; + background-color: #1c1c1c; + cursor: pointer; +} + +.image { + margin-bottom: 2em; +} diff --git a/src/imag/static/index.js b/src/imag/static/index.js new file mode 100644 index 0000000..da64dfe --- /dev/null +++ b/src/imag/static/index.js @@ -0,0 +1,50 @@ +/** + * @file + * @license + * This is free and unencumbered software released into the public domain. + * For more information, please refer to + */ + +"use strict"; + +function vote(mode, id) { + fetch(`/vote/${mode}/${id}`, { method: "POST" }) + .then((r) => { + if (!r.ok) { + alert( + "Failed to vote (keep in mind you can only vote once a day)", + ); + + throw new Error(`Failed to record the vote: ${r.status}`); + } + + fetch(`/api/image/${id}`) + .then((r) => { + if (!r.ok) { + alert("Failed to get current vote score"); + + throw new Error( + `Failed to fetch the vote score: ${r.status}`, + ); + } + + return r.json(); + }) + .then((j) => { + document.getElementById(`score-${id}`).innerText = j.score; + }) + .catch((e) => console.error(e)); + }) + .catch((e) => console.error(e)); +} + +function main() { + console.log( + "Originally made with <3 by Ari Archer on 2024/03/10, licensed under the Unlicense: https://ari.lt/gh/imag", + ); + + for (let img of document.getElementsByTagName("img")) + img.title = img.alt; +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/imag/templates/index.j2 b/src/imag/templates/index.j2 new file mode 100644 index 0000000..1f5260b --- /dev/null +++ b/src/imag/templates/index.j2 @@ -0,0 +1,187 @@ + + + + + + + + {% if title is defined %} + Imag - {{ title | escape }} + + {% else %} + Imag + + {% endif %} + + + + + + + + + + + + + + + + + + + + + +

The Imag image board ({{ imagv }}).

+ + {% with messages = get_flashed_messages(with_categories=True) %} + {% if messages %} +
+ messages from the server + {% for category, message in messages %} +
{{ message | escape }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + + + {% if q is defined and "s=newest" not in request.url %} Sort by newest {% endif %} + {% if q is defined %} Back home {% endif %} +
+ + Count: {{ images | length }} + + {% if q is not defined and "s=newest" not in request.url %} Sort by newest {% endif %} + +
+ Or post an image + +
+ + + +
+ + + + +
+ + + + +
+ + +
+
+
+ +
+
+
+ + {% if images %} + {% for image in images %} +
+ + {{ image.ocr | escape }} + +

{{ image.score }} | or

+

{{ (image.desc or "No description.") | escape }}

+

Created: {{ image.created }} - Edited: {{ image.edited }}

+
+ Edit image with ID {{ image.iid }} + +
+ + + +
+ + + + +
+ + + + +
+ + + + +
+ + +
+
+
+ {% endfor %} + {% else %} + No images found :( + {% endif %} + + diff --git a/src/imag/util.py b/src/imag/util.py new file mode 100644 index 0000000..f1089c0 --- /dev/null +++ b/src/imag/util.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""utilities""" + +import typing as t +from functools import wraps + +import flask + +from .models import AccessKey, AccessLevel + + +def require_args( + *form_args: str, +) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + """require form arguments""" + + def wrapper(fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + @wraps(fn) + def decorator(*args: t.Any, **kwargs: t.Any) -> t.Any: + for arg in form_args: + if arg not in flask.request.form.keys(): + flask.abort(400) + + return fn(*args, **kwargs) + + return decorator + + return wrapper + + +def with_access( + access_level: AccessLevel, +) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + """force access level""" + + def wrapper(fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + @wraps(fn) + def decorator(*args: t.Any, **kwargs: t.Any) -> t.Any: + if "key" not in flask.request.form: + flask.abort(400) + + access: t.Optional[AccessKey] = AccessKey.query.filter_by( # type: ignore + key=flask.request.form.get("key") + ).first() + + flask.g.setdefault("access", access.access_level) # type: ignore + + if access and (access.access_level.value >= access_level.value): # type: ignore + return fn(*args, **kwargs) # type: ignore + else: + flask.abort(403) + + return decorator + + return wrapper + + +def make_api( + response: flask.Response, + cors: bool = True, + cache: bool = False, +) -> flask.Response: + """make api endpoint ( disables cors and caching )""" + + if cors: + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + + if not cache: + response.headers["Expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + response.headers["Cache-Control"] = ( + "max-age=0, no-cache, must-revalidate, proxy-revalidate" + ) + + return response + + +def api(fn: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + """api endpoint""" + + @wraps(fn) + def wrapper(*args: t.Any, **kwargs: t.Any) -> flask.Response: + """decorator""" + return make_api( + flask.make_response(fn(*args, **kwargs)), + cache=True, + ) + + return wrapper diff --git a/src/imag/views.py b/src/imag/views.py new file mode 100644 index 0000000..6557d6a --- /dev/null +++ b/src/imag/views.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""views""" + +import os +import typing as t +from datetime import datetime + +import flask +import magic +from flask_limiter.util import get_remote_address +from werkzeug.wrappers import Response + +from . import const, models, util +from .routing import Bp + +views: Bp = Bp("views", __name__) + + +@views.get("/") +def index() -> str: + """index page""" + return flask.render_template( + "index.j2", + images=models.Image.query.order_by((models.Image.created if flask.request.args.get("s") == "newest" else models.Image.score).desc()).all(), # type: ignore + ) + + +@views.post("/") +@util.with_access(models.AccessLevel.write) +def post_image() -> Response: + """post image""" + + if "image" not in flask.request.files: + flask.abort(400) + + file: t.Any = flask.request.files["image"] + + mime: t.Any = magic.from_buffer(file.read(1024), mime=True) + file.seek(0) # reset file pointer to start of file + + if not mime.startswith("image/"): + flask.abort(400, "Invalid image file.") + + file = file.read() + image: models.Image = models.Image((flask.request.form.get("desc") or "").strip(), file) + + models.db.session.add(image) + models.db.session.commit() + + with open(os.path.join(const.IMAGE_DIR, str(image.iid)), "wb") as fp: + fp.write(file) + + return flask.redirect(flask.url_for("views.image", iid=image.iid)) + + +@views.get("/search") +def search() -> t.Union[Response, str]: + """search images based on the query""" + + query: t.Optional[str] = flask.request.args.get("q") + + if not query: + return flask.redirect("/") + + return flask.render_template( + "index.j2", + images=models.Image.by_search(query, flask.request.args.get("s") != "newest"), # type: ignore + title=query, + q=query, + ) + + +@views.get("/image/.jpg") +@views.get("/image/.png") +@views.get("/image/") +@util.api +def image(iid: int) -> flask.Response: + """get image""" + + try: + with open(os.path.join(const.IMAGE_DIR, str(iid)), "rb") as fp: + file: bytes = fp.read() + return flask.Response(file, mimetype=magic.from_buffer(file, mime=True)) # type: ignore + except Exception: + flask.abort(404) + + +@views.post("/edit/") +@util.with_access(models.AccessLevel.write) +def edit(iid: int) -> Response: + """edit image ( details )""" + + image: models.Image = models.Image.query.filter_by(iid=iid).first_or_404() + + if flask.request.form.get("delete"): + if flask.g.get("access").value < models.AccessLevel.delete.value: + flask.abort(403) + + models.db.session.delete(image) + models.db.session.commit() + os.remove(os.path.join(const.IMAGE_DIR, str(image.iid))) + flask.flash(f"Image {image.iid} deleted.") + return flask.redirect("/") + + if (desc := flask.request.form.get("desc")) is not None: + image.desc = desc + flask.flash("Image description edited.") + + if "image" in flask.request.files: + file: t.Any = flask.request.files["image"] + + mime: t.Any = magic.from_buffer(file.read(1024), mime=True) + file.seek(0) + + if mime.startswith("image/"): + file.save(os.path.join(const.IMAGE_DIR, str(image.iid))) + flask.flash("Image file edited.") + + image.edited = datetime.utcnow() # type: ignore + models.db.session.commit() + + return flask.redirect("/") + + +@views.post("/vote//") +@models.limiter.limit("1 per day", key_func=lambda: str(flask.request.view_args.get("iid")) + get_remote_address()) # type: ignore +def vote(mode: str, iid: int) -> Response: + """vote for an image""" + + mode = mode.lower() + + image: models.Image = models.Image.query.filter_by(iid=iid).first_or_404() + + if mode == "up": + image.score += 1 + elif mode == "down": + image.score -= 1 + else: + flask.abort(400) + + flask.flash(f"Your {mode}vote for image #{iid} has been saved!") + models.db.session.commit() + + return flask.redirect("/") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..c4c4b63 --- /dev/null +++ b/src/main.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""main script""" + +from warnings import filterwarnings as filter_warnings + +from imag import create_app + +app, key = create_app() + + +def main() -> int: + """entry / main function""" + + print(f"key : {key}") + app.run("127.0.0.1", 8080, True) + + return 0 + + +if __name__ == "__main__": + assert main.__annotations__.get("return") is int, "main() should return an integer" + + filter_warnings("error", category=Warning) + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8c213ed --- /dev/null +++ b/tox.ini @@ -0,0 +1,8 @@ +[tox] +envlist = py310 + +[flake8] +max-line-length = 160 + +[pycodestyle] +max-line-length = 160