diff --git a/witty-wisterias/.github/workflows/lint.yaml b/witty-wisterias/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/witty-wisterias/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/witty-wisterias/.github/workflows/review.yaml b/witty-wisterias/.github/workflows/review.yaml new file mode 100644 index 00000000..92e19fce --- /dev/null +++ b/witty-wisterias/.github/workflows/review.yaml @@ -0,0 +1,89 @@ +name: 🛡️ Require one Pull Request Approval + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened, ready_for_review] + pull_request_review: + # Also recheck when a review is submitted to automatically allow merging + types: [submitted] + +concurrency: # Make sure only one approval check runs at a time for the same PR, so the pull_request workflow doesnt fail after a review is submitted + group: approval-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + approval-check: + name: "​" # NOTE: Were using a zero-width space to save some visual clutter in the GitHub Pull Request UI + if: github.event.pull_request.draft == false # Only run if the PR is not a draft + + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + + steps: + - name: Require approval from at least one reviewer + id: approval_check + uses: peternied/required-approval@v1.3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + required-approvers-list: "bensgilbert,erri4,pedro-alvesjr,Tails5000,Vinyzu" # GitHub username is case-sensitive + min-required: 1 + continue-on-error: true + + - name: Set approval flag # This is a workarround because peternied/required-approval's output do not work, so we are checking the stdout + id: set_approved_flag + run: | + echo "approved=$(echo '${{ steps.approval_check.outcome }}' | grep -q 'success' && echo 'yes' || echo 'no')" >> $GITHUB_OUTPUT + + - name: Add reminder comment if approval is missing # Checking if approval comment was already posted + if: steps.set_approved_flag.outputs.approved == 'no' + id: find_comment_missing + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: "Waiting for at least one more approval" + + - name: Add reminder comment if missing # Add approval comment if it was not posted yet + if: steps.set_approved_flag.outputs.approved == 'no' && steps.find_comment_missing.outputs.comment-id == '' + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + ⏳ **Waiting for at least one more approval** from: + - @bensgilbert + - @erri4 + - @pedro-alvesjr + - @tails5000 + - @vinyzu + + - name: Delete reminder comment if approval is now present # Find approval comment if it was posted + if: steps.set_approved_flag.outputs.approved == 'yes' + id: find_comment_present + uses: peter-evans/find-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: "Waiting for at least one more approval" + + - name: Delete reminder comment if approval is now present # Delete approval comment if it was posted and approval is now present + if: steps.find_comment_present.outputs.comment-id != '' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: parseInt("${{ steps.find_comment_present.outputs.comment-id }}", 10) + }); + + - name: Fail workflow if approval is missing # Fail the workflow if approval is still missing + if: steps.set_approved_flag.outputs.approved == 'no' + run: | + echo "❌ Approval missing: at least one of the required users must approve." + exit 1 diff --git a/witty-wisterias/.gitignore b/witty-wisterias/.gitignore new file mode 100644 index 00000000..da547479 --- /dev/null +++ b/witty-wisterias/.gitignore @@ -0,0 +1,214 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$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 + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.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 +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# 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 +.envrc +.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/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +.vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# DS Store (macOS) +.DS_Store + +# Reflex +.web +.states diff --git a/witty-wisterias/.pre-commit-config.yaml b/witty-wisterias/.pre-commit-config.yaml new file mode 100644 index 00000000..cafd341d --- /dev/null +++ b/witty-wisterias/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-ast + - id: check-case-conflict + - id: check-docstring-first + - id: check-illegal-windows-names + - id: check-json + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: pretty-format-json + args: [--autofix] + + - repo: https://github.com/asottile/pyupgrade + rev: v3.20.0 + hooks: + - id: pyupgrade + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.7 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.17.1 + hooks: + - id: mypy + additional_dependencies: ['types-requests'] diff --git a/witty-wisterias/LICENSE.txt b/witty-wisterias/LICENSE.txt new file mode 100644 index 00000000..cbd5fc0a --- /dev/null +++ b/witty-wisterias/LICENSE.txt @@ -0,0 +1,33 @@ +MIT License + +Copyright (c) 2025 Witty Wisterias + +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. + +--- + +The Software incorporates code from which is under the following license: + +Copyright 2021 Python Discord + +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. diff --git a/witty-wisterias/README.md b/witty-wisterias/README.md new file mode 100644 index 00000000..d1d4d626 --- /dev/null +++ b/witty-wisterias/README.md @@ -0,0 +1,124 @@ +

+ 💩 ShitChat: The best worst Chat you've ever used +

+ +**Witty Wisterias** Python Discord Summer CodeJam 2025 project. +The technology is **Python in the Browser**, the theme is **Wrong Tool for the Job**, and our chosen framework +is [Reflex](https://github.com/reflex-dev/reflex). + +--- + +Are you searching for a 100% private, secure, end2end-encrypted chat application, hosted for free publicly? +
+And do you want it to be hosted in Images on a Free Image Hoster, with the Chat Inputs beeing to wrong way arround? +
+Then look no further, because **ShitChat** is the ~~wrong~~ right tool for you! + + +## Naming +Wondered where the name **ShitChat** comes from? +The name is a play on words, coming from [`Chit Chat`](https://dictionary.cambridge.org/dictionary/english/chit-chat) and Shit, as the Chat App is quite shitty... + +## Getting Started + +1. Clone the repository: + ```shell + git clone https://github.com/WittyWisterias/WittyWisterias/ + cd WittyWisterias + ``` +2. Install the dependencies: + ```shell + pip install -r requirements.txt + ``` +3. Start a local Reflex Server: + ```shell + cd witty_wisterias + reflex run + ``` +4. Allow Python access to your Webcam if prompted. +5. Open your browser and navigate to http://localhost:3000. +6. Chat! + +## Video Presentation + +https://github.com/user-attachments/assets/34d2b84e-76cb-40d1-8aca-c71dcacdc9dd + +(Unmute for some grooves) + + +## Wrong Tool for the Job +
+ Theme Aspects ✅ + +- Having to hold Handwritten Messages in front of your Webcam to send Text Messages +- Having to describe your image to send Image Messages +- Hosting the complete Chat Database in one Image File on an Free Image Hoster + +Note: We searched for wrong tools for the Cryptography, with one of more promising candidates being regex based +encryption, but we decided to not sacrifice user security and privacy for the sake of the theme. + +
+ +## Features +
+ Features Summary 💡 + +- Free and Open Database hosted as Image Files on [freeimghost.net](https://freeimghost.net/search/images/?q=ShitChat) +- 100% Private and Secure, no Backend Server needed +- Full Chat History stored in the Image Stack +- Creation of a Unique UserID, Sign-Verify / Public-Private Key Pair on first Enter +- Automatic Sharing of Verify and Public Keys in the Image Stack +- Signed Messages in Public Chat to protect against impersonifications +- End2End Encryption of Private Messages +- Storage of own Private Messages in Local Storage, as they cannot be self-decrypted +- Storage of others Verify / Public Keys in LocalStorage to protect against Image Stack/Chat History Swap Attacks +- Customization of your User Name and Profile Picture +- Sending Text via Webcam OCR using [olmocr.allenai.org](https://olmocr.allenai.org/) +- Sending Images via Image Generation using [pollinations.ai](https://pollinations.ai/) + +
+ +## Privacy and Security +
+ Information about your Privacy 🔐 + +- **No guarantee of Privacy or Security**: Even though **ShitChat** uses common, SOTA compliant cryptographic algorithms, the Chat app has not been audited for security. Use at your own risk. +- **No End2End Encryption of Public Messages**: Public Messages are, as the name says, public. They however are signed to protect against impersonification. +- **No guarantee of UserInput Privacy**: We use [olmocr.allenai.org](https://olmocr.allenai.org/) for OCR and [pollinations.ai](https://pollinations.ai/) for Image Generation. Only the results of these APIs will be shared in the Chat (so your webcam image will not be shared in the Chat). We cannot guarantee the Privacy of your Data shared with these APIs, however they do have strict Privacy Policies. +- **Use UserID to verify Identities**: The UserID is your only way to verify the identity of a user. Username and Profile Image can be changed at any time, and duplicated by another user. +- **Reliant on Local Storage**: **ShitChat** is only secure against "Database Swap" attacks as long as you do not clear your browser's local storage or switch browsers. If you do so, you will be at the risk of an Image Stack/Chat History Swap Attack. +- **Open to Everyone**: There is no friend feature, you can be Private Messaged by anyone. + +
+ + +## Preview Images +
+ Preview Images 📸 + +### See the latest Encoded ChatApp Message Stack: +https://freeimghost.net/search/images/?q=ShitChat + +#### Public Chat: +Public Chat + +#### Private Chat: +Private Chat + +#### Text Message Form: +Text Message Form + + +#### Image Message Form: +Image Message Form + + +
+ +# Credits +This project was created by (in order of contributed LOC): + +| [Vinyzu](https://github.com/Vinyzu) | [erri4](https://github.com/erri4) | [Ben Gilbert](https://github.com/bensgilbert) | [Pedro Alves](https://github.com/pedro-alvesjr) | [Tails5000](https://github.com/Tails5000) | +|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| [vinyzu](https://github.com/vinyzu) | [erri4](https://github.com/erri4) | [bensgilbert](https://github.com/bensgilbert) | [pedro-alvesjr](https://github.com/pedro-alvesjr) | [Tails5000](https://github.com/Tails5000) | +| Chat App | Database, Backend | First Frontend | Message Format | OCR Research | diff --git a/witty-wisterias/pyproject.toml b/witty-wisterias/pyproject.toml new file mode 100644 index 00000000..70c78c52 --- /dev/null +++ b/witty-wisterias/pyproject.toml @@ -0,0 +1,119 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "witty_wisterias" +version = "1.0.0" +description = "Witty Wisterias submission for the Python Discord Summer CodeJam 2025." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [ + { name = "bensgilbert" }, + { name = "erri4" }, + { name = "pedro-alvesjr" }, + { name = "Tails5000" }, + { name = "Vinyzu" }, +] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed", +] +dynamic = ["dependencies", "optional-dependencies"] + +[tool.setuptools.dynamic] +dependencies = { file = "requirements.txt" } + +[tool.setuptools.package-data] +witty_wisterias = ["py.typed"] + +[project.urls] +Homepage = "https://github.com/WittyWisterias/WittyWisterias" + +[project.scripts] +witty_wisterias = "witty_wisterias.__main__:main" + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Target Python 3.12. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# The directory containing the source code. +src = ["src"] +exclude = [ + # Want to keep Formatting in the requirements file. + "requirements.txt", +] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D205", + "D212", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Future annotations. + "FA", + # Error messages. + "EM", + "TRY003", + # Path/Opens + "PTH123", + # Trailing commas + "COM812", + # Magic Values + "PLR2004", + # Pseudo-random numbers + "S311", + # Hardcoded Password + "S105", + # Commented-out code + "ERA001", + # Boolean Type Arguments + "FBT001", + "FBT002", + # Implicit Namespaces, conflicts with Reflex + "INP001", + # Functions with too many arguments + "PLR0913", +] + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings. +convention = "google" +ignore-decorators = ["typing.overload"] + +[tool.mypy] +strict = true +disallow_untyped_decorators = false +disallow_subclassing_any = false diff --git a/witty-wisterias/requirements.txt b/witty-wisterias/requirements.txt new file mode 100644 index 00000000..1155469f --- /dev/null +++ b/witty-wisterias/requirements.txt @@ -0,0 +1,13 @@ +# Setup requirements for the project +pre-commit~=4.2.0 +ruff~=0.12.7 +setuptools~=80.9.0 +# Project dependencies +beautifulsoup4~=4.13.4 +httpx~=0.28.1 +opencv-python~=4.12.0.88 +pillow~=11.3.0 +PyNaCl~=1.5.0 +reflex~=0.8.5 +websockets~=15.0.1 +xxhash~=3.5.0 diff --git a/witty-wisterias/setup.py b/witty-wisterias/setup.py new file mode 100644 index 00000000..60684932 --- /dev/null +++ b/witty-wisterias/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/witty-wisterias/witty_wisterias/.gitignore b/witty-wisterias/witty_wisterias/.gitignore new file mode 100644 index 00000000..162c09ed --- /dev/null +++ b/witty-wisterias/witty_wisterias/.gitignore @@ -0,0 +1,6 @@ +.states +assets/external/ +*.py[cod] +.web +*.db +__pycache__/ diff --git a/witty-wisterias/witty_wisterias/__main__.py b/witty-wisterias/witty_wisterias/__main__.py new file mode 100644 index 00000000..8bda12c0 --- /dev/null +++ b/witty-wisterias/witty_wisterias/__main__.py @@ -0,0 +1,7 @@ +def main() -> None: + """Main entry point for the project.""" + return + + +if __name__ == "__main__": + main() diff --git a/witty-wisterias/witty_wisterias/assets/favicon.ico b/witty-wisterias/witty_wisterias/assets/favicon.ico new file mode 100644 index 00000000..f8ff40ba Binary files /dev/null and b/witty-wisterias/witty_wisterias/assets/favicon.ico differ diff --git a/witty-wisterias/witty_wisterias/backend/__init__.py b/witty-wisterias/witty_wisterias/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/witty-wisterias/witty_wisterias/backend/backend.py b/witty-wisterias/witty_wisterias/backend/backend.py new file mode 100644 index 00000000..56171bc8 --- /dev/null +++ b/witty-wisterias/witty_wisterias/backend/backend.py @@ -0,0 +1,296 @@ +import base64 +import json +import zlib +from dataclasses import asdict, dataclass, field +from io import BytesIO + +from PIL import Image + +from .cryptographer import Cryptographer +from .database import Database +from .exceptions import InvalidDataError +from .message_format import EventType, ExtraEventInfo, MessageFormat + + +@dataclass +class UploadStack: + """The UploadStack class is used to store the data that will be uploaded to the Database.""" + + profile_image_stack: dict[str, str] = field(default_factory=dict) + verify_keys_stack: dict[str, str] = field(default_factory=dict) + public_keys_stack: dict[str, str] = field(default_factory=dict) + message_stack: list[MessageFormat | str] = field(default_factory=list) + + @staticmethod + def from_json(data: str) -> "UploadStack": + """ + Deserialize a JSON string into an UploadStack object. + + Args: + data (str): The JSON string to deserialize. + + Returns: + UploadStack: An instance of UploadStack with the deserialized data. + """ + json_data = json.loads(data) + return UploadStack( + profile_image_stack=json_data.get("profile_image_stack", {}), + verify_keys_stack=json_data.get("verify_keys_stack", {}), + public_keys_stack=json_data.get("public_keys_stack", {}), + message_stack=[ + MessageFormat.from_json(message) + for message in json_data.get("message_stack", []) + if isinstance(message, str) + ], + ) + + +class Backend: + """Base class for the backend, which is used by the Frontend to handle Messages.""" + + @staticmethod + def decode(encoded_stack: str) -> UploadStack: + """ + Decode a base64-encoded, compressed JSON string into a list of MessageFormat objects. + + Args: + encoded_stack (str): The base64-encoded, compressed JSON string representing a stack of messages. + + Returns: + list[MessageFormat]: A list of MessageFormat objects reconstructed from the decoded data. + """ + # Check if the Message Stack is completely empty + if not encoded_stack: + return UploadStack() + + compressed_stack = base64.b64decode(encoded_stack.encode("utf-8")) + # Decompress + string_stack = zlib.decompress(compressed_stack).decode("utf-8") + # Convert String Stack to UploadStack object + return UploadStack.from_json(string_stack) + + @staticmethod + def encode(upload_stack: UploadStack) -> str: + """ + Encode a list of MessageFormat objects into a base64-encoded, compressed JSON string. + + Args: + upload_stack (UploadStack): The UploadStack to encode. + + Returns: + str: A base64-encoded, compressed JSON string representing the list of messages. + """ + # Convert each MessageFormat object to JSON + upload_stack.message_stack = [ + message.to_json() for message in upload_stack.message_stack if isinstance(message, MessageFormat) + ] + # Serialize the list to JSON + json_stack = json.dumps(asdict(upload_stack)) + # Compress the JSON string + compressed_stack = zlib.compress(json_stack.encode("utf-8")) + # Encode to base64 for safe transmission + return base64.b64encode(compressed_stack).decode("utf-8") + + @staticmethod + def push_public_keys(user_id: str, verify_key: str, public_key: str) -> None: + """ + Push public keys to the Upload Stack. + + Args: + user_id (str): The ID of the user. + verify_key (str): The verify key of the user. + public_key (str): The public key of the user. + """ + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + + # Append the verify_key to the Upload Stack if not already present + if user_id not in queried_data.verify_keys_stack: + queried_data.verify_keys_stack[user_id] = verify_key + + # Append the public_key to the Upload Stack if not already present + if user_id not in queried_data.public_keys_stack: + queried_data.public_keys_stack[user_id] = public_key + + # Upload the new Data to save it in the Database + Database.upload_data(Backend.encode(queried_data)) + + @staticmethod + def read_public_keys() -> tuple[dict[str, str], dict[str, str]]: + """ + Read verify and public keys from the Upload Stack. + + Returns: + dict[str, str]: A dictionary containing user IDs as keys and their verify keys as values. + dict[str, str]: A dictionary containing user IDs as keys and their public keys as values. + """ + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + return queried_data.verify_keys_stack, queried_data.public_keys_stack + + @staticmethod + def send_public_message(message: MessageFormat) -> None: + """ + Send a public test message to the Database. + + Args: + message (MessageFormat): The message to be sent, containing senderID, event type, content, and signing key. + """ + if not (message.sender_id and message.event_type and message.content and message.signing_key): + raise InvalidDataError("MessageFormat is not complete") + + # Query the latest Data from the Backend + queried_data = Backend.decode(Database.query_data()) + + # Append the verify_key to the Upload Stack if not already present + if message.sender_id not in queried_data.verify_keys_stack: + queried_data.verify_keys_stack[message.sender_id] = message.verify_key + + # Sign the message using the Signing Key + signed_message = Cryptographer.sign_message(message.content, message.signing_key) + # Create the Public Message to push + public_message = MessageFormat( + sender_id=message.sender_id, + event_type=message.event_type, + content=signed_message, + timestamp=message.timestamp, + extra_event_info=ExtraEventInfo( + user_name=message.sender_username, + user_image=message.sender_profile_image, + ), + ) + + # Push the new Public Message to the Message Stack + queried_data.message_stack.append(public_message) + + # Upload the new Data to save it in the Database + Database.upload_data(Backend.encode(queried_data)) + + @staticmethod + def send_private_message(message: MessageFormat) -> None: + """ + Send a private message to the Database. + + Args: + message (MessageFormat): The message to be sent, containing senderID, event type, content, and signing key. + """ + if not ( + message.sender_id + and message.receiver_id + and message.event_type + and message.content + and message.timestamp + and message.own_public_key + and message.receiver_public_key + and message.private_key + ): + raise InvalidDataError("MessageFormat is not complete") + + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + + # Append own Public Key to the Upload Stack if not already present + if message.sender_id not in queried_data.public_keys_stack: + queried_data.public_keys_stack[message.sender_id] = message.own_public_key + + # Encrypt the message content using the receiver's public key + encrypted_message = Cryptographer.encrypt_message( + message.content, message.private_key, message.receiver_public_key + ) + # Create the Private Message to push + private_message = MessageFormat( + sender_id=message.sender_id, + receiver_id=message.receiver_id, + event_type=message.event_type, + content=encrypted_message, + timestamp=message.timestamp, + extra_event_info=ExtraEventInfo( + user_name=message.sender_username, + user_image=message.sender_profile_image, + ), + ) + + # Push the new Public Message to the Message Stack + queried_data.message_stack.append(private_message) + + # Upload the new Data to save it in the Database + Database.upload_data(Backend.encode(queried_data)) + + @staticmethod + def read_public_messages() -> list[MessageFormat]: + """ + Read public text messages. + + Returns: + list[MessageFormat]: A list of verified public messages. + """ + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + + # Only verifiable Messages should be displayed + verified_messaged: list[MessageFormat] = [] + for message in queried_data.message_stack: + if isinstance(message, str): + continue + + # Checking if the message is a public message + if message.event_type not in (EventType.PUBLIC_TEXT, EventType.PUBLIC_IMAGE): + continue + + # Signature Verification + if message.sender_id in queried_data.verify_keys_stack: + verify_key = queried_data.verify_keys_stack[message.sender_id] + try: + # Verify the message content using the verify key + # Decode the image content if it's an image message + verified_content = Cryptographer.verify_message(message.content, verify_key) + + if message.event_type == EventType.PUBLIC_IMAGE: + image_data = base64.b64decode(verified_content) + message_content = Image.open(BytesIO(image_data)) + message.content = message_content.convert("RGB") + else: + message.content = verified_content + verified_messaged.append(message) + except ValueError: + pass + + return verified_messaged + + @staticmethod + def read_private_messages(user_id: str, private_key: str) -> list[MessageFormat]: + """ + Read private messages for a specific receiver. + + Args: + user_id (str): The ID of own user which tries to read private messages. + private_key (str): The private key of the receiver used for decrypting messages. + + Returns: + list[MessageFormat]: A list of decrypted private messages for the specified receiver. + """ + # Query the latest Data from the Database + queried_data = Backend.decode(Database.query_data()) + + # Only decryptable Messages should be displayed + decrypted_messages: list[MessageFormat] = [] + for message in queried_data.message_stack: + if isinstance(message, str): + continue + + # Checking if the message is a private message + if message.event_type not in (EventType.PRIVATE_TEXT, EventType.PRIVATE_IMAGE): + continue + + # Message Decryption check + if message.receiver_id == user_id and message.sender_id in queried_data.public_keys_stack: + try: + sender_public_key = queried_data.public_keys_stack[message.sender_id] + # Decrypt the message content using the receiver's private key and the sender's public key + decrypted_content = Cryptographer.decrypt_message(message.content, private_key, sender_public_key) + message.content = decrypted_content + decrypted_messages.append(message) + except ValueError: + pass + + return decrypted_messages diff --git a/witty-wisterias/witty_wisterias/backend/cryptographer.py b/witty-wisterias/witty_wisterias/backend/cryptographer.py new file mode 100644 index 00000000..85cc0b47 --- /dev/null +++ b/witty-wisterias/witty_wisterias/backend/cryptographer.py @@ -0,0 +1,158 @@ +import base64 +import random + +from nacl.public import Box, PrivateKey, PublicKey +from nacl.signing import SigningKey, VerifyKey + + +class Cryptographer: + """ + A class to handle cryptographic operations of our chat App. + Handles Public-Key Encryption and Digital Signatures of messages. + """ + + @staticmethod + def generate_random_user_id() -> str: + """ + Generates a random UserID which will be 48bit (fits exactly 2 RGB pixels). + Note: The chance that two users having the same UserID is 1 in 281,474,976,710,656. + So for this CodeJam, we can safely assume that this will never happen and don't check for Duplicates. + + Returns: + str: A random 48-bit UserID encoded in base64. + """ + # Generate random 48-bit integer (0 to 2^48 - 1) + user_id_bits = random.getrandbits(48) + # Convert to bytes (6 bytes for 48 bits) + user_id_bytes = user_id_bits.to_bytes(6, byteorder="big") + # Encode to base64 + return base64.b64encode(user_id_bytes).decode("utf-8") + + @staticmethod + def generate_signing_key_pair() -> tuple[str, str]: + """ + Generates a new base64-encoded signing-verify key pair for signing messages. + + Returns: + str: The base64-encoded signing key. + str: The base64-encoded verify key. + """ + # Generate a new signing key + nacl_signing_key = SigningKey.generate() + nacl_verify_key = nacl_signing_key.verify_key + # Encode the keys in base64 + encoded_signing_key = base64.b64encode(nacl_signing_key.encode()).decode("utf-8") + encoded_verify_key = base64.b64encode(nacl_verify_key.encode()).decode("utf-8") + # Return the signing key and its verify key in base64 encoding + return encoded_signing_key, encoded_verify_key + + @staticmethod + def generate_encryption_key_pair() -> tuple[str, str]: + """ + Generates a new base64-encoded private-public key pair. + + Returns: + str: The base64-encoded private key. + str: The base64-encoded public key. + """ + # Generate a new private key + nacl_private_key = PrivateKey.generate() + nacl_public_key = nacl_private_key.public_key + # Encode the keys in base64 + encoded_private_key = base64.b64encode(nacl_private_key.encode()).decode("utf-8") + encoded_public_key = base64.b64encode(nacl_public_key.encode()).decode("utf-8") + # Return the private key and its public key in base64 encoding + return encoded_private_key, encoded_public_key + + @staticmethod + def sign_message(message: str, signing_key: str) -> str: + """ + Signs a message using the provided signing key. + + Args: + message (str): The message to sign. + signing_key (str): The base64-encoded signing key. + + Returns: + str: The signed, base64-encoded message. + """ + # Decode the signing key from base64 + signing_key_bytes = base64.b64decode(signing_key) + # Create a SigningKey object + nacl_signing_key = SigningKey(signing_key_bytes) + # Sign the message + signed_message = nacl_signing_key.sign(message.encode("utf-8")) + return base64.b64encode(signed_message).decode("utf-8") + + @staticmethod + def verify_message(signed_message: str, verify_key: str) -> str: + """ + Verifies a signed message using the provided verify key. + + Args: + signed_message (str): The signed, base64-encoded message. + verify_key (str): The base64-encoded verify key. + + Returns: + str: The original message if verification is successful. + + Raises: + ValueError: If the verification fails. + """ + # Decode the signed message and verify key from base64 + signed_message_bytes = base64.b64decode(signed_message) + verify_key_bytes = base64.b64decode(verify_key) + # Create a VerifyKey object + nacl_verify_key = VerifyKey(verify_key_bytes) + # Verify the signed message + try: + verified_message: bytes = nacl_verify_key.verify(signed_message_bytes) + return verified_message.decode("utf-8") + except Exception as e: + raise ValueError("Verification failed") from e + + @staticmethod + def encrypt_message(message: str, sender_private_key: str, recipient_public_key: str) -> str: + """ + Encrypts a message using the recipient's public key and the sender's private key. + + Args: + message (str): The message to encrypt. + sender_private_key (str): The sender's private key in base64 encoding. + recipient_public_key (str): The recipient's public key in base64 encoding. + + Returns: + str: The encrypted, base64-encoded message. + """ + # Decode the keys from base64 + sender_private_key_bytes = base64.b64decode(sender_private_key) + recipient_public_key_bytes = base64.b64decode(recipient_public_key) + # Create the Box for encryption + nacl_box = Box(PrivateKey(sender_private_key_bytes), PublicKey(recipient_public_key_bytes)) + # Encrypt the message + encrypted_message = nacl_box.encrypt(message.encode("utf-8")) + return base64.b64encode(encrypted_message).decode("utf-8") + + @staticmethod + def decrypt_message(encrypted_message: str, recipient_private_key: str, sender_public_key: str) -> str: + """ + Decrypts a message using the recipient's private key and the sender's public key. + + Args: + encrypted_message (str): The encrypted, base64-encoded message. + recipient_private_key (str): The recipient's private key in base64 encoding. + sender_public_key (str): The sender's public key in base64 encoding. + + Returns: + str: The decrypted message. + """ + # Decode the keys from base64 + recipient_private_key_bytes = base64.b64decode(recipient_private_key) + sender_public_key_bytes = base64.b64decode(sender_public_key) + # Create the Box for decryption + nacl_box = Box(PrivateKey(recipient_private_key_bytes), PublicKey(sender_public_key_bytes)) + # Decode the encrypted message from base64 + encrypted_message_bytes = base64.b64decode(encrypted_message) + # Decrypt the message + decrypted_message: bytes = nacl_box.decrypt(encrypted_message_bytes) + return decrypted_message.decode("utf-8") diff --git a/witty-wisterias/witty_wisterias/backend/database.py b/witty-wisterias/witty_wisterias/backend/database.py new file mode 100644 index 00000000..8d2cb31a --- /dev/null +++ b/witty-wisterias/witty_wisterias/backend/database.py @@ -0,0 +1,223 @@ +import math +import random +import re +from datetime import UTC, datetime +from io import BytesIO + +# Using httpx instead of requests as it is more modern and has built-in typing support +import httpx +import xxhash +from bs4 import BeautifulSoup +from PIL import Image + +from .exceptions import InvalidResponseError + +# Global HTTP Session for the Database +HTTP_SESSION = httpx.Client(timeout=30) + +# Image Hoster URL and API Endpoints +HOSTER_URL = "https://freeimghost.net" +UPLOAD_URL = HOSTER_URL + "/upload" +JSON_URL = HOSTER_URL + "/json" +# Search Term used to query for our images (and name our files) +FILE_SEARCH_TERM = "ShitChatV1" + + +class Database: + """ + Our Database, responsible for storing and retrieving data. + We are encoding any data into .PNG Images (lossless compression). + Then we will store them here: https://freeimghost.net, which is a free image hosting service. + We will later be able to query for the latest messages via https://freeimghost.net/search/images/?q={SearchTerm} + """ + + @staticmethod + def extract_timestamp(url: str) -> float: + """ + Extracts the timestamp from the Filename of a given URL in our format. + + Args: + url (str): The URL from which to extract the timestamp. + + Returns: + float: The extracted timestamp as a float. + """ + # Use regex to find the timestamp in the URL + match = re.search(r"(\d+\.\d+)", url) + if match: + return float(match.group(1)) + # If no match is found, return 0.0 as a default value + return 0.0 + + @staticmethod + def base64_to_image(data: bytes) -> bytes: + """ + Converts arbitrary byte encoded data to image bytes. + + Args: + data (bytes): The byte encoded arbitrary data. + + Returns: + bytes: The encoded data as image bytes. + + Raises: + ValueError: If the resulting image exceeds the size limit of 20MB. + """ + # Prepend Custom Message Header for later Image Validation + # We also add a random noise header to avoid duplicates + header = FILE_SEARCH_TERM.encode() + random.randbytes(8) + validation_data = header + data + + # Check how many total pixels we need + total_pixels = math.ceil(len(validation_data) / 3) + # Calculate the size of the image (using ideal rectangle dimensions for space efficiency) + width = math.ceil(math.sqrt(total_pixels)) + height = math.ceil(total_pixels / width) + # Pad the data to fit the image size + padded_data = validation_data.ljust(width * height * 3, b"\x00") + + # Create the image bytes from the padded data + pil_image = Image.frombytes(mode="RGB", size=(width, height), data=padded_data) + # Save as PNG (lossless) in memory + buffer = BytesIO() + pil_image.save(buffer, format="PNG") + + # Get the byte content of the image file + image_bytes = buffer.getvalue() + # Check File Size (Image Hosting Service Limit) + if len(image_bytes) > 20 * 1024 * 1024: + raise ValueError("File Size exceeds limit of 20MB, shrink the Image Stack.") + return image_bytes + + @staticmethod + def get_configuration_data() -> str: + """ + Fetches the necessary configuration data for uploading images to the database. + + Returns: + str: The auth token required for uploading images. + + Raises: + InvalidResponseError: If the configuration data cannot be fetched or the auth token is not found. + """ + # Getting necessary configuration data for upload + config_response = HTTP_SESSION.get(UPLOAD_URL) + # Check if the response is successful + if config_response.status_code != 200: + raise InvalidResponseError("Failed to fetch configuration data from the image hosting service.") + + # Getting auth token from config response + auth_token_pattern = r'PF\.obj\.config\.auth_token\s*=\s*"([a-fA-F0-9]{40})";' + match = re.search(auth_token_pattern, config_response.text) + if not match: + raise InvalidResponseError("Auth token not found in the configuration response.") + # Extracting auth token + return match.group(1) + + @staticmethod + def upload_image(image_bytes: bytes) -> None: + """ + Uploads the image bytes to the Database/Image Hosting Service. + + Args: + image_bytes (bytes): The image bytes to upload. + + Raises: + InvalidResponseError: If the upload fails or the response is not as expected. + """ + # Get current UTC time + utc_time = datetime.now(UTC) + # Convert to UTC Timestamp + utc_timestamp = utc_time.timestamp() + + auth_token = Database.get_configuration_data() + # Hash the image bytes to create a checksum using xxHash64 (Specified by Image Hosting Service) + checksum = xxhash.xxh64(image_bytes).hexdigest() + + # Post Image to Image Hosting Service + response = HTTP_SESSION.post( + url=JSON_URL, + files={ + "source": (f"{FILE_SEARCH_TERM}_{utc_timestamp}.png", image_bytes, "image/png"), + }, + data={ + "type": "file", + "action": "upload", + "timestamp": str(int(utc_timestamp)), + "auth_token": auth_token, + "nsfw": "0", + "mimetype": "image/png", + "checksum": checksum, + }, + ) + # Check if the response is successful + if response.status_code != 200: + raise InvalidResponseError("Failed to upload image to the image hosting service.") + + @staticmethod + def query_data() -> str: + """ + Queries the latest data from the database. + + Returns: + str: The latest data. + + Raises: + InvalidResponseError: If the query fails or the response is not as expected. + """ + # Query all images with the FILE_SEARCH_TERM from the image hosting service + response = HTTP_SESSION.get(f"{HOSTER_URL}/search/images/?q={FILE_SEARCH_TERM}") + # Check if the response is successful + if response.status_code != 200: + raise InvalidResponseError("Failed to query latest image from the image hosting service.") + + # Extracting the latest image URL from the response using beautifulsoup + soup = BeautifulSoup(response.text, "html.parser") + # Find all image elements which are hosted on the image hosting service + image_links = [img.get("src") for img in soup.find_all("img") if HOSTER_URL in img.get("src")] + + # Sort the image elements by the timestamp in the filename (in the link) (newest first) + sorted_image_links: list[str] = sorted(image_links, key=Database.extract_timestamp, reverse=True) + + # Find the first image link that contains our validation header and return its pixel byte data + for image_link in sorted_image_links: + # Fetch the image content + image_content = HTTP_SESSION.get(image_link).content + + # Get the byte content of the image without the PNG Image File Header + image_stream = BytesIO(image_content) + pil_image = Image.open(image_stream).convert("RGB") + pixel_byte_data = pil_image.tobytes() + + # Validate the image content starts with our validation header + if pixel_byte_data.startswith(FILE_SEARCH_TERM.encode()): + # Remove the validation header and noise bytes from the first valid image + no_header_data = pixel_byte_data[len(FILE_SEARCH_TERM.encode()) + 8 :] + # Remove any padding bytes (if any) to get the original data + no_padding_data = no_header_data.rstrip(b"\x00") + + # Decode bytes into string and return it + decoded_data: str = no_padding_data.decode("utf-8", errors="ignore") + return decoded_data + + # If no valid image is found, return an empty string + return "" + + @staticmethod + def upload_data(data: str) -> None: + """ + Uploads string encoded data as an image to the database hosted on the Image Hosting Service. + + Args: + data (str): The data to upload, encoded in a string. + + Raises: + ValueError: If the resulting image exceeds the size limit of 20MB. + InvalidResponseError: If the upload fails or the response is not as expected. + """ + # Convert the string data to bytes + bytes_data = data.encode("utf-8") + # Convert the bytes data to an Image which contains encoded data + image_bytes = Database.base64_to_image(bytes_data) + # Upload the image bytes to the Image Hosting Service + Database.upload_image(image_bytes) diff --git a/witty-wisterias/witty_wisterias/backend/exceptions.py b/witty-wisterias/witty_wisterias/backend/exceptions.py new file mode 100644 index 00000000..84defcc7 --- /dev/null +++ b/witty-wisterias/witty_wisterias/backend/exceptions.py @@ -0,0 +1,6 @@ +class InvalidResponseError(Exception): + """Raise for invalid responses from the server.""" + + +class InvalidDataError(Exception): + """Raise for invalid data provided to the server.""" diff --git a/witty-wisterias/witty_wisterias/backend/message_format.py b/witty-wisterias/witty_wisterias/backend/message_format.py new file mode 100644 index 00000000..3997994e --- /dev/null +++ b/witty-wisterias/witty_wisterias/backend/message_format.py @@ -0,0 +1,257 @@ +import base64 +import json +from dataclasses import dataclass, field +from enum import Enum, auto +from io import BytesIO +from typing import TypedDict + +from PIL import Image + + +class EventType(Enum): + """Enumeration for different task types.""" + + PUBLIC_TEXT = auto() + PUBLIC_IMAGE = auto() + PRIVATE_TEXT = auto() + PRIVATE_IMAGE = auto() + + +class MessageFormatJson(TypedDict): + """ + Defines the structure of the JSON representation of a message. + This is used for serialization and deserialization of messages. + """ + + header: dict[str, str | float | None] + body: dict[str, str | dict[str, str | None]] + + +@dataclass +class ExtraEventInfo: + """Storage for extra information related to an event.""" + + user_name: str | None = field(default=None) + user_image: str | None = field(default=None) + + def to_dict(self) -> dict[str, str | None]: + """ + Convert the extra event info to a dictionary. + + Returns: + dict[str, str | None]: The Extra Event Info in a dict format. + """ + return { + "user_name": self.user_name, + "user_image": self.user_image, + } + + @staticmethod + def from_json(data: dict[str, str | None]) -> "ExtraEventInfo": + """ + Deserialize a JSON string into an ExtraEventInfo object. + + Args: + data (dict[str, str | None]): The Extra Event Info in a dict format. + + Returns: + ExtraEventInfo: : The Extra Event Info in a ExtraEventInfo object. + """ + return ExtraEventInfo(user_name=data.get("user_name", ""), user_image=data.get("user_image", "")) + + +@dataclass +class MessageFormat: + """ + Defines the standard structure for messages in the backend. + Supports serialization/deserialization for storage in images. + """ + + sender_id: str + event_type: EventType + content: str + timestamp: float + sender_username: str = field(default="") + sender_profile_image: str = field(default="") + receiver_id: str = field(default="None") + signing_key: str = field(default="") + verify_key: str = field(default="") + own_public_key: str = field(default="") + receiver_public_key: str = field(default="") + private_key: str = field(default="") + extra_event_info: ExtraEventInfo = field(default_factory=ExtraEventInfo) + + def to_dict(self) -> MessageFormatJson: + """ + Convert the message into a Python dictionary. + + Returns: + MessageFormatJson: The MessageFormat encoded in a dict. + """ + return { + "header": { + "sender_id": self.sender_id, + "receiver_id": self.receiver_id, + "event_type": self.event_type.name, + "timestamp": self.timestamp, + "signing_key": self.signing_key, + "verify_key": self.verify_key, + "own_public_key": self.own_public_key, + "private_key": self.private_key, + "receiver_public_key": self.receiver_public_key, + }, + "body": {"content": self.content, "extra_event_info": self.extra_event_info.to_dict()}, + } + + def to_json(self) -> str: + """ + Serialize the message into a JSON string. + + Returns: + str: The MessageFormat encoded in a JSON String. + """ + return json.dumps(self.to_dict(), ensure_ascii=False) + + @staticmethod + def from_json(data: str) -> "MessageFormat": + """ + Deserialize a JSON string into a MessageFormat object. + + Args: + data (str): The MessageFormat encoded in a JSON String. + + Returns: + MessageFormat: The Message Info in a MessageFormat object. + """ + obj = json.loads(data) + return MessageFormat( + sender_id=obj["header"]["sender_id"], + receiver_id=obj["header"].get("receiver_id"), + event_type=EventType[obj["header"]["event_type"]], + signing_key=obj["header"].get("signing_key"), + verify_key=obj["header"].get("verify_key"), + own_public_key=obj["header"].get("own_public_key"), + receiver_public_key=obj["header"].get("receiver_public_key"), + private_key=obj["header"].get("private_key"), + timestamp=obj["header"]["timestamp"], + content=obj["body"]["content"], + extra_event_info=ExtraEventInfo.from_json(obj["body"].get("extra_event_info", {})), + ) + + +class MessageStateJson(TypedDict): + """ + Defines the structure of the JSON representation of a message. + This is used for serialization and deserialization of messages. + """ + + message: str + user_id: str + receiver_id: str | None + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + timestamp: float + + +@dataclass +class MessageState: + """A message in the chat application state (Frontend).""" + + message: str | Image.Image + user_id: str + receiver_id: str | None + user_name: str + user_profile_image: str | None + own_message: bool + is_image_message: bool + timestamp: float + + @staticmethod + def from_message_format(message_format: MessageFormat, user_id: str) -> "MessageState": + """ + Convert a MessageFormat object to a Message object. + + Args: + message_format (MessageFormat): The MessageFormat object to convert. + user_id (str): The ChatState.user_id as str. + + Returns: + Message: A Message object created from the MessageFormat. + """ + is_image_message = message_format.event_type in (EventType.PUBLIC_IMAGE, EventType.PRIVATE_IMAGE) + if is_image_message: + # Decode the base64 image data to an Image object + image_data = base64.b64decode(message_format.content) + message_content = Image.open(BytesIO(image_data)) + message_content = message_content.convert("RGB") + else: + message_content = message_format.content + return MessageState( + message=message_content, + user_id=message_format.sender_id, + receiver_id=message_format.receiver_id if message_format.receiver_id != "None" else None, + user_name=message_format.extra_event_info.user_name or message_format.sender_id, + user_profile_image=message_format.extra_event_info.user_image, + own_message=user_id == message_format.sender_id, + is_image_message=is_image_message, + timestamp=message_format.timestamp, + ) + + @staticmethod + def from_dict(data: MessageStateJson) -> "MessageState": + """ + Convert a dictionary to a Message object. + + Args: + data (dict[str, str]): The dictionary containing message data. + + Returns: + Message: A Message object created from the dictionary. + """ + # Convert the base64 message content to a Pillow Image if it is an image message + if data.get("is_image_message", False): + # Decode the base64 image data to an Image object + image_data = base64.b64decode(data["message"]) + message_content = Image.open(BytesIO(image_data)) + message_content = message_content.convert("RGB") + else: + message_content = data["message"] + return MessageState( + message=message_content, + user_id=data["user_id"], + receiver_id=data.get("receiver_id"), + user_name=data["user_name"], + user_profile_image=data.get("user_profile_image"), + own_message=data.get("own_message", False), + is_image_message=data.get("is_image_message", False), + timestamp=float(data["timestamp"]), + ) + + def to_dict(self) -> MessageStateJson: + """ + Convert the message into a Python dictionary. + + Returns: + MessageJson: A dictionary representation of the message. + """ + # Convert the Image to Base64 if it is an Image Message + if isinstance(self.message, Image.Image): + # Convert the image to bytes and encode it in base64 (JPEG to save limited LocalStorage space) + buffered = BytesIO() + self.message.save(buffered, format="JPEG") + message_data = base64.b64encode(buffered.getvalue()).decode("utf-8") + else: + message_data = self.message + + return { + "message": message_data, + "user_id": self.user_id, + "receiver_id": self.receiver_id, + "user_name": self.user_name, + "user_profile_image": self.user_profile_image, + "own_message": self.own_message, + "is_image_message": self.is_image_message, + "timestamp": self.timestamp, + } diff --git a/witty-wisterias/witty_wisterias/backend/user_input_handler.py b/witty-wisterias/witty_wisterias/backend/user_input_handler.py new file mode 100644 index 00000000..37ec7c70 --- /dev/null +++ b/witty-wisterias/witty_wisterias/backend/user_input_handler.py @@ -0,0 +1,66 @@ +import base64 +import json + +import httpx +from websockets.sync.client import connect + +# Global HTTP Session for the User Input Handler +HTTP_SESSION = httpx.Client(timeout=30) + + +class UserInputHandler: + """ + UserInputHandler class to convert images to text and text to images, to help the Theme "Wrong tool for the job". + Which also gets Implemented in the Frontend User Input and converted here. + """ + + @staticmethod + def image_to_text(image_base64: str) -> str: + """ + Converts a base64 encoded image to text using https://olmocr.allenai.org. + + Args: + image_base64 (str): A base64-encoded string representing the image. + + Returns: + str: The text extracted from the image. + """ + # Connecting to the WebSocket OCR server + with connect("wss://olmocr.allenai.org/api/ws", max_size=10 * 1024 * 1024) as websocket: + # Removing the "data:image/jpeg;base64," prefix if it exists + image_base64 = image_base64.removeprefix("data:image/jpeg;base64,") + + # Sending the base64 image to the WebSocket server + websocket.send(json.dumps({"fileChunk": image_base64})) + websocket.send(json.dumps({"endOfFile": True})) + + # Receiving the response from the server + while True: + response_str = websocket.recv() + response_json = json.loads(response_str) + + # Check if the response contains the final processed data + if response_json.get("type") == "page_complete": + # Getting the Response data + page_data = response_json.get("data", {}).get("response", {}) + # Returning the extracted Text + extracted_text: str = page_data.get("natural_text", "No text found.") + return extracted_text + + @staticmethod + def text_to_image(text: str) -> str: + """ + Converts text to an image link using https://pollinations.ai/ + + Args: + text (str): The text to convert to an image. + + Returns: + str: The base64 encoded generated image. + """ + # Lowest Quality for best Speed (and low Database Usage) + generation_url = f"https://image.pollinations.ai/prompt/{text}?width=256&height=256&quality=low" + # Getting the Generated Image Content + generated_image = HTTP_SESSION.get(generation_url).content + # Encode the image content to base64 + return base64.b64encode(generated_image).decode("utf-8") diff --git a/witty-wisterias/witty_wisterias/frontend/__init__.py b/witty-wisterias/witty_wisterias/frontend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/witty-wisterias/witty_wisterias/frontend/app_config.py b/witty-wisterias/witty_wisterias/frontend/app_config.py new file mode 100644 index 00000000..a0828d1e --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/app_config.py @@ -0,0 +1,12 @@ +import reflex as rx + +app = rx.App( + theme=rx.theme(appearance="light", has_background=True, radius="large", accent_color="teal"), + stylesheets=[ + "https://fonts.googleapis.com/css2?family=Bitcount+Prop+Single:slnt,wght@-8,600&display=swapp", + ], + style={ + "font_family": "Bitcount Prop Single", + "background_color": "white", + }, +) diff --git a/witty-wisterias/witty_wisterias/frontend/components/__init__.py b/witty-wisterias/witty_wisterias/frontend/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/witty-wisterias/witty_wisterias/frontend/components/chat_bubble.py b/witty-wisterias/witty_wisterias/frontend/components/chat_bubble.py new file mode 100644 index 00000000..153d37d4 --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/chat_bubble.py @@ -0,0 +1,54 @@ +import reflex as rx +from PIL import Image + +from frontend.components.create_chat import create_chat_component + + +def chat_bubble_component( + message: str | Image.Image, + user_name: str, + user_id: str, + user_profile_image: str | None = None, + own_message: bool = False, + is_image_message: bool = False, +) -> rx.Component: + """ + Creates a chat bubble component for displaying messages in the chat application. + + Args: + message (str | Image.Image): The content of the message, either text or base64-encoded image. + user_name (str): The name of the user who sent the message. + user_id (str): The UserID of the user who sent the message. + user_profile_image (str): The URL of the user's profile image. + own_message (bool): Whether the message is sent by the current user. + is_image_message (bool): Whether the message is an image. If True, `message` should be a Pillow Image. + + Returns: + rx.Component: A component representing the chat bubble. + """ + avatar = rx.avatar(src=user_profile_image, fallback=user_id[:2], radius="large", size="3") + message_content = rx.vstack( + rx.text(user_name, class_name="font-semibold text-gray-600"), + rx.cond( + is_image_message, + rx.image(src=message, alt="Image message", max_width="500px", max_height="500px"), + rx.text(message, class_name="text-gray-800"), + ), + class_name="rounded-lg", + spacing="0", + ) + + # If the message is our own the Avatar should be on the right of the message content + # If it is not, it should be on the left. + chat_bubble = rx.hstack( + rx.cond( + own_message, + [message_content, avatar], + [avatar, message_content], + ), + class_name="items-start space-x-2 bg-gray-100 p-4 rounded-lg shadow-sm", + style={"alignSelf": rx.cond(own_message, "flex-end", "flex-start")}, + ) + + # Allow creating a private chat by clicking on others user's chat message + return rx.cond(own_message, chat_bubble, create_chat_component(chat_bubble, user_id)) diff --git a/witty-wisterias/witty_wisterias/frontend/components/chatapp.py b/witty-wisterias/witty_wisterias/frontend/components/chatapp.py new file mode 100644 index 00000000..c4334db7 --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/chatapp.py @@ -0,0 +1,87 @@ +import reflex as rx +from backend.message_format import MessageState + +from frontend.components.chat_bubble import chat_bubble_component +from frontend.components.image_button import send_image_component +from frontend.components.text_button import send_text_component +from frontend.states.chat_state import ChatState + + +def chat_specific_messages(message: MessageState) -> rx.Component: + """ + Returns the correct chat bubble if the message is for the selected chat. + + Args: + message (Message): The Message object, which is to be determined if it fits the selected chat. + + Returns: + rx.Component: A component representing the chat bubble for the message, if it fits. + """ + # Storing Message/User Attributes for easier access + user_id = message.user_id + receiver_id = message.receiver_id + selected_chat = ChatState.selected_chat + + return rx.cond( + # Public Chat Messages + (selected_chat == "Public") & (~receiver_id), # type: ignore[operator] + chat_bubble_component( + message.message, + rx.cond(message.user_name, message.user_name, user_id), + user_id, + message.user_profile_image, + message.own_message, + message.is_image_message, + ), + rx.cond( + # Private Chat Messages + (selected_chat != "Public") & receiver_id & ((selected_chat == receiver_id) | (selected_chat == user_id)), # type: ignore[operator] + chat_bubble_component( + message.message, + rx.cond(message.user_name, message.user_name, user_id), + user_id, + message.user_profile_image, + message.own_message, + message.is_image_message, + ), + # Fallback + rx.fragment(), + ), + ) + + +def chat_app() -> rx.Component: + """ + Main chat application component. + + Returns: + rx.Component: The main chat application component. + """ + return rx.vstack( + rx.heading( + rx.cond( + ChatState.selected_chat == "Public", "Public Chat", f"Private Chat with {ChatState.selected_chat}" + ), + spacing=0, + size="6", + align="center", + class_name="text-gray-700 mt-4 w-full", + ), + rx.divider(), + rx.auto_scroll( + rx.foreach( + ChatState.messages, + lambda message: chat_specific_messages(message), + ), + class_name="flex flex-col gap-4 pb-6 pt-3 h-full w-full bg-gray-50 p-5 rounded-xl shadow-sm", + ), + rx.divider(), + rx.hstack( + rx.box(send_text_component(), width="50%"), + rx.box(send_image_component(), width="50%"), + spacing="2", + class_name="mt-auto mb-3 w-full", + ), + spacing="4", + class_name="h-screen w-full mx-5", + ) diff --git a/witty-wisterias/witty_wisterias/frontend/components/create_chat.py b/witty-wisterias/witty_wisterias/frontend/components/create_chat.py new file mode 100644 index 00000000..e6b9e27b --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/create_chat.py @@ -0,0 +1,65 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def create_chat_component(create_chat_button: rx.Component, user_id: str | None = None) -> rx.Component: + """ + The create-new-chat button, which spawns a dialog to create a new private chat. + + Args: + create_chat_button (rx.Component): The Component which triggers the Create-Chat-Dialog. + user_id (str | None): The UserID to default to. + + Returns: + rx.Component: The Create Chat Form, with the create_chat_button as the trigger. + """ + return rx.dialog.root( + rx.dialog.trigger(create_chat_button), + rx.dialog.content( + rx.dialog.title("Create new Private Chat"), + rx.dialog.description( + "Create a new Private Chat with a user by entering their User ID.", size="2", margin_bottom="16px" + ), + rx.form( + rx.vstack( + rx.input( + placeholder="Enter Receiver UserID", + default_value=user_id, + name="receiver_id", + required=True, + variant="surface", + class_name="w-full", + ), + rx.cond( + ChatState.frame_data, + rx.image( + src=ChatState.frame_data, + width="480px", + alt="Live frame", + border="2px solid teal", + border_radius="16px", + ), + rx.hstack( + rx.spinner(size="3"), + rx.text( + "Loading Webcam image...", + color_scheme="gray", + size="5", + ), + align="center", + ), + ), + rx.hstack( + rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), + rx.dialog.close(rx.button("Send", type="submit")), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + on_submit=ChatState.send_private_text, + reset_on_submit=False, + ), + ), + ) diff --git a/witty-wisterias/witty_wisterias/frontend/components/image_button.py b/witty-wisterias/witty_wisterias/frontend/components/image_button.py new file mode 100644 index 00000000..9664e376 --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/image_button.py @@ -0,0 +1,68 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def image_form() -> rx.Component: + """ + Form for sending an image message. + + Returns: + rx.Component: The Image form component. + """ + return rx.vstack( + rx.text_area( + placeholder="Describe it here...", + size="3", + rows="5", + name="message", + required=True, + variant="surface", + class_name="w-full", + ), + rx.hstack( + rx.dialog.close(rx.button("Cancel", variant="soft", color_scheme="gray")), + rx.dialog.close(rx.button("Send", type="submit")), + ), + spacing="3", + margin_top="16px", + justify="end", + ) + + +def send_image_component() -> rx.Component: + """ + The dialog (and button) for sending an image + + Returns: + rx.Component: The Image Button Component, which triggers the Image Message Form. + """ + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.center(rx.text("Send Image")), + padding="24px", + radius="large", + width="100%", + ), + ), + rx.dialog.content( + rx.dialog.title("Send Image"), + rx.dialog.description( + "Send an image by describing it in the box below. It will be generated using AI and sent to the chat.", + size="2", + margin_bottom="16px", + ), + rx.cond( + ChatState.selected_chat == "Public", + rx.form( + image_form(), + on_submit=ChatState.send_public_image, + ), + rx.form( + image_form(), + on_submit=ChatState.send_private_image, + ), + ), + ), + ) diff --git a/witty-wisterias/witty_wisterias/frontend/components/sidebar.py b/witty-wisterias/witty_wisterias/frontend/components/sidebar.py new file mode 100644 index 00000000..4fbe1968 --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/sidebar.py @@ -0,0 +1,89 @@ +import reflex as rx + +from frontend.components.create_chat import create_chat_component +from frontend.components.user_info import user_info_component +from frontend.states.chat_state import ChatState +from frontend.states.progress_state import ProgressState + + +def chat_sidebar() -> rx.Component: + """ + Sidebar component for the chat application, which allows users to select the public Chat, different private + Chats and to view and edit their own User Information. + + Returns: + rx.Component: The Chat Sidebar Component. + """ + return rx.el.div( + rx.vstack( + rx.hstack( + rx.heading("ShitChat", size="6"), + rx.heading("v1.0.0", size="3", class_name="text-gray-500"), + spacing="0", + align="baseline", + justify="between", + class_name="w-full mb-0", + ), + rx.heading("by Witty Wisterias", size="2", class_name="text-gray-400 -mt-4", spacing="0"), + rx.divider(), + rx.heading("Public Chat", size="2", class_name="text-gray-500"), + rx.button( + "Public Chat", + color_scheme="teal", + variant="surface", + size="3", + class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + on_click=ChatState.select_chat("Public"), + ), + rx.divider(), + rx.hstack( + rx.heading("Private Chats", size="2", class_name="text-gray-500"), + create_chat_component( + rx.button( + rx.icon("circle-plus", size=16, class_name="text-gray-500"), + class_name="bg-white", + on_click=ChatState.start_webcam, + ) + ), + spacing="2", + align="center", + justify="between", + class_name="w-full mb-0", + ), + rx.foreach( + ChatState.chat_partners, + lambda user_id: rx.button( + f"Private: {user_id}", + color_scheme="teal", + variant="surface", + size="3", + class_name="w-full justify-center bg-gray-100 hover:bg-gray-200", + on_click=ChatState.select_chat(user_id), + ), + ), + rx.vstack( + rx.heading(ProgressState.progress, size="2", class_name="text-gray-500"), + rx.divider(), + rx.hstack( + rx.hstack( + rx.avatar( + src=ChatState.user_profile_image, fallback=ChatState.user_id[:2], radius="large", size="3" + ), + rx.vstack( + rx.text(ChatState.user_name | ChatState.user_id, size="3"), # type: ignore[operator] + rx.text(ChatState.user_id, size="1", class_name="text-gray-500"), + spacing="0", + ), + ), + user_info_component(), + spacing="2", + align="center", + justify="between", + class_name="mt-1 w-full", + ), + class_name="mt-auto mb-7 w-full", + ), + class_name="h-screen bg-gray-50", + ), + class_name="flex flex-col w-[340px] h-screen px-5 pt-3 mt-2 border-r border-gray-200", + ) diff --git a/witty-wisterias/witty_wisterias/frontend/components/text_button.py b/witty-wisterias/witty_wisterias/frontend/components/text_button.py new file mode 100644 index 00000000..edea0ab5 --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/text_button.py @@ -0,0 +1,88 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def text_form() -> rx.Component: + """ + Form for sending a text message. + + Returns: + rx.Component: The Text form component. + """ + return rx.vstack( + rx.cond( + ChatState.frame_data, + rx.image( + src=ChatState.frame_data, + width="480px", + alt="Live frame", + border="2px solid teal", + border_radius="16px", + ), + rx.hstack( + rx.spinner(size="3"), + rx.text( + "Loading Webcam image...", + color_scheme="gray", + size="5", + ), + align="center", + ), + ), + rx.hstack( + rx.dialog.close( + rx.button( + "Cancel", variant="soft", color_scheme="gray", type="reset", on_click=ChatState.disable_webcam + ) + ), + rx.dialog.close(rx.button("Send", type="submit")), + ), + spacing="3", + margin_top="16px", + ) + + +def send_text_component() -> rx.Component: + """ + The dialog (and button) for sending texts. + + Returns: + rx.Component: The Text Button Component, which triggers the Text Message Form. + """ + return rx.dialog.root( + rx.dialog.trigger( + rx.button( + rx.center(rx.text("Send Text")), + padding="24px", + radius="large", + on_click=ChatState.start_webcam, + width="100%", + ), + ), + rx.dialog.content( + rx.dialog.title("Send Text"), + rx.dialog.description( + "Send a text message to the chat by writing your message on a physical peace of paper and taking a" + " picture of it with your webcam.", + size="2", + margin_bottom="16px", + ), + rx.dialog.description( + "Your Webcam image is private and will not be shared in the chat.", + size="2", + margin_bottom="16px", + ), + rx.cond( + ChatState.selected_chat == "Public", + rx.form( + text_form(), + on_submit=ChatState.send_public_text, + ), + rx.form( + text_form(), + on_submit=ChatState.send_private_text, + ), + ), + ), + ) diff --git a/witty-wisterias/witty_wisterias/frontend/components/tos_accept_form.py b/witty-wisterias/witty_wisterias/frontend/components/tos_accept_form.py new file mode 100644 index 00000000..217f410e --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/tos_accept_form.py @@ -0,0 +1,29 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def tos_accept_form() -> rx.Component: + """ + Terms of Service Accept Form. + + Returns: + rx.Component: The Terms of Service Accept Form. + """ + return rx.form( + rx.vstack( + rx.text("You hereby accept the Terms of Service of:"), + rx.hstack( + rx.link("allenai.org", href="https://allenai.org/terms"), + rx.link("pollinations.ai", href="https://pollinations.ai/terms"), + rx.link("freeimghost.net", href="https://freeimghost.net/page/tos"), + align="center", + justify="center", + ), + rx.button("Accept", type="submit"), + align="center", + justify="center", + ), + on_submit=lambda _: ChatState.accept_tos(), + class_name="p-4 bg-gray-100 rounded-lg shadow-md w-screen h-screen flex items-center justify-center", + ) diff --git a/witty-wisterias/witty_wisterias/frontend/components/user_info.py b/witty-wisterias/witty_wisterias/frontend/components/user_info.py new file mode 100644 index 00000000..c5c03cae --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/components/user_info.py @@ -0,0 +1,57 @@ +import reflex as rx + +from frontend.states.chat_state import ChatState + + +def user_info_component() -> rx.Component: + """ + The dialog (and button) for editing the user information. + + Returns: + rx.Component: The User Info Edit Button, which triggers the User Info Edit Form. + """ + return rx.dialog.root( + rx.dialog.trigger(rx.button(rx.icon("user-pen", size=25, class_name="text-gray-500"), class_name="bg-white")), + rx.dialog.content( + rx.dialog.title("Edit your User Information"), + rx.dialog.description( + "Here you can edit your user information, including your username and profile picture.", + size="2", + margin_bottom="16px", + ), + rx.form( + rx.vstack( + rx.input( + placeholder="Enter your new Username", + default_value=ChatState.user_name, + name="user_name", + required=True, + variant="surface", + class_name="w-full", + ), + rx.input( + placeholder="Enter your new Profile Picture URL", + name="user_profile_image", + variant="surface", + class_name="w-full", + ), + rx.hstack( + rx.dialog.close( + rx.button( + "Cancel", + variant="soft", + color_scheme="gray", + ) + ), + rx.dialog.close( + rx.button("Send", type="submit"), + ), + ), + spacing="3", + margin_top="16px", + justify="end", + ), + on_submit=ChatState.edit_user_info, + ), + ), + ) diff --git a/witty-wisterias/witty_wisterias/frontend/frontend.py b/witty-wisterias/witty_wisterias/frontend/frontend.py new file mode 100644 index 00000000..703711b4 --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/frontend.py @@ -0,0 +1,22 @@ +import reflex as rx + +from frontend.app_config import app # noqa: F401 +from frontend.components.chatapp import chat_app +from frontend.components.sidebar import chat_sidebar +from frontend.components.tos_accept_form import tos_accept_form +from frontend.states.chat_state import ChatState + + +@rx.page(title="ShitChat by Witty Wisterias", on_load=ChatState.startup_event) +def index() -> rx.Component: + """The main page of the chat application, which includes the sidebar and chat app components.""" + return rx.cond( + ChatState.tos_accepted != "True", + tos_accept_form(), + rx.hstack( + chat_sidebar(), + chat_app(), + size="2", + class_name="overflow-hidden h-screen w-full", + ), + ) diff --git a/witty-wisterias/witty_wisterias/frontend/states/__init__.py b/witty-wisterias/witty_wisterias/frontend/states/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/witty-wisterias/witty_wisterias/frontend/states/chat_state.py b/witty-wisterias/witty_wisterias/frontend/states/chat_state.py new file mode 100644 index 00000000..8bc18a8d --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/states/chat_state.py @@ -0,0 +1,490 @@ +import asyncio +import base64 +import io +import json +from collections.abc import AsyncGenerator, Generator +from datetime import UTC, datetime +from typing import Any, Literal, cast + +import reflex as rx +from backend.backend import Backend +from backend.cryptographer import Cryptographer +from backend.message_format import EventType, MessageFormat, MessageState +from backend.user_input_handler import UserInputHandler +from PIL import Image + +from frontend.app_config import app +from frontend.states.progress_state import ProgressState +from frontend.states.webcam_state import WebcamStateMixin + + +class ChatState(WebcamStateMixin, rx.State): + """The Chat app state, used to handle Messages. Main Frontend Entrypoint.""" + + # Tos Accepted (Note: We need to use a string here because LocalStorage does not support booleans) + tos_accepted: str = rx.LocalStorage("False", name="tos_accepted", sync=True) + + # List of Messages + messages: list[MessageState] = rx.field(default_factory=list) + # We need to store our own private messages in LocalStorage, as we cannot decrypt them from the Database + own_private_messages: str = rx.LocalStorage("[]", name="private_messages", sync=True) + + # Chat Partners + chat_partners: list[str] = rx.field(default_factory=list) + # Current Selected Chat + selected_chat: str = rx.LocalStorage("Public", name="selected_chat", sync=True) + + # Own User Data + user_id: str = rx.LocalStorage("", name="user_id", sync=True) + user_name: str = rx.LocalStorage("", name="user_name", sync=True) + user_profile_image: str = rx.LocalStorage("", name="user_profile_image", sync=True) + + # Own Signing key and Others Verify Keys for Global Chat + signing_key: str = rx.LocalStorage("", name="signing_key", sync=True) + verify_keys_storage: str = rx.LocalStorage("{}", name="verify_keys_storage", sync=True) + # Own Private Keys and Others Public Keys for Private Chats + private_key: str = rx.LocalStorage("", name="private_key", sync=True) + public_keys_storage: str = rx.LocalStorage("{}", name="public_keys_storage", sync=True) + + # Verify Keys Storage Helpers + def get_key_storage(self, storage_name: Literal["verify_keys", "public_keys"]) -> dict[str, str]: + """ + Get the key storage for the specified storage name. + + Args: + storage_name (Literal["verify_keys", "public_keys"]): The name of the storage to retrieve. + + Returns: + dict[str, str]: A dictionary containing the keys and their corresponding values. + """ + storage = self.__getattribute__(f"{storage_name}_storage") + # Note: Casting Type as json.loads returns typing.Any + return cast("dict[str, str]", json.loads(storage)) + + def dump_key_storage(self, storage_name: Literal["verify_keys", "public_keys"], value: dict[str, str]) -> None: + """ + Dump the key storage to the specified storage name. + + Args: + storage_name (Literal["verify_keys", "public_keys"]): The name of the storage to dump to. + value (dict[str, str]): The dictionary containing the userIDs and their Keys. + """ + self.__setattr__(f"{storage_name}_storage", json.dumps(value)) + + def add_key_storage( + self, storage_name: Literal["verify_keys", "public_keys"], user_id: str, verify_key: str + ) -> None: + """ + Add a userID and its corresponding key to the specified storage. + + Args: + storage_name (Literal["verify_keys", "public_keys"]): The name of the storage to add to. + user_id (str): The user ID to add. + verify_key (str): The key to associate with the user ID. + """ + # Loading the Key Storage into a dict + current_keys = self.get_key_storage(storage_name) + # Adding the Key + current_keys[user_id] = verify_key + # Dumping the new Key Storage + self.dump_key_storage(storage_name, current_keys) + + # Registering Private Chat Partners to show them in the Private Chats list + def register_chat_partner(self, user_id: str) -> None: + """ + Register a new chat partner by adding their user ID to the chat partners list. + + Args: + user_id (str): The user ID of the chat partner to register. + """ + # Avoid Duplicates + if user_id not in self.chat_partners: + self.chat_partners.append(user_id) + # Sort to find the chat partner in the list more easily + self.chat_partners.sort() + + @rx.event + def accept_tos(self) -> Generator[None, None]: + """Reflex Event when the Terms of Service are accepted.""" + self.tos_accepted = "True" + yield + + @rx.event + def edit_user_info(self, form_data: dict[str, Any]) -> Generator[None, None]: + """ + Reflex Event when the user information is edited. + + Args: + form_data (dict[str, str]): The form data containing the user information. + """ + self.user_name = form_data.get("user_name", "").strip() + # A User Profile Image is not required in the Form. + self.user_profile_image = form_data.get("user_profile_image", "").strip() + yield + + @rx.event + def select_chat(self, chat_name: str) -> Generator[None, None]: + """ + Reflex Event when a chat is selected. + + Args: + chat_name (str): The name of the chat to select. + """ + self.selected_chat = chat_name + yield + + @rx.event + def start_webcam(self, _: dict[str, str]) -> Generator[None, None]: + """ + Start the webcam capture loop. + + Args: + _ (dict[str, str]): The form data containing the message in the `message` field. Unused. + """ + self.recording = True + yield ChatState.capture_loop + + @rx.event + async def send_public_text(self, _: dict[str, Any]) -> AsyncGenerator[None, None]: + """ + Reflex Event when a text message is sent. + + Args: + _ (dict[str, str]): The form data containing the message in the `message` field. Unused. + """ + # Stop Webcam Stream + ChatState.disable_webcam() + # Converting last Webcam Frame to Text + message = UserInputHandler.image_to_text(str(self.frame_data)) + + if message: + # Sending Placebo Progress Bar + yield ProgressState.public_message_progress + + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message to show in the Chat + self.messages.append( + MessageState( + message=message, + user_id=self.user_id, + user_name=self.user_name, + receiver_id=None, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=False, + timestamp=message_timestamp, + ) + ) + yield + + # Formatting the message for the Backend + message_format = MessageFormat( + sender_id=self.user_id, + event_type=EventType.PUBLIC_TEXT, + content=message, + timestamp=message_timestamp, + signing_key=self.signing_key, + verify_key=self.get_key_storage("verify_keys")[self.user_id], + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, + ) + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_public_message, message_format) + + @rx.event + async def send_public_image(self, form_data: dict[str, Any]) -> AsyncGenerator[None, None]: + """ + Reflex Event when an image message is sent. + + Args: + form_data (dict[str, str]): The form data containing the image URL in the `message` field. + """ + message = form_data.get("message", "").strip() + if message: + # Converting the Image Description to an Image + base64_image = UserInputHandler.text_to_image(message) + # Decode the Base64 string to bytes + image_data = base64.b64decode(base64_image) + # Open the image stream with PIL + pil_image = Image.open(io.BytesIO(image_data)) + + # Sending Placebo Progress Bar + yield ProgressState.public_message_progress + + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message to show in the Chat + self.messages.append( + MessageState( + message=pil_image, + user_id=self.user_id, + user_name=self.user_name, + receiver_id=None, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=True, + timestamp=message_timestamp, + ) + ) + yield + + # Formatting the message for the Backend + message_format = MessageFormat( + sender_id=self.user_id, + event_type=EventType.PUBLIC_IMAGE, + content=base64_image, + timestamp=message_timestamp, + signing_key=self.signing_key, + verify_key=self.get_key_storage("verify_keys")[self.user_id], + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, + ) + + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_public_message, message_format) + + @rx.event + async def send_private_text(self, form_data: dict[str, Any]) -> AsyncGenerator[None, None]: + """ + Reflex Event when a private text message is sent. + + Args: + form_data (dict[str, str]): The form data containing the message in the `message` field. + """ + # Stop Webcam Stream + ChatState.disable_webcam() + # Converting last Webcam Frame to Text + message = UserInputHandler.image_to_text(str(self.frame_data)) + + receiver_id = form_data.get("receiver_id", "").strip() or self.selected_chat + if message and receiver_id: + if receiver_id not in self.get_key_storage("public_keys"): + # Cant message someone who is not registered + raise ValueError("Recipients Public Key is not registered.") + + # Register Chat Partner and select the Chat + self.register_chat_partner(receiver_id) + self.selected_chat = receiver_id + yield + + # Sending Placebo Progress Bar + yield ProgressState.private_message_progress + + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message to show in the Chat + chat_message = MessageState( + message=message, + user_id=self.user_id, + user_name=self.user_name, + receiver_id=receiver_id, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=False, + timestamp=message_timestamp, + ) + + self.messages.append(chat_message) + # Also append to own private messages LocalStorage, as we cannot decrypt them from the Database + own_private_messages_json = json.loads(self.own_private_messages) + own_private_messages_json.append(chat_message.to_dict()) + # Encode back to String JSON + self.own_private_messages = json.dumps(own_private_messages_json) + yield + + # Formatting the message for the Backend + message_format = MessageFormat( + sender_id=self.user_id, + receiver_id=receiver_id, + event_type=EventType.PRIVATE_TEXT, + content=message, + timestamp=message_timestamp, + own_public_key=self.get_key_storage("public_keys")[self.user_id], + receiver_public_key=self.get_key_storage("public_keys")[receiver_id], + private_key=self.private_key, + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, + ) + + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_private_message, message_format) + + @rx.event + async def send_private_image(self, form_data: dict[str, Any]) -> AsyncGenerator[None, None]: + """ + Reflex Event when a private image message is sent. + + Args: + form_data (dict[str, str]): The form data containing the image URL in the `message` field. + """ + message = form_data.get("message", "").strip() + receiver_id = form_data.get("receiver_id", "").strip() or self.selected_chat + if message and receiver_id: + if receiver_id not in self.get_key_storage("public_keys"): + # Cant message someone who is not registered + raise ValueError("Recipients Public Key is not registered.") + + # Register Chat Partner and select the Chat + self.register_chat_partner(receiver_id) + self.selected_chat = receiver_id + yield + + # Converting the Image Description to an Image + base64_image = UserInputHandler.text_to_image(message) + # Decode the Base64 string to bytes + image_data = base64.b64decode(base64_image) + # Open the image stream with PIL + pil_image = Image.open(io.BytesIO(image_data)) + + # Sending Placebo Progress Bar + yield ProgressState.private_message_progress + + message_timestamp = datetime.now(UTC).timestamp() + # Appending new own message to show in the Chat + chat_message = MessageState( + message=pil_image, + user_id=self.user_id, + user_name=self.user_name, + receiver_id=receiver_id, + user_profile_image=self.user_profile_image, + own_message=True, + is_image_message=True, + timestamp=message_timestamp, + ) + self.messages.append(chat_message) + + # Also append to own private messages, as we cannot decrypt them from the Database + own_private_messages_json = json.loads(self.own_private_messages) + own_private_messages_json.append(chat_message.to_dict()) + # Encode back to String JSON + self.own_private_messages = json.dumps(own_private_messages_json) + yield + + # Formatting the message for the Backend + message_format = MessageFormat( + sender_id=self.user_id, + receiver_id=receiver_id, + event_type=EventType.PRIVATE_IMAGE, + content=base64_image, + timestamp=message_timestamp, + own_public_key=self.get_key_storage("public_keys")[self.user_id], + receiver_public_key=self.get_key_storage("public_keys")[receiver_id], + private_key=self.private_key, + sender_username=self.user_name, + sender_profile_image=self.user_profile_image, + ) + + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Send the Message without blocking the UI thread. + await loop.run_in_executor(None, Backend.send_private_message, message_format) + + @rx.event(background=True) + async def check_messages(self) -> None: + """Reflex Background Check for new messages.""" + # Run while tab is open + while self.router.session.client_token in app.event_namespace.token_to_sid: + # To not block the UI thread, we run this in an executor before the async with self. + loop = asyncio.get_running_loop() + # Reading Verify and Public Keys from Database + verify_keys, public_keys = await loop.run_in_executor(None, Backend.read_public_keys) + # Reading Public Messages from Database + public_messages = await loop.run_in_executor(None, Backend.read_public_messages) + # Reading Private Messages from Database + backend_private_message_formats = await loop.run_in_executor( + None, Backend.read_private_messages, self.user_id, self.private_key + ) + + async with self: + # Push Verify and Public Keys to the LocalStorage + for user_id, verify_key in verify_keys.items(): + self.add_key_storage("verify_keys", user_id, verify_key) + for user_id, public_key in public_keys.items(): + self.add_key_storage("public_keys", user_id, public_key) + + # Public Chat Messages + for public_message in public_messages: + # Check if the message is already in the chat using timestamp + message_exists = any( + all_messages.timestamp == public_message.timestamp + and all_messages.user_id == public_message.sender_id + for all_messages in self.messages + ) + + # Check if message is not already in the chat + if not message_exists: + # Convert the Backend Format to the Frontend Format (MessageState) + self.messages.append( + MessageState( + message=public_message.content, + user_id=public_message.sender_id, + user_name=str(public_message.extra_event_info.user_name), + receiver_id=None, + user_profile_image=public_message.extra_event_info.user_image, + own_message=self.user_id == public_message.sender_id, + is_image_message=public_message.event_type == EventType.PUBLIC_IMAGE, + timestamp=public_message.timestamp, + ) + ) + + # Private Chat Messages stored in the Backend + backend_private_messages = [ + MessageState.from_message_format(message_format, str(self.user_id)) + for message_format in backend_private_message_formats + ] + # Our own Private Messages, stored in the LocalStorage as we cannot self-decrypt them from the Backend + own_private_messages_json = json.loads(self.own_private_messages) + own_private_messages = [ + MessageState.from_dict(message_data) for message_data in own_private_messages_json + ] + # Sort them based on their timestamp + sorted_private_messages = sorted( + backend_private_messages + own_private_messages, + key=lambda msg: msg.timestamp, + ) + for private_message in sorted_private_messages: + # Add received chat partner to chat partners list + if private_message.user_id != self.user_id: + self.register_chat_partner(private_message.user_id) + # Check if the message is already in the chat using timestamp + message_exists = any( + msg.timestamp == private_message.timestamp and msg.user_id == private_message.user_id + for msg in self.messages + ) + + # Check if message is not already in the chat + if not message_exists: + self.messages.append(private_message) + + # Wait for 5 seconds before checking for new messages again to avoid excessive load + await asyncio.sleep(5) + + @rx.event + async def startup_event(self) -> AsyncGenerator[None, None]: + """Reflex Event that is called when the app starts up. Main Entrypoint for the Frontend and spawns Backend.""" + # Start Message Checking Background Task + yield ChatState.check_messages + + # Initialize user_id if not already set + if not self.user_id: + # Simulate fetching a user ID from an external source + self.user_id = Cryptographer.generate_random_user_id() + + # Generate new Signing Key Pair if not set + if not self.signing_key or self.user_id not in self.get_key_storage("verify_keys"): + self.signing_key, verify_key = Cryptographer.generate_signing_key_pair() + self.add_key_storage("verify_keys", self.user_id, verify_key) + + # Generate new Private Key Pair if not set + if not self.private_key or self.user_id not in self.get_key_storage("public_keys"): + self.private_key, public_key = Cryptographer.generate_encryption_key_pair() + self.add_key_storage("public_keys", self.user_id, public_key) + + # Ensure the Public Keys are Uploaded + verify_key = self.get_key_storage("verify_keys")[self.user_id] + public_key = self.get_key_storage("public_keys")[self.user_id] + Backend.push_public_keys(self.user_id, verify_key, public_key) diff --git a/witty-wisterias/witty_wisterias/frontend/states/progress_state.py b/witty-wisterias/witty_wisterias/frontend/states/progress_state.py new file mode 100644 index 00000000..4a02df6f --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/states/progress_state.py @@ -0,0 +1,77 @@ +import asyncio +from collections.abc import AsyncGenerator + +import reflex as rx + + +class ProgressState(rx.State): + """The Placebo Progress State""" + + # Own User Data + progress: str = "" + + @rx.event(background=True) + async def public_message_progress(self) -> AsyncGenerator[None, None]: + """Simulates the progress of sending a public message with a placebo progress bar.""" + public_message_states = [ + "Pulling Message Stack...", + "Signing Message...", + "Pushing Signed Message...", + "Uploading new Message Stack...", + "", + ] + + for message in public_message_states: + async with self: + # A small text fade-in animation + self.progress = "" + for char in message: + self.progress += char + await asyncio.sleep(0.005) + yield + + # Simulate some processing time (different for each state) + match message: + case "Pulling Message Stack...": + await asyncio.sleep(0.5) + case "Signing Message...": + await asyncio.sleep(0.3) + case "Pushing Signed Message...": + await asyncio.sleep(0.3) + case "Uploading new Message Stack...": + await asyncio.sleep(1) + case "": + pass + + @rx.event(background=True) + async def private_message_progress(self) -> AsyncGenerator[None, None]: + """Simulates the progress of sending a private message with a placebo progress bar.""" + public_message_states = [ + "Pulling Message Stack...", + "Encrypting Message...", + "Pushing Encrypted Message...", + "Uploading new Message Stack...", + "", + ] + + for message in public_message_states: + async with self: + # A small text fade-in animation + self.progress = "" + for char in message: + self.progress += char + await asyncio.sleep(0.005) + yield + + # Simulate some processing time (different for each state) + match message: + case "Pulling Message Stack...": + await asyncio.sleep(0.5) + case "Encrypting Message...": + await asyncio.sleep(0.3) + case "Pushing Encrypted Message...": + await asyncio.sleep(0.3) + case "Uploading new Message Stack...": + await asyncio.sleep(1) + case "": + pass diff --git a/witty-wisterias/witty_wisterias/frontend/states/webcam_state.py b/witty-wisterias/witty_wisterias/frontend/states/webcam_state.py new file mode 100644 index 00000000..8cded7df --- /dev/null +++ b/witty-wisterias/witty_wisterias/frontend/states/webcam_state.py @@ -0,0 +1,55 @@ +import asyncio +import base64 + +import cv2 +import reflex as rx +from reflex.utils.console import LogLevel, set_log_level + +from frontend.app_config import app + +# Filer race condition warnings, which can occur when the websocket is disconnected +# This is not an issue as the WebcamStateMixin is designed to handle disconnections gracefully +# Note: Reflex Console is very bad so our only option is to raise the log level to ERROR +set_log_level(LogLevel.ERROR) + +# Opening/PreLoading Webcam Video Capture for faster Load times +webcam_cap = cv2.VideoCapture(0) +webcam_cap.set(cv2.CAP_PROP_FRAME_WIDTH, 480) +webcam_cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 320) +webcam_cap.set(cv2.CAP_PROP_FPS, 60) +webcam_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + + +class WebcamStateMixin(rx.State, mixin=True): # type: ignore[call-arg] + """Mixin for managing webcam state in the application.""" + + frame_data: str | None = None + recording: bool = False + + @rx.event + def disable_webcam(self) -> None: + """Stop the webcam capture loop.""" + self.recording = False + + @rx.event(background=True) + async def capture_loop(self) -> None: + """Continuously capture frames from the webcam and update the frame data.""" + if not webcam_cap or not webcam_cap.isOpened(): + raise RuntimeError("Cannot open webcam at index 0") + + # While should record and Tab is open + while self.recording and self.router.session.client_token in app.event_namespace.token_to_sid: + ok, frame = webcam_cap.read() + if not ok: + await asyncio.sleep(0.1) + continue + + # Taking a 480p grayscale frame for better performance + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # encode JPEG with lower quality + _, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 20]) + data_url = "data:image/jpeg;base64," + base64.b64encode(buf).decode() + + async with self: + self.frame_data = data_url diff --git a/witty-wisterias/witty_wisterias/requirements.txt b/witty-wisterias/witty_wisterias/requirements.txt new file mode 100644 index 00000000..2e4c1654 --- /dev/null +++ b/witty-wisterias/witty_wisterias/requirements.txt @@ -0,0 +1,2 @@ + +reflex==0.8.5 diff --git a/witty-wisterias/witty_wisterias/rxconfig.py b/witty-wisterias/witty_wisterias/rxconfig.py new file mode 100644 index 00000000..6f8ebb04 --- /dev/null +++ b/witty-wisterias/witty_wisterias/rxconfig.py @@ -0,0 +1,9 @@ +import reflex as rx + +config = rx.Config( + app_name="frontend", + plugins=[ + rx.plugins.SitemapPlugin(), + rx.plugins.TailwindV4Plugin(), + ], +)