From ed00ec8251e54c14169ec85788f96af4dcfff74b Mon Sep 17 00:00:00 2001 From: Hugo H Date: Fri, 22 Aug 2025 10:10:49 +0100 Subject: [PATCH] Added auth and basic chat system --- .gitignore | 210 ++++++++++++++++++++ README.md | 3 +- config/example-settings.json | 33 ++++ docker-compose.yml | 30 +++ main.py | 358 +++++++++++++++++++++++++++++++++++ notes.md | 88 +++++++++ static/github-icon.png | Bin 0 -> 817 bytes templates/chat.html | 11 ++ templates/home.html | 10 + templates/login.html | 201 ++++++++++++++++++++ templates/logout.html | 17 ++ templates/oauthsignup.html | 166 ++++++++++++++++ 12 files changed, 1126 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 config/example-settings.json create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 notes.md create mode 100644 static/github-icon.png create mode 100644 templates/chat.html create mode 100644 templates/home.html create mode 100644 templates/login.html create mode 100644 templates/logout.html create mode 100644 templates/oauthsignup.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf0462d --- /dev/null +++ b/.gitignore @@ -0,0 +1,210 @@ +# AiThingy config files +config/settings.json + +# 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__/ diff --git a/README.md b/README.md index de34a6e..cff9fe2 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# AiThingy +# myAiFrontend +A frontend for Ollama diff --git a/config/example-settings.json b/config/example-settings.json new file mode 100644 index 0000000..612a479 --- /dev/null +++ b/config/example-settings.json @@ -0,0 +1,33 @@ +{ + "branding":{ + "name":"AiThingy" + }, + "auth_mode":"code", + "default_role":"none", + "default_permissions":[ + "createChat" + ], + "auth_codes":[ + { + "name":"code1", + "code":"a1b2c3", + "role":"user" + }, + { + "name":"code2", + "code":"a1b2c3", + "role":"admin" + }, + { + "name":"code3", + "code":"a1b2c3", + "role":"guest" + } + ], + "oauth_login":"true", + "github_oauth":{ + "enabled":"true", + "client_id":"client_id", + "client_secret":"client_secret" + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0637ed1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +# Use root/example as user/password credentials + +services: + + mongo: + image: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: 39zj6bNT5gaXbmuOBAYn5pZRO + MONGO_INITDB_DATABASE: database + volumes: + - ai-mongo-data:/data/db + - ai-mongo-config:/data/configdb + ports: + - 27017:27017 + mongo-express: + image: mongo-express + restart: always + ports: + - 8081:8081 + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD: example + ME_CONFIG_MONGODB_URL: mongodb://root:39zj6bNT5gaXbmuOBAYn5pZRO@mongo:27017/ + ME_CONFIG_BASICAUTH: false + +volumes: + ai-mongo-data: + ai-mongo-config: \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..57857de --- /dev/null +++ b/main.py @@ -0,0 +1,358 @@ +from flask import Flask, jsonify, request, render_template +from pymongo import MongoClient +from bson.objectid import ObjectId +from datetime import datetime +from argon2 import PasswordHasher +import random +import string +import json +import requests +from urllib.parse import parse_qs + +with open("config/settings.json", "r") as f: + settings = json.load(f) + +appName = settings["branding"]["name"] + +ph = PasswordHasher() + +github_client_id = settings["github_oauth"]["client_id"] +github_client_secret = settings["github_oauth"]["client_secret"] +github_auth_endpoint = f"https://github.com/login/oauth/authorize?response_type=code&client_id={github_client_id}&scope=user" +github_token_endpoint = "https://github.com/login/oauth/access_token" +github_user_endpoint = "https://api.github.com/user" + +mongoUser = 'root' +mongoPassword = '39zj6bNT5gaXbmuOBAYn5pZRO' +mongoHost = 'localhost' +mongoPort = '27017' +mongoDatabase = 'database' +mongoUri = f'mongodb://{mongoUser}:{mongoPassword}@{mongoHost}:{mongoPort}/' + +print(mongoUri) + +client = MongoClient(mongoUri) +mydb = client[mongoDatabase] +chatCollection = mydb.chats +usersCollection = mydb.users + +try: + client.server_info() + print("Connected to MongoDB successfuly") +except Exception as e: + print("Error connecting to MongoDB:", e) + +app = Flask(__name__) + +# Chat Details Endpoint: +# Gets details about a chat using the chatId +# Arguments: token (required), details (required), model, name +@app.route('/api/chat/<_id>/details', methods = ['GET', 'POST']) +def getChatHistory(_id): + # Get user auth token + token = request.json['token'] + # Find the correct user token in user db + user = usersCollection.find_one({'tokens.token': token}, {"_id":1,"tokens":{"$elemMatch": {"token":token}}}) + # If the user exists, continue, otherwise return fail + if (user): + # Convert _id to a string, python doesn't like ObjectId() + user['_id'] = str(user['_id']) + # Check if the token expiry is after the current date (Using unix timestamp, other mongodb Date datatype is a pain to use in python) + if (user['tokens'][0]['expiry'] > int(datetime.now().timestamp())): + # Store the userId + userId = user['_id'] + print(userId) + # Get the request details + details = request.json['details'] + # If the user is trying to GET data + if (request.method == 'GET'): + # Get the chat from the chatId + returnedChat = chatCollection.find_one({'_id': ObjectId(_id)}) + # Convert chatId into string + returnedChat['_id'] = str(returnedChat['_id']) + print("Chat " + _id + " has been found with token " + token) + # Check for detail type and return correct value from db + if (details == "history"): + return jsonify(returnedChat["messages"]) + elif (details == "users"): + return jsonify(returnedChat["permissions"]) + elif (details == "model"): + return jsonify(returnedChat["model"]) + elif (details == "name"): + return jsonify(returnedChat["name"]) + else: + # Check for the detail type and add data to db + if (details == "model"): + model = request.json['model'] + chatCollection.update_one({'_id': ObjectId(_id)}, { "$set": { "model": model } }) + if (details == "name"): + name = request.json['name'] + chatCollection.update_one({'_id': ObjectId(_id)}, { "$set": { "name": name } }) + return jsonify("Success") + else: + return jsonify("User token is invalid") + else: + return jsonify("User token is invalid") + +# Chat creation endpoint +# Create a new chat +# Arguments: token (required), name (required), model (required) +@app.route('/api/chat/create', methods = ['POST']) +def createChat(): + token = request.json['token'] + user = usersCollection.find_one({'tokens.token': token}, {"_id":1,"tokens":{"$elemMatch": {"token":token}}, "permissions":1}) + if (user): + user['_id'] = str(user['_id']) + if (user['tokens'][0]['expiry'] > int(datetime.now().timestamp())): + userId = user['_id'] + print(user) + print(user['permissions']) + if ("createChat" in user['permissions']): + print(userId) + name = request.json['name'] + model = request.json['model'] + chatCollection.insert_one( + { + "name":name, + "model":model, + "permissions": { + userId:[ + "owner", + "view", + "message" + ] + }, + "messages": [ + + ] + } + ) + return jsonify("Success") + else: + return jsonify("Incorrect permissions") + else: + return jsonify("User token is invalid") + else: + return jsonify("User token is invalid") + +# Signup page +# Returns html signup page +@app.route('/signup', methods = ['GET']) +def signup(): + pass + +# Index page +# If logged in return home menu (Or logout if token is expired), +# Otherwise return login screen +@app.route('/', methods = ['GET']) +def index(): + token = request.cookies.get('auth_token', 'none') + if (token == 'none'): + return render_template('login.html', appName=appName, githubUrl=github_auth_endpoint) + else: + user = usersCollection.find_one({'tokens.token': token}, {"_id":1,"tokens":{"$elemMatch": {"token":token}}}) + if (user): + user['_id'] = str(user['_id']) + if (user['tokens'][0]['expiry'] > int(datetime.now().timestamp())): + return render_template('home.html', appName=appName) + else: + render_template('logout.html', appName=appName) + else: + render_template('logout.html', appName=appName) + +# Login endpoint +# Api backend for login screen, check for user and returns token +# Arguments: username (required), password (required) +@app.route('/api/login', methods = ['POST']) +def handleLogin(): + try: + username = request.json['username'].lower() + user = usersCollection.find_one({"$or":[{"username":username},{"email":username}]}) + passwordHash = user["password"] + password = request.json['password'] + loggedin = ph.verify(passwordHash, password) + userId = user['_id'] + except: + return jsonify("Incorrect username or password") + if (loggedin): + newToken = ''.join(random.choices(string.ascii_letters + string.digits, k=100)) + newExpiry = int(datetime.now().timestamp()) + newExpiry = newExpiry + 2678400 + usersCollection.update_one({'_id':userId}, {"$addToSet":{'tokens':{'token':newToken, 'expiry':newExpiry}}}) + return jsonify(newToken) + +# Github callback endpoint +# Either logs in the user or returns extra details signup page +# Arguments: code (required) (provided by github) +@app.route('/api/github/authorized', methods = ['GET']) +def handleGithubLogin(): + code = request.args.get('code') + res = requests.post( + github_token_endpoint, + data=dict( + client_id=github_client_id, + client_secret=github_client_secret, + code=code, + ), + ) + res = parse_qs(res.content.decode("utf-8")) + token = res["access_token"][0] + user_email = requests.get("https://api.github.com/user/emails", headers=dict(Authorization=f"Bearer {token}")) + for i in user_email.json(): + if(i["primary"]==True): + email = i["email"] + try: + user = usersCollection.find_one({"email":user_email}) + userId = user['_id'] + newToken = ''.join(random.choices(string.ascii_letters + string.digits, k=100)) + newExpiry = int(datetime.now().timestamp()) + newExpiry = newExpiry + 2678400 + usersCollection.update_one({'_id':userId}, {"$addToSet":{'tokens':{'token':newToken, 'expiry':newExpiry}}}) + return jsonify(newToken) + except: + return render_template("oauthsignup.html", appName = appName, provider="github", accessToken=token) + +# Oauth2.0 Account signup endpoint +# Creates account using extra details and oauth access token +# Arguments: code (required) (access token provided by idp), username (required), signupcode, display (required) +@app.route('/api/oauthsignup', methods = ['POST']) +def handleOauthSignup(): + try: + user_email = requests.get("https://api.github.com/user/emails", headers=dict(Authorization=f"Bearer {request.json['code']}")) + for i in user_email.json(): + if(i["primary"]==True): + email = i["email"] + # Set user details + username = request.json['username'].lower() + passwordHash = "a" + creationDate = int(datetime.now().timestamp()) + accessCode = request.json['signupcode'] + displayName = request.json['display'] + + # Check if details are taken + sameUsername = usersCollection.count_documents({"username":username}) + sameEmail = usersCollection.count_documents({"email":email}) + if (sameUsername != 0 ) or ( sameEmail != 0): + return jsonify("User already exists") + + # Check for appropriate role + codeFound = False + if (settings["signup_mode"] == "none"): + return jsonify("Signups have been disabled") + elif (settings["signup_mode"] == "codeoptional"): + for i in settings["signup_codes"]: + if (i["code"] == accessCode): + codeFound = True + role = i["role"] + if (codeFound == False): + role = settings["default_role"] + elif (settings["signup_mode"] == "nocode"): + role = settings["default_role"] + elif (settings["signup_mode"] == "coderequired"): + for i in settings["signup_codes"]: + if (i["code"] == accessCode): + codeFound = True + role = i["role"] + if (codeFound == False): + return jsonify("Code not found") + + # Create user + usersCollection.insert_one( + { + "_id": ObjectId(), + "name":displayName, + "username":username, + "email":email, + "permissions":settings["default_permissions"], + "role":role, + "password":passwordHash, + "passkeys": [], + "tokens": [], + "creation_date": creationDate + } + ) + except: + return jsonify("An error occured") + user = usersCollection.find_one({"email":email}) + userId = user['_id'] + newToken = ''.join(random.choices(string.ascii_letters + string.digits, k=100)) + newExpiry = int(datetime.now().timestamp()) + newExpiry = newExpiry + 2678400 + usersCollection.update_one({'_id':userId}, {"$addToSet":{'tokens':{'token':newToken, 'expiry':newExpiry}}}) + return jsonify(newToken) + +# Api signup endpoint +# Create account with user details and get permission based on access code +# Arguments: username (required), email, password (required), access_code, displayname (required) +@app.route('/api/signup', methods = ['POST']) +def handleSignup(): + try: + # Set user details + username = request.json['username'].lower() + email = request.json['email'].lower() + password = request.json['password'] + passwordHash = ph.hash(password) + creationDate = int(datetime.now().timestamp()) + accessCode = request.json['access_code'] + displayName = request.json['displayname'] + + # Check if details are taken + sameUsername = usersCollection.count_documents({"username":username}) + sameEmail = usersCollection.count_documents({"email":email}) + if (sameUsername != 0 ) or ( sameEmail != 0): + return jsonify("User already exists") + + # Check for appropriate role + codeFound = False + if (settings["signup_mode"] == "none"): + return jsonify("Signups have been disabled") + elif (settings["signup_mode"] == "codeoptional"): + for i in settings["signup_codes"]: + if (i["code"] == accessCode): + codeFound = True + role = i["role"] + if (codeFound == False): + role = settings["default_role"] + elif (settings["signup_mode"] == "nocode"): + role = settings["default_role"] + elif (settings["signup_mode"] == "coderequired"): + for i in settings["signup_codes"]: + if (i["code"] == accessCode): + codeFound = True + role = i["role"] + if (codeFound == False): + return jsonify("Code not found") + + # Create user + usersCollection.insert_one( + { + "_id": ObjectId(), + "name":displayName, + "username":username, + "email":email, + "permissions":settings["default_permissions"], + "role":role, + "password":passwordHash, + "passkeys": [], + "tokens": [], + "creation_date": creationDate + } + ) + except: + return jsonify("An error occured") + +# Logout endpoint +# Logs out user and removes token from db +# Arguments: auth_token (cookie) (required) +@app.route('/logout', methods = ['GET']) +def logout(): + token = request.cookies.get('auth_token', 'none') + try: + token = request.json['remove_token'] + except: + pass + user = usersCollection.update_one({'tokens.token': token}, {"$pull":{'tokens':{'token':token}}}) + return render_template('logout.html', appName=appName) + +if __name__ == '__main__': + app.run(debug = True) \ No newline at end of file diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..b6765fb --- /dev/null +++ b/notes.md @@ -0,0 +1,88 @@ +# Notes +The notes for my aiFrontend project + +## Database +aiFrontend uses mongoDB as a database, this is used to store previous chats + +### Chats +A chat document should be formatted like this: +``` +{ + "name":"demoName", + "model": "demoModel", + "permissions": { + "demoUserUUID":[ + 'owner', + 'view', + 'message' + ] + }, + messages: [ + { + "role": "user", + "content": "demoQuestion", + "images": ["demoBase64EncodedImage (This line is optional)"] + }, + { + "role": "assistant", + "content": "demoResponse", + "tool_calls": [ + { + "function": { + "name": "demo_function_name", + "arguments": { + "demoArg":"demoArgInput" + } + } + } + ] + } + ] +} +``` +MongoDB will add an object ID automatically + +### Users +A user should be formatted like this: +``` +{ + "_id": ObjectId(), + "name":"demoName", + "username":"demousername", + "email":"demoemail@example.com", + "permissions":[ + "admin", + "createChat", + "banUser", + "unbanUser", + "createUser", + "deleteUser", + "editPermissions", + "seeUsers" + ], + "role":"user", + "password":"demoPasswordHash", + "passkeys": [ + { + "passkey":"demoPasskey1", + "name":"demoPasskeyName1" + }, + { + "passkey":"demoPasskey2", + "name":"demoPasskeyName2" + } + ], + "tokens": [ + { + "token":"demoToken1", + "expiry":"unixTimecode" + }, + { + "token":"demoToken2", + "expiry":"unixTimecode" + } + ], + "creation_date": "unixTimecode" +} +``` +The username and email should always be in lowercase \ No newline at end of file diff --git a/static/github-icon.png b/static/github-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fe3ad98de984167e3d414cb6de1b523f4e22588a GIT binary patch literal 817 zcmV-11J3-3P)zuv5hb|5pb$L>Q4utFF$gN2Jq!pc zf`{Oa#wCZifZ#==LezuitRQ#@sCW}a6jy?{Ac}?{xGx7GE-2bk@CKS@X6jXU^(5eY z@R)(={@-6S)m87+K$R+0d@%!91snmc0MCH;z!zWuxCfj8b^-H%3?qO>pdGjc1dj0v zH~{2^!}w_VCcnlo9*rMB571O8^GkrI9*jrh1CUSK{CePb3g&}Q^vW;nm0I9b%Ep6G zTm-T{2XMNA<^zcfK#dRcyNAJiAaOLQ`Q^Z$65<)!wq;tXKF#YlaTd#20gv`kFZOPKeIfqg)0k^8ci zT-~nbCj#FrqCQuNtRt_JTL88w#6Eg1 z!g8W%%R5ISwoW0m6942Cd3R`pPFh4Bq*TjifV-C5XEj1Me0&Qr*=ot%uMv6VV;;t& z#ghA-M&!9g#B^b4(WuxDtPGCe0sQnag-r347IQ7ROY%*FWYxma6#yd z^fB-qxGC$nmS%?mbdr#ch~W98f^zn$G2iE-_b>t{BEs7}48msKJSPo!A|o4Dbm`nH zO~^sd1Ly}Pkpf*s9^OK@Qp)@_U_2?(TB7N3C3?RpUk;`5-#{@M + + + + + Chat | {{ appName }} + + + + + \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..afd6ac1 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,10 @@ + + + + + + Home | {{ appName }} + + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..6fc997c --- /dev/null +++ b/templates/login.html @@ -0,0 +1,201 @@ + + + + + + Login | {{ appName }} + + + + + + + \ No newline at end of file diff --git a/templates/logout.html b/templates/logout.html new file mode 100644 index 0000000..ebc0ce7 --- /dev/null +++ b/templates/logout.html @@ -0,0 +1,17 @@ + + + + + + Logout | {{ appName }} + + +
+

You have been logged out

+

Log back in

+
+ + + \ No newline at end of file diff --git a/templates/oauthsignup.html b/templates/oauthsignup.html new file mode 100644 index 0000000..c8ed535 --- /dev/null +++ b/templates/oauthsignup.html @@ -0,0 +1,166 @@ + + + + + + Signup | {{ appName }} + + + + + + + \ No newline at end of file