This commit is contained in:
Nyx 2025-01-30 22:43:32 -06:00
commit 5ba7a3bb2c
27 changed files with 1476 additions and 0 deletions

11
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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())

View 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;

View 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
View 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
View file

@ -0,0 +1,7 @@
flask
flask-sqlalchemy
python-magic
flask-limiter
pymemcache
pytesseract
Pillow

11
scripts/run.sh Executable file
View 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
View 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
View 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
View 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
View 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
View file

29
src/imag/routing.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
[tox]
envlist = py310
[flake8]
max-line-length = 160
[pycodestyle]
max-line-length = 160