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
|
||||
import ax25
|
||||
import transaction
|
||||
from persistent import Persistent
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
@@ -7,7 +8,9 @@ import time
|
||||
from persistent.mapping import PersistentMapping
|
||||
from persistent.list import PersistentList
|
||||
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()
|
||||
|
||||
@@ -51,26 +54,29 @@ class HttpUser(Persistent):
|
||||
# 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).
|
||||
Requires an open ZODB connection.
|
||||
"""
|
||||
with get_transaction() as storage:
|
||||
root = storage.root()
|
||||
if type(db) is Connection:
|
||||
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', [])
|
||||
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.
|
||||
Requires an open ZODB connection (inside a transaction).
|
||||
Only allows enabling if the username is a valid AX.25 callsign.
|
||||
"""
|
||||
from packetserver.common.util import is_valid_ax25_callsign # our validator
|
||||
|
||||
with get_transaction() as storage:
|
||||
root = storage.root()
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
config = root.setdefault('config', PersistentMapping())
|
||||
blacklist = config.setdefault('blacklist', PersistentList())
|
||||
|
||||
@@ -89,6 +95,7 @@ class HttpUser(Persistent):
|
||||
|
||||
config._p_changed = True
|
||||
root._p_changed = True
|
||||
transaction.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Password handling (unchanged)
|
||||
|
||||
@@ -6,7 +6,7 @@ class Settings(BaseSettings):
|
||||
"""
|
||||
# Define your settings fields with type hints and optional default values
|
||||
name: str = "PacketServer"
|
||||
zeo_file: str
|
||||
zeo_file: str = ""
|
||||
operator: str | None = None
|
||||
debug_mode: bool = False
|
||||
log_level: str = "info"
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import Depends
|
||||
from typing import Annotated, Generator
|
||||
from os.path import isfile
|
||||
|
||||
import ZEO
|
||||
import ZODB
|
||||
import json
|
||||
from ZODB.Connection import Connection
|
||||
import transaction
|
||||
import logging
|
||||
|
||||
from .config import Settings # assuming Settings has zeo_file: str
|
||||
from ..common.util import convert_from_persistent
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -37,8 +42,7 @@ def init_db() -> ZODB.DB:
|
||||
return _db
|
||||
|
||||
host, port = _get_zeo_address(settings.zeo_file)
|
||||
storage = ZEO.ClientStorage((host, port))
|
||||
_db = ZODB.DB(storage)
|
||||
_db = ZEO.DB((host, port))
|
||||
return _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")
|
||||
return _db
|
||||
|
||||
def get_connection() -> Generator[Connection, None, None]:
|
||||
"""Per-request dependency: yields an open Connection, closes on exit."""
|
||||
db = get_db()
|
||||
conn = db.open()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
#def get_connection() -> Generator[Connection, None, None]:
|
||||
# """Per-request dependency: yields an open Connection, closes on exit."""
|
||||
# db = get_db()
|
||||
# conn = db.open()
|
||||
# try:
|
||||
# yield conn
|
||||
# finally:
|
||||
# #print("not closing connection")
|
||||
# #conn.close()
|
||||
# pass
|
||||
|
||||
# Optional: per-request transaction (if you want automatic commit/abort)
|
||||
def get_transaction_manager():
|
||||
@@ -62,4 +68,12 @@ def get_transaction_manager():
|
||||
|
||||
# Annotated dependencies for routers
|
||||
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 .auth import HttpUser
|
||||
from .database import get_transaction
|
||||
from .database import DbDependency
|
||||
|
||||
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.
|
||||
Injected by the standalone runner (get_db_connection available).
|
||||
"""
|
||||
|
||||
with get_transaction() as conn:
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
|
||||
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
|
||||
from persistent.list import PersistentList
|
||||
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 ..auth import HttpUser
|
||||
from ..server import templates
|
||||
@@ -129,16 +130,21 @@ async def bulletin_list_page(
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"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)
|
||||
async def bulletin_new_form(
|
||||
request: Request,
|
||||
current_user: HttpUser = Depends(get_current_http_user) # require login
|
||||
):
|
||||
return templates.TemplateResponse(
|
||||
"bulletin_new.html",
|
||||
{"request": request, "error": None}
|
||||
{"request": request, "error": None, "current_user": current_user.username}
|
||||
)
|
||||
|
||||
@html_router.post("/bulletins/new")
|
||||
@@ -185,3 +191,42 @@ async def bulletin_detail_page(
|
||||
"bulletin_detail.html",
|
||||
{"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.auth import HttpUser
|
||||
from packetserver.http.server import templates
|
||||
from packetserver.http.database import DbDependency
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
@@ -15,19 +16,23 @@ from .bulletins import list_bulletins
|
||||
|
||||
@router.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(
|
||||
db: DbDependency,
|
||||
request: Request,
|
||||
current_user: HttpUser = Depends(get_current_http_user)
|
||||
):
|
||||
# Internal call – pass explicit defaults to avoid Query object injection
|
||||
messages_resp = await api_get_messages(
|
||||
db,
|
||||
current_user=current_user,
|
||||
type="all",
|
||||
limit=100,
|
||||
since=None # prevents Query wrapper
|
||||
)
|
||||
with db.transaction() as conn:
|
||||
# Internal call – pass explicit defaults to avoid Query object injection
|
||||
|
||||
messages = messages_resp["messages"]
|
||||
|
||||
bulletins_resp = await list_bulletins(limit=10, since=None)
|
||||
bulletins_resp = await list_bulletins(conn, limit=10, since=None)
|
||||
recent_bulletins = bulletins_resp["bulletins"]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
@@ -42,11 +47,12 @@ async def dashboard(
|
||||
|
||||
@router.get("/dashboard/profile", response_class=HTMLResponse)
|
||||
async def profile_page(
|
||||
db: DbDependency,
|
||||
request: Request,
|
||||
current_user: HttpUser = Depends(get_current_http_user)
|
||||
):
|
||||
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(
|
||||
"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.auth import HttpUser
|
||||
from packetserver.http.server import templates
|
||||
from packetserver.http.database import DbDependency
|
||||
|
||||
router = APIRouter(tags=["message-detail"])
|
||||
|
||||
@router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse)
|
||||
async def message_detail_page(
|
||||
db: DbDependency,
|
||||
request: Request,
|
||||
msg_id: str = Path(..., description="Message UUID as string"),
|
||||
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)
|
||||
message_data = await api_get_message(
|
||||
db,
|
||||
msg_id=msg_id,
|
||||
mark_retrieved=True,
|
||||
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.auth import HttpUser
|
||||
from packetserver.http.database import DbDependency
|
||||
|
||||
|
||||
html_router = APIRouter(tags=["messages-html"])
|
||||
@@ -28,18 +29,18 @@ class MarkRetrievedRequest(BaseModel):
|
||||
|
||||
@router.get("/messages")
|
||||
async def get_messages(
|
||||
db: DbDependency,
|
||||
current_user: HttpUser = Depends(get_current_http_user),
|
||||
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)"),
|
||||
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:
|
||||
limit = 20
|
||||
|
||||
username = current_user.username
|
||||
|
||||
from packetserver.runners.http_server import get_db_connection
|
||||
conn = get_db_connection()
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
|
||||
if 'messages' not in root:
|
||||
@@ -81,12 +82,12 @@ async def get_messages(
|
||||
|
||||
@router.get("/messages/{msg_id}")
|
||||
async def get_message(
|
||||
db: DbDependency,
|
||||
msg_id: str = Path(..., description="UUID of the message (as string)"),
|
||||
mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"),
|
||||
current_user: HttpUser = Depends(get_current_http_user)
|
||||
):
|
||||
from packetserver.runners.http_server import get_db_connection
|
||||
conn = get_db_connection()
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
|
||||
username = current_user.username
|
||||
@@ -127,12 +128,12 @@ async def get_message(
|
||||
|
||||
@router.patch("/messages/{msg_id}")
|
||||
async def mark_message_retrieved(
|
||||
db: DbDependency,
|
||||
msg_id: str = Path(..., description="Message UUID as string"),
|
||||
payload: MarkRetrievedRequest = None,
|
||||
current_user: HttpUser = Depends(get_current_http_user)
|
||||
):
|
||||
from packetserver.runners.http_server import get_db_connection
|
||||
conn = get_db_connection()
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
|
||||
username = current_user.username
|
||||
@@ -164,21 +165,76 @@ async def mark_message_retrieved(
|
||||
@html_router.get("/messages", response_class=HTMLResponse)
|
||||
async def message_list_page(
|
||||
request: Request,
|
||||
type: str = Query("received", alias="msg_type"), # matches your filter links
|
||||
limit: Optional[int] = Query(50, le=100),
|
||||
current_user: HttpUser = Depends(get_current_http_user)
|
||||
db: DbDependency,
|
||||
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
|
||||
# Directly call the existing API endpoint function
|
||||
api_resp = await get_messages(current_user=current_user, type=type, limit=limit, since=None)
|
||||
messages = api_resp["messages"]
|
||||
from packetserver.http.server import templates # Local import – safe from circular
|
||||
|
||||
username = current_user.username.upper().strip()
|
||||
|
||||
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(
|
||||
"message_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"messages": messages,
|
||||
"msg_type": type,
|
||||
"current_user": current_user.username
|
||||
}
|
||||
"messages": paginated,
|
||||
"current_type": msg_type, # For tabs/links
|
||||
"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 typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header, Request
|
||||
from fastapi.responses import PlainTextResponse, Response, JSONResponse, StreamingResponse, RedirectResponse
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
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.auth import HttpUser
|
||||
from packetserver.http.database import DbDependency
|
||||
from packetserver.server.objects import Object
|
||||
from pydantic import BaseModel
|
||||
from packetserver.server.users import User
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["objects"])
|
||||
|
||||
@@ -22,21 +31,14 @@ class ObjectSummary(BaseModel):
|
||||
modified_at: datetime
|
||||
|
||||
@router.get("/objects", response_model=List[ObjectSummary])
|
||||
async def list_my_objects(current_user: HttpUser = Depends(get_current_http_user)):
|
||||
from packetserver.runners.http_server import get_db_connection
|
||||
|
||||
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)
|
||||
|
||||
async def list_my_objects(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
|
||||
username = current_user.username.upper().strip() # ensure uppercase consistency
|
||||
logging.debug(f"Listing objects for user {username}")
|
||||
user_objects = []
|
||||
for obj in core_objects:
|
||||
with db.transaction() as conn:
|
||||
for obj in Object.get_objects_by_username(username, conn):
|
||||
logging.debug(f"Found object {obj.uuid} for {username}")
|
||||
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"
|
||||
@@ -52,4 +54,582 @@ async def list_my_objects(current_user: HttpUser = Depends(get_current_http_user
|
||||
modified_at=obj.modified_at
|
||||
))
|
||||
|
||||
# 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,23 +3,23 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from packetserver.http.dependencies import get_current_http_user
|
||||
from packetserver.http.auth import HttpUser
|
||||
from packetserver.http.database import DbDependency
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["auth"])
|
||||
|
||||
|
||||
@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
|
||||
|
||||
from packetserver.runners.http_server import get_db_connection
|
||||
conn = get_db_connection()
|
||||
root = conn.root()
|
||||
rf_enabled = current_user.is_rf_enabled(db)
|
||||
|
||||
# Get main BBS User and safe dict
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
main_users = root.get('users', {})
|
||||
bbs_user = main_users.get(username)
|
||||
safe_profile = bbs_user.to_safe_dict() if bbs_user else {}
|
||||
rf_enabled = current_user.is_rf_enabled(conn)
|
||||
|
||||
|
||||
return {
|
||||
**safe_profile,
|
||||
|
||||
@@ -11,6 +11,7 @@ from packetserver.http.dependencies import get_current_http_user
|
||||
from packetserver.http.auth import HttpUser
|
||||
from packetserver.server.messages import Message
|
||||
from packetserver.common.util import is_valid_ax25_callsign
|
||||
from packetserver.http.database import DbDependency
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["messages"])
|
||||
|
||||
@@ -39,14 +40,15 @@ class SendMessageRequest(BaseModel):
|
||||
|
||||
@router.post("/messages")
|
||||
async def send_message(
|
||||
db: DbDependency,
|
||||
payload: SendMessageRequest,
|
||||
current_user: HttpUser = Depends(get_current_http_user)
|
||||
):
|
||||
from packetserver.runners.http_server import get_db_connection
|
||||
conn = get_db_connection()
|
||||
is_rf_enabled = current_user.is_rf_enabled(db)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
|
||||
if not current_user.is_rf_enabled(conn):
|
||||
if not is_rf_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="RF gateway access required to send messages"
|
||||
@@ -54,28 +56,51 @@ async def send_message(
|
||||
|
||||
username = current_user.username
|
||||
|
||||
users_dict = root.get('users', {})
|
||||
|
||||
# Prepare recipients
|
||||
to_list = payload.to
|
||||
to_tuple = tuple(to_list)
|
||||
if "ALL" in to_list:
|
||||
to_tuple = ("ALL",)
|
||||
to_list = [c.upper() for c in payload.to]
|
||||
is_to_all = "ALL" in to_list
|
||||
|
||||
is_bulletin = "ALL" in to_list
|
||||
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys())
|
||||
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)
|
||||
|
||||
# Create message using only supported core params
|
||||
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=to_tuple,
|
||||
msg_to=tuple(valid_recipients),
|
||||
attachments=()
|
||||
)
|
||||
|
||||
# Deliver to recipients + always sender (sent folder)
|
||||
# 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
|
||||
|
||||
for recip in set(recipients) | {username}:
|
||||
for recip in valid_recipients:
|
||||
mailbox = messages_root.setdefault(recip, PersistentList())
|
||||
mailbox.append(new_msg)
|
||||
mailbox._p_changed = True
|
||||
@@ -84,11 +109,17 @@ async def send_message(
|
||||
messages_root._p_changed = True
|
||||
transaction.commit()
|
||||
|
||||
return {
|
||||
response = {
|
||||
"status": "sent",
|
||||
"message_id": str(new_msg.msg_id),
|
||||
"from": username,
|
||||
"to": list(to_tuple),
|
||||
"to": list(valid_recipients),
|
||||
"sent_at": new_msg.sent_at.isoformat() + "Z",
|
||||
"recipients_delivered": len(delivered_to)
|
||||
}
|
||||
|
||||
if failed_recipients:
|
||||
response["warning"] = f"Some recipients not registered: {', '.join(failed_recipients)}"
|
||||
response["failed_recipients"] = failed_recipients
|
||||
|
||||
return response
|
||||
@@ -3,20 +3,37 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
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 .logging import init_logging
|
||||
|
||||
init_logging()
|
||||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
app = FastAPI(
|
||||
title="PacketServer HTTP API",
|
||||
description="RESTful interface to the AX.25 packet radio BBS",
|
||||
version="0.1.0",
|
||||
version="0.5.0",
|
||||
)
|
||||
|
||||
# Define templates EARLY (before importing dashboard)
|
||||
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
|
||||
|
||||
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.messages import html_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
|
||||
app.include_router(public.router)
|
||||
@@ -51,5 +80,7 @@ app.include_router(bulletins.html_router)
|
||||
app.include_router(message_detail_router)
|
||||
app.include_router(html_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>
|
||||
<meta charset="UTF-8">
|
||||
<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/style.css') }}">
|
||||
</head>
|
||||
<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">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard') }}">PacketServer BBS</a>
|
||||
<span class="navbar-text">
|
||||
<!-- Server name on its own "line" (centered, larger, prominent) -->
|
||||
<div class="w-100 text-center mb-3">
|
||||
<a class="navbar-brand h2 mb-0" href="{{ url_for('dashboard') }}">{{ server_name }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Bottom row: user info and navigation buttons -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-md-between w-100 gap-2">
|
||||
<span class="navbar-text text-center text-md-start order-md-1">
|
||||
Logged in as: <strong>{{ current_user }}</strong>
|
||||
{# Basic Auth note #}
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
@@ -103,8 +114,13 @@
|
||||
});
|
||||
|
||||
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.textContent = 'Message sent successfully!';
|
||||
status.textContent = msg;
|
||||
status.style.display = 'block';
|
||||
composeForm.reset();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -15,5 +15,35 @@
|
||||
<a href="/bulletins">← All Bulletins</a> |
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
</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>
|
||||
</html>
|
||||
@@ -1,27 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Bulletins - PacketServer</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bulletins</h1>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bulletins - {{ server_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1 class="mb-4">Bulletins</h1>
|
||||
|
||||
{% 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 %}
|
||||
<p><em>Log in to create bulletins.</em></p>
|
||||
<p class="mb-4"><em>Log in to create bulletins.</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% if bulletins %}
|
||||
<ul>
|
||||
<ul class="list-unstyled">
|
||||
{% for bull in bulletins %}
|
||||
<li>
|
||||
<strong><a href="/bulletins/{{ bull.id }}">{{ bull.subject }}</a></strong>
|
||||
<div class="meta">by {{ bull.author }} on {{ bull.created_at[:10] }}</div>
|
||||
<div class="preview">{{ bull.body[:200] }}{% if bull.body|length > 200 %}...{% endif %}</div>
|
||||
<hr>
|
||||
<li class="mb-4 pb-4 border-bottom">
|
||||
<strong><a href="/bulletins/{{ bull.id }}" class="text-decoration-none">{{ bull.subject }}</a></strong>
|
||||
<div class="text-muted small">by {{ bull.author }} on {{ bull.created_at[:10] }}</div>
|
||||
<div class="mt-2">{{ bull.body[:200] }}{% if bull.body|length > 200 %}...{% endif %}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -29,6 +26,6 @@
|
||||
<p>No bulletins yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="/dashboard">← Back to Dashboard</a></p>
|
||||
</body>
|
||||
</html>
|
||||
<p><a href="{{ url_for('dashboard') }}">← Back to Dashboard</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -101,8 +101,11 @@
|
||||
credentials: 'include' // sends Basic Auth
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
document.getElementById('composeAlert').innerHTML = '<div class="alert alert-success">Sent! ID: ' + data.message_id + '</div>';
|
||||
const result = await response.json();
|
||||
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);
|
||||
} else {
|
||||
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" %}
|
||||
|
||||
{% block title %}Messages - PacketServer{% endblock %}
|
||||
{% block title %}Messages{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h1>Messages</h1>
|
||||
<div class="mb-4 text-end">
|
||||
|
||||
<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
|
||||
</button>
|
||||
</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 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="message-list">
|
||||
<ul class="list-group 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 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 %}
|
||||
<p>No messages found.</p>
|
||||
<div class="alert alert-info mt-4">
|
||||
{% if current_search %}
|
||||
No messages found matching "{{ current_search }}".
|
||||
{% else %}
|
||||
No messages yet.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p><a href="/dashboard">← Back to Dashboard</a></p>
|
||||
<!-- 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>
|
||||
{% 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."""
|
||||
from typing import Union,Optional,Iterable,Self
|
||||
from typing import Union, Optional, Iterable, Self, Callable, List
|
||||
from enum import Enum
|
||||
import datetime
|
||||
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."""
|
||||
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,
|
||||
files: list[RunnerFile] = None):
|
||||
self.files = []
|
||||
files: list[RunnerFile] = None, notify_function: Callable = None):
|
||||
self.files: List[RunnerFile] = []
|
||||
if files is not None:
|
||||
for f in files:
|
||||
self.files.append(f)
|
||||
@@ -87,6 +87,7 @@ class Runner:
|
||||
self.finished_at = None
|
||||
self._result = (0,(b'', b''))
|
||||
self._artifact_archive = b''
|
||||
self.notify_function = notify_function
|
||||
if environment:
|
||||
for key in environment:
|
||||
self.env[key] = environment[key]
|
||||
@@ -98,6 +99,10 @@ class Runner:
|
||||
self.timeout_seconds = timeout_secs
|
||||
self.created_at = datetime.datetime.now(datetime.UTC)
|
||||
|
||||
def notify(self):
|
||||
if self.notify_function:
|
||||
self.notify_function()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{type(self).__name__}: {self.username}[{self.job_id}] - {self.status.name}>"
|
||||
|
||||
@@ -150,6 +155,8 @@ class Orchestrator:
|
||||
def __init__(self):
|
||||
self.runners = []
|
||||
self.runner_lock = Lock()
|
||||
self.listeners = []
|
||||
self.started: bool = False
|
||||
|
||||
def get_finished_runners(self) -> list[Runner]:
|
||||
return [r for r in self.runners if r.is_finished()]
|
||||
@@ -178,14 +185,21 @@ class Orchestrator:
|
||||
files: list[RunnerFile] = None) -> Runner:
|
||||
pass
|
||||
|
||||
def notify_listeners(self):
|
||||
"""If any runners change status, call all listener functions."""
|
||||
for func in self.listeners:
|
||||
func()
|
||||
|
||||
def manage_lifecycle(self):
|
||||
"""When called, updates runner statuses and performs any housekeeping."""
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""Do any setup and then be ready to operate"""
|
||||
self.started = True
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
"""Do any cleanup needed."""
|
||||
self.started = False
|
||||
pass
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Uses podman to run jobs in containers."""
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from ZEO import client
|
||||
|
||||
@@ -24,6 +25,7 @@ from packetserver import VERSION as packetserver_version
|
||||
import re
|
||||
from threading import Thread
|
||||
from io import BytesIO
|
||||
from typing import Callable
|
||||
|
||||
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):
|
||||
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,
|
||||
files: list[RunnerFile] = None):
|
||||
files: list[RunnerFile] = None, notify_function: Callable = None):
|
||||
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''
|
||||
if not container.inspect()['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.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.env['PACKETSERVER_JOB_HOME'] = self.job_path
|
||||
self.env['PACKETSERVER_ARTIFACT_DIR'] = os.path.join(self.job_path,'artifacts')
|
||||
|
||||
def thread_runner(self):
|
||||
self.status = RunnerStatus.RUNNING
|
||||
logging.debug(f"Thread for runner {self.job_id} started. Command for {(type(self.args))}:\n{self.args}")
|
||||
self.notify()
|
||||
# run the exec call
|
||||
if type(self.args) is str:
|
||||
logging.debug(f"Running string: {self.args}")
|
||||
@@ -60,6 +65,7 @@ class PodmanRunner(Runner):
|
||||
logging.debug(str(res))
|
||||
# cleanup housekeeping
|
||||
self.status = RunnerStatus.STOPPING
|
||||
self.notify()
|
||||
self._result = res
|
||||
# run cleanup script
|
||||
logging.debug(f"Running cleanup script for {self.job_id}")
|
||||
@@ -87,6 +93,7 @@ class PodmanRunner(Runner):
|
||||
self.status = RunnerStatus.SUCCESSFUL
|
||||
else:
|
||||
self.status = RunnerStatus.FAILED
|
||||
self.notify()
|
||||
|
||||
@property
|
||||
def has_artifacts(self) -> bool:
|
||||
@@ -162,7 +169,6 @@ class PodmanRunner(Runner):
|
||||
class PodmanOrchestrator(Orchestrator):
|
||||
def __init__(self, uri: Optional[str] = None, options: Optional[PodmanOptions] = None):
|
||||
super().__init__()
|
||||
self.started = False
|
||||
self.user_containers = {}
|
||||
self.manager_thread = None
|
||||
self._client = None
|
||||
@@ -236,7 +242,7 @@ class PodmanOrchestrator(Orchestrator):
|
||||
def podman_start_user_container(self, username: str) -> Container:
|
||||
container_env = {
|
||||
"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}")
|
||||
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)
|
||||
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,
|
||||
labels=labels, files=files)
|
||||
labels=labels, files=files, notify_function=lambda : self.notify_listeners())
|
||||
self.runners.append(runner)
|
||||
runner.start()
|
||||
return runner
|
||||
|
||||
@@ -13,14 +13,10 @@ import argparse
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
import ZODB.FileStorage
|
||||
import ZODB.DB
|
||||
import logging
|
||||
from packetserver.http.server import app
|
||||
|
||||
def main():
|
||||
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("--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")
|
||||
|
||||
@@ -14,12 +14,16 @@ import sys
|
||||
import time
|
||||
from getpass import getpass
|
||||
import ax25
|
||||
import os.path
|
||||
import os
|
||||
|
||||
import ZODB.FileStorage
|
||||
import ZODB.DB
|
||||
import transaction
|
||||
from persistent.mapping import PersistentMapping
|
||||
|
||||
os.environ['PS_APP_ZEO_FILE'] = "N/A"
|
||||
|
||||
# Import our HTTP package internals
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
if HTTP_USERS_KEY not in root:
|
||||
root[HTTP_USERS_KEY] = PersistentMapping()
|
||||
@@ -55,7 +66,8 @@ def confirm(prompt: str) -> bool:
|
||||
|
||||
def main():
|
||||
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)
|
||||
|
||||
# add
|
||||
@@ -99,12 +111,16 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
# Open the database
|
||||
if args.db:
|
||||
db = open_database(args.db)
|
||||
connection = db.open()
|
||||
root = connection.root()
|
||||
else:
|
||||
db = open_database_zeo_file(args.zeo_file)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -117,7 +133,7 @@ def main():
|
||||
print(f"Error: Trying to add valid callsign + ssid. Remove -<num> and add again.")
|
||||
sys.exit(1)
|
||||
|
||||
if callsign in users_mapping:
|
||||
if callsign in http_users_list:
|
||||
print(f"Error: HTTP user {callsign} already exists")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -127,35 +143,41 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
# Create the HTTP-specific user
|
||||
with db.transaction() as conn:
|
||||
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
|
||||
|
||||
main_users = root.setdefault('users', PersistentMapping())
|
||||
if callsign not in main_users:
|
||||
User.write_new(main_users, args.callsign) # correct: pass mapping + callsign
|
||||
print(f" → Also created regular BBS user {callsign} (with UUID)")
|
||||
new_user = User(args.callsign)
|
||||
new_user.write_new(conn.root())
|
||||
print(f" → Also created regular BBS user {callsign}")
|
||||
else:
|
||||
print(f" → Regular BBS user {callsign} already exists")
|
||||
|
||||
transaction.commit()
|
||||
print(f"Created HTTP user {callsign}")
|
||||
|
||||
elif args.command == "delete":
|
||||
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")
|
||||
sys.exit(1)
|
||||
if not confirm(f"Delete HTTP user {callsign}?"):
|
||||
sys.exit(0)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
del users_mapping[callsign]
|
||||
transaction.commit()
|
||||
print(f"Deleted HTTP user {callsign}")
|
||||
|
||||
elif args.command == "set-password":
|
||||
callsign = upper_callsign(args.callsign)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
user = users_mapping.get(callsign)
|
||||
if not user:
|
||||
print(f"Error: User {callsign} not found")
|
||||
@@ -166,40 +188,45 @@ def main():
|
||||
sys.exit(1)
|
||||
user.password_hash = ph.hash(newpass)
|
||||
user._p_changed = True
|
||||
transaction.commit()
|
||||
print(f"Password updated for {callsign}")
|
||||
|
||||
elif args.command == "enable":
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
callsign = upper_callsign(args.callsign)
|
||||
user = users_mapping.get(callsign)
|
||||
if not user:
|
||||
print(f"Error: User {callsign} not found")
|
||||
sys.exit(1)
|
||||
user.enabled = True
|
||||
user.http_enabled = True
|
||||
user._p_changed = True
|
||||
transaction.commit()
|
||||
print(f"HTTP access enabled for {callsign}")
|
||||
|
||||
elif args.command == "disable":
|
||||
callsign = upper_callsign(args.callsign)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
user = users_mapping.get(callsign)
|
||||
if not user:
|
||||
print(f"Error: User {callsign} not found")
|
||||
sys.exit(1)
|
||||
user.enabled = False
|
||||
user.http_enabled = False
|
||||
user._p_changed = True
|
||||
transaction.commit()
|
||||
print(f"HTTP access disabled for {callsign}")
|
||||
|
||||
elif args.command == "rf-enable":
|
||||
callsign = upper_callsign(args.callsign)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
user = users_mapping.get(callsign)
|
||||
if not user:
|
||||
print(f"Error: User {callsign} not found")
|
||||
sys.exit(1)
|
||||
try:
|
||||
user.set_rf_enabled(connection, True)
|
||||
transaction.commit()
|
||||
user.set_rf_enabled(db, True)
|
||||
print(f"RF gateway enabled for {callsign}")
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
@@ -207,31 +234,39 @@ def main():
|
||||
|
||||
elif args.command == "rf-disable":
|
||||
callsign = upper_callsign(args.callsign)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
user = users_mapping.get(callsign)
|
||||
if not user:
|
||||
print(f"Error: User {callsign} not found")
|
||||
sys.exit(1)
|
||||
user.set_rf_enabled(connection, False)
|
||||
transaction.commit()
|
||||
user.set_rf_enabled(db, False)
|
||||
print(f"RF gateway disabled for {callsign}")
|
||||
|
||||
elif args.command == "list":
|
||||
if not users_mapping:
|
||||
if not http_users_list:
|
||||
print("No HTTP users configured")
|
||||
else:
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
print(f"{'Callsign':<12} {'HTTP Enabled':<13} {'RF Enabled':<11} {'Created':<20} Last Login")
|
||||
print("-" * 75)
|
||||
for user in sorted(users_mapping.values(), key=lambda u: u.username):
|
||||
created = time.strftime("%Y-%m-%d %H:%M", time.localtime(user.created_at))
|
||||
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(connection) else "False"
|
||||
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":
|
||||
import json
|
||||
|
||||
callsign = upper_callsign(args.callsign)
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
users_mapping = get_or_create_http_users(root)
|
||||
http_user = users_mapping.get(callsign)
|
||||
if not http_user:
|
||||
print(f"Error: No HTTP user {callsign} found")
|
||||
@@ -247,8 +282,8 @@ def main():
|
||||
"http_user": {
|
||||
"username": http_user.username,
|
||||
"http_enabled": http_user.http_enabled,
|
||||
"rf_enabled": http_user.is_rf_enabled(connection),
|
||||
"blacklisted": not http_user.is_rf_enabled(connection), # explicit inverse
|
||||
"rf_enabled": http_user.is_rf_enabled(conn),
|
||||
"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)),
|
||||
"failed_attempts": http_user.failed_attempts,
|
||||
},
|
||||
@@ -277,6 +312,8 @@ def main():
|
||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
with db.transaction() as conn:
|
||||
root = conn.root()
|
||||
bbs_users = root.get('users', {})
|
||||
http_users = get_or_create_http_users(root)
|
||||
|
||||
@@ -308,7 +345,6 @@ def main():
|
||||
print("Use 'set-password <call>' to set a known password before enabling login")
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import tempfile
|
||||
|
||||
import pe.app
|
||||
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 copy import deepcopy
|
||||
import ax25
|
||||
@@ -50,6 +50,7 @@ class Server:
|
||||
self.check_job_queue = True
|
||||
self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
|
||||
self.job_check_interval = 60
|
||||
self.default_job_check_interval = 60
|
||||
self.quick_job = False
|
||||
if data_dir:
|
||||
data_path = Path(data_dir)
|
||||
@@ -74,6 +75,13 @@ class Server:
|
||||
logging.debug("no config, writing blank default config")
|
||||
conn.root.config = PersistentMapping(deepcopy(default_server_config))
|
||||
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']:
|
||||
logging.debug("Adding 'SYSTEM' to blacklist in case someone feels like violating FCC rules.")
|
||||
conn.root.config['blacklist'].append('SYSTEM')
|
||||
@@ -105,6 +113,7 @@ class Server:
|
||||
if val in ['podman']:
|
||||
logging.debug(f"Enabling {val} orchestrator")
|
||||
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()
|
||||
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)
|
||||
if self.quick_job:
|
||||
logging.debug("Setting the final quick job timer.")
|
||||
if self.default_job_check_interval > 5:
|
||||
self.job_check_interval = 5
|
||||
self.quick_job = False
|
||||
else:
|
||||
self.job_check_interval = 60
|
||||
self.job_check_interval = self.default_job_check_interval
|
||||
|
||||
def server_connection_bouncer(self, conn: PacketServerConnection):
|
||||
logging.debug("new connection bouncer checking user status")
|
||||
@@ -225,6 +235,13 @@ class Server:
|
||||
self.ping_job_queue()
|
||||
if (self.orchestrator is not None) and self.orchestrator.started and self.check_job_queue:
|
||||
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
|
||||
while self.orchestrator.runners_available():
|
||||
if len(storage.root.job_queue) > 0:
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
|
||||
default_server_name = "Packet Server BBS"
|
||||
|
||||
default_server_config = {
|
||||
"motd": "Welcome to this PacketServer BBS!",
|
||||
"operator": "placeholder",
|
||||
"max_message_length": 2000
|
||||
"operator": "email_callsign_name_whatever",
|
||||
"max_message_length": 2000,
|
||||
"server_name": default_server_name
|
||||
}
|
||||
|
||||
jobs_default = {
|
||||
|
||||
}
|
||||
@@ -5,15 +5,17 @@ import persistent
|
||||
import persistent.list
|
||||
from persistent.mapping import PersistentMapping
|
||||
import datetime
|
||||
from typing import Self,Union,Optional,Tuple
|
||||
from typing import Self,Union,Optional,Tuple,List
|
||||
from traceback import format_exc
|
||||
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
|
||||
from packetserver.common.constants import no_values, yes_values
|
||||
from packetserver.server.db import get_user_db_json
|
||||
import ZODB
|
||||
from ZODB.Connection import Connection
|
||||
from persistent.list import PersistentList
|
||||
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 tarfile
|
||||
import time
|
||||
@@ -23,6 +25,7 @@ from packetserver.runner import Orchestrator, Runner, RunnerStatus, RunnerFile
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
import base64
|
||||
from uuid import UUID
|
||||
|
||||
class JobStatus(Enum):
|
||||
CREATED = 1
|
||||
@@ -58,6 +61,37 @@ def get_new_job_id(root: PersistentMapping) -> int:
|
||||
root['job_counter'] = current + 1
|
||||
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):
|
||||
@classmethod
|
||||
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,
|
||||
"artifacts": [],
|
||||
"status": self.status.name,
|
||||
"env": self.env,
|
||||
"id": self.id
|
||||
}
|
||||
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]:
|
||||
send_blank_response(conn, req, 401, "job post must contain cmd key containing str or list[str]")
|
||||
return
|
||||
files = []
|
||||
files: List[RunnerFile] = []
|
||||
if 'db' in req.payload:
|
||||
logging.debug(f"Fetching a user db as requested.")
|
||||
try:
|
||||
@@ -312,6 +347,11 @@ def handle_new_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB)
|
||||
val = req.payload['files'][key]
|
||||
if type(val) is bytes:
|
||||
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 = {}
|
||||
if 'env' in req.payload:
|
||||
if type(req.payload['env']) is dict:
|
||||
@@ -360,6 +400,29 @@ def handle_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB):
|
||||
else:
|
||||
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):
|
||||
logging.debug(f"{req} being processed by job_root_handler")
|
||||
if not user_authorized(conn, db):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Server object storage system."""
|
||||
import traceback
|
||||
from copy import deepcopy
|
||||
|
||||
import persistent
|
||||
@@ -9,6 +10,7 @@ import datetime
|
||||
from typing import Self,Union,Optional
|
||||
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
|
||||
import ZODB
|
||||
from ZODB.Connection import Connection
|
||||
import logging
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
@@ -76,6 +78,10 @@ class Object(persistent.Persistent):
|
||||
self._binary = False
|
||||
self.touch()
|
||||
|
||||
@property
|
||||
def data_bytes(self):
|
||||
return self._data
|
||||
|
||||
@property
|
||||
def owner(self) -> Optional[UUID]:
|
||||
return self._owner
|
||||
@@ -96,12 +102,12 @@ class Object(persistent.Persistent):
|
||||
logging.debug(f"chowning object {self} to user {username}")
|
||||
un = username.strip().upper()
|
||||
old_owner_uuid = self._owner
|
||||
with db.transaction() as db:
|
||||
user = User.get_user_by_username(username, db.root())
|
||||
old_owner = User.get_user_by_uuid(old_owner_uuid, db.root())
|
||||
with db.transaction() as conn:
|
||||
user = User.get_user_by_username(username, conn.root())
|
||||
old_owner = User.get_user_by_uuid(old_owner_uuid, conn.root())
|
||||
if 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:
|
||||
logging.debug(f"The object has an old owner user: {old_owner}")
|
||||
@@ -114,20 +120,33 @@ class Object(persistent.Persistent):
|
||||
raise KeyError(f"User '{un}' not found.")
|
||||
|
||||
@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)
|
||||
|
||||
@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()
|
||||
objs = []
|
||||
with db.transaction() as db:
|
||||
user = User.get_user_by_username(username, db.root())
|
||||
if type(db) is Connection:
|
||||
conn = db
|
||||
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, db)
|
||||
obj = cls.get_object_by_uuid(u, conn.root())
|
||||
if obj:
|
||||
objs.append(obj)
|
||||
except:
|
||||
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:
|
||||
@@ -138,15 +157,24 @@ class Object(persistent.Persistent):
|
||||
def uuid(self) -> Optional[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:
|
||||
raise KeyError("Object already has UUID. Manually clear it to write it again.")
|
||||
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()
|
||||
db.root.objects[self.uuid] = self
|
||||
conn.root.objects[self.uuid] = self
|
||||
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
|
||||
|
||||
def to_dict(self, include_data: bool = True) -> dict:
|
||||
|
||||
@@ -129,7 +129,7 @@ class User(persistent.Persistent):
|
||||
uid = uuid.UUID(str(user_uuid))
|
||||
for user in db_root['users']:
|
||||
if uid == db_root['users'][user].uuid:
|
||||
return db_root['users'][user].uuid
|
||||
return db_root['users'][user]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user