mirror of
https://github.com/DarrylNixon/ghostforge
synced 2024-04-22 06:27:20 -07:00
Layout work. Learning how to Alembic.
This commit is contained in:
parent
1fa3d8a372
commit
7598e2c8fc
37 changed files with 806 additions and 173 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
15
.dockerignore
Normal file
15
.dockerignore
Normal 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/
|
|
@ -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 execution.
|
||||
DATABASE_CONTAINER_NAME=ghostforge-db
|
||||
DATABASE_USER=ghost
|
||||
DATABASE_NAME=ghostforge
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_PASSWORD=
|
||||
#
|
||||
# POSTGRES_PASSWORD is used for database authentication and should
|
||||
# be secure or randomized (optionally using the method in README.md).
|
||||
POSTGRES_CONTAINER=ghostforge-db
|
||||
POSTGRES_USER=ghost
|
||||
POSTGRES_DB=ghostforge
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# GHOSTFORGE_*_WEB_PORT variables are used to determine what
|
||||
# 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=/data
|
||||
|
||||
# JWT_SECRET is used for authentication purposes and should be
|
||||
# randomized using the method in README.md.
|
||||
# JWT_SECRET is used for authentication purposes and can easily be
|
||||
# securely randomized using the method in README.md.
|
||||
GHOSTFORGE_JWT_SECRET=
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -120,7 +120,7 @@ celerybeat.pid
|
|||
*.sage.py
|
||||
|
||||
# Environments
|
||||
# .env
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
|
37
Dockerfile
37
Dockerfile
|
@ -1,18 +1,37 @@
|
|||
FROM python:3.11-alpine
|
||||
|
||||
ENV DATABASE_PASSWORD ""
|
||||
RUN if [ -z "${DATABASE_PASSWORD}" ]; then echo "ghostforge build error: Set DATABASE_PASSWORD in .env."; exit 1; fi
|
||||
RUN if [ -z "${GHOSTFORGE_JWT_SECRET}" ]; then echo "ghostforge build error: Set GHOSTFORGE_JWT_SECRET in .env."; exit 1; fi
|
||||
# Enforcement to ensure passwords environment variables are not left blank.
|
||||
# This won't stop bad passwords from being used, but at least won't cause
|
||||
# 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
|
||||
COPY . .
|
||||
RUN rm .env
|
||||
RUN mkdir -p "${GHOSTFORGE_DATA_DIR}"
|
||||
|
||||
# Install ghostforge from the work directory.
|
||||
RUN pip install .
|
||||
|
||||
ENV GHOSTFORGE_INTERNAL_WEB_PORT=8080
|
||||
ENV PYTHONPATH=/ghostforge/ghostforge
|
||||
# Expose the web "serve" port specific in the environment variables.
|
||||
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}
|
||||
CMD [ "ghostforge_serve" ]
|
||||
# TODO: Is this line necessary?
|
||||
# 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" ]
|
||||
|
|
|
@ -23,7 +23,8 @@ You'll need `docker-compose` installed or you can convert the contents of `docke
|
|||
```bash
|
||||
git clone https://github.com/darrylnixon/ghostforge.git && \
|
||||
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';
|
||||
docker-compose up --detach --build;
|
||||
docker exec --interactive --tty ghostforge ghostforge_adduser;
|
||||
|
|
108
alembic.ini
Normal file
108
alembic.ini
Normal 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 |
|
@ -6,16 +6,29 @@ services:
|
|||
restart: unless-stopped
|
||||
volumes:
|
||||
- ghostforge-db-data:/var/lib/postgresql/data
|
||||
ports: [ "5432:5432" ]
|
||||
env_file: [ .env ]
|
||||
ghostforge:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args: [GHOSTFORGE_ENV=prod]
|
||||
ports: ["${GHOSTFORGE_HOST_WEB_PORT}:${GHOSTFORGE_WEB_PORT}"]
|
||||
args:
|
||||
- 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
|
||||
container_name: ghostforge
|
||||
restart: unless-stopped
|
||||
depends_on: [ghostforge-db]
|
||||
depends_on: [ ghostforge-db ]
|
||||
env_file: [ .env ]
|
||||
volumes:
|
||||
- ./migrations/versions:/ghostforge/migrations/versions
|
||||
volumes:
|
||||
ghostforge-db-data:
|
||||
name: ghostforge-db-data
|
||||
|
|
|
@ -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."}
|
|
@ -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
|
|
@ -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
|
|
@ -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 {}
|
|
@ -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
20
ghostforge/db.py
Normal 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
|
|
@ -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
11
ghostforge/serve.py
Normal 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)
|
53
ghostforge/static/css/ghostforge.css
Normal file
53
ghostforge/static/css/ghostforge.css
Normal 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);
|
||||
}
|
1
ghostforge/static/css/spectre-exp.min.css
vendored
Normal file
1
ghostforge/static/css/spectre-exp.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ghostforge/static/css/spectre-icons.min.css
vendored
Normal file
1
ghostforge/static/css/spectre-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ghostforge/static/css/spectre.min.css
vendored
Normal file
1
ghostforge/static/css/spectre.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
ghostforge/static/img/default-avatar.png
Normal file
BIN
ghostforge/static/img/default-avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
ghostforge/static/img/favicon.ico
Normal file
BIN
ghostforge/static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 239 KiB |
BIN
ghostforge/static/img/ghostforge-sidebar.png
Normal file
BIN
ghostforge/static/img/ghostforge-sidebar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 KiB |
BIN
ghostforge/static/img/github-inverted.png
Normal file
BIN
ghostforge/static/img/github-inverted.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 885 B |
104
ghostforge/static/js/ghostforge.js
Normal file
104
ghostforge/static/js/ghostforge.js
Normal 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
45
ghostforge/templates.py
Normal 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",
|
||||
}
|
29
ghostforge/templates/base.html
Normal file
29
ghostforge/templates/base.html
Normal 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>
|
40
ghostforge/templates/modals/search.html
Normal file
40
ghostforge/templates/modals/search.html
Normal 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>
|
40
ghostforge/templates/navigation/side.html
Normal file
40
ghostforge/templates/navigation/side.html
Normal 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>
|
55
ghostforge/templates/navigation/top.html
Normal file
55
ghostforge/templates/navigation/top.html
Normal 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>
|
62
ghostforge/templates/user.html
Normal file
62
ghostforge/templates/user.html
Normal 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
15
ghostforge/users.py
Normal 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
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration with an async dbapi.
|
101
migrations/env.py
Normal file
101
migrations/env.py
Normal 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
25
migrations/script.py.mako
Normal 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"}
|
|
@ -11,19 +11,27 @@ readme = "README.md"
|
|||
requires-python = ">=3.9"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"fastapi>=0.95.2",
|
||||
"uvicorn>=0.22.0",
|
||||
"loguru>=0.7.0",
|
||||
"passlib>=1.7.4",
|
||||
"pydantic>=1.10.8",
|
||||
"fastapi==0.95.2",
|
||||
"uvicorn==0.22.0",
|
||||
"loguru==0.7.0",
|
||||
"passlib==1.7.4",
|
||||
"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]
|
||||
ghostforge_serve = "ghostforge.cli:start_api"
|
||||
ghostforge_serve = "ghostforge.serve:start"
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/DarrylNixon/ghostforge"
|
||||
"Bug Tracker" = "https://github.com/DarrylNixon/ghostforge/issues"
|
||||
homepage = "https://github.com/DarrylNixon/ghostforge"
|
||||
repository = "https://github.com/DarrylNixon/ghostforge"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["ghostforge"]
|
||||
|
||||
[tool.bandit]
|
||||
exclude_dirs = ["/doc", "/build"]
|
||||
|
|
Loading…
Reference in a new issue