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