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

BIN
.DS_Store vendored Normal file

Binary file not shown.

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
.gitignore
.git/
.env
.env.sample
.flake8
.pre-commit-config.yaml
.dockerignore
Dockerfile
build/
**/__pycache__/
**/*.md
**/*.pyc
**/*.pyo
**/*.mo
*.egg-info/

View file

@ -1,11 +1,13 @@
# DATABASE_* variables are used both to initially configure the # POSTGRES_* variables are used both to initially configure the
# ghostforge postgresql database and to later access the data from # ghostforge postgresql database and to later access the data from
# ghostforge execution. # ghostforge execution.
DATABASE_CONTAINER_NAME=ghostforge-db #
DATABASE_USER=ghost # POSTGRES_PASSWORD is used for database authentication and should
DATABASE_NAME=ghostforge # be secure or randomized (optionally using the method in README.md).
DATABASE_PORT=5432 POSTGRES_CONTAINER=ghostforge-db
DATABASE_PASSWORD= POSTGRES_USER=ghost
POSTGRES_DB=ghostforge
POSTGRES_PASSWORD=
# GHOSTFORGE_*_WEB_PORT variables are used to determine what # GHOSTFORGE_*_WEB_PORT variables are used to determine what
# port the web interface is served on within the container (INTERNAL) # port the web interface is served on within the container (INTERNAL)
@ -25,6 +27,6 @@ GHOSTFORGE_ENV=prod
# GHOSTFORGE_DATA_DIR is created within the ghostforge container. # GHOSTFORGE_DATA_DIR is created within the ghostforge container.
GHOSTFORGE_DATA_DIR=/data GHOSTFORGE_DATA_DIR=/data
# JWT_SECRET is used for authentication purposes and should be # JWT_SECRET is used for authentication purposes and can easily be
# randomized using the method in README.md. # securely randomized using the method in README.md.
GHOSTFORGE_JWT_SECRET= GHOSTFORGE_JWT_SECRET=

2
.gitignore vendored
View file

@ -120,7 +120,7 @@ celerybeat.pid
*.sage.py *.sage.py
# Environments # Environments
# .env .env
.venv .venv
env/ env/
venv/ venv/

View file

@ -1,18 +1,37 @@
FROM python:3.11-alpine FROM python:3.11-alpine
ENV DATABASE_PASSWORD "" # Enforcement to ensure passwords environment variables are not left blank.
RUN if [ -z "${DATABASE_PASSWORD}" ]; then echo "ghostforge build error: Set DATABASE_PASSWORD in .env."; exit 1; fi # This won't stop bad passwords from being used, but at least won't cause
RUN if [ -z "${GHOSTFORGE_JWT_SECRET}" ]; then echo "ghostforge build error: Set GHOSTFORGE_JWT_SECRET in .env."; exit 1; fi # errors or, worse, weaker crypt.
ARG POSTGRES_PASSWORD
ENV ENV_POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ARG GHOSTFORGE_JWT_SECRET
ENV ENV_GHOSTFORGE_JWT_SECRET=${GHOSTFORGE_JWT_SECRET}
RUN [ ! -z "${ENV_POSTGRES_PASSWORD}" ] || { echo "ghostforge build error: Set POSTGRES_PASSWORD in .env."; exit 1; }
RUN [ ! -z "${ENV_GHOSTFORGE_JWT_SECRET}" ] || { echo "ghostforge build error: Set GHOSTFORGE_JWT_SECRET in .env."; exit 1; }
# Create the data directory specified using the environment variables.
# This is redundant for mapped volumes, but necessary if the data
# directory is specified but not mapped.
ARG GHOSTFORGE_DATA_DIR
ENV ENV_GHOSTFORGE_DATA_DIR=${GHOSTFORGE_DATA_DIR}
RUN mkdir -p "${ENV_GHOSTFORGE_DATA_DIR}"
# Copy project into Docker image, skipping entries in .dockerignore.
WORKDIR /ghostforge WORKDIR /ghostforge
COPY . . COPY . .
RUN rm .env
RUN mkdir -p "${GHOSTFORGE_DATA_DIR}"
# Install ghostforge from the work directory.
RUN pip install . RUN pip install .
ENV GHOSTFORGE_INTERNAL_WEB_PORT=8080 # Expose the web "serve" port specific in the environment variables.
ENV PYTHONPATH=/ghostforge/ghostforge ARG GHOSTFORGE_INTERNAL_WEB_PORT
ENV ENV_GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT}
EXPOSE ${ENV_GHOSTFORGE_INTERNAL_WEB_PORT}
EXPOSE ${GHOSTFORGE_INTERNAL_WEB_PORT} # TODO: Is this line necessary?
CMD [ "ghostforge_serve" ] # ENV PYTHONPATH=/ghostforge/ghostforge
# TODO: Replace with ghostforge_serve when it works.
# This currently just keeps the container running for development.
CMD [ "python3", "-m", "http.server" ]

View file

@ -23,7 +23,8 @@ You'll need `docker-compose` installed or you can convert the contents of `docke
```bash ```bash
git clone https://github.com/darrylnixon/ghostforge.git && \ git clone https://github.com/darrylnixon/ghostforge.git && \
cd ghostforge && \ cd ghostforge && \
PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^DATABASE_PASSWORD=.*/DATABASE_PASSWORD=$PW/" .env' && \ cp .env.sample .env && \
PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PW/" .env' && \
JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env'; JWT=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^GHOSTFORGE_JWT_SECRET=.*/GHOSTFORGE_JWT_SECRET=$JWT/" .env';
docker-compose up --detach --build; docker-compose up --detach --build;
docker exec --interactive --tty ghostforge ghostforge_adduser; docker exec --interactive --tty ghostforge ghostforge_adduser;

108
alembic.ini Normal file
View file

@ -0,0 +1,108 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# sqlalchemy.url = driver://user:pass@localhost/dbname
sqlalchemy.url = postgresql+asyncpg://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_CONTAINER)s:5432/%(POSTGRES_DB)s
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -6,16 +6,29 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ghostforge-db-data:/var/lib/postgresql/data - ghostforge-db-data:/var/lib/postgresql/data
ports: [ "5432:5432" ]
env_file: [ .env ]
ghostforge: ghostforge:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: [GHOSTFORGE_ENV=prod] args:
ports: ["${GHOSTFORGE_HOST_WEB_PORT}:${GHOSTFORGE_WEB_PORT}"] - GHOSTFORGE_ENV=${GHOSTFORGE_ENV}
- GHOSTFORGE_DATA_DIR=${GHOSTFORGE_DATA_DIR}
- GHOSTFORGE_JWT_SECRET=${GHOSTFORGE_JWT_SECRET}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- GHOSTFORGE_INTERNAL_WEB_PORT=${GHOSTFORGE_INTERNAL_WEB_PORT}
ports:
[
"${GHOSTFORGE_HOST_WEB_PORT}:${GHOSTFORGE_INTERNAL_WEB_PORT}"
]
image: ghostforge:latest image: ghostforge:latest
container_name: ghostforge container_name: ghostforge
restart: unless-stopped restart: unless-stopped
depends_on: [ghostforge-db] depends_on: [ ghostforge-db ]
env_file: [ .env ]
volumes:
- ./migrations/versions:/ghostforge/migrations/versions
volumes: volumes:
ghostforge-db-data: ghostforge-db-data:
name: ghostforge-db-data name: ghostforge-db-data

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 sqlmodel import Field
from sqlmodel import SQLModel
from pydantic import BaseModel
from pydantic import Field
class UserSchema(BaseModel): class User(SQLModel, table=True):
name: str = Field(...) id: int = Field(default=None, primary_key=True)
password: str = Field(...) name: str = Field(unique=True)
created: datetime.datetime = datetime.datetime password: str
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"}}

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}"}
)

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

101
migrations/env.py Normal file
View file

@ -0,0 +1,101 @@
import asyncio
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlmodel import SQLModel
# from ghostforge.models import User
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
section = config.config_ini_section
# this is ghostforge-specific, and loads the POSTGRES_*
# environment variables from .env into the alembic context
# for use in dynamically building the postgres URL string.
for var in os.environ:
if var.startswith("POSTGRES_"):
config.set_section_option(section, var, os.environ.get(var))
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
migrations/script.py.mako Normal file
View file

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -11,19 +11,27 @@ readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
license = { text = "MIT" } license = { text = "MIT" }
dependencies = [ dependencies = [
"fastapi>=0.95.2", "fastapi==0.95.2",
"uvicorn>=0.22.0", "uvicorn==0.22.0",
"loguru>=0.7.0", "loguru==0.7.0",
"passlib>=1.7.4", "passlib==1.7.4",
"pydantic>=1.10.8", "pydantic==1.10.8",
"alembic==1.11.1",
"asyncpg==0.27.0",
"sqlmodel==0.0.8",
"greenlet==2.0.2",
"jinja2==3.1.2",
] ]
[project.scripts] [project.scripts]
ghostforge_serve = "ghostforge.cli:start_api" ghostforge_serve = "ghostforge.serve:start"
[project.urls] [project.urls]
"Homepage" = "https://github.com/DarrylNixon/ghostforge" homepage = "https://github.com/DarrylNixon/ghostforge"
"Bug Tracker" = "https://github.com/DarrylNixon/ghostforge/issues" repository = "https://github.com/DarrylNixon/ghostforge"
[tool.setuptools]
py-modules = ["ghostforge"]
[tool.bandit] [tool.bandit]
exclude_dirs = ["/doc", "/build"] exclude_dirs = ["/doc", "/build"]