Compare commits
56 Commits
60165d658c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2242b4557 | ||
|
|
f2d6f8723a | ||
|
|
f8305945a4 | ||
|
|
fd9d113eef | ||
|
|
4e24bb4fb4 | ||
|
|
77a2157f7b | ||
|
|
747d23af54 | ||
|
|
55b0e1806b | ||
|
|
04d34fdf32 | ||
|
|
d3e66f45b2 | ||
|
|
e7d308ab69 | ||
|
|
e54ba05c19 | ||
|
|
22ed9c0aa5 | ||
|
|
e77b08fd0b | ||
|
|
13eac22741 | ||
|
|
342f32f499 | ||
|
|
a206e82874 | ||
|
|
ea60fc2286 | ||
|
|
2f68866398 | ||
|
|
c060ddb060 | ||
|
|
ec0cb0ce45 | ||
|
|
c81fd68ea2 | ||
|
|
ac7569833a | ||
|
|
e3213d9611 | ||
|
|
443da0523c | ||
|
|
30ecf63e29 | ||
|
|
ba00890f79 | ||
|
|
fda75aa822 | ||
|
|
7d99eecc61 | ||
|
|
6dfaaa76d4 | ||
|
|
6237d3f58a | ||
|
|
333a8dabc9 | ||
|
|
1a0fd25031 | ||
|
|
1f455f47ed | ||
|
|
522bd9e70e | ||
|
|
0c75e9ebbc | ||
|
|
7d01d24196 | ||
|
|
005588794e | ||
|
|
aea9a27deb | ||
|
|
5e2e3cd858 | ||
|
|
1566bc4093 | ||
|
|
1ab752d170 | ||
|
|
88d00f97a5 | ||
|
|
2693ad49b8 | ||
|
|
07e6519679 | ||
|
|
d5983b6bf3 | ||
|
|
159a20f043 | ||
|
|
b59eafa9ca | ||
|
|
d913674426 | ||
|
|
bec626678e | ||
|
|
bc8a649ff4 | ||
|
|
5018012dc7 | ||
|
|
5f39349496 | ||
|
|
e3d5f953b1 | ||
|
|
2051cda1b4 | ||
|
|
00cf6ab674 |
7
examples/misc/script.py
Normal file
7
examples/misc/script.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from packetserver.server import Server
|
||||||
|
from packetserver.common import PacketServerConnection
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
s = Server('localhost', 8000, 'KQ4PEC')
|
||||||
|
s.start()
|
||||||
|
cm = s.app._engine._active_handler._handlers[1]._connection_map
|
||||||
51
examples/misc/test.py
Normal file
51
examples/misc/test.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from packetserver.common import DummyPacketServerConnection, Request, Response, Message
|
||||||
|
from packetserver.server import TestServer
|
||||||
|
from packetserver.server.objects import Object
|
||||||
|
from packetserver.server.messages import Message as Mail
|
||||||
|
from packetserver.server.messages import Attachment
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
server_callsign = "KQ4PEC"
|
||||||
|
client_callsign = 'KQ4PEC-7'
|
||||||
|
#client_callsign = "TEST1"
|
||||||
|
|
||||||
|
ts = TestServer(server_callsign, zeo=True)
|
||||||
|
ts.start()
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
print("creating connection")
|
||||||
|
|
||||||
|
conn = DummyPacketServerConnection(client_callsign, server_callsign, incoming=True)
|
||||||
|
print(conn.remote_callsign)
|
||||||
|
print(conn.call_to)
|
||||||
|
print(conn.call_from)
|
||||||
|
conn.connected()
|
||||||
|
|
||||||
|
req = Request.blank()
|
||||||
|
|
||||||
|
req.set_var('fetch_attachments', 1)
|
||||||
|
req.path = "message"
|
||||||
|
|
||||||
|
#req.method=Request.Method.POST
|
||||||
|
#attach = [Attachment("test.txt", "Hello sir, I hope that this message finds you well. The other day..")]
|
||||||
|
#req.payload = Mail("Hi there from a test user!", "KQ4PEC", attachments=attach).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
#req.payload = Object(name="test.txt", data="hello there").to_dict()
|
||||||
|
|
||||||
|
print("sending request")
|
||||||
|
conn.data_received(0, bytearray(req.pack()))
|
||||||
|
#ts.send_test_data(conn, bytearray(req.pack()))
|
||||||
|
print("Waiting on response.")
|
||||||
|
time.sleep(.5)
|
||||||
|
ts.stop()
|
||||||
|
msg = conn.sent_data.unpack()
|
||||||
|
#print(f"msg: {msg}")
|
||||||
|
response = Response(Message.partial_unpack(msg))
|
||||||
|
#print(type(response.payload))
|
||||||
|
#print(f"Response: {response}: {response.payload}")
|
||||||
|
print(json.dumps(response.payload, indent=4))
|
||||||
51
examples/misc/testdb.py
Normal file
51
examples/misc/testdb.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from packetserver.common import DummyPacketServerConnection, Request, Response, Message
|
||||||
|
from packetserver.server import TestServer
|
||||||
|
from packetserver.server.objects import Object
|
||||||
|
from packetserver.server.messages import Message as Mail
|
||||||
|
from packetserver.server.messages import Attachment
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
server_callsign = "KQ4PEC"
|
||||||
|
client_callsign = 'KQ4PEC-7'
|
||||||
|
#client_callsign = "TEST1"
|
||||||
|
|
||||||
|
ts = TestServer(server_callsign, zeo=True)
|
||||||
|
ts.start()
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
print("creating connection")
|
||||||
|
|
||||||
|
conn = DummyPacketServerConnection(client_callsign, server_callsign, incoming=True)
|
||||||
|
print(conn.remote_callsign)
|
||||||
|
print(conn.call_to)
|
||||||
|
print(conn.call_from)
|
||||||
|
conn.connected()
|
||||||
|
|
||||||
|
req = Request.blank()
|
||||||
|
|
||||||
|
req.set_var('fetch_attachments', 1)
|
||||||
|
req.path = "message"
|
||||||
|
|
||||||
|
#req.method=Request.Method.POST
|
||||||
|
#attach = [Attachment("test.txt", "Hello sir, I hope that this message finds you well. The other day..")]
|
||||||
|
#req.payload = Mail("Hi there from a test user!", "KQ4PEC", attachments=attach).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
#req.payload = Object(name="test.txt", data="hello there").to_dict()
|
||||||
|
|
||||||
|
print("sending request")
|
||||||
|
conn.data_received(0, bytearray(req.pack()))
|
||||||
|
#ts.send_test_data(conn, bytearray(req.pack()))
|
||||||
|
print("Waiting on response.")
|
||||||
|
time.sleep(.5)
|
||||||
|
ts.stop()
|
||||||
|
msg = conn.sent_data.unpack()
|
||||||
|
#print(f"msg: {msg}")
|
||||||
|
response = Response(Message.partial_unpack(msg))
|
||||||
|
#print(type(response.payload))
|
||||||
|
#print(f"Response: {response}: {response.payload}")
|
||||||
|
print(json.dumps(response.payload, indent=4))
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# packetserver/http/auth.py
|
# packetserver/http/auth.py
|
||||||
import ax25
|
import ax25
|
||||||
|
import transaction
|
||||||
from persistent import Persistent
|
from persistent import Persistent
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
@@ -7,7 +8,9 @@ import time
|
|||||||
from persistent.mapping import PersistentMapping
|
from persistent.mapping import PersistentMapping
|
||||||
from persistent.list import PersistentList
|
from persistent.list import PersistentList
|
||||||
from packetserver.common.util import is_valid_ax25_callsign
|
from packetserver.common.util import is_valid_ax25_callsign
|
||||||
from .database import get_db, get_transaction
|
from .database import DbDependency
|
||||||
|
from typing import Union
|
||||||
|
from ZODB.Connection import Connection
|
||||||
|
|
||||||
ph = PasswordHasher()
|
ph = PasswordHasher()
|
||||||
|
|
||||||
@@ -51,26 +54,29 @@ class HttpUser(Persistent):
|
|||||||
# rf enabled checks..
|
# rf enabled checks..
|
||||||
#
|
#
|
||||||
|
|
||||||
def is_rf_enabled(self) -> bool:
|
def is_rf_enabled(self, db: Union[DbDependency,Connection]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
|
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
|
||||||
Requires an open ZODB connection.
|
Requires an open ZODB connection.
|
||||||
"""
|
"""
|
||||||
with get_transaction() as storage:
|
if type(db) is Connection:
|
||||||
root = storage.root()
|
root = db.root()
|
||||||
|
blacklist = root.get('config', {}).get('blacklist', [])
|
||||||
|
return self.username not in blacklist
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
blacklist = root.get('config', {}).get('blacklist', [])
|
blacklist = root.get('config', {}).get('blacklist', [])
|
||||||
return self.username not in blacklist
|
return self.username not in blacklist
|
||||||
|
|
||||||
def set_rf_enabled(self, connection, allow: bool):
|
def set_rf_enabled(self, db: DbDependency, allow: bool):
|
||||||
"""
|
"""
|
||||||
Enable/disable RF gateway by adding/removing from global blacklist.
|
Enable/disable RF gateway by adding/removing from global blacklist.
|
||||||
Requires an open ZODB connection (inside a transaction).
|
Requires an open ZODB connection (inside a transaction).
|
||||||
Only allows enabling if the username is a valid AX.25 callsign.
|
Only allows enabling if the username is a valid AX.25 callsign.
|
||||||
"""
|
"""
|
||||||
from packetserver.common.util import is_valid_ax25_callsign # our validator
|
from packetserver.common.util import is_valid_ax25_callsign # our validator
|
||||||
|
with db.transaction() as conn:
|
||||||
with get_transaction() as storage:
|
root = conn.root()
|
||||||
root = storage.root()
|
|
||||||
config = root.setdefault('config', PersistentMapping())
|
config = root.setdefault('config', PersistentMapping())
|
||||||
blacklist = config.setdefault('blacklist', PersistentList())
|
blacklist = config.setdefault('blacklist', PersistentList())
|
||||||
|
|
||||||
@@ -89,6 +95,7 @@ class HttpUser(Persistent):
|
|||||||
|
|
||||||
config._p_changed = True
|
config._p_changed = True
|
||||||
root._p_changed = True
|
root._p_changed = True
|
||||||
|
transaction.commit()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Password handling (unchanged)
|
# Password handling (unchanged)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class Settings(BaseSettings):
|
|||||||
"""
|
"""
|
||||||
# Define your settings fields with type hints and optional default values
|
# Define your settings fields with type hints and optional default values
|
||||||
name: str = "PacketServer"
|
name: str = "PacketServer"
|
||||||
zeo_file: str
|
zeo_file: str = ""
|
||||||
operator: str | None = None
|
operator: str | None = None
|
||||||
debug_mode: bool = False
|
debug_mode: bool = False
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from typing import Annotated, Generator
|
from typing import Annotated, Generator
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
|
|
||||||
import ZEO
|
import ZEO
|
||||||
import ZODB
|
import ZODB
|
||||||
|
import json
|
||||||
from ZODB.Connection import Connection
|
from ZODB.Connection import Connection
|
||||||
import transaction
|
import transaction
|
||||||
|
import logging
|
||||||
|
|
||||||
from .config import Settings # assuming Settings has zeo_file: str
|
from .config import Settings # assuming Settings has zeo_file: str
|
||||||
|
from ..common.util import convert_from_persistent
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
@@ -37,8 +42,7 @@ def init_db() -> ZODB.DB:
|
|||||||
return _db
|
return _db
|
||||||
|
|
||||||
host, port = _get_zeo_address(settings.zeo_file)
|
host, port = _get_zeo_address(settings.zeo_file)
|
||||||
storage = ZEO.ClientStorage((host, port))
|
_db = ZEO.DB((host, port))
|
||||||
_db = ZODB.DB(storage)
|
|
||||||
return _db
|
return _db
|
||||||
|
|
||||||
def get_db() -> ZODB.DB:
|
def get_db() -> ZODB.DB:
|
||||||
@@ -47,14 +51,16 @@ def get_db() -> ZODB.DB:
|
|||||||
raise RuntimeError("Database not initialized – call init_db() on startup")
|
raise RuntimeError("Database not initialized – call init_db() on startup")
|
||||||
return _db
|
return _db
|
||||||
|
|
||||||
def get_connection() -> Generator[Connection, None, None]:
|
#def get_connection() -> Generator[Connection, None, None]:
|
||||||
"""Per-request dependency: yields an open Connection, closes on exit."""
|
# """Per-request dependency: yields an open Connection, closes on exit."""
|
||||||
db = get_db()
|
# db = get_db()
|
||||||
conn = db.open()
|
# conn = db.open()
|
||||||
try:
|
# try:
|
||||||
yield conn
|
# yield conn
|
||||||
finally:
|
# finally:
|
||||||
conn.close()
|
# #print("not closing connection")
|
||||||
|
# #conn.close()
|
||||||
|
# pass
|
||||||
|
|
||||||
# Optional: per-request transaction (if you want automatic commit/abort)
|
# Optional: per-request transaction (if you want automatic commit/abort)
|
||||||
def get_transaction_manager():
|
def get_transaction_manager():
|
||||||
@@ -62,4 +68,12 @@ def get_transaction_manager():
|
|||||||
|
|
||||||
# Annotated dependencies for routers
|
# Annotated dependencies for routers
|
||||||
DbDependency = Annotated[ZODB.DB, Depends(get_db)]
|
DbDependency = Annotated[ZODB.DB, Depends(get_db)]
|
||||||
ConnectionDependency = Annotated[Connection, Depends(get_connection)]
|
#ConnectionDependency = Annotated[Connection, Depends(get_connection)]
|
||||||
|
|
||||||
|
def get_server_config_from_db(db: DbDependency) -> dict:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
db_config = convert_from_persistent(conn.root.config)
|
||||||
|
if type(db_config) is not dict:
|
||||||
|
raise RuntimeError("The config property is not a dict.")
|
||||||
|
db_config['server_callsign'] = conn.root.server_callsign
|
||||||
|
return db_config
|
||||||
@@ -3,18 +3,17 @@ from fastapi import Depends, HTTPException, status
|
|||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
|
||||||
from .auth import HttpUser
|
from .auth import HttpUser
|
||||||
from .database import get_transaction
|
from .database import DbDependency
|
||||||
|
|
||||||
security = HTTPBasic()
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
|
||||||
async def get_current_http_user(credentials: HTTPBasicCredentials = Depends(security)):
|
async def get_current_http_user(db: DbDependency, credentials: HTTPBasicCredentials = Depends(security)):
|
||||||
"""
|
"""
|
||||||
Authenticate via Basic Auth using HttpUser from ZODB.
|
Authenticate via Basic Auth using HttpUser from ZODB.
|
||||||
Injected by the standalone runner (get_db_connection available).
|
Injected by the standalone runner (get_db_connection available).
|
||||||
"""
|
"""
|
||||||
|
with db.transaction() as conn:
|
||||||
with get_transaction() as conn:
|
|
||||||
root = conn.root()
|
root = conn.root()
|
||||||
|
|
||||||
http_users = root.get("httpUsers")
|
http_users = root.get("httpUsers")
|
||||||
|
|||||||
13
packetserver/http/logging.py
Normal file
13
packetserver/http/logging.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from .config import Settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
def init_logging():
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
desired_level = settings.log_level.upper().strip()
|
||||||
|
|
||||||
|
if desired_level not in logging.getLevelNamesMapping():
|
||||||
|
raise ValueError(f"Invalid log level '{desired_level}'")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.getLevelName(desired_level))
|
||||||
@@ -6,8 +6,9 @@ from datetime import datetime
|
|||||||
import transaction
|
import transaction
|
||||||
from persistent.list import PersistentList
|
from persistent.list import PersistentList
|
||||||
from ZODB.Connection import Connection
|
from ZODB.Connection import Connection
|
||||||
|
import logging
|
||||||
|
|
||||||
from packetserver.http.database import DbDependency, ConnectionDependency, get_db
|
from packetserver.http.database import DbDependency
|
||||||
from ..dependencies import get_current_http_user
|
from ..dependencies import get_current_http_user
|
||||||
from ..auth import HttpUser
|
from ..auth import HttpUser
|
||||||
from ..server import templates
|
from ..server import templates
|
||||||
@@ -129,16 +130,21 @@ async def bulletin_list_page(
|
|||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"bulletin_list.html",
|
"bulletin_list.html",
|
||||||
{"request": request, "bulletins": bulletins, "current_user": current_user.username}
|
{
|
||||||
|
"request": request,
|
||||||
|
"bulletins": bulletins,
|
||||||
|
"current_user": current_user.username
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@html_router.get("/bulletins/new", response_class=HTMLResponse)
|
@html_router.get("/bulletins/new", response_class=HTMLResponse)
|
||||||
async def bulletin_new_form(
|
async def bulletin_new_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user) # require login
|
||||||
):
|
):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"bulletin_new.html",
|
"bulletin_new.html",
|
||||||
{"request": request, "error": None}
|
{"request": request, "error": None, "current_user": current_user.username}
|
||||||
)
|
)
|
||||||
|
|
||||||
@html_router.post("/bulletins/new")
|
@html_router.post("/bulletins/new")
|
||||||
@@ -185,3 +191,42 @@ async def bulletin_detail_page(
|
|||||||
"bulletin_detail.html",
|
"bulletin_detail.html",
|
||||||
{"request": request, "bulletin": bulletin, "current_user": current_user.username}
|
{"request": request, "bulletin": bulletin, "current_user": current_user.username}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.delete("/bulletins/{bid}", status_code=204)
|
||||||
|
async def delete_bulletin(
|
||||||
|
bid: int,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
bulletins_list: PersistentList = root.get("bulletins", PersistentList())
|
||||||
|
|
||||||
|
# Find the bulletin
|
||||||
|
bulletin_to_delete = None
|
||||||
|
for b in bulletins_list:
|
||||||
|
if b.id == bid:
|
||||||
|
bulletin_to_delete = b
|
||||||
|
break
|
||||||
|
|
||||||
|
if not bulletin_to_delete:
|
||||||
|
raise HTTPException(status_code=404, detail="Bulletin not found")
|
||||||
|
|
||||||
|
if bulletin_to_delete.author != username:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to delete this bulletin")
|
||||||
|
|
||||||
|
# Remove it
|
||||||
|
bulletins_list.remove(bulletin_to_delete)
|
||||||
|
|
||||||
|
logging.info(f"User {username} deleted bulletin {bid}")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Bulletin delete failed for {username} on {bid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete bulletin")
|
||||||
|
|
||||||
|
return None # 204 No Content
|
||||||
@@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from packetserver.http.dependencies import get_current_http_user
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
from packetserver.http.auth import HttpUser
|
from packetserver.http.auth import HttpUser
|
||||||
from packetserver.http.server import templates
|
from packetserver.http.server import templates
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
|
||||||
router = APIRouter(tags=["dashboard"])
|
router = APIRouter(tags=["dashboard"])
|
||||||
|
|
||||||
@@ -15,38 +16,43 @@ from .bulletins import list_bulletins
|
|||||||
|
|
||||||
@router.get("/dashboard", response_class=HTMLResponse)
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
async def dashboard(
|
async def dashboard(
|
||||||
|
db: DbDependency,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
):
|
):
|
||||||
# Internal call – pass explicit defaults to avoid Query object injection
|
|
||||||
messages_resp = await api_get_messages(
|
messages_resp = await api_get_messages(
|
||||||
|
db,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
type="all",
|
type="all",
|
||||||
limit=100,
|
limit=100,
|
||||||
since=None # prevents Query wrapper
|
since=None # prevents Query wrapper
|
||||||
)
|
)
|
||||||
messages = messages_resp["messages"]
|
with db.transaction() as conn:
|
||||||
|
# Internal call – pass explicit defaults to avoid Query object injection
|
||||||
|
|
||||||
bulletins_resp = await list_bulletins(limit=10, since=None)
|
messages = messages_resp["messages"]
|
||||||
recent_bulletins = bulletins_resp["bulletins"]
|
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
bulletins_resp = await list_bulletins(conn, limit=10, since=None)
|
||||||
"dashboard.html",
|
recent_bulletins = bulletins_resp["bulletins"]
|
||||||
{
|
|
||||||
"request": request,
|
return templates.TemplateResponse(
|
||||||
"current_user": current_user.username,
|
"dashboard.html",
|
||||||
"messages": messages,
|
{
|
||||||
"bulletins": recent_bulletins
|
"request": request,
|
||||||
}
|
"current_user": current_user.username,
|
||||||
)
|
"messages": messages,
|
||||||
|
"bulletins": recent_bulletins
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/dashboard/profile", response_class=HTMLResponse)
|
@router.get("/dashboard/profile", response_class=HTMLResponse)
|
||||||
async def profile_page(
|
async def profile_page(
|
||||||
|
db: DbDependency,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
):
|
):
|
||||||
from packetserver.http.routers.profile import profile as api_profile
|
from packetserver.http.routers.profile import profile as api_profile
|
||||||
profile_data = await api_profile(current_user=current_user)
|
profile_data = await api_profile(db, current_user=current_user)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"profile.html",
|
"profile.html",
|
||||||
|
|||||||
362
packetserver/http/routers/jobs.py
Normal file
362
packetserver/http/routers/jobs.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Form, UploadFile, File, status
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from typing import List, Optional, Union, Tuple, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import gzip
|
||||||
|
import shlex
|
||||||
|
from traceback import format_exc
|
||||||
|
|
||||||
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
|
from packetserver.http.auth import HttpUser
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
from packetserver.server.jobs import Job, JobStatus
|
||||||
|
from packetserver.http.server import templates
|
||||||
|
from packetserver.server.db import get_user_db_json
|
||||||
|
from packetserver.server.jobs import RunnerFile, add_object_to_file_list
|
||||||
|
from packetserver.server.objects import Object
|
||||||
|
from packetserver.server.users import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1", tags=["jobs"])
|
||||||
|
dashboard_router = APIRouter(tags=["jobs"])
|
||||||
|
|
||||||
|
def tokenize_cmd(cmd: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Tokenize a command string with basic shell-like quoting support.
|
||||||
|
Uses shlex with posix=True for proper "double" and 'single' quote handling.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return shlex.split(cmd)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid command quoting: {e}")
|
||||||
|
|
||||||
|
class JobSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
cmd: Union[str, List[str]]
|
||||||
|
owner: str
|
||||||
|
created_at: datetime
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
status: str # JobStatus.name
|
||||||
|
return_code: int
|
||||||
|
env: Dict[str, str] = {}
|
||||||
|
|
||||||
|
class JobDetail(JobSummary):
|
||||||
|
output: str # base64-encoded
|
||||||
|
errors: str # base64-encoded
|
||||||
|
artifacts: List[Tuple[str, str]] # list of (filename, base64_data)
|
||||||
|
env: Dict[str, str] = {}
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
class JobCreate(BaseModel):
|
||||||
|
cmd: Union[str, List[str]]
|
||||||
|
env: Optional[Dict[str, str]] = None
|
||||||
|
files: Optional[Dict[str, str]] = None
|
||||||
|
objs: Optional[List[str]] = None
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=List[JobSummary])
|
||||||
|
async def list_user_jobs(
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username.upper().strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
user_jobs = Job.get_jobs_by_username(username, root)
|
||||||
|
|
||||||
|
# Sort newest first
|
||||||
|
user_jobs.sort(key=lambda j: j.created_at, reverse=True)
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for j in user_jobs:
|
||||||
|
summaries.append(JobSummary(
|
||||||
|
id=j.id,
|
||||||
|
cmd=j.cmd,
|
||||||
|
owner=j.owner,
|
||||||
|
created_at=j.created_at,
|
||||||
|
started_at=j.started_at,
|
||||||
|
finished_at=j.finished_at,
|
||||||
|
status=j.status.name,
|
||||||
|
return_code=j.return_code,
|
||||||
|
env=j.env
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Job list failed for {username}: {e}\n{format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list jobs")
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
@router.get("/jobs/{jid}", response_model=JobDetail)
|
||||||
|
async def get_job_detail(
|
||||||
|
jid: int,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username.upper().strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
job = Job.get_job_by_id(jid, root)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
if job.owner != username:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to view this job")
|
||||||
|
|
||||||
|
job_dict = job.to_dict(include_data=True, binary_safe=True)
|
||||||
|
|
||||||
|
return JobDetail(
|
||||||
|
id=job.id,
|
||||||
|
cmd=job.cmd,
|
||||||
|
owner=job.owner,
|
||||||
|
created_at=job.created_at,
|
||||||
|
started_at=job.started_at,
|
||||||
|
finished_at=job.finished_at,
|
||||||
|
status=job.status.name,
|
||||||
|
return_code=job.return_code,
|
||||||
|
output=job_dict["output"],
|
||||||
|
errors=job_dict["errors"],
|
||||||
|
artifacts=job_dict["artifacts"],
|
||||||
|
env=job_dict.get("env", {})
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Job detail failed for {username} on {jid}: {e}\n{format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve job")
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_job(
|
||||||
|
job_id: int,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user),
|
||||||
|
):
|
||||||
|
username = current_user.username.upper().strip()
|
||||||
|
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root
|
||||||
|
|
||||||
|
# 1. Check if job exists
|
||||||
|
if job_id not in root.jobs:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
job = root.jobs[job_id]
|
||||||
|
|
||||||
|
# 2. Ownership check
|
||||||
|
if job.owner.upper() != username:
|
||||||
|
raise HTTPException(status_code=403, detail="You do not own this job")
|
||||||
|
|
||||||
|
# 3. Remove from user's job list
|
||||||
|
if username in root.user_jobs:
|
||||||
|
user_job_list = root.user_jobs[username]
|
||||||
|
if job_id in user_job_list:
|
||||||
|
user_job_list.remove(job_id)
|
||||||
|
|
||||||
|
# 4. Delete the job itself
|
||||||
|
del root.jobs[job_id]
|
||||||
|
|
||||||
|
# No content to return on successful delete
|
||||||
|
return None
|
||||||
|
|
||||||
|
@dashboard_router.get("/jobs", response_class=HTMLResponse)
|
||||||
|
async def jobs_list_page(
|
||||||
|
request: Request,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
jobs = await list_user_jobs(db=db, current_user=current_user)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"jobs.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user.username,
|
||||||
|
"jobs": jobs
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@dashboard_router.get("/jobs/new", response_class=HTMLResponse)
|
||||||
|
async def new_job_form(
|
||||||
|
request: Request,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"job_new.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user.username
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@dashboard_router.post("/jobs/new")
|
||||||
|
async def create_job_from_form(
|
||||||
|
db: DbDependency,
|
||||||
|
request: Request,
|
||||||
|
cmd: str = Form(...),
|
||||||
|
env_keys: List[str] = Form(default=[]),
|
||||||
|
env_values: List[str] = Form(default=[]),
|
||||||
|
files: List[UploadFile] = File(default=[]),
|
||||||
|
include_db: Optional[str] = Form(None),
|
||||||
|
shell_mode: Optional[str] = Form(None),
|
||||||
|
objs: Optional[str] = Form(None),
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
logging.debug("new job form post received")
|
||||||
|
# Build env dict from parallel lists
|
||||||
|
env = {}
|
||||||
|
for k, v in zip(env_keys, env_values):
|
||||||
|
if k.strip():
|
||||||
|
env[k.strip()] = v.strip()
|
||||||
|
|
||||||
|
# Build files dict for API (filename → base64)
|
||||||
|
files_dict = {}
|
||||||
|
for upload in files:
|
||||||
|
if upload.filename:
|
||||||
|
content = await upload.read()
|
||||||
|
files_dict[upload.filename] = base64.b64encode(content).decode('ascii')
|
||||||
|
|
||||||
|
if shell_mode == "on":
|
||||||
|
# Pass entire raw cmd string to bash -c
|
||||||
|
cmd_args = ["bash", "-c", cmd.strip()]
|
||||||
|
else:
|
||||||
|
# Use shlex to respect quotes
|
||||||
|
cmd_args = tokenize_cmd(cmd.strip())
|
||||||
|
if not cmd_args:
|
||||||
|
raise HTTPException(status_code=400, detail="Command cannot be empty")
|
||||||
|
|
||||||
|
if include_db == "on":
|
||||||
|
try:
|
||||||
|
username_lower = current_user.username.lower()
|
||||||
|
# get_user_db_json needs the raw ZODB.DB instance
|
||||||
|
user_db_bytes = get_user_db_json(username_lower,db)
|
||||||
|
# Base64-encode for payload consistency
|
||||||
|
b64_db = base64.b64encode(user_db_bytes).decode('utf-8')
|
||||||
|
logging.debug(f"DB base64: f{b64_db}")
|
||||||
|
files_dict["user-db.json.gz"] = b64_db
|
||||||
|
logging.debug(f"Injected user-db.json.gz for {current_user.username}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Failed to generate user-db.json.gz: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to include user database")
|
||||||
|
|
||||||
|
objs_list = None
|
||||||
|
if objs:
|
||||||
|
objs_list = [uuid_str.strip() for uuid_str in objs.split(",") if uuid_str.strip()]
|
||||||
|
|
||||||
|
# Prepare payload for the existing API
|
||||||
|
payload = {
|
||||||
|
"cmd": cmd_args,
|
||||||
|
"env": env if env else None,
|
||||||
|
"files": files_dict if files_dict else None,
|
||||||
|
"objs": objs_list,
|
||||||
|
}
|
||||||
|
logging.debug("Calling internal API to create the job")
|
||||||
|
# Call the API internally
|
||||||
|
response = await create_job(
|
||||||
|
payload=JobCreate(**{k: v for k, v in payload.items() if v is not None}),
|
||||||
|
db=db,
|
||||||
|
current_user=current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to the new job detail page
|
||||||
|
logging.debug("Job queued")
|
||||||
|
return RedirectResponse(url=f"/jobs/{response.id}", status_code=303)
|
||||||
|
|
||||||
|
@dashboard_router.get("/jobs/{jid}", response_class=HTMLResponse)
|
||||||
|
async def job_detail_page(
|
||||||
|
request: Request,
|
||||||
|
jid: int,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
job = await get_job_detail(jid=jid, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"job_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user.username,
|
||||||
|
"job": job
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/jobs", response_model=JobSummary, status_code=201)
|
||||||
|
async def create_job(
|
||||||
|
payload: JobCreate,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username.upper().strip()
|
||||||
|
logging.debug(f"New job create function called for '{username}'")
|
||||||
|
try:
|
||||||
|
# Process files: convert base64 dict to list of RunnerFile
|
||||||
|
runner_files = []
|
||||||
|
if payload.files:
|
||||||
|
for filename, b64_data in payload.files.items():
|
||||||
|
try:
|
||||||
|
data_bytes = base64.b64decode(b64_data)
|
||||||
|
runner_files.append(RunnerFile(filename, data=data_bytes))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid base64 for file {filename}")
|
||||||
|
# Handle attached objects by UUID
|
||||||
|
logging.debug("handling objects")
|
||||||
|
if payload.objs:
|
||||||
|
logging.debug("Adding objects to files list")
|
||||||
|
for obj_uuid_str in payload.objs:
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
add_object_to_file_list(obj_uuid_str, runner_files, username, conn)
|
||||||
|
except KeyError as ke:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Object not found: {str(ke)}")
|
||||||
|
except Exception as exc: # Catches permission issues or invalid UUID
|
||||||
|
raise HTTPException(status_code=403 if "private" in str(exc).lower() else 400,
|
||||||
|
detail=f"Cannot attach object {obj_uuid_str}: {str(exc)}")
|
||||||
|
logging.debug("Creating job instance now")
|
||||||
|
# Create the Job instance
|
||||||
|
if type(payload.cmd) is str:
|
||||||
|
payload.cmd = payload.cmd.replace('\r', '')
|
||||||
|
else:
|
||||||
|
for i in range(0,len(payload.cmd)):
|
||||||
|
payload.cmd[i] = payload.cmd[i].replace('\r', '')
|
||||||
|
new_job = Job(
|
||||||
|
cmd=payload.cmd,
|
||||||
|
owner=username,
|
||||||
|
env=payload.env or {},
|
||||||
|
files=runner_files
|
||||||
|
)
|
||||||
|
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
new_jid = new_job.queue(root)
|
||||||
|
|
||||||
|
logging.info(f"User {username} queued job {new_jid}: {payload.cmd} with {len(runner_files)} files")
|
||||||
|
logging.debug("New job created.")
|
||||||
|
return JobSummary(
|
||||||
|
id=new_jid,
|
||||||
|
cmd=new_job.cmd,
|
||||||
|
owner=new_job.owner,
|
||||||
|
created_at=new_job.created_at,
|
||||||
|
started_at=new_job.started_at,
|
||||||
|
finished_at=new_job.finished_at,
|
||||||
|
status=new_job.status.name,
|
||||||
|
return_code=new_job.return_code
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as ve:
|
||||||
|
raise HTTPException(status_code=400, detail=str(ve))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Job creation failed for {username}: {e}\n{format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to queue job")
|
||||||
|
|
||||||
@@ -4,11 +4,13 @@ from fastapi.responses import HTMLResponse
|
|||||||
from packetserver.http.dependencies import get_current_http_user
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
from packetserver.http.auth import HttpUser
|
from packetserver.http.auth import HttpUser
|
||||||
from packetserver.http.server import templates
|
from packetserver.http.server import templates
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
|
||||||
router = APIRouter(tags=["message-detail"])
|
router = APIRouter(tags=["message-detail"])
|
||||||
|
|
||||||
@router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse)
|
@router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse)
|
||||||
async def message_detail_page(
|
async def message_detail_page(
|
||||||
|
db: DbDependency,
|
||||||
request: Request,
|
request: Request,
|
||||||
msg_id: str = Path(..., description="Message UUID as string"),
|
msg_id: str = Path(..., description="Message UUID as string"),
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
@@ -18,6 +20,7 @@ async def message_detail_page(
|
|||||||
|
|
||||||
# Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual)
|
# Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual)
|
||||||
message_data = await api_get_message(
|
message_data = await api_get_message(
|
||||||
|
db,
|
||||||
msg_id=msg_id,
|
msg_id=msg_id,
|
||||||
mark_retrieved=True,
|
mark_retrieved=True,
|
||||||
current_user=current_user
|
current_user=current_user
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field, validator
|
|||||||
|
|
||||||
from packetserver.http.dependencies import get_current_http_user
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
from packetserver.http.auth import HttpUser
|
from packetserver.http.auth import HttpUser
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
|
||||||
|
|
||||||
html_router = APIRouter(tags=["messages-html"])
|
html_router = APIRouter(tags=["messages-html"])
|
||||||
@@ -28,157 +29,212 @@ class MarkRetrievedRequest(BaseModel):
|
|||||||
|
|
||||||
@router.get("/messages")
|
@router.get("/messages")
|
||||||
async def get_messages(
|
async def get_messages(
|
||||||
|
db: DbDependency,
|
||||||
current_user: HttpUser = Depends(get_current_http_user),
|
current_user: HttpUser = Depends(get_current_http_user),
|
||||||
type: str = Query("received", description="received, sent, or all"),
|
type: str = Query("received", description="received, sent, or all"),
|
||||||
limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"),
|
limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"),
|
||||||
since: Optional[str] = Query(None, description="ISO UTC timestamp filter (e.g. 2025-12-01T00:00:00Z)")
|
since: Optional[str] = Query(None, description="ISO UTC timestamp filter (e.g. 2025-12-01T00:00:00Z)"),
|
||||||
|
|
||||||
):
|
):
|
||||||
if limit is None or limit < 1:
|
if limit is None or limit < 1:
|
||||||
limit = 20
|
limit = 20
|
||||||
|
|
||||||
username = current_user.username
|
username = current_user.username
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
|
||||||
from packetserver.runners.http_server import get_db_connection
|
if 'messages' not in root:
|
||||||
conn = get_db_connection()
|
root['messages'] = PersistentMapping()
|
||||||
root = conn.root()
|
if username not in root['messages']:
|
||||||
|
root['messages'][username] = persistent.list.PersistentList()
|
||||||
|
|
||||||
if 'messages' not in root:
|
mailbox = root['messages'][username]
|
||||||
root['messages'] = PersistentMapping()
|
|
||||||
if username not in root['messages']:
|
|
||||||
root['messages'][username] = persistent.list.PersistentList()
|
|
||||||
|
|
||||||
mailbox = root['messages'][username]
|
since_dt = None
|
||||||
|
if since:
|
||||||
|
try:
|
||||||
|
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid 'since' format")
|
||||||
|
|
||||||
since_dt = None
|
messages = []
|
||||||
if since:
|
for msg in mailbox:
|
||||||
try:
|
if type == "received" and msg.msg_from == username:
|
||||||
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
continue
|
||||||
except ValueError:
|
if type == "sent" and msg.msg_from != username:
|
||||||
raise HTTPException(status_code=400, detail="Invalid 'since' format")
|
continue
|
||||||
|
if since_dt and msg.sent_at < since_dt:
|
||||||
|
continue
|
||||||
|
|
||||||
messages = []
|
messages.append({
|
||||||
for msg in mailbox:
|
"id": str(msg.msg_id),
|
||||||
if type == "received" and msg.msg_from == username:
|
"from": msg.msg_from,
|
||||||
continue
|
"to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to],
|
||||||
if type == "sent" and msg.msg_from != username:
|
"sent_at": msg.sent_at.isoformat() + "Z",
|
||||||
continue
|
"text": msg.text,
|
||||||
if since_dt and msg.sent_at < since_dt:
|
"has_attachments": len(msg.attachments) > 0,
|
||||||
continue
|
"retrieved": msg.retrieved,
|
||||||
|
})
|
||||||
|
|
||||||
messages.append({
|
messages.sort(key=lambda m: m["sent_at"], reverse=True)
|
||||||
"id": str(msg.msg_id),
|
|
||||||
"from": msg.msg_from,
|
|
||||||
"to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to],
|
|
||||||
"sent_at": msg.sent_at.isoformat() + "Z",
|
|
||||||
"text": msg.text,
|
|
||||||
"has_attachments": len(msg.attachments) > 0,
|
|
||||||
"retrieved": msg.retrieved,
|
|
||||||
})
|
|
||||||
|
|
||||||
messages.sort(key=lambda m: m["sent_at"], reverse=True)
|
return {"messages": messages[:limit], "total_returned": len(messages[:limit])}
|
||||||
|
|
||||||
return {"messages": messages[:limit], "total_returned": len(messages[:limit])}
|
|
||||||
|
|
||||||
@router.get("/messages/{msg_id}")
|
@router.get("/messages/{msg_id}")
|
||||||
async def get_message(
|
async def get_message(
|
||||||
|
db: DbDependency,
|
||||||
msg_id: str = Path(..., description="UUID of the message (as string)"),
|
msg_id: str = Path(..., description="UUID of the message (as string)"),
|
||||||
mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"),
|
mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"),
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
):
|
):
|
||||||
from packetserver.runners.http_server import get_db_connection
|
with db.transaction() as conn:
|
||||||
conn = get_db_connection()
|
root = conn.root()
|
||||||
root = conn.root()
|
|
||||||
|
|
||||||
username = current_user.username
|
username = current_user.username
|
||||||
|
|
||||||
messages_root = root.get('messages', {})
|
messages_root = root.get('messages', {})
|
||||||
mailbox = messages_root.get(username)
|
mailbox = messages_root.get(username)
|
||||||
if not mailbox:
|
if not mailbox:
|
||||||
raise HTTPException(status_code=404, detail="Mailbox not found")
|
raise HTTPException(status_code=404, detail="Mailbox not found")
|
||||||
|
|
||||||
# Find message by ID
|
# Find message by ID
|
||||||
target_msg = None
|
target_msg = None
|
||||||
for msg in mailbox:
|
for msg in mailbox:
|
||||||
if str(msg.msg_id) == msg_id:
|
if str(msg.msg_id) == msg_id:
|
||||||
target_msg = msg
|
target_msg = msg
|
||||||
break
|
break
|
||||||
|
|
||||||
if not target_msg:
|
if not target_msg:
|
||||||
raise HTTPException(status_code=404, detail="Message not found")
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
|
||||||
# Optionally mark as retrieved
|
# Optionally mark as retrieved
|
||||||
if mark_retrieved and not target_msg.retrieved:
|
if mark_retrieved and not target_msg.retrieved:
|
||||||
target_msg.retrieved = True
|
target_msg.retrieved = True
|
||||||
target_msg._p_changed = True
|
target_msg._p_changed = True
|
||||||
mailbox._p_changed = True
|
mailbox._p_changed = True
|
||||||
# Explicit transaction for the write
|
# Explicit transaction for the write
|
||||||
transaction.get().commit()
|
transaction.get().commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(target_msg.msg_id),
|
"id": str(target_msg.msg_id),
|
||||||
"from": target_msg.msg_from or "UNKNOWN",
|
"from": target_msg.msg_from or "UNKNOWN",
|
||||||
"to": list(target_msg.msg_to),
|
"to": list(target_msg.msg_to),
|
||||||
"sent_at": target_msg.sent_at.isoformat() + "Z",
|
"sent_at": target_msg.sent_at.isoformat() + "Z",
|
||||||
"text": target_msg.text,
|
"text": target_msg.text,
|
||||||
"retrieved": target_msg.retrieved,
|
"retrieved": target_msg.retrieved,
|
||||||
"has_attachments": len(target_msg.attachments) > 0,
|
"has_attachments": len(target_msg.attachments) > 0,
|
||||||
# Future: "attachments": [...] metadata
|
# Future: "attachments": [...] metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.patch("/messages/{msg_id}")
|
@router.patch("/messages/{msg_id}")
|
||||||
async def mark_message_retrieved(
|
async def mark_message_retrieved(
|
||||||
|
db: DbDependency,
|
||||||
msg_id: str = Path(..., description="Message UUID as string"),
|
msg_id: str = Path(..., description="Message UUID as string"),
|
||||||
payload: MarkRetrievedRequest = None,
|
payload: MarkRetrievedRequest = None,
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
):
|
):
|
||||||
from packetserver.runners.http_server import get_db_connection
|
with db.transaction() as conn:
|
||||||
conn = get_db_connection()
|
root = conn.root()
|
||||||
root = conn.root()
|
|
||||||
|
|
||||||
username = current_user.username
|
username = current_user.username
|
||||||
mailbox = root.get('messages', {}).get(username)
|
mailbox = root.get('messages', {}).get(username)
|
||||||
|
|
||||||
if not mailbox:
|
if not mailbox:
|
||||||
raise HTTPException(status_code=404, detail="Mailbox not found")
|
raise HTTPException(status_code=404, detail="Mailbox not found")
|
||||||
|
|
||||||
target_msg = None
|
target_msg = None
|
||||||
for msg in mailbox:
|
for msg in mailbox:
|
||||||
if str(msg.msg_id) == msg_id:
|
if str(msg.msg_id) == msg_id:
|
||||||
target_msg = msg
|
target_msg = msg
|
||||||
break
|
break
|
||||||
|
|
||||||
if not target_msg:
|
if not target_msg:
|
||||||
raise HTTPException(status_code=404, detail="Message not found")
|
raise HTTPException(status_code=404, detail="Message not found")
|
||||||
|
|
||||||
if target_msg.retrieved:
|
if target_msg.retrieved:
|
||||||
# Already marked – idempotent success
|
# Already marked – idempotent success
|
||||||
return {"status": "already_retrieved", "id": msg_id}
|
return {"status": "already_retrieved", "id": msg_id}
|
||||||
|
|
||||||
target_msg.retrieved = True
|
target_msg.retrieved = True
|
||||||
target_msg._p_changed = True
|
target_msg._p_changed = True
|
||||||
mailbox._p_changed = True
|
mailbox._p_changed = True
|
||||||
transaction.get().commit()
|
transaction.get().commit()
|
||||||
|
|
||||||
return {"status": "marked_retrieved", "id": msg_id}
|
return {"status": "marked_retrieved", "id": msg_id}
|
||||||
|
|
||||||
@html_router.get("/messages", response_class=HTMLResponse)
|
@html_router.get("/messages", response_class=HTMLResponse)
|
||||||
async def message_list_page(
|
async def message_list_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
type: str = Query("received", alias="msg_type"), # matches your filter links
|
db: DbDependency,
|
||||||
limit: Optional[int] = Query(50, le=100),
|
current_user: HttpUser = Depends(get_current_http_user),
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
msg_type: str = Query("received", alias="type"), # Change alias to "type" for cleaner URLs
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50, ge=1, le=200),
|
||||||
):
|
):
|
||||||
from packetserver.http.server import templates
|
from packetserver.http.server import templates # Local import – safe from circular
|
||||||
# Directly call the existing API endpoint function
|
|
||||||
api_resp = await get_messages(current_user=current_user, type=type, limit=limit, since=None)
|
username = current_user.username.upper().strip()
|
||||||
messages = api_resp["messages"]
|
|
||||||
|
valid_types = {"received", "sent", "all"}
|
||||||
|
if msg_type not in valid_types:
|
||||||
|
msg_type = "received"
|
||||||
|
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
mailbox = root.get("messages", {}).get(username, [])
|
||||||
|
|
||||||
|
# Build full list of message dicts (similar to API)
|
||||||
|
messages = []
|
||||||
|
for msg in mailbox:
|
||||||
|
messages.append({
|
||||||
|
"id": str(msg.msg_id),
|
||||||
|
"from": msg.msg_from,
|
||||||
|
"to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to],
|
||||||
|
"sent_at": msg.sent_at.isoformat() + "Z",
|
||||||
|
"text": msg.text,
|
||||||
|
"has_attachments": len(msg.attachments) > 0,
|
||||||
|
"retrieved": msg.retrieved,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Type filter
|
||||||
|
if msg_type == "received":
|
||||||
|
filtered = [m for m in messages if username in m["to"]]
|
||||||
|
elif msg_type == "sent":
|
||||||
|
filtered = [m for m in messages if m["from"] == username]
|
||||||
|
else:
|
||||||
|
filtered = messages
|
||||||
|
|
||||||
|
# Search filter (case-insensitive across from/to/text)
|
||||||
|
if search:
|
||||||
|
search_lower = search.strip().lower()
|
||||||
|
filtered = [
|
||||||
|
m for m in filtered
|
||||||
|
if search_lower in m["from"].lower()
|
||||||
|
or any(search_lower in t.lower() for t in m["to"])
|
||||||
|
or search_lower in m["text"].lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sort newest first
|
||||||
|
filtered.sort(key=lambda m: m["sent_at"], reverse=True)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
total = len(filtered)
|
||||||
|
start = (page - 1) * per_page
|
||||||
|
paginated = filtered[start:start + per_page]
|
||||||
|
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"message_list.html",
|
"message_list.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"messages": messages,
|
"messages": paginated,
|
||||||
"msg_type": type,
|
"current_type": msg_type, # For tabs/links
|
||||||
"current_user": current_user.username
|
"current_search": search, # For preserving/clearing search
|
||||||
}
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"current_user": current_user.username,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header, Request
|
||||||
from typing import List
|
from fastapi.responses import PlainTextResponse, Response, JSONResponse, StreamingResponse, RedirectResponse
|
||||||
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import logging
|
||||||
|
from traceback import format_exc
|
||||||
|
import base64
|
||||||
|
import traceback
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
|
import re
|
||||||
|
|
||||||
from packetserver.http.dependencies import get_current_http_user
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
from packetserver.http.auth import HttpUser
|
from packetserver.http.auth import HttpUser
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
from packetserver.server.objects import Object
|
from packetserver.server.objects import Object
|
||||||
from pydantic import BaseModel
|
from packetserver.server.users import User
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["objects"])
|
router = APIRouter(prefix="/api/v1", tags=["objects"])
|
||||||
|
|
||||||
@@ -22,34 +31,605 @@ class ObjectSummary(BaseModel):
|
|||||||
modified_at: datetime
|
modified_at: datetime
|
||||||
|
|
||||||
@router.get("/objects", response_model=List[ObjectSummary])
|
@router.get("/objects", response_model=List[ObjectSummary])
|
||||||
async def list_my_objects(current_user: HttpUser = Depends(get_current_http_user)):
|
async def list_my_objects(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
|
||||||
from packetserver.runners.http_server import get_db_connection
|
username = current_user.username.upper().strip() # ensure uppercase consistency
|
||||||
|
logging.debug(f"Listing objects for user {username}")
|
||||||
conn = get_db_connection()
|
|
||||||
root = conn.root()
|
|
||||||
|
|
||||||
username = current_user.username # uppercase callsign
|
|
||||||
|
|
||||||
core_objects = Object.get_objects_by_username(username, root)
|
|
||||||
|
|
||||||
# Sort newest first by created_at
|
|
||||||
core_objects.sort(key=lambda o: o.created_at, reverse=True)
|
|
||||||
|
|
||||||
user_objects = []
|
user_objects = []
|
||||||
for obj in core_objects:
|
with db.transaction() as conn:
|
||||||
content_type, _ = mimetypes.guess_type(obj.name)
|
for obj in Object.get_objects_by_username(username, conn):
|
||||||
if content_type is None:
|
logging.debug(f"Found object {obj.uuid} for {username}")
|
||||||
content_type = "application/octet-stream" if obj.binary else "text/plain"
|
if obj: # should always exist, but guard anyway
|
||||||
|
content_type, _ = mimetypes.guess_type(obj.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" if obj.binary else "text/plain"
|
||||||
|
|
||||||
user_objects.append(ObjectSummary(
|
user_objects.append(ObjectSummary(
|
||||||
uuid=obj.uuid,
|
uuid=obj.uuid,
|
||||||
name=obj.name,
|
name=obj.name,
|
||||||
binary=obj.binary,
|
binary=obj.binary,
|
||||||
size=obj.size,
|
size=obj.size,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
private=obj.private,
|
private=obj.private,
|
||||||
created_at=obj.created_at,
|
created_at=obj.created_at,
|
||||||
modified_at=obj.modified_at
|
modified_at=obj.modified_at
|
||||||
))
|
))
|
||||||
|
|
||||||
return user_objects
|
# Sort newest first
|
||||||
|
user_objects.sort(key=lambda x: x.created_at, reverse=True)
|
||||||
|
|
||||||
|
return user_objects
|
||||||
|
|
||||||
|
@router.post("/objects", response_model=ObjectSummary)
|
||||||
|
async def upload_object(
|
||||||
|
db: DbDependency,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
name: Optional[str] = Form(None),
|
||||||
|
private: bool = Form(True),
|
||||||
|
force_text: bool = Form(False), # NEW: force treat as UTF-8 text
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty file upload")
|
||||||
|
|
||||||
|
obj_name = (name or file.filename or "unnamed_object").strip()
|
||||||
|
if len(obj_name) > 300:
|
||||||
|
raise HTTPException(status_code=400, detail="Object name too long (max 300 chars)")
|
||||||
|
if not obj_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid object name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Handle force_text logic
|
||||||
|
if force_text:
|
||||||
|
try:
|
||||||
|
text_content = content.decode('utf-8', errors='strict')
|
||||||
|
object_data = text_content.replace('\r', '') # str → will set binary=False
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Content is not valid UTF-8 and cannot be forced as text")
|
||||||
|
else:
|
||||||
|
object_data = content # bytes → will set binary=True
|
||||||
|
|
||||||
|
# Create and persist the object
|
||||||
|
new_object = Object(name=obj_name, data=object_data)
|
||||||
|
new_object.private = private
|
||||||
|
|
||||||
|
obj_uuid = new_object.write_new(db, username=username)
|
||||||
|
|
||||||
|
if force_text:
|
||||||
|
obj_type = 'string'
|
||||||
|
else:
|
||||||
|
obj_type = 'binary'
|
||||||
|
|
||||||
|
logging.info(f"User {username} uploaded {obj_type} object {obj_uuid} ({obj_name}, {len(content)} bytes, force_text={force_text})")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Object upload failed for {username}: {e}\n{format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to store object")
|
||||||
|
|
||||||
|
# Build summary (matching your existing list endpoint)
|
||||||
|
content_type, _ = mimetypes.guess_type(new_object.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" if new_object.binary else "text/plain"
|
||||||
|
|
||||||
|
return ObjectSummary(
|
||||||
|
uuid=obj_uuid,
|
||||||
|
name=new_object.name,
|
||||||
|
binary=new_object.binary,
|
||||||
|
size=new_object.size,
|
||||||
|
content_type=content_type,
|
||||||
|
private=new_object.private,
|
||||||
|
created_at=new_object.created_at,
|
||||||
|
modified_at=new_object.modified_at
|
||||||
|
)
|
||||||
|
|
||||||
|
class TextObjectCreate(BaseModel):
|
||||||
|
text: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
private: bool = True
|
||||||
|
|
||||||
|
@router.post("/objects/text", response_model=None) # Remove response_model to allow mixed returns
|
||||||
|
async def create_text_object(
|
||||||
|
request: Request,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
# Determine content type and parse accordingly
|
||||||
|
content_type = request.headers.get("content-type", "").lower()
|
||||||
|
|
||||||
|
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
|
||||||
|
form = await request.form()
|
||||||
|
name = form.get("name")
|
||||||
|
text = form.get("text")
|
||||||
|
private_str = form.get("private") # "on" if checked, None otherwise
|
||||||
|
is_form = True
|
||||||
|
elif "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
json_data = await request.json()
|
||||||
|
name = json_data.get("name")
|
||||||
|
text = json_data.get("text")
|
||||||
|
private_str = json_data.get("private")
|
||||||
|
is_form = False
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=415, detail="Unsupported Media Type")
|
||||||
|
|
||||||
|
# Validate text
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="Text content cannot be empty")
|
||||||
|
text = text.replace("\r", "")
|
||||||
|
|
||||||
|
# Normalize name (optional, default like original)
|
||||||
|
obj_name = (name or "text_object.txt").strip()
|
||||||
|
if len(obj_name) > 300:
|
||||||
|
raise HTTPException(status_code=400, detail="Object name too long (max 300 chars)")
|
||||||
|
if not obj_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid object name")
|
||||||
|
|
||||||
|
# Normalize private to bool (handles form "on"/None, JSON bool, or string)
|
||||||
|
if isinstance(private_str, bool):
|
||||||
|
private = private_str
|
||||||
|
elif isinstance(private_str, str):
|
||||||
|
private = private_str.lower() in ("true", "on", "1", "yes")
|
||||||
|
else:
|
||||||
|
private = False # Default to False if invalid/missing
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Create object with str data → forces binary=False
|
||||||
|
new_object = Object(name=obj_name, data=text)
|
||||||
|
new_object.private = private
|
||||||
|
|
||||||
|
obj_uuid = new_object.write_new(db, username=username)
|
||||||
|
|
||||||
|
logging.info(f"User {username} created text object {obj_uuid} ({obj_name}, {len(text)} chars)")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Text object creation failed for {username}: {e}\n{format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create text object")
|
||||||
|
|
||||||
|
# Build summary (for JSON responses)
|
||||||
|
content_type_guess, _ = mimetypes.guess_type(new_object.name)
|
||||||
|
if content_type_guess is None:
|
||||||
|
content_type_guess = "text/plain" # always text here
|
||||||
|
|
||||||
|
summary = ObjectSummary(
|
||||||
|
uuid=obj_uuid,
|
||||||
|
name=new_object.name,
|
||||||
|
binary=new_object.binary, # should be False
|
||||||
|
size=new_object.size,
|
||||||
|
content_type=content_type_guess,
|
||||||
|
private=new_object.private,
|
||||||
|
created_at=new_object.created_at,
|
||||||
|
modified_at=new_object.modified_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return based on input type
|
||||||
|
if is_form:
|
||||||
|
return RedirectResponse(url="/objects", status_code=303) # Back to HTML list
|
||||||
|
else:
|
||||||
|
return JSONResponse(content=summary.model_dump(), status_code=201)
|
||||||
|
|
||||||
|
class BinaryObjectCreate(BaseModel):
|
||||||
|
data_base64: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
private: bool = True
|
||||||
|
|
||||||
|
@router.post("/objects/binary", response_model=ObjectSummary)
|
||||||
|
async def create_binary_object(
|
||||||
|
payload: BinaryObjectCreate,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
# Decode base64
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(payload.data_base64, validate=True)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 encoding")
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Binary content cannot be empty")
|
||||||
|
|
||||||
|
obj_name = (payload.name or "binary_object.bin").strip()
|
||||||
|
if len(obj_name) > 300:
|
||||||
|
raise HTTPException(status_code=400, detail="Object name too long (max 300 chars)")
|
||||||
|
if not obj_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid object name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Pass bytes → forces binary=True
|
||||||
|
new_object = Object(name=obj_name, data=content)
|
||||||
|
new_object.private = payload.private
|
||||||
|
|
||||||
|
obj_uuid = new_object.write_new(db, username=username)
|
||||||
|
|
||||||
|
logging.info(f"User {username} created binary object {obj_uuid} ({obj_name}, {len(content)} bytes via base64)")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Binary object creation failed for {username}: {e}\n{format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create binary object")
|
||||||
|
|
||||||
|
# Build summary
|
||||||
|
content_type, _ = mimetypes.guess_type(new_object.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" # always safe for binary
|
||||||
|
|
||||||
|
return ObjectSummary(
|
||||||
|
uuid=obj_uuid,
|
||||||
|
name=new_object.name,
|
||||||
|
binary=new_object.binary, # should be True
|
||||||
|
size=new_object.size,
|
||||||
|
content_type=content_type,
|
||||||
|
private=new_object.private,
|
||||||
|
created_at=new_object.created_at,
|
||||||
|
modified_at=new_object.modified_at
|
||||||
|
)
|
||||||
|
|
||||||
|
class ObjectUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
private: Optional[bool] = None
|
||||||
|
data_text: Optional[str] = None # Update to text content → forces binary=False
|
||||||
|
data_base64: Optional[str] = None # Update to binary content → forces binary=True
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def check_mutually_exclusive_content(cls, values: dict) -> dict:
|
||||||
|
if values.get('data_text') is not None and values.get('data_base64') is not None:
|
||||||
|
raise ValueError('data_text and data_base64 cannot be provided together')
|
||||||
|
return values
|
||||||
|
|
||||||
|
@router.patch("/objects/{uuid}", response_model=ObjectSummary)
|
||||||
|
async def update_object(
|
||||||
|
uuid: UUID,
|
||||||
|
payload: ObjectUpdate,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
if all(v is None for v in [payload.name, payload.private, payload.data_text, payload.data_base64]):
|
||||||
|
raise HTTPException(status_code=400, detail="No updates provided")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
obj = Object.get_object_by_uuid(uuid, root)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Object not found")
|
||||||
|
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user or user.uuid != obj.owner:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to modify this object")
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
if payload.name is not None:
|
||||||
|
new_name = payload.name.strip()
|
||||||
|
if len(new_name) > 300:
|
||||||
|
raise HTTPException(status_code=400, detail="Object name too long (max 300 chars)")
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid object name")
|
||||||
|
obj.name = new_name
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if payload.private is not None:
|
||||||
|
obj.private = payload.private
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if payload.data_text is not None:
|
||||||
|
if not payload.data_text:
|
||||||
|
raise HTTPException(status_code=400, detail="Text content cannot be empty")
|
||||||
|
obj.data = payload.data_text # str → forces binary=False, calls touch()
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if payload.data_base64 is not None:
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(payload.data_base64, validate=True)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid base64 encoding")
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Binary content cannot be empty")
|
||||||
|
obj.data = content # bytes → forces binary=True, calls touch()
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=400, detail="No valid updates applied")
|
||||||
|
|
||||||
|
logging.info(f"User {username} updated object {uuid}")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Object update failed for {username} on {uuid}: {e}\n{traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update object")
|
||||||
|
|
||||||
|
content_type, _ = mimetypes.guess_type(obj.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" if obj.binary else "text/plain"
|
||||||
|
|
||||||
|
return ObjectSummary(
|
||||||
|
uuid=obj.uuid,
|
||||||
|
name=obj.name,
|
||||||
|
binary=obj.binary,
|
||||||
|
size=obj.size,
|
||||||
|
content_type=content_type,
|
||||||
|
private=obj.private,
|
||||||
|
created_at=obj.created_at,
|
||||||
|
modified_at=obj.modified_at
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/objects/{uuid}", status_code=204)
|
||||||
|
async def delete_object(
|
||||||
|
uuid: UUID,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
|
||||||
|
obj = Object.get_object_by_uuid(uuid, root)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Object not found")
|
||||||
|
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user or user.uuid != obj.owner:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to delete this object")
|
||||||
|
|
||||||
|
# Remove references
|
||||||
|
user.remove_obj_uuid(uuid) # from user's object_uuids set
|
||||||
|
del conn.root.objects[uuid] # from global objects mapping
|
||||||
|
|
||||||
|
logging.info(f"User {username} deleted object {uuid}")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Object delete failed for {username} on {uuid}: {e}\n{traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete object")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@router.get("/objects/{uuid}/text", response_class=PlainTextResponse)
|
||||||
|
async def get_object_text(
|
||||||
|
uuid: UUID,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
|
||||||
|
obj = Object.get_object_by_uuid(uuid, root)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Object not found")
|
||||||
|
|
||||||
|
# Authorization check
|
||||||
|
if obj.private:
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user or user.uuid != obj.owner:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to access this private object")
|
||||||
|
|
||||||
|
# Only allow text objects
|
||||||
|
if obj.binary:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This endpoint is for text objects only. Use /download or /binary for binary content."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Safe to return as str since binary=False guarantees valid UTF-8
|
||||||
|
content = obj.data # will be str
|
||||||
|
|
||||||
|
logging.info(f"User {username} downloaded text object {uuid} ({obj.name})")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Text download failed for {username} on {uuid}: {e}\n{traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve text object")
|
||||||
|
|
||||||
|
return PlainTextResponse(content=content, media_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
class ObjectBinaryResponse(BaseModel):
|
||||||
|
uuid: UUID
|
||||||
|
name: str
|
||||||
|
binary: bool
|
||||||
|
size: int
|
||||||
|
content_type: str
|
||||||
|
data_base64: str
|
||||||
|
private: bool
|
||||||
|
created_at: datetime
|
||||||
|
modified_at: datetime
|
||||||
|
|
||||||
|
@router.get("/objects/{uuid}/binary", response_model=ObjectBinaryResponse)
|
||||||
|
async def get_object_binary(
|
||||||
|
uuid: UUID,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
|
||||||
|
obj = Object.get_object_by_uuid(uuid, root)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Object not found")
|
||||||
|
|
||||||
|
# Authorization check for private objects
|
||||||
|
if obj.private:
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user or user.uuid != obj.owner:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to access this private object")
|
||||||
|
|
||||||
|
# Get content as bytes (works for both text and binary)
|
||||||
|
content_bytes = obj.data_bytes # uses the property that always returns bytes
|
||||||
|
|
||||||
|
# Encode to base64
|
||||||
|
data_base64 = base64.b64encode(content_bytes).decode('ascii')
|
||||||
|
|
||||||
|
# Guess content_type
|
||||||
|
content_type, _ = mimetypes.guess_type(obj.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" if obj.binary else "text/plain"
|
||||||
|
|
||||||
|
logging.info(f"User {username} downloaded binary/base64 object {uuid} ({obj.name})")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Binary download failed for {username} on {uuid}: {e}\n{traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve object")
|
||||||
|
|
||||||
|
return ObjectBinaryResponse(
|
||||||
|
uuid=obj.uuid,
|
||||||
|
name=obj.name,
|
||||||
|
binary=obj.binary,
|
||||||
|
size=obj.size,
|
||||||
|
content_type=content_type,
|
||||||
|
data_base64=data_base64,
|
||||||
|
private=obj.private,
|
||||||
|
created_at=obj.created_at,
|
||||||
|
modified_at=obj.modified_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Helper to sanitize filename for Content-Disposition
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
# Remove path separators and control chars
|
||||||
|
filename = re.sub(r'[<>:"/\\|?*\x00-\x1F]', '_', filename)
|
||||||
|
return filename or "download"
|
||||||
|
|
||||||
|
@router.get("/objects/{uuid}/download")
|
||||||
|
async def download_object(
|
||||||
|
uuid: UUID,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user),
|
||||||
|
accept: str = Header(None) # Optional: for future content negotiation if needed
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
|
||||||
|
obj = Object.get_object_by_uuid(uuid, root)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Object not found")
|
||||||
|
|
||||||
|
# Authorization check for private objects
|
||||||
|
if obj.private:
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user or user.uuid != obj.owner:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to access this private object")
|
||||||
|
|
||||||
|
# Get content as bytes
|
||||||
|
content_bytes = obj.data_bytes
|
||||||
|
|
||||||
|
# Guess content type
|
||||||
|
content_type, _ = mimetypes.guess_type(obj.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" if obj.binary else "text/plain"
|
||||||
|
|
||||||
|
# Sanitize filename for header
|
||||||
|
safe_filename = sanitize_filename(obj.name)
|
||||||
|
|
||||||
|
# Headers for download
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'attachment; filename="{safe_filename}"',
|
||||||
|
"Content-Length": str(obj.size),
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.info(f"User {username} downloaded object {uuid} ({obj.name}) via streaming")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Download failed for {username} on {uuid}: {e}\n{traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to stream object")
|
||||||
|
|
||||||
|
# Stream the bytes directly (efficient, no full load in memory beyond ZODB)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([content_bytes]), # single chunk since ZODB objects are usually small-ish
|
||||||
|
media_type=content_type,
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/objects/{uuid}", response_model=ObjectSummary)
|
||||||
|
async def get_object_metadata(
|
||||||
|
uuid: UUID,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
try:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
|
||||||
|
obj = Object.get_object_by_uuid(uuid, root)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=404, detail="Object not found")
|
||||||
|
|
||||||
|
# Authorization: private objects only visible to owner
|
||||||
|
if obj.private:
|
||||||
|
user = User.get_user_by_username(username, root)
|
||||||
|
if not user or user.uuid != obj.owner:
|
||||||
|
raise HTTPException(status_code=403, detail="Not authorized to view this private object")
|
||||||
|
|
||||||
|
# Guess content_type for summary
|
||||||
|
content_type, _ = mimetypes.guess_type(obj.name)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = "application/octet-stream" if obj.binary else "text/plain"
|
||||||
|
|
||||||
|
logging.info(f"User {username} retrieved metadata for object {uuid} ({obj.name})")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Metadata retrieval failed for {username} on {uuid}: {e}\n{traceback.format_exc()}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to retrieve object metadata")
|
||||||
|
|
||||||
|
return ObjectSummary(
|
||||||
|
uuid=obj.uuid,
|
||||||
|
name=obj.name,
|
||||||
|
binary=obj.binary,
|
||||||
|
size=obj.size,
|
||||||
|
content_type=content_type,
|
||||||
|
private=obj.private,
|
||||||
|
created_at=obj.created_at,
|
||||||
|
modified_at=obj.modified_at
|
||||||
|
)
|
||||||
89
packetserver/http/routers/objects_html.py
Normal file
89
packetserver/http/routers/objects_html.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, Form, File, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from uuid import UUID
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
|
from packetserver.http.auth import HttpUser
|
||||||
|
from packetserver.http.server import templates
|
||||||
|
from packetserver.http.routers.objects import router as api_router # to call internal endpoints
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
from packetserver.http.routers.objects import get_object_metadata as api_get_metadata
|
||||||
|
from packetserver.http.routers.objects import ObjectUpdate
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["objects_html"])
|
||||||
|
|
||||||
|
# Internal reference to the list function (assuming it's list_my_objects)
|
||||||
|
from packetserver.http.routers.objects import list_my_objects as api_list_objects
|
||||||
|
|
||||||
|
@router.get("/objects", response_class=HTMLResponse)
|
||||||
|
async def objects_page(
|
||||||
|
db: DbDependency,
|
||||||
|
request: Request,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
# Call the API list endpoint internally
|
||||||
|
objects_resp = await api_list_objects(db, current_user=current_user) # db injected via dependency
|
||||||
|
objects = objects_resp # it's already the list
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"objects.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user.username,
|
||||||
|
"objects": objects
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/objects/{uuid}", response_class=HTMLResponse)
|
||||||
|
async def object_detail_page(
|
||||||
|
request: Request,
|
||||||
|
uuid: UUID,
|
||||||
|
db: DbDependency,
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
# Call the existing metadata API function
|
||||||
|
obj = await api_get_metadata(uuid=uuid, db=db, current_user=current_user)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"object_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"current_user": current_user.username,
|
||||||
|
"obj": obj
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/objects/{uuid}")
|
||||||
|
async def update_object(
|
||||||
|
db: DbDependency,
|
||||||
|
uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(None),
|
||||||
|
private: str = Form("off"), # checkbox sends "on" if checked
|
||||||
|
new_text: str = Form(None),
|
||||||
|
new_file: UploadFile = File(None),
|
||||||
|
new_base64: str = Form(None),
|
||||||
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
|
):
|
||||||
|
payload = {}
|
||||||
|
if name is not None:
|
||||||
|
payload["name"] = name
|
||||||
|
payload["private"] = (private == "on")
|
||||||
|
|
||||||
|
if new_text is not None and new_text.strip():
|
||||||
|
new_text = new_text.replace("\r","")
|
||||||
|
payload["data_text"] = new_text.strip()
|
||||||
|
elif new_file and new_file.filename:
|
||||||
|
content = await new_file.read()
|
||||||
|
payload["data_base64"] = base64.b64encode(content).decode('ascii')
|
||||||
|
elif new_base64 and new_base64.strip():
|
||||||
|
payload["data_base64"] = new_base64.strip()
|
||||||
|
|
||||||
|
# Call the PATCH API internally (simple requests or direct function call)
|
||||||
|
from packetserver.http.routers.objects import update_object as api_update
|
||||||
|
await api_update(uuid=uuid, payload=ObjectUpdate(**payload), db=db, current_user=current_user)
|
||||||
|
|
||||||
|
# Redirect back to the detail page (or /objects list)
|
||||||
|
return RedirectResponse(url=f"/objects/{uuid}", status_code=303)
|
||||||
@@ -3,28 +3,28 @@ from fastapi import APIRouter, Depends
|
|||||||
|
|
||||||
from packetserver.http.dependencies import get_current_http_user
|
from packetserver.http.dependencies import get_current_http_user
|
||||||
from packetserver.http.auth import HttpUser
|
from packetserver.http.auth import HttpUser
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["auth"])
|
router = APIRouter(prefix="/api/v1", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
async def profile(current_user: HttpUser = Depends(get_current_http_user)):
|
async def profile(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
|
||||||
username = current_user.username
|
username = current_user.username
|
||||||
|
rf_enabled = current_user.is_rf_enabled(db)
|
||||||
from packetserver.runners.http_server import get_db_connection
|
|
||||||
conn = get_db_connection()
|
|
||||||
root = conn.root()
|
|
||||||
|
|
||||||
# Get main BBS User and safe dict
|
# Get main BBS User and safe dict
|
||||||
main_users = root.get('users', {})
|
with db.transaction() as conn:
|
||||||
bbs_user = main_users.get(username)
|
root = conn.root()
|
||||||
safe_profile = bbs_user.to_safe_dict() if bbs_user else {}
|
main_users = root.get('users', {})
|
||||||
rf_enabled = current_user.is_rf_enabled(conn)
|
bbs_user = main_users.get(username)
|
||||||
|
safe_profile = bbs_user.to_safe_dict() if bbs_user else {}
|
||||||
|
|
||||||
return {
|
|
||||||
**safe_profile,
|
return {
|
||||||
"http_enabled": current_user.http_enabled,
|
**safe_profile,
|
||||||
"rf_enabled": rf_enabled,
|
"http_enabled": current_user.http_enabled,
|
||||||
"http_created_at": current_user.created_at,
|
"rf_enabled": rf_enabled,
|
||||||
"http_last_login": current_user.last_login,
|
"http_created_at": current_user.created_at,
|
||||||
}
|
"http_last_login": current_user.last_login,
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ from packetserver.http.dependencies import get_current_http_user
|
|||||||
from packetserver.http.auth import HttpUser
|
from packetserver.http.auth import HttpUser
|
||||||
from packetserver.server.messages import Message
|
from packetserver.server.messages import Message
|
||||||
from packetserver.common.util import is_valid_ax25_callsign
|
from packetserver.common.util import is_valid_ax25_callsign
|
||||||
|
from packetserver.http.database import DbDependency
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1", tags=["messages"])
|
router = APIRouter(prefix="/api/v1", tags=["messages"])
|
||||||
|
|
||||||
@@ -39,56 +40,86 @@ class SendMessageRequest(BaseModel):
|
|||||||
|
|
||||||
@router.post("/messages")
|
@router.post("/messages")
|
||||||
async def send_message(
|
async def send_message(
|
||||||
|
db: DbDependency,
|
||||||
payload: SendMessageRequest,
|
payload: SendMessageRequest,
|
||||||
current_user: HttpUser = Depends(get_current_http_user)
|
current_user: HttpUser = Depends(get_current_http_user)
|
||||||
):
|
):
|
||||||
from packetserver.runners.http_server import get_db_connection
|
is_rf_enabled = current_user.is_rf_enabled(db)
|
||||||
conn = get_db_connection()
|
with db.transaction() as conn:
|
||||||
root = conn.root()
|
root = conn.root()
|
||||||
|
|
||||||
if not current_user.is_rf_enabled(conn):
|
if not is_rf_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="RF gateway access required to send messages"
|
detail="RF gateway access required to send messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
username = current_user.username
|
||||||
|
|
||||||
|
users_dict = root.get('users', {})
|
||||||
|
|
||||||
|
# Prepare recipients
|
||||||
|
to_list = [c.upper() for c in payload.to]
|
||||||
|
is_to_all = "ALL" in to_list
|
||||||
|
|
||||||
|
if is_to_all:
|
||||||
|
# Deliver to all registered users
|
||||||
|
valid_recipients = list(users_dict.keys())
|
||||||
|
failed_recipients = []
|
||||||
|
else:
|
||||||
|
# Private message validation
|
||||||
|
valid_recipients = []
|
||||||
|
failed_recipients = []
|
||||||
|
for recip in to_list:
|
||||||
|
if recip in users_dict:
|
||||||
|
valid_recipients.append(recip)
|
||||||
|
else:
|
||||||
|
failed_recipients.append(recip)
|
||||||
|
|
||||||
|
if not valid_recipients:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"No valid recipients found. Failed: {', '.join(failed_recipients)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Create message
|
||||||
|
new_msg = Message(
|
||||||
|
text=payload.text,
|
||||||
|
msg_from=username,
|
||||||
|
msg_to=tuple(valid_recipients),
|
||||||
|
attachments=()
|
||||||
)
|
)
|
||||||
|
|
||||||
username = current_user.username
|
# Deliver to valid recipients + always sender (sent folder)
|
||||||
|
messages_root = root.setdefault('messages', PersistentMapping())
|
||||||
|
delivered_to = set()
|
||||||
|
# Always give sender a copy in their mailbox (acts as Sent folder)
|
||||||
|
sender_mailbox = messages_root.setdefault(username, PersistentList())
|
||||||
|
sender_mailbox.append(new_msg)
|
||||||
|
sender_mailbox._p_changed = True
|
||||||
|
delivered_to.add(username) # now accurate
|
||||||
|
|
||||||
# Prepare recipients
|
for recip in valid_recipients:
|
||||||
to_list = payload.to
|
mailbox = messages_root.setdefault(recip, PersistentList())
|
||||||
to_tuple = tuple(to_list)
|
mailbox.append(new_msg)
|
||||||
if "ALL" in to_list:
|
mailbox._p_changed = True
|
||||||
to_tuple = ("ALL",)
|
delivered_to.add(recip)
|
||||||
|
|
||||||
is_bulletin = "ALL" in to_list
|
messages_root._p_changed = True
|
||||||
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys())
|
transaction.commit()
|
||||||
|
|
||||||
# Create message using only supported core params
|
response = {
|
||||||
new_msg = Message(
|
"status": "sent",
|
||||||
text=payload.text,
|
"message_id": str(new_msg.msg_id),
|
||||||
msg_from=username,
|
"from": username,
|
||||||
msg_to=to_tuple,
|
"to": list(valid_recipients),
|
||||||
attachments=()
|
"sent_at": new_msg.sent_at.isoformat() + "Z",
|
||||||
)
|
"recipients_delivered": len(delivered_to)
|
||||||
|
}
|
||||||
|
|
||||||
# Deliver to recipients + always sender (sent folder)
|
if failed_recipients:
|
||||||
messages_root = root.setdefault('messages', PersistentMapping())
|
response["warning"] = f"Some recipients not registered: {', '.join(failed_recipients)}"
|
||||||
delivered_to = set()
|
response["failed_recipients"] = failed_recipients
|
||||||
|
|
||||||
for recip in set(recipients) | {username}:
|
return response
|
||||||
mailbox = messages_root.setdefault(recip, PersistentList())
|
|
||||||
mailbox.append(new_msg)
|
|
||||||
mailbox._p_changed = True
|
|
||||||
delivered_to.add(recip)
|
|
||||||
|
|
||||||
messages_root._p_changed = True
|
|
||||||
transaction.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "sent",
|
|
||||||
"message_id": str(new_msg.msg_id),
|
|
||||||
"from": username,
|
|
||||||
"to": list(to_tuple),
|
|
||||||
"sent_at": new_msg.sent_at.isoformat() + "Z",
|
|
||||||
"recipients_delivered": len(delivered_to)
|
|
||||||
}
|
|
||||||
@@ -3,20 +3,37 @@ from fastapi import FastAPI
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from .database import init_db, get_db, get_server_config_from_db
|
||||||
from .routers import public, profile, messages, send
|
from .routers import public, profile, messages, send
|
||||||
|
from .logging import init_logging
|
||||||
|
|
||||||
|
init_logging()
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent.resolve()
|
BASE_DIR = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="PacketServer HTTP API",
|
title="PacketServer HTTP API",
|
||||||
description="RESTful interface to the AX.25 packet radio BBS",
|
description="RESTful interface to the AX.25 packet radio BBS",
|
||||||
version="0.1.0",
|
version="0.5.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Define templates EARLY (before importing dashboard)
|
# Define templates EARLY (before importing dashboard)
|
||||||
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
templates = Jinja2Templates(directory=BASE_DIR / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def b64decode_filter(value: str) -> str:
|
||||||
|
try:
|
||||||
|
decoded_bytes = base64.b64decode(value)
|
||||||
|
# Assume UTF-8 text (common for job output/errors)
|
||||||
|
return decoded_bytes.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return "[Invalid base64 data]"
|
||||||
|
|
||||||
|
templates.env.filters["b64decode"] = b64decode_filter
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
def timestamp_to_date(ts):
|
def timestamp_to_date(ts):
|
||||||
@@ -39,6 +56,18 @@ from .routers import dashboard, bulletins
|
|||||||
from .routers.message_detail import router as message_detail_router
|
from .routers.message_detail import router as message_detail_router
|
||||||
from .routers.messages import html_router
|
from .routers.messages import html_router
|
||||||
from .routers.objects import router as objects_router
|
from .routers.objects import router as objects_router
|
||||||
|
from .routers import objects_html
|
||||||
|
from .routers.jobs import router as jobs_router
|
||||||
|
from .routers.jobs import dashboard_router as jobs_html_router
|
||||||
|
|
||||||
|
# initialize database
|
||||||
|
init_db()
|
||||||
|
db = get_db()
|
||||||
|
server_config = get_server_config_from_db(db)
|
||||||
|
templates.env.globals['server_name'] = server_config['server_name']
|
||||||
|
templates.env.globals['server_callsign'] = server_config['server_callsign']
|
||||||
|
templates.env.globals['motd'] = server_config['motd']
|
||||||
|
templates.env.globals['server_operator'] = server_config['operator']
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(public.router)
|
app.include_router(public.router)
|
||||||
@@ -51,5 +80,7 @@ app.include_router(bulletins.html_router)
|
|||||||
app.include_router(message_detail_router)
|
app.include_router(message_detail_router)
|
||||||
app.include_router(html_router)
|
app.include_router(html_router)
|
||||||
app.include_router(objects_router)
|
app.include_router(objects_router)
|
||||||
|
app.include_router(objects_html.router)
|
||||||
|
app.include_router(jobs_router)
|
||||||
|
app.include_router(jobs_html_router)
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,33 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}PacketServer Dashboard{% endblock %}</title>
|
<title>{% block title %}{{ server_name }} Dashboard{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/bootstrap.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/bootstrap.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
<nav class="navbar navbar-dark bg-primary mb-4">
|
<nav class="navbar navbar-dark bg-primary mb-4 py-3"> <!-- Added py-3 for a bit more vertical padding -->
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('dashboard') }}">PacketServer BBS</a>
|
<!-- Server name on its own "line" (centered, larger, prominent) -->
|
||||||
<span class="navbar-text">
|
<div class="w-100 text-center mb-3">
|
||||||
Logged in as: <strong>{{ current_user }}</strong>
|
<a class="navbar-brand h2 mb-0" href="{{ url_for('dashboard') }}">{{ server_name }}</a>
|
||||||
{# Basic Auth note #}
|
</div>
|
||||||
<small class="text-light ms-3">(Close browser to logout)</small>
|
|
||||||
</span>
|
<!-- Bottom row: user info and navigation buttons -->
|
||||||
<a href="{{ url_for('profile_page') }}" class="btn btn-outline-light btn-sm me-2">Profile</a>
|
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-md-between w-100 gap-2">
|
||||||
<a href="/messages" class="btn btn-outline-light btn-sm me-2">Messages</a>
|
<span class="navbar-text text-center text-md-start order-md-1">
|
||||||
<a href="/bulletins" class="btn btn-outline-light btn-sm me-2">Bulletins</a>
|
Logged in as: <strong>{{ current_user }}</strong>
|
||||||
|
<small class="text-light ms-3">(Close browser to logout)</small>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="order-md-3">
|
||||||
|
<a href="{{ url_for('profile_page') }}" class="btn btn-outline-light btn-sm me-2">Profile</a>
|
||||||
|
<a href="/messages" class="btn btn-outline-light btn-sm me-2">Messages</a>
|
||||||
|
<a href="/bulletins" class="btn btn-outline-light btn-sm me-2">Bulletins</a>
|
||||||
|
<a href="/objects" class="btn btn-outline-light btn-sm me-2">Objects</a>
|
||||||
|
<a href="/jobs" class="btn btn-outline-light btn-sm me-2">Jobs</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -103,8 +114,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
let msg = 'Message sent successfully!';
|
||||||
|
if (result.warning) {
|
||||||
|
msg += ' ' + result.warning;
|
||||||
|
}
|
||||||
status.className = 'alert alert-success';
|
status.className = 'alert alert-success';
|
||||||
status.textContent = 'Message sent successfully!';
|
status.textContent = msg;
|
||||||
status.style.display = 'block';
|
status.style.display = 'block';
|
||||||
composeForm.reset();
|
composeForm.reset();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -15,5 +15,35 @@
|
|||||||
<a href="/bulletins">← All Bulletins</a> |
|
<a href="/bulletins">← All Bulletins</a> |
|
||||||
<a href="/dashboard">Dashboard</a>
|
<a href="/dashboard">Dashboard</a>
|
||||||
</p>
|
</p>
|
||||||
|
{% if bulletin.author == current_user %}
|
||||||
|
<div class="card mt-5 border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h5 class="mb-0">Danger Zone</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text">Once you delete a bulletin, there is no going back. Please be certain.</p>
|
||||||
|
<button type="button" class="btn btn-danger"
|
||||||
|
onclick='deleteBulletin({{ bulletin.id }}, "{{ bulletin.subject | replace("'", "\\'") | replace('"', '\\"') }}")'>
|
||||||
|
Delete This Bulletin Permanently
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function deleteBulletin(id, subject) {
|
||||||
|
console.log("Delete clicked for bulletin " + id);
|
||||||
|
if (!confirm(`Permanently delete bulletin "${subject}"? This cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/v1/bulletins/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/bulletins';
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
alert('Delete failed: ' + (errorText || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,34 +1,31 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block title %}Bulletins - {{ server_name }}{% endblock %}
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Bulletins - PacketServer</title>
|
{% block content %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
<div class="container mt-4">
|
||||||
</head>
|
<h1 class="mb-4">Bulletins</h1>
|
||||||
<body>
|
|
||||||
<h1>Bulletins</h1>
|
|
||||||
|
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<p><a href="/bulletins/new">Create New Bulletin</a></p>
|
<p class="mb-4"><a href="/bulletins/new" class="btn btn-primary">Create New Bulletin</a></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p><em>Log in to create bulletins.</em></p>
|
<p class="mb-4"><em>Log in to create bulletins.</em></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if bulletins %}
|
{% if bulletins %}
|
||||||
<ul>
|
<ul class="list-unstyled">
|
||||||
{% for bull in bulletins %}
|
{% for bull in bulletins %}
|
||||||
<li>
|
<li class="mb-4 pb-4 border-bottom">
|
||||||
<strong><a href="/bulletins/{{ bull.id }}">{{ bull.subject }}</a></strong>
|
<strong><a href="/bulletins/{{ bull.id }}" class="text-decoration-none">{{ bull.subject }}</a></strong>
|
||||||
<div class="meta">by {{ bull.author }} on {{ bull.created_at[:10] }}</div>
|
<div class="text-muted small">by {{ bull.author }} on {{ bull.created_at[:10] }}</div>
|
||||||
<div class="preview">{{ bull.body[:200] }}{% if bull.body|length > 200 %}...{% endif %}</div>
|
<div class="mt-2">{{ bull.body[:200] }}{% if bull.body|length > 200 %}...{% endif %}</div>
|
||||||
<hr>
|
</li>
|
||||||
</li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No bulletins yet.</p>
|
<p>No bulletins yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p><a href="/dashboard">← Back to Dashboard</a></p>
|
<p><a href="{{ url_for('dashboard') }}">← Back to Dashboard</a></p>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
{% endblock %}
|
||||||
@@ -101,8 +101,11 @@
|
|||||||
credentials: 'include' // sends Basic Auth
|
credentials: 'include' // sends Basic Auth
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json();
|
const result = await response.json();
|
||||||
document.getElementById('composeAlert').innerHTML = '<div class="alert alert-success">Sent! ID: ' + data.message_id + '</div>';
|
let msg = 'Message sent!';
|
||||||
|
if (result.warning) msg += ` ${result.warning}`;
|
||||||
|
status.textContent = msg;
|
||||||
|
document.getElementById('composeAlert').innerHTML = '<div class="alert alert-success">' + msg + '</div>';
|
||||||
setTimeout(() => location.reload(), 1500);
|
setTimeout(() => location.reload(), 1500);
|
||||||
} else {
|
} else {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
|
|||||||
208
packetserver/http/templates/job_detail.html
Normal file
208
packetserver/http/templates/job_detail.html
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Job {{ job.id }} - {{ job.cmd|truncate(50) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Job #{{ job.id }}</h2>
|
||||||
|
|
||||||
|
<!-- Action buttons (Back + Delete) -->
|
||||||
|
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||||
|
<a href="/jobs" class="btn btn-outline-secondary">← Back to Jobs</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteJobModal">
|
||||||
|
Delete Job
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal fade" id="deleteJobModal" tabindex="-1" aria-labelledby="deleteJobModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-danger text-white">
|
||||||
|
<h5 class="modal-title" id="deleteJobModalLabel">Confirm Job Deletion</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to <strong>permanently delete</strong> Job #{{ job.id }}?</p>
|
||||||
|
<p class="mb-0">This will remove:</p>
|
||||||
|
<ul>
|
||||||
|
<li>All command output and errors</li>
|
||||||
|
<li>Any artifacts</li>
|
||||||
|
<li>Job metadata and history</li>
|
||||||
|
</ul>
|
||||||
|
<strong class="text-danger">This action cannot be undone.</strong>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
|
||||||
|
<!-- Plain form that POSTs with _method=DELETE -->
|
||||||
|
<form action="/api/v1/jobs/{{ job.id }}" method="post" style="display: inline;">
|
||||||
|
<input type="hidden" name="_method" value="DELETE">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick="deleteJob({{ job.id }})">
|
||||||
|
Yes, Delete Permanently
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Command
|
||||||
|
{% if job.cmd|length > 1 or ('bash' in job.cmd and job.cmd|length == 3) %}
|
||||||
|
<span class="badge bg-info float-end">Multi-arg</span>
|
||||||
|
{% elif job.cmd|length == 1 %}
|
||||||
|
<span class="badge bg-secondary float-end">Single arg</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if job.cmd is string %}
|
||||||
|
<!-- Legacy fallback: old jobs stored cmd as single string -->
|
||||||
|
<pre><code>{{ job.cmd }}</code></pre>
|
||||||
|
<small class="text-muted">Legacy single-string command</small>
|
||||||
|
{% else %}
|
||||||
|
<!-- Modern list display -->
|
||||||
|
<ol class="mb-0 ps-4">
|
||||||
|
{% for arg in job.cmd %}
|
||||||
|
<li class="mb-2">
|
||||||
|
<code>{{ arg | e }}</code>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
{% if job.cmd[:2] == ['bash', '-c'] %}
|
||||||
|
<hr class="my-3">
|
||||||
|
<p class="mb-0"><strong>Full command passed to bash -c:</strong></p>
|
||||||
|
<pre><code>{{ job.cmd[2] }}</code></pre>
|
||||||
|
<small class="text-muted">This job used shell mode</small>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if job.output or job.errors %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Output & Errors
|
||||||
|
</div>
|
||||||
|
<ul class="nav nav-tabs card-header-tabs">
|
||||||
|
{% if job.output %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" data-bs-toggle="tab" href="#output">Stdout</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if job.errors %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link{% if not job.output %} active{% endif %}" data-bs-toggle="tab" href="#errors">Stderr</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<div class="card-body tab-content">
|
||||||
|
{% if job.output %}
|
||||||
|
<div class="tab-pane fade show active" id="output">
|
||||||
|
<pre><code>{{ job.output | b64decode | forceescape }}</code></pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if job.errors %}
|
||||||
|
<div class="tab-pane fade{% if job.output %} show{% else %} show active{% endif %}" id="errors">
|
||||||
|
<pre><code>{{ job.errors | b64decode | forceescape }}</code></pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if job.artifacts %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Artifacts</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for name, b64 in job.artifacts %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
{{ name }}
|
||||||
|
<a href="data:application/octet-stream;base64,{{ b64 }}" download="{{ name }}" class="btn btn-sm btn-primary">Download</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Job Details</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item"><strong>Status:</strong>
|
||||||
|
{% set status = job.status | upper %}
|
||||||
|
{% if status == "QUEUED" %}
|
||||||
|
<span class="badge bg-secondary">Queued</span>
|
||||||
|
{% elif status == "RUNNING" %}
|
||||||
|
<span class="badge bg-primary">Running</span>
|
||||||
|
{% elif status == "COMPLETED" %}
|
||||||
|
<span class="badge bg-success">Completed</span>
|
||||||
|
{% elif status == "FAILED" %}
|
||||||
|
<span class="badge bg-danger">Failed</span>
|
||||||
|
{% elif status == "CANCELLED" %}
|
||||||
|
<span class="badge bg-warning">Cancelled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark">{{ job.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item"><strong>Owner:</strong> {{ job.owner }}</li>
|
||||||
|
<li class="list-group-item"><strong>Created:</strong> {{ job.created_at.strftime('%b %d, %Y %H:%M') }}</li>
|
||||||
|
<li class="list-group-item"><strong>Started:</strong> {% if job.started_at %}{{ job.started_at.strftime('%H:%M:%S') }}{% else %}-{% endif %}</li>
|
||||||
|
<li class="list-group-item"><strong>Finished:</strong> {% if job.finished_at %}{{ job.finished_at.strftime('%H:%M:%S') }}{% else %}-{% endif %}</li>
|
||||||
|
<li class="list-group-item"><strong>Return Code:</strong> {% if job.return_code is not none %}{{ job.return_code }}{% else %}-{% endif %}</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<strong>Environment Variables:</strong>
|
||||||
|
{% if job.env %}
|
||||||
|
<ul class="list-unstyled mt-2 mb-0">
|
||||||
|
{% for key, value in job.env.items() %}
|
||||||
|
<li><code>{{ key }}={{ value }}</code></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function deleteJob(jobId) {
|
||||||
|
if (!confirm('Really delete Job #' + jobId + ' permanently? This cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/jobs/${jobId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include' // For auth cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Job deleted successfully!');
|
||||||
|
window.location.href = '/jobs';
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert('Failed to delete: ' + (error.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Network error during delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
77
packetserver/http/templates/job_new.html
Normal file
77
packetserver/http/templates/job_new.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}New Job - {{ current_user }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">Queue New Job</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Command</label>
|
||||||
|
<textarea name="cmd" class="form-control" rows="3" placeholder="e.g. python script.py --input data.txt" required></textarea>
|
||||||
|
<div class="form-text">Space-separated command and arguments (like a shell command).</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="shell_mode" name="shell_mode" value="on">
|
||||||
|
<label class="form-check-label" for="shell_mode">
|
||||||
|
Run command through shell (bash -c)
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
Allows full shell syntax, pipes, redirects, globbing, and quoted arguments with spaces.
|
||||||
|
The entire command will be passed as a single string to <code>bash -c</code>.
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Environment Variables</label>
|
||||||
|
<div id="env-fields">
|
||||||
|
<div class="row mb-2 env-row">
|
||||||
|
<div class="col"><input type="text" name="env_keys" class="form-control" placeholder="KEY"></div>
|
||||||
|
<div class="col"><input type="text" name="env_values" class="form-control" placeholder="value"></div>
|
||||||
|
<div class="col-auto"><button type="button" class="btn btn-outline-danger btn-sm" onclick="this.parentElement.parentElement.remove()">Remove</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="addEnvRow()">+ Add Env Var</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Files (scripts, config, data)</label>
|
||||||
|
<input type="file" name="files" class="form-control" multiple>
|
||||||
|
<div class="form-text">Uploaded files will be available in the container's working directory.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Attach Existing Objects by UUID</label>
|
||||||
|
<input type="text" name="objs" class="form-control" placeholder="e.g. 123e4567-e89b-12d3-a456-426614174000, another-uuid-here">
|
||||||
|
<div class="form-text">Comma-separated list of object UUIDs (your own or public). Leave blank for none.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="include_db" name="include_db" value="on">
|
||||||
|
<label class="form-check-label" for="include_db">
|
||||||
|
Include user database (db flag)
|
||||||
|
<small class="text-muted d-block">Adds user-db.json.gz with the full user database snapshot to job files</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary me-2">Queue Job</button>
|
||||||
|
<a href="/jobs" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function addEnvRow() {
|
||||||
|
const container = document.getElementById('env-fields');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row mb-2 env-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="col"><input type="text" name="env_keys" class="form-control" placeholder="KEY"></div>
|
||||||
|
<div class="col"><input type="text" name="env_values" class="form-control" placeholder="value"></div>
|
||||||
|
<div class="col-auto"><button type="button" class="btn btn-outline-danger btn-sm" onclick="this.parentElement.parentElement.remove()">Remove</button></div>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
81
packetserver/http/templates/jobs.html
Normal file
81
packetserver/http/templates/jobs.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}My Jobs - {{ current_user }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">My Jobs</h2>
|
||||||
|
<a href="/jobs/new" class="btn btn-success">
|
||||||
|
<i class="bi bi-plus-lg"></i> New Job
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if jobs %}
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Command</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Finished</th>
|
||||||
|
<th>Return Code</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job in jobs %}
|
||||||
|
<tr class="clickable-row" style="cursor: pointer;" onclick="window.location='/jobs/{{ job.id }}'">
|
||||||
|
<td>{{ job.id }}</td>
|
||||||
|
<td>
|
||||||
|
<code>
|
||||||
|
{% if job.cmd is string %}
|
||||||
|
{{ job.cmd | truncate(80, True, '...') }}
|
||||||
|
{% else %}
|
||||||
|
{{ job.cmd | join(' ') | truncate(80, True, '...') }}
|
||||||
|
{% endif %}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set status = job.status | upper %}
|
||||||
|
{% if status == "QUEUED" %}
|
||||||
|
<span class="badge bg-secondary">Queued</span>
|
||||||
|
{% elif status == "RUNNING" %}
|
||||||
|
<span class="badge bg-primary">Running</span>
|
||||||
|
{% elif status == "COMPLETED" %}
|
||||||
|
<span class="badge bg-success">Completed</span>
|
||||||
|
{% elif status == "FAILED" %}
|
||||||
|
<span class="badge bg-danger">Failed</span>
|
||||||
|
{% elif status == "CANCELLED" %}
|
||||||
|
<span class="badge bg-warning">Cancelled</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark">{{ job.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ job.created_at.strftime('%b %d, %Y %H:%M') }}</td>
|
||||||
|
<td>{% if job.started_at %}{{ job.started_at.strftime('%H:%M') }}{% else %}-{% endif %}</td>
|
||||||
|
<td>{% if job.finished_at %}{{ job.finished_at.strftime('%H:%M') }}{% else %}-{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if job.return_code is not none %}
|
||||||
|
<code>{{ job.return_code }}</code>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No jobs yet. When you run containerized tasks (e.g., packet processing scripts), they'll appear here.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.clickable-row:hover {
|
||||||
|
background-color: rgba(0,123,255,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,38 +1,107 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Messages - PacketServer{% endblock %}
|
{% block title %}Messages{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Messages</h1>
|
<div class="container mt-4">
|
||||||
<div class="mb-4 text-end">
|
<h1>Messages</h1>
|
||||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#composeModal">
|
|
||||||
|
<div class="mb-4 d-flex justify-content-between align-items-start flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<!-- Type tabs -->
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_type == 'received' %}active{% endif %}"
|
||||||
|
href="?type=received{% if current_search %}&search={{ current_search }}{% endif %}&page=1">Received</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_type == 'sent' %}active{% endif %}"
|
||||||
|
href="?type=sent{% if current_search %}&search={{ current_search }}{% endif %}&page=1">Sent</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if current_type == 'all' %}active{% endif %}"
|
||||||
|
href="?type=all{% if current_search %}&search={{ current_search }}{% endif %}&page=1">All</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<!-- Search form -->
|
||||||
|
<form method="get" class="d-flex align-items-center">
|
||||||
|
<input type="hidden" name="type" value="{{ current_type }}">
|
||||||
|
<input type="text" name="search" class="form-control me-2" placeholder="Search messages..." value="{{ current_search or '' }}">
|
||||||
|
<button type="submit" class="btn btn-outline-primary">Search</button>
|
||||||
|
</form>
|
||||||
|
{% if current_search %}
|
||||||
|
<a href="?type={{ current_type }}" class="btn btn-outline-secondary">Clear</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Compose button -->
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#composeModal">
|
||||||
Compose New Message
|
Compose New Message
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total > 0 %}
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Showing {{ ((page-1)*per_page) + 1 }}–{{ page*per_page if page*per_page < total else total }} of {{ total }} messages
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="list-group message-list">
|
||||||
|
{% for msg in messages %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<strong>
|
||||||
|
<a href="/dashboard/message/{{ msg.id }}">
|
||||||
|
{{ msg.text[:80] }}{% if msg.text|length > 80 %}...{% endif %}
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
|
{% if msg.has_attachments %}<span class="badge bg-info ms-2">Attachments</span>{% endif %}
|
||||||
|
{% if not msg.retrieved %}<span class="badge bg-warning ms-2">Unread</span>{% endif %}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
From: {{ msg.from }} | To: {{ msg.to | join(', ') }} | {{ msg.sent_at[:10] }} {{ msg.sent_at[11:16] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-secondary small mt-2">
|
||||||
|
{{ msg.text[:200] }}{% if msg.text|length > 200 %}...{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info mt-4">
|
||||||
|
{% if current_search %}
|
||||||
|
No messages found matching "{{ current_search }}".
|
||||||
|
{% else %}
|
||||||
|
No messages yet.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav aria-label="Messages pagination" class="mt-5">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ page - 1 }}&type={{ current_type }}{% if current_search %}&search={{ current_search }}{% endif %}">Previous</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
<li class="page-item {% if p == page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ p }}&type={{ current_type }}{% if current_search %}&search={{ current_search }}{% endif %}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ page + 1 }}&type={{ current_type }}{% if current_search %}&search={{ current_search }}{% endif %}">Next</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/dashboard" class="btn btn-outline-secondary">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<a href="?msg_type=received" class="btn btn-sm {% if msg_type == 'received' %}btn-primary{% else %}btn-outline-primary{% endif %}">Received</a>
|
|
||||||
<a href="?msg_type=sent" class="btn btn-sm {% if msg_type == 'sent' %}btn-primary{% else %}btn-outline-primary{% endif %}">Sent</a>
|
|
||||||
<a href="?msg_type=all" class="btn btn-sm {% if msg_type == 'all' %}btn-primary{% else %}btn-outline-primary{% endif %}">All</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
<ul class="message-list">
|
|
||||||
{% for msg in messages %}
|
|
||||||
<li>
|
|
||||||
<strong><a href="/dashboard/message/{{ msg.id }}">{{ msg.text[:60] }}{% if msg.text|length > 60 %}...{% endif %}</a></strong>
|
|
||||||
{% if msg.has_attachments %}<span class="text-info"> (Attachments)</span>{% endif %}
|
|
||||||
{% if not msg.retrieved %}<span class="text-warning"> (Unread)</span>{% endif %}
|
|
||||||
<span class="meta">
|
|
||||||
From: {{ msg.from }} | To: {{ msg.to | join(', ') }} | {{ msg.sent_at[:10] }} {{ msg.sent_at[11:19] }}
|
|
||||||
</span>
|
|
||||||
<div class="preview">{{ msg.text[:200] }}{% if msg.text|length > 200 %}...{% endif %}</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p>No messages found.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p><a href="/dashboard">← Back to Dashboard</a></p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
86
packetserver/http/templates/object_detail.html
Normal file
86
packetserver/http/templates/object_detail.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ obj.name }} - Edit Object{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<h2>Edit Object: {{ obj.name }}</h2>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" name="name" class="form-control" value="{{ obj.name }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input type="checkbox" name="private" class="form-check-input" {% if obj.private %}checked{% endif %}>
|
||||||
|
<label class="form-check-label">Private (only you can see/download)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h5>Replace Content</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">New Text Content (forces text type)</label>
|
||||||
|
<textarea name="new_text" class="form-control" rows="8" placeholder="Paste new text here to overwrite as text object"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Or Upload New File (forces binary)</label>
|
||||||
|
<input type="file" name="new_file" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Or Paste Base64 (advanced, forces binary)</label>
|
||||||
|
<textarea name="new_base64" class="form-control" rows="4" placeholder="Paste base64-encoded data"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-success me-2">Save Changes</button>
|
||||||
|
<a href="/objects" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Danger Zone</h5>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="deleteObject('{{ obj.uuid }}', '{{ obj.name | e }}')">Delete This Object Permanently</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Current Details</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item"><strong>UUID:</strong> {{ obj.uuid }}</li>
|
||||||
|
<li class="list-group-item"><strong>Size:</strong> {{ obj.size }} bytes</li>
|
||||||
|
<li class="list-group-item"><strong>Type:</strong> {% if obj.binary %}Binary{% else %}Text{% endif %}</li>
|
||||||
|
<li class="list-group-item"><strong>Uploaded:</strong> {{ obj.created_at.strftime('%b %d, %Y') }}</li>
|
||||||
|
<li class="list-group-item"><strong>Modified:</strong> {{ obj.modified_at.strftime('%b %d, %Y') }}</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card-body">
|
||||||
|
<a href="/api/v1/objects/{{ obj.uuid }}/download" class="btn btn-primary w-100 mb-2">Download Current Version</a>
|
||||||
|
{% if not obj.binary %}
|
||||||
|
<a href="/api/v1/objects/{{ obj.uuid }}/text" class="btn btn-outline-info w-100" target="_blank">View as Text</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function deleteObject(uuid, name) {
|
||||||
|
if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return;
|
||||||
|
const response = await fetch(`/api/v1/objects/${uuid}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/objects';
|
||||||
|
} else {
|
||||||
|
alert('Delete failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
104
packetserver/http/templates/objects.html
Normal file
104
packetserver/http/templates/objects.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}My Objects - {{ current_user }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="mb-4">My Objects</h2>
|
||||||
|
|
||||||
|
<!-- Simple File Upload Form -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Upload File</h5>
|
||||||
|
<form action="/api/v1/objects" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="file" name="file" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="Optional name">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input type="checkbox" name="private" class="form-check-input" checked>
|
||||||
|
<label class="form-check-label">Private</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Upload</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Text Object -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Create Text Object</h5>
|
||||||
|
<form action="/api/v1/objects/text" method="post">
|
||||||
|
<div class="mb-3">
|
||||||
|
<textarea name="text" class="form-control" rows="4" placeholder="Enter text content..." required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="Optional name (e.g. note.txt)">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input type="checkbox" name="private" class="form-check-input" checked>
|
||||||
|
<label class="form-check-label">Private</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Objects Table -->
|
||||||
|
{% if objects %}
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
<th>Visibility</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for obj in objects %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/objects/{{ obj.uuid }}"><strong>{{ obj.name }}</strong></a></td>
|
||||||
|
<td>
|
||||||
|
{% if obj.size < 1024 %}
|
||||||
|
{{ obj.size }} bytes
|
||||||
|
{% elif obj.size < 1048576 %}
|
||||||
|
{{ "%0.1f" | format(obj.size / 1024) }} KB
|
||||||
|
{% else %}
|
||||||
|
{{ "%0.1f" | format(obj.size / 1048576) }} MB
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if obj.binary %}
|
||||||
|
<span class="badge bg-secondary">Binary</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-info">Text</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ obj.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if obj.private %}
|
||||||
|
<span class="badge bg-warning">Private</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Public</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap d-flex align-items-center gap-2">
|
||||||
|
{% if not obj.binary %}
|
||||||
|
<a href="/api/v1/objects/{{ obj.uuid }}/text" class="btn btn-sm btn-outline-info" target="_blank">View Text</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/api/v1/objects/{{ obj.uuid }}/download" class="btn btn-sm btn-primary">Download</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No objects uploaded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Package runs arbitrary commands/jobs via different mechanisms."""
|
"""Package runs arbitrary commands/jobs via different mechanisms."""
|
||||||
from typing import Union,Optional,Iterable,Self
|
from typing import Union, Optional, Iterable, Self, Callable, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import datetime
|
import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -73,8 +73,8 @@ class Runner:
|
|||||||
"""Abstract class to take arguments and run a job and track the status and results."""
|
"""Abstract class to take arguments and run a job and track the status and results."""
|
||||||
def __init__(self, username: str, args: Union[str, list[str]], job_id: int, environment: Optional[dict] = None,
|
def __init__(self, username: str, args: Union[str, list[str]], job_id: int, environment: Optional[dict] = None,
|
||||||
timeout_secs: str = 300, labels: Optional[list] = None,
|
timeout_secs: str = 300, labels: Optional[list] = None,
|
||||||
files: list[RunnerFile] = None):
|
files: list[RunnerFile] = None, notify_function: Callable = None):
|
||||||
self.files = []
|
self.files: List[RunnerFile] = []
|
||||||
if files is not None:
|
if files is not None:
|
||||||
for f in files:
|
for f in files:
|
||||||
self.files.append(f)
|
self.files.append(f)
|
||||||
@@ -87,6 +87,7 @@ class Runner:
|
|||||||
self.finished_at = None
|
self.finished_at = None
|
||||||
self._result = (0,(b'', b''))
|
self._result = (0,(b'', b''))
|
||||||
self._artifact_archive = b''
|
self._artifact_archive = b''
|
||||||
|
self.notify_function = notify_function
|
||||||
if environment:
|
if environment:
|
||||||
for key in environment:
|
for key in environment:
|
||||||
self.env[key] = environment[key]
|
self.env[key] = environment[key]
|
||||||
@@ -98,6 +99,10 @@ class Runner:
|
|||||||
self.timeout_seconds = timeout_secs
|
self.timeout_seconds = timeout_secs
|
||||||
self.created_at = datetime.datetime.now(datetime.UTC)
|
self.created_at = datetime.datetime.now(datetime.UTC)
|
||||||
|
|
||||||
|
def notify(self):
|
||||||
|
if self.notify_function:
|
||||||
|
self.notify_function()
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{type(self).__name__}: {self.username}[{self.job_id}] - {self.status.name}>"
|
return f"<{type(self).__name__}: {self.username}[{self.job_id}] - {self.status.name}>"
|
||||||
|
|
||||||
@@ -150,6 +155,8 @@ class Orchestrator:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.runners = []
|
self.runners = []
|
||||||
self.runner_lock = Lock()
|
self.runner_lock = Lock()
|
||||||
|
self.listeners = []
|
||||||
|
self.started: bool = False
|
||||||
|
|
||||||
def get_finished_runners(self) -> list[Runner]:
|
def get_finished_runners(self) -> list[Runner]:
|
||||||
return [r for r in self.runners if r.is_finished()]
|
return [r for r in self.runners if r.is_finished()]
|
||||||
@@ -178,14 +185,21 @@ class Orchestrator:
|
|||||||
files: list[RunnerFile] = None) -> Runner:
|
files: list[RunnerFile] = None) -> Runner:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def notify_listeners(self):
|
||||||
|
"""If any runners change status, call all listener functions."""
|
||||||
|
for func in self.listeners:
|
||||||
|
func()
|
||||||
|
|
||||||
def manage_lifecycle(self):
|
def manage_lifecycle(self):
|
||||||
"""When called, updates runner statuses and performs any housekeeping."""
|
"""When called, updates runner statuses and performs any housekeeping."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Do any setup and then be ready to operate"""
|
"""Do any setup and then be ready to operate"""
|
||||||
|
self.started = True
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Do any cleanup needed."""
|
"""Do any cleanup needed."""
|
||||||
|
self.started = False
|
||||||
pass
|
pass
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Uses podman to run jobs in containers."""
|
"""Uses podman to run jobs in containers."""
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from ZEO import client
|
from ZEO import client
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ from packetserver import VERSION as packetserver_version
|
|||||||
import re
|
import re
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
env_splitter_rex = '''([a-zA-Z0-9]+)=([a-zA-Z0-9]*)'''
|
env_splitter_rex = '''([a-zA-Z0-9]+)=([a-zA-Z0-9]*)'''
|
||||||
|
|
||||||
@@ -33,9 +35,9 @@ PodmanOptions = namedtuple("PodmanOptions", ["default_timeout", "max_timeout", "
|
|||||||
class PodmanRunner(Runner):
|
class PodmanRunner(Runner):
|
||||||
def __init__(self, username: str, args: Union[str, list[str]], job_id: int, container: Container,
|
def __init__(self, username: str, args: Union[str, list[str]], job_id: int, container: Container,
|
||||||
environment: Optional[dict] = None, timeout_secs: str = 300, labels: Optional[list] = None,
|
environment: Optional[dict] = None, timeout_secs: str = 300, labels: Optional[list] = None,
|
||||||
files: list[RunnerFile] = None):
|
files: list[RunnerFile] = None, notify_function: Callable = None):
|
||||||
super().__init__(username, args, job_id, environment=environment, timeout_secs=timeout_secs,
|
super().__init__(username, args, job_id, environment=environment, timeout_secs=timeout_secs,
|
||||||
labels=labels, files=files)
|
labels=labels, files=files, notify_function=notify_function)
|
||||||
self._artifact_archive = b''
|
self._artifact_archive = b''
|
||||||
if not container.inspect()['State']['Running']:
|
if not container.inspect()['State']['Running']:
|
||||||
raise ValueError(f"Container {container} is not in state Running.")
|
raise ValueError(f"Container {container} is not in state Running.")
|
||||||
@@ -44,10 +46,13 @@ class PodmanRunner(Runner):
|
|||||||
self.env['PACKETSERVER_JOBID'] = str(job_id)
|
self.env['PACKETSERVER_JOBID'] = str(job_id)
|
||||||
self.job_path = os.path.join("/home", self.username, ".packetserver", str(job_id))
|
self.job_path = os.path.join("/home", self.username, ".packetserver", str(job_id))
|
||||||
self.archive_path = os.path.join("/artifact_output", f"{str(job_id)}.tar.gz")
|
self.archive_path = os.path.join("/artifact_output", f"{str(job_id)}.tar.gz")
|
||||||
|
self.env['PACKETSERVER_JOB_HOME'] = self.job_path
|
||||||
|
self.env['PACKETSERVER_ARTIFACT_DIR'] = os.path.join(self.job_path,'artifacts')
|
||||||
|
|
||||||
def thread_runner(self):
|
def thread_runner(self):
|
||||||
self.status = RunnerStatus.RUNNING
|
self.status = RunnerStatus.RUNNING
|
||||||
logging.debug(f"Thread for runner {self.job_id} started. Command for {(type(self.args))}:\n{self.args}")
|
logging.debug(f"Thread for runner {self.job_id} started. Command for {(type(self.args))}:\n{self.args}")
|
||||||
|
self.notify()
|
||||||
# run the exec call
|
# run the exec call
|
||||||
if type(self.args) is str:
|
if type(self.args) is str:
|
||||||
logging.debug(f"Running string: {self.args}")
|
logging.debug(f"Running string: {self.args}")
|
||||||
@@ -60,6 +65,7 @@ class PodmanRunner(Runner):
|
|||||||
logging.debug(str(res))
|
logging.debug(str(res))
|
||||||
# cleanup housekeeping
|
# cleanup housekeeping
|
||||||
self.status = RunnerStatus.STOPPING
|
self.status = RunnerStatus.STOPPING
|
||||||
|
self.notify()
|
||||||
self._result = res
|
self._result = res
|
||||||
# run cleanup script
|
# run cleanup script
|
||||||
logging.debug(f"Running cleanup script for {self.job_id}")
|
logging.debug(f"Running cleanup script for {self.job_id}")
|
||||||
@@ -87,6 +93,7 @@ class PodmanRunner(Runner):
|
|||||||
self.status = RunnerStatus.SUCCESSFUL
|
self.status = RunnerStatus.SUCCESSFUL
|
||||||
else:
|
else:
|
||||||
self.status = RunnerStatus.FAILED
|
self.status = RunnerStatus.FAILED
|
||||||
|
self.notify()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_artifacts(self) -> bool:
|
def has_artifacts(self) -> bool:
|
||||||
@@ -162,7 +169,6 @@ class PodmanRunner(Runner):
|
|||||||
class PodmanOrchestrator(Orchestrator):
|
class PodmanOrchestrator(Orchestrator):
|
||||||
def __init__(self, uri: Optional[str] = None, options: Optional[PodmanOptions] = None):
|
def __init__(self, uri: Optional[str] = None, options: Optional[PodmanOptions] = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.started = False
|
|
||||||
self.user_containers = {}
|
self.user_containers = {}
|
||||||
self.manager_thread = None
|
self.manager_thread = None
|
||||||
self._client = None
|
self._client = None
|
||||||
@@ -236,7 +242,7 @@ class PodmanOrchestrator(Orchestrator):
|
|||||||
def podman_start_user_container(self, username: str) -> Container:
|
def podman_start_user_container(self, username: str) -> Container:
|
||||||
container_env = {
|
container_env = {
|
||||||
"PACKETSERVER_VERSION": packetserver_version,
|
"PACKETSERVER_VERSION": packetserver_version,
|
||||||
"PACKETSERVER_USER": username.strip().lower()
|
"PACKETSERVER_USER": username.strip().lower(),
|
||||||
}
|
}
|
||||||
logging.debug(f"Starting user container for {username} with command {podman_run_command}")
|
logging.debug(f"Starting user container for {username} with command {podman_run_command}")
|
||||||
con = self.client.containers.create(self.opts.image_name, name=self.get_container_name(username),
|
con = self.client.containers.create(self.opts.image_name, name=self.get_container_name(username),
|
||||||
@@ -411,7 +417,7 @@ class PodmanOrchestrator(Orchestrator):
|
|||||||
self.touch_user_container(username)
|
self.touch_user_container(username)
|
||||||
logging.debug(f"Queuing a runner on container {con}, with command '{args}' of type '{type(args)}'")
|
logging.debug(f"Queuing a runner on container {con}, with command '{args}' of type '{type(args)}'")
|
||||||
runner = PodmanRunner(username, args, job_id, con, environment=environment, timeout_secs=timeout_secs,
|
runner = PodmanRunner(username, args, job_id, con, environment=environment, timeout_secs=timeout_secs,
|
||||||
labels=labels, files=files)
|
labels=labels, files=files, notify_function=lambda : self.notify_listeners())
|
||||||
self.runners.append(runner)
|
self.runners.append(runner)
|
||||||
runner.start()
|
runner.start()
|
||||||
return runner
|
return runner
|
||||||
|
|||||||
@@ -13,14 +13,10 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import ZODB.FileStorage
|
|
||||||
import ZODB.DB
|
|
||||||
import logging
|
|
||||||
from packetserver.http.server import app
|
from packetserver.http.server import app
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Run the PacketServer HTTP API server")
|
parser = argparse.ArgumentParser(description="Run the PacketServer HTTP API server")
|
||||||
parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
|
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
||||||
parser.add_argument("--port", type=int, default=8080, help="Port to listen on (default: 8080)")
|
parser.add_argument("--port", type=int, default=8080, help="Port to listen on (default: 8080)")
|
||||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload during development")
|
parser.add_argument("--reload", action="store_true", help="Enable auto-reload during development")
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
import ax25
|
import ax25
|
||||||
|
import os.path
|
||||||
|
import os
|
||||||
|
|
||||||
import ZODB.FileStorage
|
import ZODB.FileStorage
|
||||||
import ZODB.DB
|
import ZODB.DB
|
||||||
import transaction
|
import transaction
|
||||||
from persistent.mapping import PersistentMapping
|
from persistent.mapping import PersistentMapping
|
||||||
|
|
||||||
|
os.environ['PS_APP_ZEO_FILE'] = "N/A"
|
||||||
|
|
||||||
# Import our HTTP package internals
|
# Import our HTTP package internals
|
||||||
from packetserver.http.auth import HttpUser, ph # ph = PasswordHasher
|
from packetserver.http.auth import HttpUser, ph # ph = PasswordHasher
|
||||||
|
|
||||||
@@ -42,6 +46,13 @@ def open_database(db_arg: str) -> ZODB.DB:
|
|||||||
return ZODB.DB(storage)
|
return ZODB.DB(storage)
|
||||||
|
|
||||||
|
|
||||||
|
def open_database_zeo_file(filename: str) -> ZODB.DB:
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
return open_database(open(filename,'r').read().strip())
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError("Must provide a filename to a zeo address.")
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_http_users(root):
|
def get_or_create_http_users(root):
|
||||||
if HTTP_USERS_KEY not in root:
|
if HTTP_USERS_KEY not in root:
|
||||||
root[HTTP_USERS_KEY] = PersistentMapping()
|
root[HTTP_USERS_KEY] = PersistentMapping()
|
||||||
@@ -55,7 +66,8 @@ def confirm(prompt: str) -> bool:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Manage PacketServer HTTP API users")
|
parser = argparse.ArgumentParser(description="Manage PacketServer HTTP API users")
|
||||||
parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
|
parser.add_argument("--db", required=False, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
|
||||||
|
parser.add_argument("--zeo-file", required=False, help="zeo address file")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
# add
|
# add
|
||||||
@@ -99,12 +111,16 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Open the database
|
# Open the database
|
||||||
db = open_database(args.db)
|
if args.db:
|
||||||
connection = db.open()
|
db = open_database(args.db)
|
||||||
root = connection.root()
|
else:
|
||||||
|
db = open_database_zeo_file(args.zeo_file)
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
users_mapping = get_or_create_http_users(root)
|
with db.transaction() as conn:
|
||||||
|
root = conn.root()
|
||||||
|
http_users_list = list(get_or_create_http_users(root).keys())
|
||||||
|
|
||||||
upper_callsign = lambda c: c.upper()
|
upper_callsign = lambda c: c.upper()
|
||||||
|
|
||||||
@@ -117,7 +133,7 @@ def main():
|
|||||||
print(f"Error: Trying to add valid callsign + ssid. Remove -<num> and add again.")
|
print(f"Error: Trying to add valid callsign + ssid. Remove -<num> and add again.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if callsign in users_mapping:
|
if callsign in http_users_list:
|
||||||
print(f"Error: HTTP user {callsign} already exists")
|
print(f"Error: HTTP user {callsign} already exists")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -127,79 +143,90 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Create the HTTP-specific user
|
# Create the HTTP-specific user
|
||||||
http_user = HttpUser(args.callsign, password)
|
with db.transaction() as conn:
|
||||||
users_mapping[callsign] = http_user
|
root = conn.root()
|
||||||
|
http_user = HttpUser(args.callsign, password)
|
||||||
|
users_mapping = get_or_create_http_users(conn.root())
|
||||||
|
users_mapping[callsign] = http_user
|
||||||
|
# Sync: create corresponding regular BBS user using proper write_new for UUID/uniqueness
|
||||||
|
from packetserver.server.users import User
|
||||||
|
|
||||||
# Sync: create corresponding regular BBS user using proper write_new for UUID/uniqueness
|
main_users = root.setdefault('users', PersistentMapping())
|
||||||
from packetserver.server.users import User
|
if callsign not in main_users:
|
||||||
|
new_user = User(args.callsign)
|
||||||
main_users = root.setdefault('users', PersistentMapping())
|
new_user.write_new(conn.root())
|
||||||
if callsign not in main_users:
|
print(f" → Also created regular BBS user {callsign}")
|
||||||
User.write_new(main_users, args.callsign) # correct: pass mapping + callsign
|
else:
|
||||||
print(f" → Also created regular BBS user {callsign} (with UUID)")
|
print(f" → Regular BBS user {callsign} already exists")
|
||||||
else:
|
print(f"Created HTTP user {callsign}")
|
||||||
print(f" → Regular BBS user {callsign} already exists")
|
|
||||||
|
|
||||||
transaction.commit()
|
|
||||||
print(f"Created HTTP user {callsign}")
|
|
||||||
|
|
||||||
elif args.command == "delete":
|
elif args.command == "delete":
|
||||||
callsign = upper_callsign(args.callsign)
|
callsign = upper_callsign(args.callsign)
|
||||||
if callsign not in users_mapping:
|
if callsign not in http_users_list:
|
||||||
print(f"Error: User {callsign} not found")
|
print(f"Error: User {callsign} not found")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if not confirm(f"Delete HTTP user {callsign}?"):
|
if not confirm(f"Delete HTTP user {callsign}?"):
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
del users_mapping[callsign]
|
with db.transaction() as conn:
|
||||||
transaction.commit()
|
root = conn.root()
|
||||||
print(f"Deleted HTTP user {callsign}")
|
users_mapping = get_or_create_http_users(root)
|
||||||
|
del users_mapping[callsign]
|
||||||
|
print(f"Deleted HTTP user {callsign}")
|
||||||
|
|
||||||
elif args.command == "set-password":
|
elif args.command == "set-password":
|
||||||
callsign = upper_callsign(args.callsign)
|
callsign = upper_callsign(args.callsign)
|
||||||
user = users_mapping.get(callsign)
|
with db.transaction() as conn:
|
||||||
if not user:
|
root = conn.root()
|
||||||
print(f"Error: User {callsign} not found")
|
users_mapping = get_or_create_http_users(root)
|
||||||
sys.exit(1)
|
user = users_mapping.get(callsign)
|
||||||
newpass = args.newpassword or getpass("New password: ")
|
if not user:
|
||||||
if not newpass:
|
print(f"Error: User {callsign} not found")
|
||||||
print("Error: No password provided")
|
sys.exit(1)
|
||||||
sys.exit(1)
|
newpass = args.newpassword or getpass("New password: ")
|
||||||
user.password_hash = ph.hash(newpass)
|
if not newpass:
|
||||||
user._p_changed = True
|
print("Error: No password provided")
|
||||||
transaction.commit()
|
sys.exit(1)
|
||||||
print(f"Password updated for {callsign}")
|
user.password_hash = ph.hash(newpass)
|
||||||
|
user._p_changed = True
|
||||||
|
print(f"Password updated for {callsign}")
|
||||||
|
|
||||||
elif args.command == "enable":
|
elif args.command == "enable":
|
||||||
callsign = upper_callsign(args.callsign)
|
with db.transaction() as conn:
|
||||||
user = users_mapping.get(callsign)
|
root = conn.root()
|
||||||
if not user:
|
users_mapping = get_or_create_http_users(root)
|
||||||
print(f"Error: User {callsign} not found")
|
callsign = upper_callsign(args.callsign)
|
||||||
sys.exit(1)
|
user = users_mapping.get(callsign)
|
||||||
user.enabled = True
|
if not user:
|
||||||
user._p_changed = True
|
print(f"Error: User {callsign} not found")
|
||||||
transaction.commit()
|
sys.exit(1)
|
||||||
print(f"HTTP access enabled for {callsign}")
|
user.http_enabled = True
|
||||||
|
user._p_changed = True
|
||||||
|
print(f"HTTP access enabled for {callsign}")
|
||||||
|
|
||||||
elif args.command == "disable":
|
elif args.command == "disable":
|
||||||
callsign = upper_callsign(args.callsign)
|
callsign = upper_callsign(args.callsign)
|
||||||
user = users_mapping.get(callsign)
|
with db.transaction() as conn:
|
||||||
if not user:
|
root = conn.root()
|
||||||
print(f"Error: User {callsign} not found")
|
users_mapping = get_or_create_http_users(root)
|
||||||
sys.exit(1)
|
user = users_mapping.get(callsign)
|
||||||
user.enabled = False
|
if not user:
|
||||||
user._p_changed = True
|
print(f"Error: User {callsign} not found")
|
||||||
transaction.commit()
|
sys.exit(1)
|
||||||
print(f"HTTP access disabled for {callsign}")
|
user.http_enabled = False
|
||||||
|
user._p_changed = True
|
||||||
|
print(f"HTTP access disabled for {callsign}")
|
||||||
|
|
||||||
elif args.command == "rf-enable":
|
elif args.command == "rf-enable":
|
||||||
callsign = upper_callsign(args.callsign)
|
callsign = upper_callsign(args.callsign)
|
||||||
user = users_mapping.get(callsign)
|
with db.transaction() as conn:
|
||||||
if not user:
|
root = conn.root()
|
||||||
print(f"Error: User {callsign} not found")
|
users_mapping = get_or_create_http_users(root)
|
||||||
sys.exit(1)
|
user = users_mapping.get(callsign)
|
||||||
|
if not user:
|
||||||
|
print(f"Error: User {callsign} not found")
|
||||||
|
sys.exit(1)
|
||||||
try:
|
try:
|
||||||
user.set_rf_enabled(connection, True)
|
user.set_rf_enabled(db, True)
|
||||||
transaction.commit()
|
|
||||||
print(f"RF gateway enabled for {callsign}")
|
print(f"RF gateway enabled for {callsign}")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"Error: {e}")
|
print(f"Error: {e}")
|
||||||
@@ -207,67 +234,75 @@ def main():
|
|||||||
|
|
||||||
elif args.command == "rf-disable":
|
elif args.command == "rf-disable":
|
||||||
callsign = upper_callsign(args.callsign)
|
callsign = upper_callsign(args.callsign)
|
||||||
user = users_mapping.get(callsign)
|
with db.transaction() as conn:
|
||||||
if not user:
|
root = conn.root()
|
||||||
print(f"Error: User {callsign} not found")
|
users_mapping = get_or_create_http_users(root)
|
||||||
sys.exit(1)
|
user = users_mapping.get(callsign)
|
||||||
user.set_rf_enabled(connection, False)
|
if not user:
|
||||||
transaction.commit()
|
print(f"Error: User {callsign} not found")
|
||||||
|
sys.exit(1)
|
||||||
|
user.set_rf_enabled(db, False)
|
||||||
print(f"RF gateway disabled for {callsign}")
|
print(f"RF gateway disabled for {callsign}")
|
||||||
|
|
||||||
elif args.command == "list":
|
elif args.command == "list":
|
||||||
if not users_mapping:
|
if not http_users_list:
|
||||||
print("No HTTP users configured")
|
print("No HTTP users configured")
|
||||||
else:
|
else:
|
||||||
print(f"{'Callsign':<12} {'HTTP Enabled':<13} {'RF Enabled':<11} {'Created':<20} Last Login")
|
with db.transaction() as conn:
|
||||||
print("-" * 75)
|
root = conn.root()
|
||||||
for user in sorted(users_mapping.values(), key=lambda u: u.username):
|
users_mapping = get_or_create_http_users(root)
|
||||||
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(user.created_at))
|
print(f"{'Callsign':<12} {'HTTP Enabled':<13} {'RF Enabled':<11} {'Created':<20} Last Login")
|
||||||
last = (time.strftime("%Y-%m-%d %H:%M", time.localtime(user.last_login))
|
print("-" * 75)
|
||||||
if user.last_login else "-")
|
for user in sorted(users_mapping.values(), key=lambda u: u.username):
|
||||||
rf_status = "True" if user.is_rf_enabled(connection) else "False"
|
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(user.created_at))
|
||||||
print(f"{user.username:<12} {str(user.http_enabled):<13} {rf_status:<11} {created:<20} {last}")
|
last = (time.strftime("%Y-%m-%d %H:%M", time.localtime(user.last_login))
|
||||||
|
if user.last_login else "-")
|
||||||
|
rf_status = "True" if user.is_rf_enabled(conn) else "False"
|
||||||
|
print(f"{user.username:<12} {str(user.http_enabled):<13} {rf_status:<11} {created:<20} {last}")
|
||||||
|
|
||||||
elif args.command == "dump":
|
elif args.command == "dump":
|
||||||
import json
|
import json
|
||||||
|
|
||||||
callsign = upper_callsign(args.callsign)
|
callsign = upper_callsign(args.callsign)
|
||||||
http_user = users_mapping.get(callsign)
|
with db.transaction() as conn:
|
||||||
if not http_user:
|
root = conn.root()
|
||||||
print(f"Error: No HTTP user {callsign} found")
|
users_mapping = get_or_create_http_users(root)
|
||||||
sys.exit(1)
|
http_user = users_mapping.get(callsign)
|
||||||
|
if not http_user:
|
||||||
|
print(f"Error: No HTTP user {callsign} found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
main_users = root.get('users', {})
|
main_users = root.get('users', {})
|
||||||
bbs_user = main_users.get(callsign)
|
bbs_user = main_users.get(callsign)
|
||||||
if not bbs_user:
|
if not bbs_user:
|
||||||
print(f"Error: No corresponding BBS user {callsign} found")
|
print(f"Error: No corresponding BBS user {callsign} found")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
dump_data = {
|
dump_data = {
|
||||||
"http_user": {
|
"http_user": {
|
||||||
"username": http_user.username,
|
"username": http_user.username,
|
||||||
"http_enabled": http_user.http_enabled,
|
"http_enabled": http_user.http_enabled,
|
||||||
"rf_enabled": http_user.is_rf_enabled(connection),
|
"rf_enabled": http_user.is_rf_enabled(conn),
|
||||||
"blacklisted": not http_user.is_rf_enabled(connection), # explicit inverse
|
"blacklisted": not http_user.is_rf_enabled(conn), # explicit inverse
|
||||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(http_user.created_at)),
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(http_user.created_at)),
|
||||||
"failed_attempts": http_user.failed_attempts,
|
"failed_attempts": http_user.failed_attempts,
|
||||||
},
|
},
|
||||||
"bbs_user": {
|
"bbs_user": {
|
||||||
"username": bbs_user.username,
|
"username": bbs_user.username,
|
||||||
"uuid": str(bbs_user.uuid) if hasattr(bbs_user, 'uuid') and bbs_user.uuid else None,
|
"uuid": str(bbs_user.uuid) if hasattr(bbs_user, 'uuid') and bbs_user.uuid else None,
|
||||||
"hidden": bbs_user.hidden,
|
"hidden": bbs_user.hidden,
|
||||||
"enabled": bbs_user.enabled, # BBS enabled flag
|
"enabled": bbs_user.enabled, # BBS enabled flag
|
||||||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.created_at.timestamp())) if hasattr(bbs_user.created_at, "timestamp") else str(bbs_user.created_at),
|
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.created_at.timestamp())) if hasattr(bbs_user.created_at, "timestamp") else str(bbs_user.created_at),
|
||||||
"last_seen": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.last_seen.timestamp())) if hasattr(bbs_user.last_seen, "timestamp") else str(bbs_user.last_seen),
|
"last_seen": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(bbs_user.last_seen.timestamp())) if hasattr(bbs_user.last_seen, "timestamp") else str(bbs_user.last_seen),
|
||||||
"bio": bbs_user.bio.strip() or None,
|
"bio": bbs_user.bio.strip() or None,
|
||||||
"status": bbs_user.status.strip() or None,
|
"status": bbs_user.status.strip() or None,
|
||||||
"email": bbs_user.email.strip() if bbs_user.email != " " else None,
|
"email": bbs_user.email.strip() if bbs_user.email != " " else None,
|
||||||
"location": bbs_user.location.strip() if bbs_user.location != " " else None,
|
"location": bbs_user.location.strip() if bbs_user.location != " " else None,
|
||||||
"socials": bbs_user.socials,
|
"socials": bbs_user.socials,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
print(json.dumps(dump_data, indent=4))
|
print(json.dumps(dump_data, indent=4))
|
||||||
|
|
||||||
elif args.command == "sync-missing":
|
elif args.command == "sync-missing":
|
||||||
import secrets
|
import secrets
|
||||||
@@ -277,38 +312,39 @@ def main():
|
|||||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
bbs_users = root.get('users', {})
|
with db.transaction() as conn:
|
||||||
http_users = get_or_create_http_users(root)
|
root = conn.root()
|
||||||
|
bbs_users = root.get('users', {})
|
||||||
|
http_users = get_or_create_http_users(root)
|
||||||
|
|
||||||
missing = [call for call in bbs_users if call not in http_users and call != "SYSTEM"]
|
missing = [call for call in bbs_users if call not in http_users and call != "SYSTEM"]
|
||||||
if not missing:
|
if not missing:
|
||||||
print("No missing HTTP users—all BBS users have HttpUser objects")
|
print("No missing HTTP users—all BBS users have HttpUser objects")
|
||||||
else:
|
|
||||||
print(f"Found {len(missing)} BBS users without HTTP accounts:")
|
|
||||||
for call in sorted(missing):
|
|
||||||
print(f" - {call}")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
print("\n--dry-run: No changes made")
|
|
||||||
else:
|
else:
|
||||||
confirm_msg = f"Create {len(missing)} new HttpUser objects (http_enabled={'True' if args.enable else 'False'})?"
|
print(f"Found {len(missing)} BBS users without HTTP accounts:")
|
||||||
if not confirm(confirm_msg):
|
for call in sorted(missing):
|
||||||
print("Aborted")
|
print(f" - {call}")
|
||||||
else:
|
|
||||||
created_count = 0
|
|
||||||
for call in missing:
|
|
||||||
password = generate_password() # strong random, not printed
|
|
||||||
new_http = HttpUser(call, password)
|
|
||||||
new_http.http_enabled = args.enable
|
|
||||||
http_users[call] = new_http
|
|
||||||
created_count += 1
|
|
||||||
|
|
||||||
transaction.commit()
|
if args.dry_run:
|
||||||
print(f"\nSync complete: {created_count} HTTP users added (passwords random & hidden)")
|
print("\n--dry-run: No changes made")
|
||||||
print("Use 'set-password <call>' to set a known password before enabling login")
|
else:
|
||||||
|
confirm_msg = f"Create {len(missing)} new HttpUser objects (http_enabled={'True' if args.enable else 'False'})?"
|
||||||
|
if not confirm(confirm_msg):
|
||||||
|
print("Aborted")
|
||||||
|
else:
|
||||||
|
created_count = 0
|
||||||
|
for call in missing:
|
||||||
|
password = generate_password() # strong random, not printed
|
||||||
|
new_http = HttpUser(call, password)
|
||||||
|
new_http.http_enabled = args.enable
|
||||||
|
http_users[call] = new_http
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
transaction.commit()
|
||||||
|
print(f"\nSync complete: {created_count} HTTP users added (passwords random & hidden)")
|
||||||
|
print("Use 'set-password <call>' to set a known password before enabling login")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
connection.close()
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import tempfile
|
|||||||
|
|
||||||
import pe.app
|
import pe.app
|
||||||
from packetserver.common import Response, Message, Request, PacketServerConnection, send_response, send_blank_response
|
from packetserver.common import Response, Message, Request, PacketServerConnection, send_response, send_blank_response
|
||||||
from packetserver.server.constants import default_server_config
|
from packetserver.server.constants import default_server_config, default_server_name
|
||||||
from packetserver.server.users import User
|
from packetserver.server.users import User
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
import ax25
|
import ax25
|
||||||
@@ -50,6 +50,7 @@ class Server:
|
|||||||
self.check_job_queue = True
|
self.check_job_queue = True
|
||||||
self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
|
self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
|
||||||
self.job_check_interval = 60
|
self.job_check_interval = 60
|
||||||
|
self.default_job_check_interval = 60
|
||||||
self.quick_job = False
|
self.quick_job = False
|
||||||
if data_dir:
|
if data_dir:
|
||||||
data_path = Path(data_dir)
|
data_path = Path(data_dir)
|
||||||
@@ -74,6 +75,13 @@ class Server:
|
|||||||
logging.debug("no config, writing blank default config")
|
logging.debug("no config, writing blank default config")
|
||||||
conn.root.config = PersistentMapping(deepcopy(default_server_config))
|
conn.root.config = PersistentMapping(deepcopy(default_server_config))
|
||||||
conn.root.config['blacklist'] = PersistentList()
|
conn.root.config['blacklist'] = PersistentList()
|
||||||
|
logging.debug(f"Setting server callsign in db to: {self.callsign}")
|
||||||
|
conn.root.server_callsign = self.callsign
|
||||||
|
for key in ['motd', 'operator']:
|
||||||
|
if key not in conn.root.config:
|
||||||
|
conn.root.config[key] = ""
|
||||||
|
if 'server_name' not in conn.root.config:
|
||||||
|
conn.root.config['server_name'] = default_server_name
|
||||||
if 'SYSTEM' not in conn.root.config['blacklist']:
|
if 'SYSTEM' not in conn.root.config['blacklist']:
|
||||||
logging.debug("Adding 'SYSTEM' to blacklist in case someone feels like violating FCC rules.")
|
logging.debug("Adding 'SYSTEM' to blacklist in case someone feels like violating FCC rules.")
|
||||||
conn.root.config['blacklist'].append('SYSTEM')
|
conn.root.config['blacklist'].append('SYSTEM')
|
||||||
@@ -105,6 +113,7 @@ class Server:
|
|||||||
if val in ['podman']:
|
if val in ['podman']:
|
||||||
logging.debug(f"Enabling {val} orchestrator")
|
logging.debug(f"Enabling {val} orchestrator")
|
||||||
self.orchestrator = get_orchestrator_from_config(conn.root.config['jobs_config'])
|
self.orchestrator = get_orchestrator_from_config(conn.root.config['jobs_config'])
|
||||||
|
self.orchestrator.listeners.append(lambda : self.ping_job_queue())
|
||||||
|
|
||||||
self.app = pe.app.Application()
|
self.app = pe.app.Application()
|
||||||
PacketServerConnection.receive_subscribers.append(lambda x: self.server_receiver(x))
|
PacketServerConnection.receive_subscribers.append(lambda x: self.server_receiver(x))
|
||||||
@@ -123,10 +132,11 @@ class Server:
|
|||||||
self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
|
self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
|
||||||
if self.quick_job:
|
if self.quick_job:
|
||||||
logging.debug("Setting the final quick job timer.")
|
logging.debug("Setting the final quick job timer.")
|
||||||
self.job_check_interval = 5
|
if self.default_job_check_interval > 5:
|
||||||
|
self.job_check_interval = 5
|
||||||
self.quick_job = False
|
self.quick_job = False
|
||||||
else:
|
else:
|
||||||
self.job_check_interval = 60
|
self.job_check_interval = self.default_job_check_interval
|
||||||
|
|
||||||
def server_connection_bouncer(self, conn: PacketServerConnection):
|
def server_connection_bouncer(self, conn: PacketServerConnection):
|
||||||
logging.debug("new connection bouncer checking user status")
|
logging.debug("new connection bouncer checking user status")
|
||||||
@@ -225,6 +235,13 @@ class Server:
|
|||||||
self.ping_job_queue()
|
self.ping_job_queue()
|
||||||
if (self.orchestrator is not None) and self.orchestrator.started and self.check_job_queue:
|
if (self.orchestrator is not None) and self.orchestrator.started and self.check_job_queue:
|
||||||
with self.db.transaction() as storage:
|
with self.db.transaction() as storage:
|
||||||
|
if 'job_check_interval' in storage.root.config:
|
||||||
|
try:
|
||||||
|
self.default_job_check_interval = int(storage.root.config['job_check_interval'])
|
||||||
|
if self.job_check_interval > self.default_job_check_interval:
|
||||||
|
self.job_check_interval = self.default_job_check_interval
|
||||||
|
except:
|
||||||
|
logging.warning(f"Invalid config value for 'job_check_interval'")
|
||||||
# queue as many jobs as possible
|
# queue as many jobs as possible
|
||||||
while self.orchestrator.runners_available():
|
while self.orchestrator.runners_available():
|
||||||
if len(storage.root.job_queue) > 0:
|
if len(storage.root.job_queue) > 0:
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
|
||||||
|
default_server_name = "Packet Server BBS"
|
||||||
|
|
||||||
default_server_config = {
|
default_server_config = {
|
||||||
"motd": "Welcome to this PacketServer BBS!",
|
"motd": "Welcome to this PacketServer BBS!",
|
||||||
"operator": "placeholder",
|
"operator": "email_callsign_name_whatever",
|
||||||
"max_message_length": 2000
|
"max_message_length": 2000,
|
||||||
|
"server_name": default_server_name
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs_default = {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -5,15 +5,17 @@ import persistent
|
|||||||
import persistent.list
|
import persistent.list
|
||||||
from persistent.mapping import PersistentMapping
|
from persistent.mapping import PersistentMapping
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Self,Union,Optional,Tuple
|
from typing import Self,Union,Optional,Tuple,List
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
|
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
|
||||||
from packetserver.common.constants import no_values, yes_values
|
from packetserver.common.constants import no_values, yes_values
|
||||||
from packetserver.server.db import get_user_db_json
|
from packetserver.server.db import get_user_db_json
|
||||||
import ZODB
|
import ZODB
|
||||||
|
from ZODB.Connection import Connection
|
||||||
from persistent.list import PersistentList
|
from persistent.list import PersistentList
|
||||||
import logging
|
import logging
|
||||||
from packetserver.server.users import user_authorized
|
from packetserver.server.users import user_authorized, User
|
||||||
|
from packetserver.server.objects import Object
|
||||||
import gzip
|
import gzip
|
||||||
import tarfile
|
import tarfile
|
||||||
import time
|
import time
|
||||||
@@ -23,6 +25,7 @@ from packetserver.runner import Orchestrator, Runner, RunnerStatus, RunnerFile
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import base64
|
import base64
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
class JobStatus(Enum):
|
class JobStatus(Enum):
|
||||||
CREATED = 1
|
CREATED = 1
|
||||||
@@ -58,6 +61,37 @@ def get_new_job_id(root: PersistentMapping) -> int:
|
|||||||
root['job_counter'] = current + 1
|
root['job_counter'] = current + 1
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
def add_object_to_file_list(object_id: Union[str,UUID], file_list: List[RunnerFile], username: str, conn: Connection):
|
||||||
|
if type(object_id) is str:
|
||||||
|
object_id = UUID(object_id)
|
||||||
|
logging.debug("Adding an object to file list for new job.")
|
||||||
|
root = conn.root()
|
||||||
|
logging.debug("Got db root from transaction/connection object.")
|
||||||
|
obj = Object.get_object_by_uuid(object_id, root)
|
||||||
|
logging.debug(f"Looked up object {obj}")
|
||||||
|
if obj is None:
|
||||||
|
raise KeyError(f"Object '{object_id}' does not exist.")
|
||||||
|
if obj.private:
|
||||||
|
owner_uuid = obj.owner
|
||||||
|
owner = User.get_user_by_uuid(owner_uuid, root)
|
||||||
|
logging.debug(f"Looked up owner of object: {owner}")
|
||||||
|
if not (owner.username.lower() == username.lower()):
|
||||||
|
raise PermissionError(f"Specified object {object_id} not public and not owned by user.")
|
||||||
|
logging.debug("Checking paths now.")
|
||||||
|
unique_path = obj.name
|
||||||
|
runner_paths = []
|
||||||
|
for i in file_list:
|
||||||
|
runner_paths.append(i.destination_path)
|
||||||
|
suffix = 1
|
||||||
|
while unique_path in runner_paths:
|
||||||
|
unique_path = obj.name + f"_{suffix}"
|
||||||
|
suffix = suffix + 1
|
||||||
|
|
||||||
|
rf = RunnerFile(unique_path,data=obj.data_bytes)
|
||||||
|
file_list.append(rf)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Job(persistent.Persistent):
|
class Job(persistent.Persistent):
|
||||||
@classmethod
|
@classmethod
|
||||||
def update_job_from_runner(cls, runner: Runner, db_root: PersistentMapping) -> True:
|
def update_job_from_runner(cls, runner: Runner, db_root: PersistentMapping) -> True:
|
||||||
@@ -202,6 +236,7 @@ class Job(persistent.Persistent):
|
|||||||
"return_code": self.return_code,
|
"return_code": self.return_code,
|
||||||
"artifacts": [],
|
"artifacts": [],
|
||||||
"status": self.status.name,
|
"status": self.status.name,
|
||||||
|
"env": self.env,
|
||||||
"id": self.id
|
"id": self.id
|
||||||
}
|
}
|
||||||
if include_data:
|
if include_data:
|
||||||
@@ -296,7 +331,7 @@ def handle_new_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB)
|
|||||||
if type(req.payload['cmd']) not in [str, list]:
|
if type(req.payload['cmd']) not in [str, list]:
|
||||||
send_blank_response(conn, req, 401, "job post must contain cmd key containing str or list[str]")
|
send_blank_response(conn, req, 401, "job post must contain cmd key containing str or list[str]")
|
||||||
return
|
return
|
||||||
files = []
|
files: List[RunnerFile] = []
|
||||||
if 'db' in req.payload:
|
if 'db' in req.payload:
|
||||||
logging.debug(f"Fetching a user db as requested.")
|
logging.debug(f"Fetching a user db as requested.")
|
||||||
try:
|
try:
|
||||||
@@ -312,6 +347,11 @@ def handle_new_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB)
|
|||||||
val = req.payload['files'][key]
|
val = req.payload['files'][key]
|
||||||
if type(val) is bytes:
|
if type(val) is bytes:
|
||||||
files.append(RunnerFile(key, data=val))
|
files.append(RunnerFile(key, data=val))
|
||||||
|
if 'objs' in req.payload:
|
||||||
|
if type(req.payload['objs']) is list:
|
||||||
|
with db.transaction() as db_connection:
|
||||||
|
for obj in req.payload['objs']:
|
||||||
|
add_object_to_file_list(obj, files, username, db_connection)
|
||||||
env = {}
|
env = {}
|
||||||
if 'env' in req.payload:
|
if 'env' in req.payload:
|
||||||
if type(req.payload['env']) is dict:
|
if type(req.payload['env']) is dict:
|
||||||
@@ -360,6 +400,29 @@ def handle_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB):
|
|||||||
else:
|
else:
|
||||||
send_blank_response(conn, req, status_code=404)
|
send_blank_response(conn, req, status_code=404)
|
||||||
|
|
||||||
|
def handle_job_delete(req: Request, conn: PacketServerConnection, db: ZODB.DB):
|
||||||
|
spl = [x for x in req.path.split("/") if x.strip() != ""]
|
||||||
|
if (len(spl) == 2) and (spl[1].isdigit()):
|
||||||
|
jid = int(spl[1])
|
||||||
|
logging.debug(f"Asked to delete job {jid}")
|
||||||
|
with db.transaction() as storage:
|
||||||
|
username = ax25.Address(conn.remote_callsign).call.upper().strip()
|
||||||
|
if jid in storage.user_jobs[username]:
|
||||||
|
logging.debug(f"User {username} is authorized to delete job {jid}")
|
||||||
|
if jid in storage.jobs:
|
||||||
|
del storage.jobs[jid]
|
||||||
|
storage.user_jobs[username].remove(jid)
|
||||||
|
logging.debug(f"Deleted job {jid}")
|
||||||
|
send_blank_response(conn, req, status_code=204, payload=f"Deleted job {jid}")
|
||||||
|
else:
|
||||||
|
if jid in storage.jobs:
|
||||||
|
logging.error(f"Job with no owner detected: {jid}")
|
||||||
|
send_blank_response(conn, req, status_code=500, payload="Job not owned by any user.")
|
||||||
|
else:
|
||||||
|
send_blank_response(conn, req, status_code=404)
|
||||||
|
else:
|
||||||
|
send_blank_response(conn, req, 400, payload="bad delete job request")
|
||||||
|
|
||||||
def job_root_handler(req: Request, conn: PacketServerConnection, db: ZODB.DB):
|
def job_root_handler(req: Request, conn: PacketServerConnection, db: ZODB.DB):
|
||||||
logging.debug(f"{req} being processed by job_root_handler")
|
logging.debug(f"{req} being processed by job_root_handler")
|
||||||
if not user_authorized(conn, db):
|
if not user_authorized(conn, db):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Server object storage system."""
|
"""Server object storage system."""
|
||||||
|
import traceback
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
import persistent
|
import persistent
|
||||||
@@ -9,6 +10,7 @@ import datetime
|
|||||||
from typing import Self,Union,Optional
|
from typing import Self,Union,Optional
|
||||||
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
|
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
|
||||||
import ZODB
|
import ZODB
|
||||||
|
from ZODB.Connection import Connection
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -76,6 +78,10 @@ class Object(persistent.Persistent):
|
|||||||
self._binary = False
|
self._binary = False
|
||||||
self.touch()
|
self.touch()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_bytes(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def owner(self) -> Optional[UUID]:
|
def owner(self) -> Optional[UUID]:
|
||||||
return self._owner
|
return self._owner
|
||||||
@@ -96,12 +102,12 @@ class Object(persistent.Persistent):
|
|||||||
logging.debug(f"chowning object {self} to user {username}")
|
logging.debug(f"chowning object {self} to user {username}")
|
||||||
un = username.strip().upper()
|
un = username.strip().upper()
|
||||||
old_owner_uuid = self._owner
|
old_owner_uuid = self._owner
|
||||||
with db.transaction() as db:
|
with db.transaction() as conn:
|
||||||
user = User.get_user_by_username(username, db.root())
|
user = User.get_user_by_username(username, conn.root())
|
||||||
old_owner = User.get_user_by_uuid(old_owner_uuid, db.root())
|
old_owner = User.get_user_by_uuid(old_owner_uuid, conn.root())
|
||||||
if user:
|
if user:
|
||||||
logging.debug(f"new owner user exists: {user}")
|
logging.debug(f"new owner user exists: {user}")
|
||||||
db.root.objects[self.uuid].owner = user.uuid
|
conn.root.objects[self.uuid].owner = user.uuid
|
||||||
if old_owner_uuid:
|
if old_owner_uuid:
|
||||||
if old_owner:
|
if old_owner:
|
||||||
logging.debug(f"The object has an old owner user: {old_owner}")
|
logging.debug(f"The object has an old owner user: {old_owner}")
|
||||||
@@ -114,39 +120,61 @@ class Object(persistent.Persistent):
|
|||||||
raise KeyError(f"User '{un}' not found.")
|
raise KeyError(f"User '{un}' not found.")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_object_by_uuid(cls, obj: UUID, db_root: PersistentMapping):
|
def get_object_by_uuid(cls, obj: UUID, db_root: PersistentMapping) -> Union[None,Self]:
|
||||||
return db_root['objects'].get(obj)
|
return db_root['objects'].get(obj)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_objects_by_username(cls, username: str, db: ZODB.DB) -> list[Self]:
|
def get_objects_by_username(cls, username: str, db: Union[ZODB.DB,Connection]) -> list[Self]:
|
||||||
un = username.strip().upper()
|
un = username.strip().upper()
|
||||||
objs = []
|
objs = []
|
||||||
with db.transaction() as db:
|
if type(db) is Connection:
|
||||||
user = User.get_user_by_username(username, db.root())
|
conn = db
|
||||||
|
user = User.get_user_by_username(un, conn.root())
|
||||||
if user:
|
if user:
|
||||||
uuids = user.object_uuids
|
uuids = user.object_uuids
|
||||||
for u in uuids:
|
for u in uuids:
|
||||||
try:
|
try:
|
||||||
obj = cls.get_object_by_uuid(u, db)
|
obj = cls.get_object_by_uuid(u, conn.root())
|
||||||
if obj:
|
if obj:
|
||||||
objs.append(obj)
|
objs.append(obj)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
with db.transaction() as conn:
|
||||||
|
user = User.get_user_by_username(un, conn.root())
|
||||||
|
if user:
|
||||||
|
uuids = user.object_uuids
|
||||||
|
for u in uuids:
|
||||||
|
try:
|
||||||
|
obj = cls.get_object_by_uuid(u, conn.root())
|
||||||
|
if obj:
|
||||||
|
objs.append(obj)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
return objs
|
return objs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uuid(self) -> Optional[UUID]:
|
def uuid(self) -> Optional[UUID]:
|
||||||
return self._uuid
|
return self._uuid
|
||||||
|
|
||||||
def write_new(self, db: ZODB.DB) -> UUID:
|
def write_new(self, db: ZODB.DB, username: str = None) -> UUID:
|
||||||
if self.uuid:
|
if self.uuid:
|
||||||
raise KeyError("Object already has UUID. Manually clear it to write it again.")
|
raise KeyError("Object already has UUID. Manually clear it to write it again.")
|
||||||
self._uuid = uuid.uuid4()
|
self._uuid = uuid.uuid4()
|
||||||
with db.transaction() as db:
|
|
||||||
while self.uuid in db.root.objects:
|
with db.transaction() as conn:
|
||||||
|
while self.uuid in conn.root.objects:
|
||||||
self._uuid = uuid.uuid4()
|
self._uuid = uuid.uuid4()
|
||||||
db.root.objects[self.uuid] = self
|
conn.root.objects[self.uuid] = self
|
||||||
self.touch()
|
self.touch()
|
||||||
|
logging.debug(f"New object assigned uuid {self.uuid}")
|
||||||
|
if username:
|
||||||
|
logging.debug(f"Attempting to assign new object to user: {username}")
|
||||||
|
try:
|
||||||
|
self.chown(username,db)
|
||||||
|
logging.debug(f"New object assigned to user: {username}")
|
||||||
|
except:
|
||||||
|
logging.warning(f"Unable to chown this object to user {username}: {traceback.format_exc()}")
|
||||||
return self.uuid
|
return self.uuid
|
||||||
|
|
||||||
def to_dict(self, include_data: bool = True) -> dict:
|
def to_dict(self, include_data: bool = True) -> dict:
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class User(persistent.Persistent):
|
|||||||
uid = uuid.UUID(str(user_uuid))
|
uid = uuid.UUID(str(user_uuid))
|
||||||
for user in db_root['users']:
|
for user in db_root['users']:
|
||||||
if uid == db_root['users'][user].uuid:
|
if uid == db_root['users'][user].uuid:
|
||||||
return db_root['users'][user].uuid
|
return db_root['users'][user]
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user