basic proxy implementation

This commit is contained in:
AF 2023-02-27 11:35:57 +00:00
parent 28a5c527b6
commit 9e688b76b4
13 changed files with 759 additions and 0 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
.git*

212
.gitignore vendored Normal file
View File

@ -0,0 +1,212 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$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
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.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/
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM python:3.11
WORKDIR /code/
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir --upgrade -r requirements.txt
COPY app app
RUN python3 -m app.main
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

126
app/main.py Normal file
View File

@ -0,0 +1,126 @@
import asyncio
from pathlib import Path
from typing import Any, Callable, Coroutine, TypeVar
import aiohttp
from fastapi import FastAPI, HTTPException
from fastapi.responses import (
FileResponse,
JSONResponse,
PlainTextResponse,
RedirectResponse,
Response,
)
app = FastAPI()
root = Path(__file__).parent / "static"
@app.get("/")
async def home():
return FileResponse(root / "home.html")
@app.get("/operator/")
async def operatorhome():
return FileResponse(root / "operator.html")
@app.get("/login/")
async def login():
return FileResponse(root / "login.html")
base = "http://v6d3music/"
@app.get("/authlink/")
async def authlink():
async with aiohttp.ClientSession() as s, s.get(f"{base}authlink/") as response:
return PlainTextResponse(await response.text())
T = TypeVar("T")
async def url_get(
url: str, params: dict[str, str], handle: Callable[[aiohttp.ClientResponse], Coroutine[Any, Any, T]]
) -> T:
async with aiohttp.ClientSession() as s, s.get(url, params=params) as response:
return await handle(response)
async def url_post(
url: str, params: dict[str, str], json: dict, handle: Callable[[aiohttp.ClientResponse], Coroutine[Any, Any, T]]
) -> T:
async with aiohttp.ClientSession() as s, s.post(url, params=params, json=json) as response:
print(await response.read())
return await handle(response)
async def repeat(repeated: Callable[[], Coroutine[Any, Any, T]]) -> T:
for _ in range(60):
try:
return await repeated()
except aiohttp.ClientConnectorError:
await asyncio.sleep(1)
raise HTTPException(504)
async def handle_auth(response: aiohttp.ClientResponse):
if 300 <= response.status <= 399:
return RedirectResponse("/")
else:
return Response(content=await response.read(), media_type=response.content_type, status_code=response.status)
@app.get("/auth/")
async def auth(session: str | None = None, state: str | None = None, code: str | None = None):
match session, state, code:
case str() as session, str() as state, str() as code:
params: dict[str, str] = {"session": session, "state": state, "code": code}
return await repeat(lambda: url_get(f"{base}auth/", params, handle_auth))
case None, None, None:
return FileResponse(root / "auth.html")
case _:
raise HTTPException(400)
async def handle_json(response: aiohttp.ClientResponse):
return JSONResponse(await response.json())
@app.get("/state/")
async def state(session: str):
return await repeat(lambda: url_get(f"{base}state/", {"session": session}, handle_json))
@app.get("/status/")
async def status(session: str):
return await repeat(lambda: url_get(f"{base}status/", {"session": session}, handle_json))
@app.get("/main.js")
async def mainjs():
return FileResponse(root / "main.js")
@app.get("/operator.js")
async def operatorjs():
return FileResponse(root / "operator.js")
@app.get("/main.css")
async def maincss():
return FileResponse(root / "main.css")
@app.get("/operator.css")
async def operatorcss():
return FileResponse(root / "operator.css")
@app.post("/api/")
async def api(json: dict, session: str):
return await repeat(lambda: url_post(f"{base}api/", {"session": session}, json, handle_json))

8
app/static/auth.html Normal file
View File

@ -0,0 +1,8 @@
<link rel="stylesheet" href="/main.css">
<div id="root"></div>
<script src="/main.js"></script>
<script>
(async () => {
window.location = window.location + `&session=${sessionStr()}`;
})();
</script>

17
app/static/home.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="/main.css" />
</head>
<body>
<div id="root-container">
<div class="sidebars"></div>
<div id="root"></div>
<div class="sidebars"></div>
</div>
<script src="/main.js"></script>
<script>
(async () => {
root.append(await pageHome());
})();
</script>
</body>

12
app/static/login.html Normal file
View File

@ -0,0 +1,12 @@
<link rel="stylesheet" href="/main.css">
<div id="root"></div>
<script src="/main.js"></script>
<script>
(async () => {
const a = await aAuth();
root.append(a);
logEl(sessionStr());
logEl(await sessionState());
a.click();
})();
</script>

50
app/static/main.css Normal file
View File

@ -0,0 +1,50 @@
html,
body,
input {
color: white;
background: black;
margin: 0;
}
::-webkit-scrollbar {
width: 1em;
}
::-webkit-scrollbar-track {
background: #111;
}
::-webkit-scrollbar-thumb {
background: #444;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
html,
body,
#root-container {
height: 100%;
}
#root-container {
display: flex;
height: 100%;
}
#root {
width: 0%;
min-width: min(40em, 100%);
flex: auto;
overflow-y: scroll;
}
.sidebars {
width: 100%;
background: #050505;
}
#homeroot {
padding: 1em;
}

204
app/static/main.js Normal file
View File

@ -0,0 +1,204 @@
const genRanHex = (size) =>
[...Array(size)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join("");
const sessionStr = () => {
if (!localStorage.getItem("session"))
localStorage.setItem("session", genRanHex(64));
return localStorage.getItem("session");
};
const sessionState = async () => {
const response = await fetch(`/state/?session=${sessionStr()}`);
return await response.json();
};
const sessionStatus = (() => {
let task;
return async () => {
if (task === undefined) {
task = (async () => {
const response = await fetch(`/status/?session=${sessionStr()}`);
return await response.json();
})();
}
return await task;
};
})();
const root = document.querySelector("#root");
const logEl = (msg) => {
const el = document.createElement("pre");
el.innerText = msg;
root.append(el);
};
const sessionClient = async () => {
const session = await sessionStatus();
return session && session["client"];
};
const sessionUser = async () => {
const client = await sessionClient();
return client && client["user"];
};
const userAvatarUrl = async () => {
const user = await sessionUser();
return user && user["avatar"];
};
const userUsername = async () => {
const user = await sessionUser();
return user && user["username"];
};
const userAvatarImg = async () => {
const avatar = await userAvatarUrl();
if (avatar) {
const img = document.createElement("img");
img.src = avatar;
img.width = 64;
img.height = 64;
img.alt = await userUsername();
return img;
} else {
return baseEl("span");
}
};
const userId = async () => {
const user = await sessionUser();
return user && user["id"];
};
const baseEl = (tag, ...appended) => {
const element = document.createElement(tag);
element.append(...appended);
return element;
};
const aLogin = () => {
const a = document.createElement("a");
a.href = "/login/";
a.innerText = "login";
return a;
};
const aAuthLink = async () => {
const response = await fetch("/authlink/");
return await response.text();
};
const aAuth = async () => {
const a = document.createElement("a");
const [authlink, sessionstate] = await Promise.all([
aAuthLink(),
sessionState(),
]);
a.href = authlink + "&state=" + sessionstate;
a.innerText = "auth";
return a;
};
const aApi = async (request) => {
const response = await fetch(`/api/?session=${sessionStr()}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
return await response.json();
};
const aGuilds = async () => {
return await aApi({ type: "guilds" });
};
const aQueue = async () => {
const requests = {};
for (const guild of await aGuilds()) {
requests[guild] = {
type: "*",
guild,
voice: null,
main: null,
catches: { "you are not connected to voice": null, "*": null },
requests: { volume: {}, playing: {}, queueformat: {}, queuejson: {} },
};
}
const responses = await aApi({ type: "*", requests });
for (const [guild, response] of Object.entries(responses)) {
if (response !== null && response.error === undefined) {
response.guild = guild;
response.time = Date.now() / 1000;
response.delta = () => Date.now() / 1000 - response.time;
let index = 0;
for (const audio of response.queuejson) {
audio.playing = response.playing && index === 0;
audio.delta = () => (audio.playing ? response.delta() : 0);
audio.now = () => audio.seconds + audio.delta();
audio.ts = () => {
const seconds_total = Math.round(audio.now());
const seconds = seconds_total % 60;
const minutes_total = (seconds_total - seconds) / 60;
const minutes = minutes_total % 60;
const hours = (minutes_total - minutes) / 60;
return `${hours}:${("00" + minutes).slice(-2)}:${(
"00" + seconds
).slice(-2)}`;
};
index += 1;
}
return response;
}
}
return null;
};
const sleep = (s) => {
return new Promise((resolve) => setTimeout(resolve, 1000 * s));
};
const audioWidget = (audio) => {
const description = baseEl("span", audio.description);
const timecode = baseEl("span", audio.timecode);
const duration = baseEl("span", audio.duration);
audio.tce = timecode;
return baseEl("div", "audio", " ", timecode, "/", duration, " ", description);
};
const aUpdateQueueOnce = async (queue, el) => {
el.innerHTML = "";
if (queue !== null) {
for (const audio of queue.queuejson) {
el.append(audioWidget(audio));
}
}
};
const aUpdateQueueSetup = async (el) => {
let queue = await aQueue();
await aUpdateQueueOnce(queue, el);
(async () => {
while (true) {
await sleep(2);
if (queue !== null && queue.queuejson.length > 100) {
await sleep((queue.queuejson.length - 100) / 200);
}
const newQueue = await aQueue();
await aUpdateQueueOnce(newQueue, el);
queue = newQueue;
}
})();
(async () => {
while (true) {
await sleep(0.25);
if (queue !== null) {
for (const audio of queue.queuejson) {
audio.tce.innerText = audio.ts();
break;
}
}
}
})();
};
const aQueueWidget = async () => {
const el = baseEl("div");
if (await sessionUser()) await aUpdateQueueSetup(el);
return el;
};
const pageHome = async () => {
const el = document.createElement("div");
el.append(
baseEl("div", aLogin()),
baseEl("div", await userAvatarImg()),
baseEl("div", await userId()),
baseEl("div", await userUsername()),
baseEl("div", await aQueueWidget())
);
el.id = "homeroot";
return el;
};

20
app/static/operator.css Normal file
View File

@ -0,0 +1,20 @@
#operatorroot {
height: 10em;
}
/* #operation {
} */
#workerpool {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1em;
padding: 1em;
height: 5em;
overflow: hidden;
}
.workerview {
background: #0f0f0f;
overflow: hidden;
}

24
app/static/operator.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<head>
<link rel="stylesheet" href="/main.css" />
<link rel="stylesheet" href="/operator.css" />
</head>
<body>
<div id="root-container">
<div class="sidebars"></div>
<div id="root"><div id="operatorroot"></div></div>
<div class="sidebars"></div>
</div>
<script src="/main.js"></script>
<script>
(async () => {
root.append(await pageHome());
})();
</script>
<script src="/operator.js"></script>
<script>
(async () => {
operatorroot.append(await pageOperator());
})();
</script>
</body>

69
app/static/operator.js Normal file
View File

@ -0,0 +1,69 @@
aApi({
type: "guilds",
operator: null,
catches: { "not an operator": null, "*": null },
}).then(console.log);
aApi({
type: "sleep",
operator: null,
duration: 1,
echo: {},
time: null,
catches: { "not an operator": null, "*": null },
}).then(console.log);
aApi({
type: "*",
idkey: "target",
idbase: {
type: "*",
requests: {
Count: {},
Concurrency: {},
},
},
operator: null,
requests: {
"v6d3music.api.Api().api": {},
"v6d3music.processing.pool.UnitJob.run": {},
},
catches: { "not an operator": null, "*": null },
time: null,
}).then((value) => console.log(JSON.stringify(value, undefined, 2)));
aApi({
type: "pool",
operator: null,
catches: { "not an operator": null, "*": null },
}).then((value) => console.log(JSON.stringify(value, undefined, 2)));
const elJob = (job) => {
const jobview = document.createElement("div");
jobview.classList.add("jobview");
jobview.innerText = JSON.stringify(job);
return jobview;
};
const elWorker = (worker) => {
const workerview = document.createElement("div");
workerview.classList.add("workerview");
workerview.append(`qsize: ${worker.qsize}`);
workerview.append(elJob(worker.job));
return workerview;
};
const elPool = async () => {
const pool = document.createElement("div");
pool.id = "workerpool";
const workers = await aApi({
type: "pool",
operator: null,
catches: { "not an operator": null, "*": null },
});
if (workers === null || workers.error !== undefined) return null;
for (const worker of workers) {
pool.append(elWorker(worker));
}
return pool;
};
const pageOperator = async () => {
const operation = document.createElement("div");
operation.id = "operation";
operation.append(await elPool());
return operation;
};

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
aiohttp==3.8.4
fastapi==0.92.0
uvicorn[standard]==0.20.0