From d20079f3ed8f261bcda0f5379f4c9e23ee941527 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 24 Jan 2025 14:00:10 +0100 Subject: [PATCH] Complete system. --- .gitignore | 3 + Dockerfile | 4 +- pyproject.toml | 4 +- src/snek/app.py | 17 ++- src/snek/form/register.py | 2 +- src/snek/mapper/__init__.py | 12 ++ src/snek/mapper/user.py | 6 + src/snek/model/__init__.py | 12 ++ src/snek/model/user.py | 4 +- src/snek/service/__init__.py | 12 ++ src/snek/service/user.py | 16 +++ src/snek/static/generic-form.js | 4 + src/snek/static/html-frame.js | 11 +- src/snek/static/markdown-frame.js | 39 +++++ src/snek/static/style.css | 20 +++ src/snek/system/api.py | 0 src/snek/system/form.py | 177 +++++++---------------- src/snek/system/http.py | 78 ++++++---- src/snek/system/mapper.py | 64 +++++++++ src/snek/system/markdown.py | 43 ++++++ src/snek/system/middleware.py | 16 ++- src/snek/system/model.py | 231 +++++++++++++++++++----------- src/snek/system/security.py | 20 +++ src/snek/system/service.py | 40 ++++++ src/snek/system/view.py | 38 +++++ src/snek/templates/about.html | 7 + src/snek/templates/about.md | 15 ++ src/snek/templates/base.html | 3 +- src/snek/templates/index.html | 3 +- src/snek/templates/login.html | 2 +- src/snek/templates/register.html | 2 +- src/snek/view/about.py | 14 ++ src/snek/view/base.py | 31 ---- src/snek/view/index.py | 2 +- src/snek/view/login.py | 2 +- src/snek/view/login_form.py | 2 +- src/snek/view/register.py | 2 +- src/snek/view/register_form.py | 2 +- src/snek/view/{view.py => web.py} | 2 +- 39 files changed, 663 insertions(+), 299 deletions(-) create mode 100644 src/snek/mapper/__init__.py create mode 100644 src/snek/mapper/user.py create mode 100644 src/snek/service/__init__.py create mode 100644 src/snek/service/user.py create mode 100644 src/snek/static/markdown-frame.js create mode 100644 src/snek/static/style.css create mode 100644 src/snek/system/api.py create mode 100644 src/snek/system/mapper.py create mode 100644 src/snek/system/markdown.py create mode 100644 src/snek/system/security.py create mode 100644 src/snek/system/service.py create mode 100644 src/snek/system/view.py create mode 100644 src/snek/templates/about.html create mode 100644 src/snek/templates/about.md create mode 100644 src/snek/view/about.py delete mode 100644 src/snek/view/base.py rename src/snek/view/{view.py => web.py} (73%) diff --git a/.gitignore b/.gitignore index 3747073..ece77be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .vscode .history +.resources +.backup* +docs *.db* *.png # ---> Python diff --git a/Dockerfile b/Dockerfile index 47c0ece..9af8e87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf -FROM python:3.10-alpine +FROM python:3.12.8-alpine3.21 WORKDIR /code ENV FLASK_APP=app.py ENV FLASK_RUN_HOST=0.0.0.0 @@ -37,5 +37,5 @@ RUN pip install --upgrade pip RUN pip install -e . EXPOSE 8081 -python -m snek.app +CMD ["python","-m","snek.app"] #CMD ["gunicorn", "-w", "10", "-k", "aiohttp.worker.GunicornWebWorker", "snek.gunicorn:app","--bind","0.0.0.0:8081"] diff --git a/pyproject.toml b/pyproject.toml index d98557e..cc36846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "beautifulsoup4", "gunicorn", "imgkit", - "wkhtmltopdf" + "wkhtmltopdf", + "jinja-markdown2", + "mistune" ] diff --git a/src/snek/app.py b/src/snek/app.py index 0e2ed63..deac5d3 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -3,14 +3,16 @@ import pathlib from aiohttp import web from app.app import Application as BaseApplication +from jinja_markdown2 import MarkdownExtension from snek.system import http from snek.system.middleware import cors_middleware +from snek.view.about import AboutHTMLView, AboutMDView from snek.view.index import IndexView from snek.view.login import LoginView from snek.view.login_form import LoginFormView from snek.view.register import RegisterView from snek.view.register_form import RegisterFormView -from snek.view.view import WebView +from snek.view.web import WebView class Application(BaseApplication): @@ -24,6 +26,7 @@ class Application(BaseApplication): super().__init__( middlewares=middlewares, template_path=self.template_path, *args, **kwargs ) + self.jinja2_env.add_extension(MarkdownExtension) self.setup_router() def setup_router(self): @@ -34,12 +37,14 @@ class Application(BaseApplication): name="static", show_index=True, ) - self.router.add_view("/web", WebView) - self.router.add_view("/login", LoginView) - self.router.add_view("/login-form", LoginFormView) - self.router.add_view("/register", RegisterView) + self.router.add_view("/about.html", AboutHTMLView) + self.router.add_view("/about.md", AboutMDView) + self.router.add_view("/web.html", WebView) + self.router.add_view("/login.html", LoginView) + self.router.add_view("/login-form.json", LoginFormView) + self.router.add_view("/register.html", RegisterView) - self.router.add_view("/register-form", RegisterFormView) + self.router.add_view("/register-form.json", RegisterFormView) self.router.add_get("/http-get", self.handle_http_get) self.router.add_get("/http-photo", self.handle_http_photo) diff --git a/src/snek/form/register.py b/src/snek/form/register.py index 60399fb..7dff3e4 100644 --- a/src/snek/form/register.py +++ b/src/snek/form/register.py @@ -15,7 +15,7 @@ class RegisterForm(Form): ) email = FormInputElement( name="email", - required=True, + required=False, regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", place_holder="Email address", type="email" diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py new file mode 100644 index 0000000..dc9e047 --- /dev/null +++ b/src/snek/mapper/__init__.py @@ -0,0 +1,12 @@ +import functools +from snek.mapper.user import UserMapper + +@functools.cache +def get_mappers(app=None): + return dict( + user=UserMapper(app=app) + + ) + +def get_mapper(name, app=None): + return get_mappers(app=app)[name] \ No newline at end of file diff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py new file mode 100644 index 0000000..5b8671e --- /dev/null +++ b/src/snek/mapper/user.py @@ -0,0 +1,6 @@ +from snek.system.mapper import BaseMapper +from snek.model.user import UserModel + +class UserMapper(BaseMapper): + table_name = "user" + model: UserModel \ No newline at end of file diff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py index e69de29..52af21a 100644 --- a/src/snek/model/__init__.py +++ b/src/snek/model/__init__.py @@ -0,0 +1,12 @@ +from snek.model.user import UserModel +import functools + +@functools.cache +def get_models(): + return dict( + user=UserModel + + ) + +def get_model(name): + return get_models()[name] diff --git a/src/snek/model/user.py b/src/snek/model/user.py index 44553f8..254b6c9 100644 --- a/src/snek/model/user.py +++ b/src/snek/model/user.py @@ -1,6 +1,6 @@ from snek.system.model import BaseModel,ModelField -class User(BaseModel): +class UserModel(BaseModel): username = ModelField( name="username", @@ -11,7 +11,7 @@ class User(BaseModel): ) email = ModelField( name="email", - required=True, + required=False, regex=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" ) password = ModelField(name="password",required=True,regex=r"^[a-zA-Z0-9_.+-]{6,}") diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py new file mode 100644 index 0000000..4038f70 --- /dev/null +++ b/src/snek/service/__init__.py @@ -0,0 +1,12 @@ +from snek.service.user import UserService +import functools + +@functools.cache +def get_services(app): + + return dict( + user = UserService(app=app) + + ) +def get_service(name, app=None): + return get_services(app=app)[name] \ No newline at end of file diff --git a/src/snek/service/user.py b/src/snek/service/user.py new file mode 100644 index 0000000..cde4b8c --- /dev/null +++ b/src/snek/service/user.py @@ -0,0 +1,16 @@ +from snek.system.service import BaseService +from snek.system import security + +class UserService: + mapper_name = "user" + + async def create_user(self, username, password): + if await self.exists(username=username): + raise Exception("User already exists.") + model = await self.new() + model.username = username + model.password = await security.hash(password) + if await self.save(model): + return model + raise Exception(f"Failed to create user: {model.errors}.") + \ No newline at end of file diff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js index 11fea47..58a67e2 100644 --- a/src/snek/static/generic-form.js +++ b/src/snek/static/generic-form.js @@ -224,7 +224,11 @@ class GenericForm extends HTMLElement { } @media (max-width: 500px) { + width:100%; + height:100%; form { + height:100%; + width: 100%; width: 80%; } }` diff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js index 22581ce..0d5d4c9 100644 --- a/src/snek/static/html-frame.js +++ b/src/snek/static/html-frame.js @@ -26,7 +26,16 @@ class HTMLFrame extends HTMLElement { throw new Error(`Error: ${response.status} ${response.statusText}`); } const html = await response.text(); - this.container.innerHTML = html; + if(url.endsWith(".md")){ + const parent = this + const markdownElement = document.createElement('div') + markdownElement.innerHTML = html + document.body.appendChild(markdownElement) + //parent.parentElement.appendChild(markdownElement) + + }else{ + this.container.innerHTML = html; + } } catch (error) { this.container.textContent = `Error: ${error.message}`; diff --git a/src/snek/static/markdown-frame.js b/src/snek/static/markdown-frame.js new file mode 100644 index 0000000..e2b7a77 --- /dev/null +++ b/src/snek/static/markdown-frame.js @@ -0,0 +1,39 @@ + + + +class HTMLFrame extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.container = document.createElement('div'); + this.shadowRoot.appendChild(this.container); + } + + connectedCallback() { + this.container.classList.add("html_frame") + const url = this.getAttribute('url'); + if (url) { + const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get") + if(!url.startsWith("/")) + fullUrl.searchParams.set('url', url) + this.loadAndRender(fullUrl.toString()); + } else { + this.container.textContent = "No source URL!"; + } + } + + async loadAndRender(url) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Error: ${response.status} ${response.statusText}`); + } + const html = await response.text(); + this.container.innerHTML = html; + + } catch (error) { + this.container.textContent = `Error: ${error.message}`; + } + } + } + customElements.define('markdown-frame', HTMLFrame); \ No newline at end of file diff --git a/src/snek/static/style.css b/src/snek/static/style.css new file mode 100644 index 0000000..990fcf9 --- /dev/null +++ b/src/snek/static/style.css @@ -0,0 +1,20 @@ +h1 { + font-size: 2em; + color: #f05a28; + margin-bottom: 20px; +} + +h2 { + font-size: 1.4em; + color: #f05a28; + margin-bottom: 20px; +} +body { + background-color: #000; + color: #efefef; + +} +div { + text-align: left; + +} \ No newline at end of file diff --git a/src/snek/system/api.py b/src/snek/system/api.py new file mode 100644 index 0000000..e69de29 diff --git a/src/snek/system/form.py b/src/snek/system/form.py index 68f7c0f..f9ebebb 100644 --- a/src/snek/system/form.py +++ b/src/snek/system/form.py @@ -1,171 +1,96 @@ -from snek.system import model +# Written by retoor@molodetz.nl + +# This code defines a framework for handling HTML elements as Python objects, including specific classes for HTML, form input, and form button elements. It offers methods to convert these elements to JSON, manipulate them, and validate form data. + +# This code uses the `snek.system.model` library for managing model fields. + +# MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from snek.system import model class HTMLElement(model.ModelField): - def __init__(self,id:str=None, tag:str="div", name:str=None,html:str=None, class_name:str=None, text:str=None, *args, **kwargs): - """ - Create a new HTMLElement. - - :param id: The id of the element - :param tag: The tag of the element - :param name: The name of the element, used to generate a class name if not provided - :param html: The inner html of the element - :param class_name: The class name of the element - :param text: The text of the element - """ + def __init__(self, id=None, tag="div", name=None, html=None, class_name=None, text=None, *args, **kwargs): self.tag = tag self.text = text - self.id = id + self.id = id self.class_name = class_name or name - self.html = html - super().__init__(name=name,*args, **kwargs) + self.html = html + super().__init__(name=name, *args, **kwargs) def to_json(self): - """ - Return a json representation of the element. - - This will return a dict with the following keys: - - - text: The text of the element - - id: The id of the element - - html: The inner html of the element - - class_name: The class name of the element - - tag: The tag of the element - - :return: A json representation of the element - :rtype: dict - """ result = super().to_json() - result['text'] = self.text - result['id'] = self.id - result['html'] = self.html + result['text'] = self.text + result['id'] = self.id + result['html'] = self.html result['class_name'] = self.class_name result['tag'] = self.tag - return result + return result class FormElement(HTMLElement): pass - + class FormInputElement(FormElement): - - def __init__(self,type="text",place_holder=None, *args, **kwargs): - """ - Initialize a FormInputElement with specified attributes. - - :param type: The type of the input element (default is "text"). - :param place_holder: The placeholder text for the input element. - :param args: Additional positional arguments. - :param kwargs: Additional keyword arguments. - """ - + def __init__(self, type="text", place_holder=None, *args, **kwargs): super().__init__(tag="input", *args, **kwargs) - self.place_holder = place_holder + self.place_holder = place_holder self.type = type - def to_json(self): - """ - Return a json representation of the element. - - This will return a dict with the following keys: - - - place_holder: The placeholder text for the input element - - type: The type of the input element - - :return: A json representation of the element - :rtype: dict - """ data = super().to_json() data["place_holder"] = self.place_holder data["type"] = self.type - return data - -class FormButtonElement(FormElement): - # Just use the label text property to assign a button label. - def __init__(self, tag="button", *args, **kwargs): - """ - Initialize a FormButtonElement with specified attributes. + return data - :param tag: The tag of the button element (default is "button"). - :param args: Additional positional arguments. - :param kwargs: Additional keyword arguments. - """ +class FormButtonElement(FormElement): + def __init__(self, tag="button", *args, **kwargs): super().__init__(tag=tag, *args, **kwargs) - class Form(model.BaseModel): - @property def html_elements(self): - """ - Return a list of all :class:`HTMLElement` objects in the form. - - This is a convenience property that filters the :attr:`fields` list to only - include elements that are instances of :class:`HTMLElement`. - - :return: A list of :class:`HTMLElement` objects - :rtype: list - """ json_elements = super().to_json() - return [element for element in self.fields if isinstance(element,HTMLElement)] + return [element for element in self.fields if isinstance(element, HTMLElement)] + def set_user_data(self, data): - """ - Set user data for the form by updating the fields with the provided data. - - This method extracts the 'fields' key from the provided data dictionary - and passes it to the parent class's `set_user_data` method to update the - form fields accordingly. - - :param data: A dictionary containing the form data, expected to have a - 'fields' key with the data to update the form fields. - """ - return super().set_user_data(data.get('fields')) def to_json(self, encode=False): - """ - Return a JSON representation of the form, including field values and metadata. - - This method returns a dictionary with the following keys: - - - ``fields``: A dictionary of field names to their current values. - - ``is_valid``: A boolean indicating whether the form is valid. - - ``errors``: A dictionary of field names to lists of error strings. - - If the ``encode`` argument is ``True``, the dictionary will be JSON-encoded - before being returned. Otherwise, the dictionary is returned directly. - - :param encode: If ``True``, JSON-encode the returned dictionary. - :type encode: bool - :return: A JSON representation of the form. - :rtype: dict - """ elements = super().to_json() html_elements = {} for element in elements.keys(): - print("DDD!",element,flush=True) - field = getattr(self,element) - if isinstance(field,HTMLElement): - print("QQQQ!",element,flush=True) + field = getattr(self, element) + if isinstance(field, HTMLElement): try: html_elements[element] = elements[element] except KeyError: - pass + pass + return dict(fields=html_elements, is_valid=self.is_valid, errors=self.errors) - return dict(fields=html_elements,is_valid=self.is_valid,errors=self.errors) @property def errors(self): - """ - Return a list of all error strings from all fields in the form. - - The list will be empty if all fields are valid. - - :return: A list of error strings. - :rtype: list - """ result = [] for field in self.html_elements: - result += field.errors - return result + result += field.errors + return result + @property def is_valid(self): - return all(element.is_valid for element in self.html_elements) + return all(element.is_valid for element in self.html_elements) \ No newline at end of file diff --git a/src/snek/system/http.py b/src/snek/system/http.py index 0b16bee..b5e8b4f 100644 --- a/src/snek/system/http.py +++ b/src/snek/system/http.py @@ -1,77 +1,99 @@ -from aiohttp import web -import aiohttp +# Written by retoor@molodetz.nl + +# This script enables downloading, processing, and caching web content, including taking website screenshots and repairing links in HTML content. + +# Imports used: aiohttp, aiohttp.web for creating web servers and handling async requests; app.cache for caching utilities; BeautifulSoup from bs4 for HTML parsing; imgkit for creating screenshots. + +# The MIT License (MIT) +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +from aiohttp import web +import aiohttp from app.cache import time_cache_async from bs4 import BeautifulSoup from urllib.parse import urljoin -import pathlib -import uuid -import imgkit +import pathlib +import uuid +import imgkit import asyncio import zlib -import io +import io async def crc32(data): try: data = data.encode() except: - pass - result = "crc32" + str(zlib.crc32(data)) - return result + pass + return "crc32" + str(zlib.crc32(data)) -async def get_file(name,suffix=".cache"): +async def get_file(name, suffix=".cache"): name = await crc32(name) path = pathlib.Path(".").joinpath("cache") if not path.exists(): - path.mkdir(parents=True,exist_ok=True) - path = path.joinpath(name + suffix) - return path - - + path.mkdir(parents=True, exist_ok=True) + return path.joinpath(name + suffix) async def public_touch(name=None): - path = pathlib.Path(".").joinpath(str(uuid.uuid4())+name) + path = pathlib.Path(".").joinpath(str(uuid.uuid4()) + name) path.open("wb").close() - return path + return path async def create_site_photo(url): loop = asyncio.get_event_loop() if not url.startswith("https"): url = "https://" + url - output_path = await get_file("site-screenshot-" + url,".png") + output_path = await get_file("site-screenshot-" + url, ".png") if output_path.exists(): return output_path output_path.touch() + def make_photo(): imgkit.from_url(url, output_path.absolute()) - return output_path + return output_path - return await loop.run_in_executor(None,make_photo) + return await loop.run_in_executor(None, make_photo) async def repair_links(base_url, html_content): soup = BeautifulSoup(html_content, "html.parser") for tag in soup.find_all(['a', 'img', 'link']): - if tag.has_attr('href') and not tag['href'].startswith("http"): # For and tags + if tag.has_attr('href') and not tag['href'].startswith("http"): tag['href'] = urljoin(base_url, tag['href']) - if tag.has_attr('src') and not tag['src'].startswith("http"): # For tags + if tag.has_attr('src') and not tag['src'].startswith("http"): tag['src'] = urljoin(base_url, tag['src']) - print("Fixed: ",tag['src']) return soup.prettify() async def is_html_content(content: bytes): try: content = content.decode(errors='ignore') except: - pass - marks = [' types.Optional[BaseModel] + if uid: + kwargs['uid'] = uid + model = self.new() + record = self.table.find_one(**kwargs) + return self.model_class.from_record(mapper=self,record=record) + + async def exists(self, **kwargs): + return self.table.exists(**kwargs) + + async def count(self, **kwargs) -> int: + return self.table.count(**kwargs) + + async def save(self, model:BaseModel) -> bool: + record = model.record + if not record.get('uid'): + raise Exception(f"Attempt to save without uid: {record}.") + return self.table.upsert(record,['uid']) + + async def find(self, **kwargs) -> types.List[BaseModel]: + if not kwargs.get("_limit"): + kwargs["_limit"] = self.default_limit + for record in self.table.find(**kwargs): + yield self.model_class.from_record(mapper=self,record=record) + + async def delete(self, kwargs=None)-> int: + if not kwargs or not isinstance(kwargs, dict): + raise Exception("Can't execute delete with no filter.") + return self.table.delete(**kwargs) diff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py new file mode 100644 index 0000000..bded949 --- /dev/null +++ b/src/snek/system/markdown.py @@ -0,0 +1,43 @@ + +# Original source: https://brandonjay.dev/posts/2021/render-markdown-html-in-python-with-jinja2 + +from mistune import escape +from mistune import Markdown +from mistune import HTMLRenderer +from pygments import highlight +from pygments.lexers import get_lexer_by_name +from pygments.formatters import html +from pygments.styles import get_style_by_name + + +class MarkdownRenderer(HTMLRenderer): + def __init__(self, app, template): + self.template = template + + self.app = app + self.env = self.app.jinja2_env + formatter = html.HtmlFormatter() + self.env.globals['highlight_styles'] = formatter.get_style_defs() + def _escape(self,str): + return str ##escape(str) + def block_code(self, code, lang=None,info=None): + if not lang: + lang = info + if not lang: + return f"
{code}
" + #return '\n
%s
\n' % escape(code) + lexer = get_lexer_by_name(lang, stripall=True) + formatter = html.HtmlFormatter(lineseparator="
") + print(code, lang,info, flush=True) + return highlight(code, lexer, formatter) + def render(self): + markdown_string = self.app.template_path.joinpath(self.template).read_text() + renderer = MarkdownRenderer(self.app,self.template) + markdown = Markdown(renderer=renderer) + return markdown(markdown_string) + + +async def render_markdown(app, markdown_string): + renderer = MarkdownRenderer(app,None) + markdown = Markdown(renderer=renderer) + return markdown(markdown_string) \ No newline at end of file diff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py index 6b801ab..7fe457f 100644 --- a/src/snek/system/middleware.py +++ b/src/snek/system/middleware.py @@ -1,4 +1,12 @@ -from aiohttp import web +# Written by retoor@molodetz.nl + +# This code provides middleware functions for an aiohttp server to manage and modify CORS (Cross-Origin Resource Sharing) headers. + +# Imports from 'aiohttp' library are used to create middleware; they are not part of Python's standard library. + +# MIT License: This code is distributed under the MIT License. + +from aiohttp import web @web.middleware async def no_cors_middleware(request, handler): @@ -7,16 +15,15 @@ async def no_cors_middleware(request, handler): return response @web.middleware -async def cors_allow_middleware(request ,handler): +async def cors_allow_middleware(request, handler): response = await handler(request) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE" response.headers["Access-Control-Allow-Headers"] = "*" - return response + return response @web.middleware async def cors_middleware(request, handler): - # Handle preflight (OPTIONS) requests if request.method == "OPTIONS": response = web.Response() response.headers["Access-Control-Allow-Origin"] = "*" @@ -24,7 +31,6 @@ async def cors_middleware(request, handler): response.headers["Access-Control-Allow-Headers"] = "*" return response - # Handle actual requests response = await handler(request) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" diff --git a/src/snek/system/model.py b/src/snek/system/model.py index a699a9e..eb490b3 100644 --- a/src/snek/system/model.py +++ b/src/snek/system/model.py @@ -1,36 +1,67 @@ +# Written by retoor@molodetz.nl + +# The script defines a flexible validation and field management system for models, with capabilities for setting attributes, validation, error handling, and JSON conversion. It includes classes for managing various field types with specific properties such as UUID, timestamps for creation and updates, and custom validation rules. + +# This script utilizes external Python libraries such as 're' for regex operations, 'uuid' for generating unique identifiers, and 'json' for data interchange. The 'datetime' and 'timezone' modules from the Python standard library are used for date and time operations. 'OrderedDict' from 'collections' provides enhanced dictionary capabilities, and 'copy' allows deep copying of objects. + +# MIT License +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + import re import uuid -import json -from datetime import datetime , timezone +import json +from datetime import datetime, timezone from collections import OrderedDict -import copy +import copy TIMESTAMP_REGEX = r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}\+\d{2}:\d{2}$" + def now(): return str(datetime.now(timezone.utc)) + def add_attrs(**kwargs): def decorator(func): for key, value in kwargs.items(): setattr(func, key, value) - return func return decorator -def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**kwargs): - def decorator(func): - return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func) + +def validate_attrs(required=False, min_length=None, max_length=None, regex=None, **kwargs): + def decorator(func): + return add_attrs(required=required, min_length=min_length, max_length=max_length, regex=regex, **kwargs)(func) + class Validator: _index = 0 + @property def value(self): - return self._value + return self._value - @value.setter - def value(self,val): - self._value = json.loads(json.dumps(val,default=str)) + @value.setter + def value(self, val): + self._value = json.loads(json.dumps(val, default=str)) @property def initial_value(self): @@ -39,48 +70,49 @@ class Validator: def custom_validation(self): return True - def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs): + def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, **kwargs): self.index = Validator._index Validator._index += 1 - self.required = required - self.min_num = min_num + self.required = required + self.min_num = min_num self.max_num = max_num - self.min_length = min_length - self.max_length = max_length - self.regex = regex - self._value = None + self.min_length = min_length + self.max_length = max_length + self.regex = regex + self._value = None self.value = value - print("xxxx", value,flush=True) - + print("xxxx", value, flush=True) + self.kind = kind - self.help_text = help_text + self.help_text = help_text self.__dict__.update(kwargs) - @property + + @property def errors(self): error_list = [] if self.value is None and self.required: error_list.append("Field is required.") - return error_list - - if self.value is None: - return error_list + return error_list - if self.kind == float or self.kind == int: + if self.value is None: + return error_list + + if self.kind in [int, float]: if self.min_num is not None and self.value < self.min_num: - error_list.append("Field should be minimal {}.".format(self.min_num)) + error_list.append(f"Field should be minimal {self.min_num}.") if self.max_num is not None and self.value > self.max_num: - error_list.append("Field should be maximal {}.".format(self.max_num)) + error_list.append(f"Field should be maximal {self.max_num}.") if self.min_length is not None and len(self.value) < self.min_length: - error_list.append("Field should be minimal {} characters long.".format(self.min_length)) + error_list.append(f"Field should be minimal {self.min_length} characters long.") if self.max_length is not None and len(self.value) > self.max_length: - error_list.append("Field should be maximal {} characters long.".format(self.max_length)) - print(self.regex, self.value,flush=True) - if not self.regex is None and not self.value is None and not re.match(self.regex, self.value): - error_list.append("Invalid value.".format(self.regex)) - if not self.kind is None and type(self.value) != self.kind: - error_list.append("Invalid kind. It is supposed to be {}.".format(self.kind)) - return error_list - + error_list.append(f"Field should be maximal {self.max_length} characters long.") + print(self.regex, self.value, flush=True) + if self.regex and self.value and not re.match(self.regex, self.value): + error_list.append("Invalid value.") + if self.kind and not isinstance(self.value, self.kind): + error_list.append(f"Invalid kind. It is supposed to be {self.kind}.") + return error_list + def validate(self): if self.errors: raise ValueError("\n", self.errors) @@ -94,8 +126,6 @@ class Validator: except ValueError: return False - - def to_json(self): return { "required": self.required, @@ -109,25 +139,27 @@ class Validator: "help_text": self.help_text, "errors": self.errors, "is_valid": self.is_valid, - "index":self.index + "index": self.index } + class ModelField(Validator): index = 1 - def __init__(self,name=None,save=True, *args, **kwargs): - self.name = name + + def __init__(self, name=None, save=True, *args, **kwargs): + self.name = name self.save = save super().__init__(*args, **kwargs) def to_json(self): result = super().to_json() result['name'] = self.name - return result + return result class CreatedField(ModelField): - + @property def initial_value(self): return now() @@ -136,67 +168,99 @@ class CreatedField(ModelField): if not self.value: self.value = now() + class UpdatedField(ModelField): def update(self): self.value = now() + class DeletedField(ModelField): def update(self): self.value = now() + class UUIDField(ModelField): - - @property + + @property def initial_value(self): return str(uuid.uuid4()) class BaseModel: - - uid = UUIDField(name="uid",required=True) - created_at = CreatedField(name="created_at",required=True, regex=TIMESTAMP_REGEX, place_holder="Created at") - updated_at = UpdatedField(name="updated_at",regex=TIMESTAMP_REGEX,place_holder="Updated at") - deleted_at = DeletedField(name="deleted_at",regex=TIMESTAMP_REGEX, place_holder="Deleted at") - + uid = UUIDField(name="uid", required=True) + created_at = CreatedField(name="created_at", required=True, regex=TIMESTAMP_REGEX, place_holder="Created at") + updated_at = UpdatedField(name="updated_at", regex=TIMESTAMP_REGEX, place_holder="Updated at") + deleted_at = DeletedField(name="deleted_at", regex=TIMESTAMP_REGEX, place_holder="Deleted at") + + @classmethod + def from_record(cls, record, mapper): + model = cls.__new__() + model.mapper = mapper + model.record = record + return model + + @property + def mapper(self): + return self._mapper + + @mapper.setter + def mapper(self, value): + self._mapper = value + + @property + def record(self): + return {field.name: field.value for field in self.fields} + + @record.setter + def record(self, value): + for key, value in self._record.items(): + field = self.fields.get(key) + if not field: + continue + field.value = value + return self + def __init__(self, *args, **kwargs): print(self.__dict__) print(dir(self.__class__)) + self._mapper = None self.fields = {} for key in dir(self.__class__): - obj = getattr(self.__class__,key) + obj = getattr(self.__class__, key) - if isinstance(obj,Validator): + if isinstance(obj, Validator): self.__dict__[key] = copy.deepcopy(obj) print("JAAA") - self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value) + self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value) self.fields[key] = self.__dict__[key] def __setitem__(self, key, value): obj = self.__dict__.get(key) - if isinstance(obj,Validator): - obj.value = value + if isinstance(obj, Validator): + obj.value = value def __getattr__(self, key): obj = self.__dict__.get(key) - if isinstance(obj,Validator): + if isinstance(obj, Validator): print("HPAPP") - return obj.value + return obj.value return obj def set_user_data(self, data): for key, value in data.items(): field = self.fields.get(key) if not field: - continue + continue if value.get('name'): value = value.get('value') field.value = value - - @property + + + @property def is_valid(self): for field in self.fields.values(): if not field.is_valid: @@ -205,46 +269,44 @@ class BaseModel: def __getitem__(self, key): obj = self.__dict__.get(key) - if isinstance(obj,Validator): - return obj.value + if isinstance(obj, Validator): + return obj.value def __setattr__(self, key, value): - obj = getattr(self,key) - if isinstance(obj,Validator): + obj = getattr(self, key) + if isinstance(obj, Validator): obj.value = value else: - self.__dict__[key] = value #setattr(self,key,value) - #def __getattr__(self, key): - # obj = self.__dict__.get(key) - # if isinstance(obj,Validator): - # return obj.value - @property + self.__dict__[key] = value + + @property def record(self): obj = self.to_json() record = {} - for key,value in obj.items(): - if getattr(self,key).save: + for key, value in obj.items(): + if getattr(self, key).save: record[key] = value.get('value') return record - def to_json(self,encode=False): + def to_json(self, encode=False): model_data = OrderedDict({ "uid": self.uid.value, "created_at": self.created_at.value, "updated_at": self.updated_at.value, "deleted_at": self.deleted_at.value }) - - for key,value in self.__dict__.items(): + + for key, value in self.__dict__.items(): if key == "record": continue value = self.__dict__[key] - if hasattr(value,"value"): + if hasattr(value, "value"): model_data[key] = value.to_json() if encode: - return json.dumps(model_data,indent=2) + return json.dumps(model_data, indent=2) return model_data + class FormElement(ModelField): def __init__(self, place_holder=None, *args, **kwargs): @@ -252,15 +314,14 @@ class FormElement(ModelField): self.place_holder = place_holder - class FormElement(ModelField): - def __init__(self,place_holder=None, *args, **kwargs): - self.place_holder = place_holder + def __init__(self, place_holder=None, *args, **kwargs): + self.place_holder = place_holder super().__init__(*args, **kwargs) def to_json(self): data = super().to_json() - data["name"] = self.name + data["name"] = self.name data["place_holder"] = self.place_holder - return data + return data \ No newline at end of file diff --git a/src/snek/system/security.py b/src/snek/system/security.py new file mode 100644 index 0000000..b319f54 --- /dev/null +++ b/src/snek/system/security.py @@ -0,0 +1,20 @@ +import hashlib + +DEFAULT_SALT = b"snekker-de-snek-" + +async def hash(data,salt=DEFAULT_SALT): + try: + data = data.encode(errors="ignore") + except AttributeError: + pass + try: + salt = salt.encode(errors="ignore") + except AttributeError: + pass + salted = salt + data + + obj = hashlib.sha256(salted) + return obj.hexdigest() + +async def verify(string:str, hashed:str): + return await hash(string) == hashed diff --git a/src/snek/system/service.py b/src/snek/system/service.py new file mode 100644 index 0000000..5a8b553 --- /dev/null +++ b/src/snek/system/service.py @@ -0,0 +1,40 @@ + + + +from snek.mapper import get_mapper +from snek.system.mapper import BaseMapper +from snek.model.user import UserModel + +class BaseService: + + mapper_name:BaseMapper = None + + def __init__(self, app): + self.app = app + if self.mapper_name: + self.mapper = get_mapper(self.mapper_name, app=self.app) + else: + self.mapper = None + + async def exists(self, **kwargs): + return self.mapper.exists(**kwargs) + + async def count(self, **kwargs): + return self.mapper.count(**kwargs) + + async def new(self, **kwargs): + return await self.mapper.new() + + async def get(self, **kwargs): + return await self.mapper.get(**kwargs) + + async def save(self, model:UserModel): + if model.is_valid: + return self.mapper.save(model) and True + return False + + async def find(self, **kwargs): + return await self.mapper.find(**kwargs) + + async def delete(self, **kwargs): + return await self.mapper.delete(**kwargs) \ No newline at end of file diff --git a/src/snek/system/view.py b/src/snek/system/view.py new file mode 100644 index 0000000..458aa20 --- /dev/null +++ b/src/snek/system/view.py @@ -0,0 +1,38 @@ +from aiohttp import web + +from snek.system.markdown import render_markdown + +class BaseView(web.View): + + @property + def app(self): + return self.request.app + + @property + def db(self): + return self.app.db + + async def json_response(self, data): + return web.json_response(data) + + async def render_template(self, template_name, context=None): + if template_name.endswith(".md"): + response = await self.request.app.render_template(template_name,self.request,context) + body = await render_markdown(self.app, response.body.decode()) + return web.Response(body=body,content_type="text/html") + return await self.request.app.render_template(template_name, self.request,context) + +class BaseFormView(BaseView): + + form = None + + async def get(self): + form = self.form() + return await self.json_response(form.to_json()) + + async def post(self): + form = self.form() + post = await self.request.json() + form.set_user_data(post['form']) + return await self.json_response(form.to_json()) + diff --git a/src/snek/templates/about.html b/src/snek/templates/about.html new file mode 100644 index 0000000..0f1b8a9 --- /dev/null +++ b/src/snek/templates/about.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block main %} + + + +{% endblock %} \ No newline at end of file diff --git a/src/snek/templates/about.md b/src/snek/templates/about.md new file mode 100644 index 0000000..134fbc9 --- /dev/null +++ b/src/snek/templates/about.md @@ -0,0 +1,15 @@ +# Snek + +## What is a snek? +A snek is a danger noodle. + +## Design choices +I made several design choices: +- Implemented **the worst 3rd party markdown to html renderer ever**. See this nice *bullet list*. + - Only password requirement is thats it requires six characters. Users are responsibly for their own security. Snek is not so arrogant to determine if a password is strong enough. It's up to what user prefers. Snek does not have a forgot-my-password service tho. + - Email is not required for registration. Email is (maybe) used in future for resetting password. + - Database is SQLite by default. Postgres is also possible. In that case you have to change `db_path` prefix to `postgres:///` and add a postgres docker container to the docker setup. + - Homebrew made ORM framework based on dataset. + - Homebrew made Form framework based on the homebrew made ORM. Most forms are ModelForms but always require an service to be saved for sake of consistency and structure. + - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates. + - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer. diff --git a/src/snek/templates/base.html b/src/snek/templates/base.html index 5b1bdf2..9f57467 100644 --- a/src/snek/templates/base.html +++ b/src/snek/templates/base.html @@ -4,9 +4,8 @@ {% block title %}{% endblock %} + - - diff --git a/src/snek/templates/index.html b/src/snek/templates/index.html index 1ee1f77..c2200fb 100644 --- a/src/snek/templates/index.html +++ b/src/snek/templates/index.html @@ -14,7 +14,8 @@ Or - +
Design choices + See web Application so far diff --git a/src/snek/templates/login.html b/src/snek/templates/login.html index c09ec70..c70d429 100644 --- a/src/snek/templates/login.html +++ b/src/snek/templates/login.html @@ -1,5 +1,5 @@ {% extends "base.html" %} {% block main %} - + {% endblock %} diff --git a/src/snek/templates/register.html b/src/snek/templates/register.html index 61da961..f0a82e0 100644 --- a/src/snek/templates/register.html +++ b/src/snek/templates/register.html @@ -1,5 +1,5 @@ {% extends "base.html" %} {% block main %} - + {% endblock %} \ No newline at end of file diff --git a/src/snek/view/about.py b/src/snek/view/about.py new file mode 100644 index 0000000..593d5a9 --- /dev/null +++ b/src/snek/view/about.py @@ -0,0 +1,14 @@ + + +from snek.system.view import BaseView + + +class AboutHTMLView(BaseView): + + async def get(self): + return await self.render_template("about.html") + +class AboutMDView(BaseView): + + async def get(self): + return await self.render_template("about.md") \ No newline at end of file diff --git a/src/snek/view/base.py b/src/snek/view/base.py deleted file mode 100644 index d962ee5..0000000 --- a/src/snek/view/base.py +++ /dev/null @@ -1,31 +0,0 @@ -from aiohttp import web - -class BaseView(web.View): - - @property - def app(self): - return self.request.app - - @property - def db(self): - return self.app.db - - def json_response(self, data): - return web.json_response(data) - - def render_template(self, template_name, context=None): - return self.request.app.render_template(template_name, self.request,context) - -class BaseFormView(BaseView): - - form = None - - async def get(self): - form = self.form() - return self.json_response(form.to_json()) - - async def post(self): - form = self.form() - post = await self.request.json() - form.set_user_data(post['form']) - return self.json_response(form.to_json()) \ No newline at end of file diff --git a/src/snek/view/index.py b/src/snek/view/index.py index a5d8b92..c7861fa 100644 --- a/src/snek/view/index.py +++ b/src/snek/view/index.py @@ -1,4 +1,4 @@ -from snek.view.base import BaseView +from snek.system.view import BaseView class IndexView(BaseView): diff --git a/src/snek/view/login.py b/src/snek/view/login.py index 3a3beaf..ffedc79 100644 --- a/src/snek/view/login.py +++ b/src/snek/view/login.py @@ -1,5 +1,5 @@ from snek.form.register import RegisterForm -from snek.view.base import BaseView +from snek.system.view import BaseView class LoginView(BaseView): diff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py index 26527da..e9b6eac 100644 --- a/src/snek/view/login_form.py +++ b/src/snek/view/login_form.py @@ -1,4 +1,4 @@ -from snek.view.base import BaseFormView +from snek.system.view import BaseFormView from snek.form.login import LoginForm class LoginFormView(BaseFormView): diff --git a/src/snek/view/register.py b/src/snek/view/register.py index 095b7a3..e3b3038 100644 --- a/src/snek/view/register.py +++ b/src/snek/view/register.py @@ -1,4 +1,4 @@ -from snek.view.base import BaseView +from snek.system.view import BaseView class RegisterView(BaseView): diff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py index 0ae7630..8099b01 100644 --- a/src/snek/view/register_form.py +++ b/src/snek/view/register_form.py @@ -1,5 +1,5 @@ from snek.form.register import RegisterForm -from snek.view.base import BaseFormView +from snek.system.view import BaseFormView class RegisterFormView(BaseFormView): form = RegisterForm \ No newline at end of file diff --git a/src/snek/view/view.py b/src/snek/view/web.py similarity index 73% rename from src/snek/view/view.py rename to src/snek/view/web.py index ea642a3..b06563a 100644 --- a/src/snek/view/view.py +++ b/src/snek/view/web.py @@ -1,4 +1,4 @@ -from snek.view.base import BaseView +from snek.system.view import BaseView class WebView(BaseView):