Layout work. Learning how to Alembic.

This commit is contained in:
Darryl Nixon 2023-05-25 18:33:08 -07:00
parent 1fa3d8a372
commit 7598e2c8fc
37 changed files with 806 additions and 173 deletions

View file

@ -1,35 +0,0 @@
from fastapi import Body
from fastapi import Depends
from fastapi import FastAPI
from ghostforge.auth import check_user
from ghostforge.auth.bearer import JWTBearer
from ghostforge.auth.handler import signJWT
from ghostforge.models import UserLoginSchema
# define("port", default=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), help="Webserver port", type=int)
# define("db_host", default=os.environ.get("DATABASE_CONTAINER_NAME", "localhost"), help="Host/IP of db")
# define("db_port", default=os.environ.get("DATABASE_PORT", 5432), help="Port of db", type=int)
# define("db_database", default=os.environ.get("DATABASE_NAME", "ghostforge"), help="Name of db")
# define("db_user", default=os.environ.get("DATABASE_USER", "ghostforge"), help="User with access to db")
# define("db_password", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user")
# define("secret_key", default=os.environ.get("DATABASE_PASSWORD", "secure"), help="Password for db user")
app = FastAPI()
@app.get("/", tags=["root"])
async def read_root() -> dict:
return {"message": "Welcome!"}
@app.post("/login", tags=["user"])
async def user_login(user: UserLoginSchema = Body(...)):
if check_user(user):
return signJWT(user.name)
return {"error": "Wrong login details!"}
@app.post("/auth_check", dependencies=[Depends(JWTBearer())], tags=["auth_check"])
async def auth_check(post) -> dict:
return {"data": "post added."}

View file

@ -1,11 +0,0 @@
from ghostforge.models import UserLoginSchema
users = [{"name": "test", "password": "pw"}]
def check_user(data: UserLoginSchema):
print(data)
for user in users:
if user["name"] == data.name and user["password"] == data.password:
return True
return False

View file

@ -1,35 +0,0 @@
from fastapi import HTTPException
from fastapi import Request
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.security import HTTPBearer
from ghostforge.auth.handler import decodeJWT
# from https://github.com/testdrivenio/fastapi-jwt/
class JWTBearer(HTTPBearer):
def __init__(self, auto_error: bool = True):
super(JWTBearer, self).__init__(auto_error=auto_error)
async def __call__(self, request: Request):
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request)
if credentials:
if not credentials.scheme == "Bearer":
raise HTTPException(status_code=403, detail="Invalid authentication scheme.")
if not self.verify_jwt(credentials.credentials):
raise HTTPException(status_code=403, detail="Invalid token or expired token.")
return credentials.credentials
else:
raise HTTPException(status_code=403, detail="Invalid authorization code.")
def verify_jwt(self, jwtoken: str) -> bool:
isTokenValid: bool = False
try:
payload = decodeJWT(jwtoken)
except Exception:
payload = None
if payload:
isTokenValid = True
return isTokenValid

View file

@ -1,28 +0,0 @@
import os
import time
from typing import Dict
import jwt
# Based on handler from https://github.com/testdrivenio/fastapi-jwt/
JWT_SECRET = os.environ.get("GHOSTFORGE_JWT_SECRET")
def token_response(token: str):
return {"access_token": token}
def signJWT(user: str) -> Dict[str, str]:
payload = {"user_id": user, "expires": time.time() + 600}
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
return token_response(token)
def decodeJWT(token: str) -> dict:
try:
decoded_token = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return decoded_token if decoded_token["expires"] >= time.time() else None
except Exception:
return {}

View file

@ -1,9 +0,0 @@
import os
import uvicorn
def start_api() -> None:
uvicorn.run(
"ghostforge.api:app", host="0.0.0.0", port=os.environ.get("GHOSTFORGE_INTERNAL_WEB_PORT", 1337), reload=True
)

20
ghostforge/db.py Normal file
View file

@ -0,0 +1,20 @@
import os
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker
database_url = (
f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:'
+ f'{os.environ.get("POSTGRES_PASSWORD")}@'
+ os.environ.get("POSTGRES_CONTAINER")
+ f':5432/{os.environ.get("POSTGRES_DB")}'
)
engine = create_async_engine(database_url, echo=True, future=True)
async def get_session() -> AsyncSession:
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session

View file

@ -1,27 +1,8 @@
import datetime
from pydantic import BaseModel
from pydantic import Field
from sqlmodel import Field
from sqlmodel import SQLModel
class UserSchema(BaseModel):
name: str = Field(...)
password: str = Field(...)
created: datetime.datetime = datetime.datetime
class Config:
schema_extra = {
"example": {
"name": "Jeremy Tootsieroll",
"password": "notarealpassword",
"created": "2021-03-05T08:21:00.000Z",
}
}
class UserLoginSchema(BaseModel):
name: str = Field(...)
password: str = Field(...)
class Config:
schema_extra = {"example": {"name": "Jeremy Tootsieroll", "password": "notarealpassword"}}
class User(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
name: str = Field(unique=True)
password: str

11
ghostforge/serve.py Normal file
View file

@ -0,0 +1,11 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from users import gf as gf_users
# from ghostforge.models import User
gf = FastAPI()
gf.mount("/static", StaticFiles(directory="static"), name="static")
gf.include_router(gf_users)

View file

@ -0,0 +1,53 @@
.nav-menus {
margin-top: 6px;
padding: 0 .75rem;
}
.nav-menus .nav-item {
font-size: 0.7rem;
}
.nav-menus .nav-item.header {
text-transform: uppercase;
font-weight: 600;
}
.menu-nav {
padding-left: 1.3rem;
}
.version-string {
background: #303742;
border-radius: .1rem;
color: #fff;
font-size: .7rem;
line-height: 1.25;
padding: .1rem .2rem;
text-align: center;
}
img.brand {
margin: 6px;
max-width: 100%;
height: auto;
}
img.version-size {
height: .7rem;
vertical-align: middle;
}
summary.left-nav {
display: block;
}
summary.left-nav::before {
margin-left: 1ch;
display: inline-block;
transition: 0.2s;
content: '\203A';
}
details[open] summary.left-nav::before {
transform: rotate(90deg);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
ghostforge/static/css/spectre.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

View file

@ -0,0 +1,104 @@
// Save and restore navigation menu state from local storage.
function saveMenuState() {
var leftNavigation = document.getElementById("left-navigation");
if (!leftNavigation) {
return;
}
var rootMenuItems = leftNavigation.getElementsByTagName("details");
console.log(rootMenuItems);
var rootMenuStates = {};
for (var i = 0; i < rootMenuItems.length; i++) {
var item = rootMenuItems[i];
var id = item.getAttribute("id");
var isOpen = item.hasAttribute("open");
rootMenuStates[id] = isOpen;
}
var jsonMenuStates = JSON.stringify(rootMenuStates);
localStorage.setItem("menuState", jsonMenuStates);
}
function restoreMenuState() {
var leftNavigation = document.getElementById("left-navigation");
if (!leftNavigation) {
return;
}
var jsonMenuStates = localStorage.getItem("menuState");
var rootMenuStates = JSON.parse(jsonMenuStates) || {};
var rootMenuItems = leftNavigation.getElementsByTagName("details");
for (var i = 0; i < rootMenuItems.length; i++) {
var item = rootMenuItems[i];
var id = item.getAttribute("id");
var isOpen = rootMenuStates[id];
if (isOpen === undefined) {
isOpen = false;
}
if (isOpen) {
item.setAttribute("open", "");
} else {
item.removeAttribute("open");
}
}
}
// Trap tab-targeting for ADA-compliant use
// https://scribe.bus-hit.me/how-to-trap-focus-inside-modal-to-make-it-ada-compliant-6a50f9a70700
function modal_focus(modal_name) {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const modal = document.querySelector(modal_name);
const firstFocusableElement = modal.querySelectorAll(focusableElements)[0];
const focusableContent = modal.querySelectorAll(focusableElements);
const lastFocusableElement = focusableContent[focusableContent.length - 1];
document.addEventListener('keydown', function (e) {
let isTabPressed = e.key === 'Tab' || e.keyCode === 9;
if (!isTabPressed) {
return;
}
if (e.shiftKey) {
if (document.activeElement === firstFocusableElement) {
lastFocusableElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
e.preventDefault();
}
}
});
firstFocusableElement.focus();
}
// Hotkeys
document.addEventListener("keydown", function (event) {
// Ctrl/Cmd+G for search modal
if (event.key == "g") {
const nav_search_button = document.getElementById("nav_search_button");
const search_box = document.getElementById("search_string");
console.log(search_box);
if (event.ctrlKey) {
event.preventDefault(); // prevent default Ctrl+G behavior (find in page)
nav_search_button.click();
search_box.focus();
}
// Add support for Apple+G on Macs
if (event.metaKey) {
event.preventDefault(); // prevent default Apple+G behavior (bookmark)
nav_search_button.click();
search_box.focus();
}
}
});
// Run various onload/unload functions since script is loaded before elements are
window.onload = function () {
restoreMenuState();
modal_focus("#search-modal");
};
window.onbeforeunload = saveMenuState;

45
ghostforge/templates.py Normal file
View file

@ -0,0 +1,45 @@
import importlib.metadata
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
# Inject version string and Github URL into every template for navbar display.
try:
gf_version = importlib.metadata.version(__package__)
except ValueError:
gf_version = "Local"
templates.env.globals["gf_version"] = f"v{gf_version}"
templates.env.globals["gf_repository_url"] = "https://github.com/DarrylNixon/ghostforge"
# Same, but build the navbar from an ordered dictionary for centralization.
# Since 3.7 (we require >= 3.9), dicts are guaranteed ordered as inserted.
templates.env.globals["gf_navbar"] = {
"Management": {
"Dashboard": "/user/0",
"New Ghost": "/user/0",
"Active Ghosts": "/user/0",
"Archived Ghosts": "/user/0",
},
"Research": {
"Guidebook": "/user/0",
"Cheat Sheet": "/user/0",
},
"Settings": {
"Your Profile": "/user/0",
"Configuration": "/user/0",
"Integrations": "/user/0",
"Manage Users": "/user/0",
},
"Meta": {
"About GhostForge": "/user/0",
"System Logs": "/user/0",
"Logout": "/user/0",
},
}
templates.env.globals["avatar_menu"] = {
"Dashboard": "/user/0",
"Your Profile": "/user/0",
"Logout": "/user/0",
}

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}">
<title>{% block title %}{{ title }}{% endblock title %}</title>
<link href="{{ url_for('static', path='/css/spectre.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/spectre-exp.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/spectre-icons.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet">
<script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script>
</head>
<body>
<div class="off-canvas off-canvas-sidebar-show d-flex">
{% include "navigation/side.html" %}
<div class="off-canvas-content">
<div class="container">
{% include "navigation/top.html" %}
{% block content %}
{% endblock content %}
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,40 @@
<div align="center">
<a class="btn btn-primary btn-sm" href="#search-modal" id="nav_search_button">
<i class="icon icon-search"></i>
⌘-g
</a>
</div>
<div class="modal modal-sm" id="search-modal"><a class="modal-overlay" href="#modals-sizes" aria-label="Close"></a>
<div class="modal-container" role="document">
<div class="modal-header"><a class="btn btn-clear float-right" href="#modals-sizes" aria-label="Close"></a>
<div class="modal-title h5">Search Ghosts</div>
</div>
<div class="modal-body">
<div class="content">
<form>
<div class="form-group">
<label class="form-label" for="search_string">
<h6>Search Term</h6>
</label>
<input class="form-input" id="search_string" type="text" placeholder="Type a string here">
</div>
<div class="form-group">
<label class="form-label">
<h6>Scope</h6>
</label>
<label class="form-radio">
<input type="radio" name="scope"><i class="form-icon"></i> All text fields
</label>
<label class="form-radio">
<input type="radio" name="scope" checked=""><i class="form-icon"></i> Name fields only
</label>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary">Search</button>
<a class="btn btn-link" href="#modals-sizes" aria-label="Close">Close</a>
</div>
</div>
</div>

View file

@ -0,0 +1,40 @@
<a class="off-canvas-toggle btn btn-primary btn-action" href="#left-navigation"><i class="icon icon-menu"></i></a>
<div id="left-navigation" class="off-canvas-sidebar">
<div class="navigation-menu">
<a href="/" class="logo">
<img src="{{ url_for('static', path='/img/ghostforge-sidebar.png') }}" class="brand">
</a>
<div>{% include "modals/search.html" %}</div>
<div class="nav-menus">
{% for key, value in gf_navbar.items() %}
<details id="{{ key }}" , class="accordion">
<summary class="accordion-header c-hand left-nav">
<span class="nav-item header">{{ key }}</span>
</summary>
<div class="accordion-body">
<ul class="menu menu-nav">
{% for subkey, subvalue in value.items() %}
<li class="menu-item">
<a href="{{ subvalue }}" class="nav-item">{{
subkey
}}</a>
</li>
{% endfor %}
</ul>
</div>
</details>
{% endfor %}
<!-- ETC -->
</div>
</div>
<div align="right">
<kbd>
{{ gf_version }}
<a href="{{ gf_repository_url }}">
<img src="{{ url_for('static', path='/img/github-inverted.png') }}" class="version-size">
</a>
</kbd>
</div>
</div>
<a class="off-canvas-overlay" href="#close"></a>

View file

@ -0,0 +1,55 @@
<header class="navbar">
<section class="navbar-section">
{% if crumbs %}
<ul class="breadcrumb">
{% for name, url in crumbs %}
<li class="breadcrumb-item">
{% if url %}
<a href="{{ url }}">{{ name }}</a>
{% else %}
{{ name }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</section>
<section class="navbar-section">
<div class="dropdown dropdown-right">
<div class="btn-group">
<a href="#" class="dropdown-toggle" tabindex="0">
<figure class="avatar avatar-lg badge" data-badge="3">
<img src="{{ url_for('static', path='/img/default-avatar.png') }}">
</figure>
</a>
<ul class="menu">
<li class="menu-item">
<div class="tile tile-centered">
<div class="tile-icon">
<img src="{{ url_for('static', path='/img/default-avatar.png') }}" class="avatar"
alt="Avatar">
</div>
<div class="tile-content">
Steve Rogers
</div>
</div>
</li>
<li class="divider"></li>
{% for key, value in avatar_menu.items() %}
<li class="menu-item">
{% if value is mapping and "quantity" in value and value["quantity"] > 0 %}
<div class="menu-badge">
<label class="label label-primary">{{ value["quantity"] }}</label>
</div>
{% set value = value["path"] %}
{% endif %}
<a href="{{ value }}">
{{ key }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</section>
</header>

View file

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>Words!</h1>
</div>
</div>
<div class="columns">
<div class="column col-3"></div>
<div class="column col-6">
<div class="panel">
<div class="panel-header text-center">
<figure class="avatar avatar-lg"><img src="../img/avatar-2.png" alt="Avatar"></figure>
<div class="panel-title h5 mt-10">Bruce Banner</div>
<div class="panel-subtitle">THE HULK</div>
</div>
<nav class="panel-nav">
<ul class="tab tab-block">
<li class="tab-item active"><a href="#panels">Profile</a></li>
<li class="tab-item"><a href="#panels">Files</a></li>
<li class="tab-item"><a href="#panels">Tasks</a></li>
</ul>
</nav>
<div class="panel-body">
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">E-mail</div>
<div class="tile-subtitle">bruce.banner@hulk.com</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
data-tooltip="Edit E-mail"><i class="icon icon-edit"></i></button>
</div>
</div>
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">Skype</div>
<div class="tile-subtitle">bruce.banner</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button>
</div>
</div>
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">Location</div>
<div class="tile-subtitle">Dayton, Ohio</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg"><i class="icon icon-edit"></i></button>
</div>
</div>
</div>
<div class="panel-footer">
<button class="btn btn-primary btn-block">Save</button>
</div>
</div>
</div>
<div class="column col-3"></div>
</div>
{% endblock content %}

15
ghostforge/users.py Normal file
View file

@ -0,0 +1,15 @@
from fastapi import APIRouter
from fastapi import Request
from fastapi.responses import HTMLResponse
from templates import templates
gf = APIRouter()
@gf.get("/user/{id}", response_class=HTMLResponse)
async def get_user(request: Request, id: str):
crumbs = [("settings", False), ("users", "/users"), (id, False)]
return templates.TemplateResponse(
"user.html", {"request": request, "crumbs": crumbs, "user": "test", "id": id, "title": f"User {id}"}
)