Import
This commit is contained in:
commit
5ba7a3bb2c
27 changed files with 1476 additions and 0 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal file
|
@ -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
|
||||
|
170
.gitignore
vendored
Normal file
170
.gitignore
vendored
Normal file
|
@ -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/
|
64
README.md
Normal file
64
README.md
Normal file
|
@ -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 : <https://github.com/tesseract-ocr>
|
||||
- tesseract english data ( or whatever other languages ) : <https://github.com/tesseract-ocr/tessdata/blob/main/eng.traineddata>
|
||||
|
||||
# bot
|
||||
|
||||
i made a matrix bot to integrate well with this, it is open source : <https://ari.lt/gh/quotes-bot>, 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 )
|
26
UNLICENSE
Normal file
26
UNLICENSE
Normal file
|
@ -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 <http://unlicense.org/>
|
20
doc/api.md
Normal file
20
doc/api.md
Normal file
|
@ -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/<id>` - get image information
|
||||
- `GET /api/search/<id>?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
|
79
doc/nginx.conf
Normal file
79
doc/nginx.conf
Normal file
|
@ -0,0 +1,79 @@
|
|||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
worker_rlimit_nofile 16384; # 1024 * <gb of memory your server has>
|
||||
|
||||
events {
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
worker_connections 6144; # 1024 * <core count your server has>
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
16
doc/views.md
Normal file
16
doc/views.md
Normal file
|
@ -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/<id>` or `/image/<id>.png` or `/image/<id>.jpg` ( like `/image/69` or `/image/69.png` ) - returns the image file of the associated ID - all origins
|
||||
- `POST /edit/<image id>` - edit the image data, supports all the same arguments as `POST /`, but updates the `edited` date
|
6
migrations/1.0.0-score-table.sql
Normal file
6
migrations/1.0.0-score-table.sql
Normal file
|
@ -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;
|
48
migrations/3.0.0-ocr.py
Normal file
48
migrations/3.0.0-ocr.py
Normal file
|
@ -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]} <images directory> <max ocr size>", 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())
|
13
migrations/3.1.0-delete-role.sql
Normal file
13
migrations/3.1.0-delete-role.sql
Normal file
|
@ -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;
|
46
migrations/3.1.1-case-sensitive-ocr.py
Normal file
46
migrations/3.1.1-case-sensitive-ocr.py
Normal file
|
@ -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]} <images directory> <max ocr size>", 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())
|
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
|
@ -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__/.*",
|
||||
]
|
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
@ -0,0 +1,7 @@
|
|||
flask
|
||||
flask-sqlalchemy
|
||||
python-magic
|
||||
flask-limiter
|
||||
pymemcache
|
||||
pytesseract
|
||||
Pillow
|
11
scripts/run.sh
Executable file
11
scripts/run.sh
Executable file
|
@ -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 "$@"
|
86
src/imag/__init__.py
Normal file
86
src/imag/__init__.py
Normal file
|
@ -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
|
105
src/imag/api.py
Normal file
105
src/imag/api.py
Normal file
|
@ -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/<int:iid>")
|
||||
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
|
12
src/imag/const.py
Normal file
12
src/imag/const.py
Normal file
|
@ -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
|
163
src/imag/models.py
Normal file
163
src/imag/models.py
Normal file
|
@ -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
|
0
src/imag/py.typed
Normal file
0
src/imag/py.typed
Normal file
29
src/imag/routing.py
Normal file
29
src/imag/routing.py
Normal file
|
@ -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
|
38
src/imag/static/index.css
Normal file
38
src/imag/static/index.css
Normal file
|
@ -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;
|
||||
}
|
50
src/imag/static/index.js
Normal file
50
src/imag/static/index.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* @file
|
||||
* @license
|
||||
* This is free and unencumbered software released into the public domain.
|
||||
* For more information, please refer to <http://unlicense.org/>
|
||||
*/
|
||||
|
||||
"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 <ari@ari.lt> 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);
|
187
src/imag/templates/index.j2
Normal file
187
src/imag/templates/index.j2
Normal file
|
@ -0,0 +1,187 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
{% if title is defined %}
|
||||
<title>Imag - {{ title | escape }}</title>
|
||||
<meta name="description" content="The Imag image board | {{ title | escape }}" />
|
||||
{% else %}
|
||||
<title>Imag</title>
|
||||
<meta name="description" content="The Imag image board" />
|
||||
{% endif %}
|
||||
|
||||
<meta
|
||||
name="keywords"
|
||||
content="imageboard, image board, image, image hosting"
|
||||
/>
|
||||
<meta
|
||||
name="robots"
|
||||
content="follow, index, max-snippet:-1, max-video-preview:-1, max-image-preview:large"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="black" />
|
||||
|
||||
<meta name="license" content="Unlicense" />
|
||||
|
||||
<!-- preloads the css ( technically you can replace it with a style tag -->
|
||||
|
||||
<script type="text/javascript">
|
||||
<!--//--><![CDATA[//><!--
|
||||
/**
|
||||
* @licstart The following is the entire license notice for the JavaScript
|
||||
* code in this page.
|
||||
*
|
||||
* 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 <http://unlicense.org/>
|
||||
*
|
||||
* @licend The above is the entire license notice for the JavaScript code
|
||||
* in this page.
|
||||
*/
|
||||
//--><!]]>
|
||||
</script>
|
||||
|
||||
<link
|
||||
href="{{ url_for("static", filename="index.css") }}"
|
||||
rel="preload"
|
||||
referrerpolicy="no-referrer"
|
||||
type="text/css"
|
||||
as="style"
|
||||
onload="this.onload=null;this.rel='stylesheet'"
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
href="{{ url_for("static", filename="index.css") }}"
|
||||
rel="stylesheet"
|
||||
referrerpolicy="no-referrer"
|
||||
type="text/css"
|
||||
/>
|
||||
</noscript>
|
||||
|
||||
<script src="{{ url_for("static", filename="index.js") }}" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>The <a href="https://ari.lt/gh/imag">Imag</a> image board ({{ imagv }}).</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||
{% if messages %}
|
||||
<details open>
|
||||
<summary>messages from the server</summary>
|
||||
{% for category, message in messages %}
|
||||
<div data-category="{{ category }}">{{ message | escape }}</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div>
|
||||
<form method="GET" action="/search">
|
||||
<label for="q">Search query:</label>
|
||||
<input type="text" name="q" id="q" placeholder="query" {% if q is defined %} value="{{ q | escape }}" {% endif %} required />
|
||||
<button type="submit">Search</button>
|
||||
{% if q is defined and "s=newest" not in request.url %} <a href="{{ request.url }}&s=newest">Sort by newest</a> {% endif %}
|
||||
{% if q is defined %} <a href="/">Back home</a> {% endif %}
|
||||
</form>
|
||||
|
||||
<i>Count: {{ images | length }}</i>
|
||||
|
||||
{% if q is not defined and "s=newest" not in request.url %} <a href="/?s=newest">Sort by newest</a> {% endif %}
|
||||
|
||||
<details>
|
||||
<summary>Or post an image</summary>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<label for="desc">Image description:</label>
|
||||
<input type="text" name="desc" id="desc" placeholder="description" maxlength="{{ desc_len }}" />
|
||||
|
||||
<br />
|
||||
|
||||
<label for="key">Access key:</label>
|
||||
<input type="password" name="key" id="key" placeholder="key" maxlength="{{ key_len }}" required />
|
||||
|
||||
<br />
|
||||
|
||||
<label for="image">Image:</label>
|
||||
<input type="file" accept="image/*" name="image" id="image" placeholder="the image" required />
|
||||
|
||||
<br />
|
||||
|
||||
<button type="post">Post</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
|
||||
{% if images %}
|
||||
{% for image in images %}
|
||||
<div class="image" id="{{ image.iid }}">
|
||||
<a target="_blank" href="{{ url_for("views.image", iid=image.iid) }}">
|
||||
<img loading="lazy" src="{{ url_for("views.image", iid=image.iid) }}" alt="{{ image.ocr | escape }}" />
|
||||
</a>
|
||||
<p><span id="score-{{ image.iid }}">{{ image.score }}</span> | <button onclick="vote('up',{{ image.iid }})" title="upvote">👍</button> or <button onclick="vote('down',{{ image.iid }})" title="downvote">👎</button></p>
|
||||
<p>{{ (image.desc or "No description.") | escape }}</p>
|
||||
<p>Created: <date>{{ image.created }}</date> - Edited: <date>{{ image.edited }}</date></p>
|
||||
<details>
|
||||
<summary>Edit image with ID {{ image.iid }}</summary>
|
||||
|
||||
<form method="POST" action="{{ url_for("views.edit", iid=image.iid) }}" enctype="multipart/form-data">
|
||||
<label for="desc">Image description:</label>
|
||||
<input type="text" name="desc" id="desc" placeholder="description" maxlength="{{ desc_len }}" value="{{ (image.desc or "") | escape }}" />
|
||||
|
||||
<br />
|
||||
|
||||
<label for="key">Access key:</label>
|
||||
<input type="password" name="key" id="key" placeholder="key" maxlength="{{ key_len }}" required />
|
||||
|
||||
<br />
|
||||
|
||||
<label for="image">Replace image:</label>
|
||||
<input type="file" accept="image/*" name="image" id="image" placeholder="the image" />
|
||||
|
||||
<br />
|
||||
|
||||
<label for="delete">Delete image:</label>
|
||||
<input type="checkbox" name="delete" id="delete" />
|
||||
|
||||
<br />
|
||||
|
||||
<button type="post">Commit changes</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<i>No images found :(</i>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
90
src/imag/util.py
Normal file
90
src/imag/util.py
Normal file
|
@ -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
|
145
src/imag/views.py
Normal file
145
src/imag/views.py
Normal file
|
@ -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/<int:iid>.jpg")
|
||||
@views.get("/image/<int:iid>.png")
|
||||
@views.get("/image/<int:iid>")
|
||||
@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/<int:iid>")
|
||||
@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/<string:mode>/<int:iid>")
|
||||
@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("/")
|
25
src/main.py
Normal file
25
src/main.py
Normal file
|
@ -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())
|
8
tox.ini
Normal file
8
tox.ini
Normal file
|
@ -0,0 +1,8 @@
|
|||
[tox]
|
||||
envlist = py310
|
||||
|
||||
[flake8]
|
||||
max-line-length = 160
|
||||
|
||||
[pycodestyle]
|
||||
max-line-length = 160
|
Loading…
Add table
Reference in a new issue