Compare commits

...

56 Commits

Author SHA1 Message Date
Michael Woods
f2242b4557 message pagination 2025-12-28 19:28:56 -05:00
Michael Woods
f2d6f8723a message pagination 2025-12-28 19:25:14 -05:00
Michael Woods
f8305945a4 Revert "message pagination"
This reverts commit fd9d113eef.
2025-12-28 19:14:48 -05:00
Michael Woods
fd9d113eef message pagination 2025-12-28 19:11:48 -05:00
Michael Woods
4e24bb4fb4 Delete button works now. 2025-12-28 16:27:07 -05:00
Michael Woods
77a2157f7b Adding delete job. Form not working yet. 2025-12-28 16:12:15 -05:00
Michael Woods
747d23af54 Added job delete over RF. 2025-12-28 15:55:39 -05:00
Michael Woods
55b0e1806b Lots of object and job related tweaks. 2025-12-28 15:36:58 -05:00
Michael Woods
04d34fdf32 A couple other tweaks. 2025-12-28 12:04:08 -05:00
Michael Woods
d3e66f45b2 Made some changes to jobs system to make it faster to respond when jobs created and change status. 2025-12-28 11:53:42 -05:00
Michael Woods
e7d308ab69 Jobs interface is looking really good now.. 2025-12-28 00:09:47 -05:00
Michael Woods
e54ba05c19 Working. 2025-12-27 23:11:13 -05:00
Michael Woods
22ed9c0aa5 Adding db flag for new job form. Not working yet. 2025-12-27 22:51:59 -05:00
Michael Woods
e77b08fd0b Fixed user manager script after db changes from a while back. Fixed some object router code that was broken. 2025-12-27 21:14:19 -05:00
Michael Woods
13eac22741 Fixed small username bug. 2025-12-27 15:47:24 -05:00
Michael Woods
342f32f499 Menubar adjusted. 2025-12-27 15:40:21 -05:00
Michael Woods
a206e82874 Some database and config updates. Trying to adjust the base profile menubar. 2025-12-27 15:36:33 -05:00
Michael Woods
ea60fc2286 A few changes to ensure that some default config values always exist. 2025-12-27 12:49:58 -05:00
Michael Woods
2f68866398 Changes to jobs, made a change to the base server to store the callsign in a config location and set it upon startup. 2025-12-27 11:38:10 -05:00
Michael Woods
c060ddb060 Jobs dashboard additions. 2025-12-26 16:03:06 -05:00
Michael Woods
ec0cb0ce45 Jobs html view changes. 2025-12-26 15:53:12 -05:00
Michael Woods
c81fd68ea2 Jobs dashboard changes. 2025-12-26 15:33:44 -05:00
Michael Woods
ac7569833a Trying fixes. 2025-12-26 15:19:56 -05:00
Michael Woods
e3213d9611 Trying fixes. 2025-12-26 15:01:52 -05:00
Michael Woods
443da0523c Fix didn't seem to work. 2025-12-26 14:50:52 -05:00
Michael Woods
30ecf63e29 Bulletin delete usually working. One bulletin won't delete.. 2025-12-26 14:48:26 -05:00
Michael Woods
ba00890f79 Needed to add await to one of the functions. 2025-12-26 14:39:02 -05:00
Michael Woods
fda75aa822 Object detail page added. but error. 2025-12-26 14:35:07 -05:00
Michael Woods
7d99eecc61 Adding object detail page. 2025-12-26 14:29:27 -05:00
Michael Woods
6dfaaa76d4 Another fix that didn't work. 2025-12-26 14:20:23 -05:00
Michael Woods
6237d3f58a Latest changes applied. 2025-12-26 14:18:39 -05:00
Michael Woods
333a8dabc9 Tried another fix that helped a little. Not there yet. 2025-12-26 14:14:27 -05:00
Michael Woods
1a0fd25031 Applied a fix for the delete button that didn't work. 2025-12-26 14:12:25 -05:00
Michael Woods
1f455f47ed Adding objects dashboard. 2025-12-26 14:04:44 -05:00
Michael Woods
522bd9e70e Added metadata object retrieval without full download. 2025-12-26 00:19:15 -05:00
Michael Woods
0c75e9ebbc Normal download streaming added. 2025-12-26 00:16:58 -05:00
Michael Woods
7d01d24196 get binary option from http api. 2025-12-26 00:12:02 -05:00
Michael Woods
005588794e Downloader text worked. 2025-12-26 00:05:33 -05:00
Michael Woods
aea9a27deb Added object deleter. 2025-12-25 23:58:53 -05:00
Michael Woods
5e2e3cd858 Object update validator fixed. 2025-12-25 23:50:20 -05:00
Michael Woods
1566bc4093 Object patcher 2025-12-25 23:47:22 -05:00
Michael Woods
1ab752d170 Binary uploader works too. 2025-12-25 23:35:07 -05:00
Michael Woods
88d00f97a5 Object upload works now. Added a json text object uploader. 2025-12-25 23:21:41 -05:00
Michael Woods
2693ad49b8 Fixed some server object code bugs.. probably object code is borked everywhere that uses get_objects_by_username.
I really needed to adopt a single standard with what type of connection object gets passed to a classmethod.
2025-12-25 23:12:54 -05:00
Michael Woods
07e6519679 Added some logging and object upload code. 2025-12-25 22:32:44 -05:00
Michael Woods
d5983b6bf3 Fixed send behavior. 2025-12-25 21:06:09 -05:00
Michael Woods
159a20f043 Removed bulletin wording from send.py. 2025-12-25 20:49:43 -05:00
Michael Woods
b59eafa9ca Tried a fix that didn't work in base. 2025-12-25 20:38:20 -05:00
Michael Woods
d913674426 Partially fixed bad send behavior. 2025-12-25 20:32:12 -05:00
Michael Woods
bec626678e Bunch of fixes for new database model. 2025-12-25 20:02:47 -05:00
Michael Woods
bc8a649ff4 Fixed database code everywhere, dashboard is still happy everywhere now. 2025-12-25 17:16:24 -05:00
Michael Woods
5018012dc7 Updated database methodology for everything else. 2025-12-25 16:06:29 -05:00
Michael Woods
5f39349496 Added auth to bulletin new. 2025-12-25 15:39:07 -05:00
Michael Woods
e3d5f953b1 Reapply "Suggestions from grok about database.py. Updated bulletins to use new database logic."
This reverts commit 2051cda1b4.
2025-12-25 15:35:25 -05:00
Michael Woods
2051cda1b4 Revert "Suggestions from grok about database.py. Updated bulletins to use new database logic."
This reverts commit 60165d658c.
2025-12-25 15:32:21 -05:00
Michael Woods
00cf6ab674 Removed a transaction.commit() that was unnecessary. 2025-12-25 15:31:28 -05:00
37 changed files with 2653 additions and 470 deletions

7
examples/misc/script.py Normal file
View 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
View 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
View 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))

View File

@@ -1,5 +1,6 @@
# packetserver/http/auth.py # packetserver/http/auth.py
import ax25 import ax25
import transaction
from persistent import Persistent from persistent import Persistent
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import VerifyMismatchError
@@ -7,7 +8,9 @@ import time
from persistent.mapping import PersistentMapping from persistent.mapping import PersistentMapping
from persistent.list import PersistentList from persistent.list import PersistentList
from packetserver.common.util import is_valid_ax25_callsign from packetserver.common.util import is_valid_ax25_callsign
from .database import get_db, get_transaction from .database import DbDependency
from typing import Union
from ZODB.Connection import Connection
ph = PasswordHasher() ph = PasswordHasher()
@@ -51,26 +54,29 @@ class HttpUser(Persistent):
# rf enabled checks.. # rf enabled checks..
# #
def is_rf_enabled(self) -> bool: def is_rf_enabled(self, db: Union[DbDependency,Connection]) -> bool:
""" """
Check if RF gateway is enabled (i.e., callsign NOT in global blacklist). Check if RF gateway is enabled (i.e., callsign NOT in global blacklist).
Requires an open ZODB connection. Requires an open ZODB connection.
""" """
with get_transaction() as storage: if type(db) is Connection:
root = storage.root() root = db.root()
blacklist = root.get('config', {}).get('blacklist', [])
return self.username not in blacklist
with db.transaction() as conn:
root = conn.root()
blacklist = root.get('config', {}).get('blacklist', []) blacklist = root.get('config', {}).get('blacklist', [])
return self.username not in blacklist return self.username not in blacklist
def set_rf_enabled(self, connection, allow: bool): def set_rf_enabled(self, db: DbDependency, allow: bool):
""" """
Enable/disable RF gateway by adding/removing from global blacklist. Enable/disable RF gateway by adding/removing from global blacklist.
Requires an open ZODB connection (inside a transaction). Requires an open ZODB connection (inside a transaction).
Only allows enabling if the username is a valid AX.25 callsign. Only allows enabling if the username is a valid AX.25 callsign.
""" """
from packetserver.common.util import is_valid_ax25_callsign # our validator from packetserver.common.util import is_valid_ax25_callsign # our validator
with db.transaction() as conn:
with get_transaction() as storage: root = conn.root()
root = storage.root()
config = root.setdefault('config', PersistentMapping()) config = root.setdefault('config', PersistentMapping())
blacklist = config.setdefault('blacklist', PersistentList()) blacklist = config.setdefault('blacklist', PersistentList())
@@ -89,6 +95,7 @@ class HttpUser(Persistent):
config._p_changed = True config._p_changed = True
root._p_changed = True root._p_changed = True
transaction.commit()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Password handling (unchanged) # Password handling (unchanged)

View File

@@ -6,7 +6,7 @@ class Settings(BaseSettings):
""" """
# Define your settings fields with type hints and optional default values # Define your settings fields with type hints and optional default values
name: str = "PacketServer" name: str = "PacketServer"
zeo_file: str zeo_file: str = ""
operator: str | None = None operator: str | None = None
debug_mode: bool = False debug_mode: bool = False
log_level: str = "info" log_level: str = "info"

View File

@@ -1,13 +1,18 @@
from copy import deepcopy
from fastapi import Depends from fastapi import Depends
from typing import Annotated, Generator from typing import Annotated, Generator
from os.path import isfile from os.path import isfile
import ZEO import ZEO
import ZODB import ZODB
import json
from ZODB.Connection import Connection from ZODB.Connection import Connection
import transaction import transaction
import logging
from .config import Settings # assuming Settings has zeo_file: str from .config import Settings # assuming Settings has zeo_file: str
from ..common.util import convert_from_persistent
settings = Settings() settings = Settings()
@@ -37,8 +42,7 @@ def init_db() -> ZODB.DB:
return _db return _db
host, port = _get_zeo_address(settings.zeo_file) host, port = _get_zeo_address(settings.zeo_file)
storage = ZEO.ClientStorage((host, port)) _db = ZEO.DB((host, port))
_db = ZODB.DB(storage)
return _db return _db
def get_db() -> ZODB.DB: def get_db() -> ZODB.DB:
@@ -47,14 +51,16 @@ def get_db() -> ZODB.DB:
raise RuntimeError("Database not initialized call init_db() on startup") raise RuntimeError("Database not initialized call init_db() on startup")
return _db return _db
def get_connection() -> Generator[Connection, None, None]: #def get_connection() -> Generator[Connection, None, None]:
"""Per-request dependency: yields an open Connection, closes on exit.""" # """Per-request dependency: yields an open Connection, closes on exit."""
db = get_db() # db = get_db()
conn = db.open() # conn = db.open()
try: # try:
yield conn # yield conn
finally: # finally:
conn.close() # #print("not closing connection")
# #conn.close()
# pass
# Optional: per-request transaction (if you want automatic commit/abort) # Optional: per-request transaction (if you want automatic commit/abort)
def get_transaction_manager(): def get_transaction_manager():
@@ -62,4 +68,12 @@ def get_transaction_manager():
# Annotated dependencies for routers # Annotated dependencies for routers
DbDependency = Annotated[ZODB.DB, Depends(get_db)] DbDependency = Annotated[ZODB.DB, Depends(get_db)]
ConnectionDependency = Annotated[Connection, Depends(get_connection)] #ConnectionDependency = Annotated[Connection, Depends(get_connection)]
def get_server_config_from_db(db: DbDependency) -> dict:
with db.transaction() as conn:
db_config = convert_from_persistent(conn.root.config)
if type(db_config) is not dict:
raise RuntimeError("The config property is not a dict.")
db_config['server_callsign'] = conn.root.server_callsign
return db_config

View File

@@ -3,18 +3,17 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .auth import HttpUser from .auth import HttpUser
from .database import get_transaction from .database import DbDependency
security = HTTPBasic() security = HTTPBasic()
async def get_current_http_user(credentials: HTTPBasicCredentials = Depends(security)): async def get_current_http_user(db: DbDependency, credentials: HTTPBasicCredentials = Depends(security)):
""" """
Authenticate via Basic Auth using HttpUser from ZODB. Authenticate via Basic Auth using HttpUser from ZODB.
Injected by the standalone runner (get_db_connection available). Injected by the standalone runner (get_db_connection available).
""" """
with db.transaction() as conn:
with get_transaction() as conn:
root = conn.root() root = conn.root()
http_users = root.get("httpUsers") http_users = root.get("httpUsers")

View 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))

View File

@@ -6,8 +6,9 @@ from datetime import datetime
import transaction import transaction
from persistent.list import PersistentList from persistent.list import PersistentList
from ZODB.Connection import Connection from ZODB.Connection import Connection
import logging
from packetserver.http.database import DbDependency, ConnectionDependency, get_db from packetserver.http.database import DbDependency
from ..dependencies import get_current_http_user from ..dependencies import get_current_http_user
from ..auth import HttpUser from ..auth import HttpUser
from ..server import templates from ..server import templates
@@ -129,16 +130,21 @@ async def bulletin_list_page(
return templates.TemplateResponse( return templates.TemplateResponse(
"bulletin_list.html", "bulletin_list.html",
{"request": request, "bulletins": bulletins, "current_user": current_user.username} {
"request": request,
"bulletins": bulletins,
"current_user": current_user.username
}
) )
@html_router.get("/bulletins/new", response_class=HTMLResponse) @html_router.get("/bulletins/new", response_class=HTMLResponse)
async def bulletin_new_form( async def bulletin_new_form(
request: Request, request: Request,
current_user: HttpUser = Depends(get_current_http_user) # require login
): ):
return templates.TemplateResponse( return templates.TemplateResponse(
"bulletin_new.html", "bulletin_new.html",
{"request": request, "error": None} {"request": request, "error": None, "current_user": current_user.username}
) )
@html_router.post("/bulletins/new") @html_router.post("/bulletins/new")
@@ -185,3 +191,42 @@ async def bulletin_detail_page(
"bulletin_detail.html", "bulletin_detail.html",
{"request": request, "bulletin": bulletin, "current_user": current_user.username} {"request": request, "bulletin": bulletin, "current_user": current_user.username}
) )
@router.delete("/bulletins/{bid}", status_code=204)
async def delete_bulletin(
bid: int,
db: DbDependency,
current_user: HttpUser = Depends(get_current_http_user)
):
username = current_user.username
try:
with db.transaction() as conn:
root = conn.root()
bulletins_list: PersistentList = root.get("bulletins", PersistentList())
# Find the bulletin
bulletin_to_delete = None
for b in bulletins_list:
if b.id == bid:
bulletin_to_delete = b
break
if not bulletin_to_delete:
raise HTTPException(status_code=404, detail="Bulletin not found")
if bulletin_to_delete.author != username:
raise HTTPException(status_code=403, detail="Not authorized to delete this bulletin")
# Remove it
bulletins_list.remove(bulletin_to_delete)
logging.info(f"User {username} deleted bulletin {bid}")
except HTTPException:
raise
except Exception as e:
logging.error(f"Bulletin delete failed for {username} on {bid}: {e}")
raise HTTPException(status_code=500, detail="Failed to delete bulletin")
return None # 204 No Content

View File

@@ -5,6 +5,7 @@ from fastapi.responses import HTMLResponse
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.server import templates from packetserver.http.server import templates
from packetserver.http.database import DbDependency
router = APIRouter(tags=["dashboard"]) router = APIRouter(tags=["dashboard"])
@@ -15,19 +16,23 @@ from .bulletins import list_bulletins
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/dashboard", response_class=HTMLResponse)
async def dashboard( async def dashboard(
db: DbDependency,
request: Request, request: Request,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
# Internal call pass explicit defaults to avoid Query object injection
messages_resp = await api_get_messages( messages_resp = await api_get_messages(
db,
current_user=current_user, current_user=current_user,
type="all", type="all",
limit=100, limit=100,
since=None # prevents Query wrapper since=None # prevents Query wrapper
) )
with db.transaction() as conn:
# Internal call pass explicit defaults to avoid Query object injection
messages = messages_resp["messages"] 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"] recent_bulletins = bulletins_resp["bulletins"]
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -42,11 +47,12 @@ async def dashboard(
@router.get("/dashboard/profile", response_class=HTMLResponse) @router.get("/dashboard/profile", response_class=HTMLResponse)
async def profile_page( async def profile_page(
db: DbDependency,
request: Request, request: Request,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
from packetserver.http.routers.profile import profile as api_profile from packetserver.http.routers.profile import profile as api_profile
profile_data = await api_profile(current_user=current_user) profile_data = await api_profile(db, current_user=current_user)
return templates.TemplateResponse( return templates.TemplateResponse(
"profile.html", "profile.html",

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

View File

@@ -4,11 +4,13 @@ from fastapi.responses import HTMLResponse
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.server import templates from packetserver.http.server import templates
from packetserver.http.database import DbDependency
router = APIRouter(tags=["message-detail"]) router = APIRouter(tags=["message-detail"])
@router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse) @router.get("/dashboard/message/{msg_id}", response_class=HTMLResponse)
async def message_detail_page( async def message_detail_page(
db: DbDependency,
request: Request, request: Request,
msg_id: str = Path(..., description="Message UUID as string"), msg_id: str = Path(..., description="Message UUID as string"),
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
@@ -18,6 +20,7 @@ async def message_detail_page(
# Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual) # Call with mark_retrieved=True to auto-mark as read on view (optional—remove if you prefer manual)
message_data = await api_get_message( message_data = await api_get_message(
db,
msg_id=msg_id, msg_id=msg_id,
mark_retrieved=True, mark_retrieved=True,
current_user=current_user current_user=current_user

View File

@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field, validator
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.database import DbDependency
html_router = APIRouter(tags=["messages-html"]) html_router = APIRouter(tags=["messages-html"])
@@ -28,18 +29,18 @@ class MarkRetrievedRequest(BaseModel):
@router.get("/messages") @router.get("/messages")
async def get_messages( async def get_messages(
db: DbDependency,
current_user: HttpUser = Depends(get_current_http_user), current_user: HttpUser = Depends(get_current_http_user),
type: str = Query("received", description="received, sent, or all"), type: str = Query("received", description="received, sent, or all"),
limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"), limit: Optional[int] = Query(20, le=100, description="Max messages to return (default 20, max 100)"),
since: Optional[str] = Query(None, description="ISO UTC timestamp filter (e.g. 2025-12-01T00:00:00Z)") since: Optional[str] = Query(None, description="ISO UTC timestamp filter (e.g. 2025-12-01T00:00:00Z)"),
): ):
if limit is None or limit < 1: if limit is None or limit < 1:
limit = 20 limit = 20
username = current_user.username username = current_user.username
with db.transaction() as conn:
from packetserver.runners.http_server import get_db_connection
conn = get_db_connection()
root = conn.root() root = conn.root()
if 'messages' not in root: if 'messages' not in root:
@@ -81,12 +82,12 @@ async def get_messages(
@router.get("/messages/{msg_id}") @router.get("/messages/{msg_id}")
async def get_message( async def get_message(
db: DbDependency,
msg_id: str = Path(..., description="UUID of the message (as string)"), msg_id: str = Path(..., description="UUID of the message (as string)"),
mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"), mark_retrieved: bool = Query(False, description="If true, mark message as retrieved/read"),
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
from packetserver.runners.http_server import get_db_connection with db.transaction() as conn:
conn = get_db_connection()
root = conn.root() root = conn.root()
username = current_user.username username = current_user.username
@@ -127,12 +128,12 @@ async def get_message(
@router.patch("/messages/{msg_id}") @router.patch("/messages/{msg_id}")
async def mark_message_retrieved( async def mark_message_retrieved(
db: DbDependency,
msg_id: str = Path(..., description="Message UUID as string"), msg_id: str = Path(..., description="Message UUID as string"),
payload: MarkRetrievedRequest = None, payload: MarkRetrievedRequest = None,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
from packetserver.runners.http_server import get_db_connection with db.transaction() as conn:
conn = get_db_connection()
root = conn.root() root = conn.root()
username = current_user.username username = current_user.username
@@ -164,21 +165,76 @@ async def mark_message_retrieved(
@html_router.get("/messages", response_class=HTMLResponse) @html_router.get("/messages", response_class=HTMLResponse)
async def message_list_page( async def message_list_page(
request: Request, request: Request,
type: str = Query("received", alias="msg_type"), # matches your filter links db: DbDependency,
limit: Optional[int] = Query(50, le=100), current_user: HttpUser = Depends(get_current_http_user),
current_user: HttpUser = Depends(get_current_http_user) msg_type: str = Query("received", alias="type"), # Change alias to "type" for cleaner URLs
search: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
): ):
from packetserver.http.server import templates from packetserver.http.server import templates # Local import safe from circular
# Directly call the existing API endpoint function
api_resp = await get_messages(current_user=current_user, type=type, limit=limit, since=None) username = current_user.username.upper().strip()
messages = api_resp["messages"]
valid_types = {"received", "sent", "all"}
if msg_type not in valid_types:
msg_type = "received"
with db.transaction() as conn:
root = conn.root()
mailbox = root.get("messages", {}).get(username, [])
# Build full list of message dicts (similar to API)
messages = []
for msg in mailbox:
messages.append({
"id": str(msg.msg_id),
"from": msg.msg_from,
"to": list(msg.msg_to) if isinstance(msg.msg_to, tuple) else [msg.msg_to],
"sent_at": msg.sent_at.isoformat() + "Z",
"text": msg.text,
"has_attachments": len(msg.attachments) > 0,
"retrieved": msg.retrieved,
})
# Type filter
if msg_type == "received":
filtered = [m for m in messages if username in m["to"]]
elif msg_type == "sent":
filtered = [m for m in messages if m["from"] == username]
else:
filtered = messages
# Search filter (case-insensitive across from/to/text)
if search:
search_lower = search.strip().lower()
filtered = [
m for m in filtered
if search_lower in m["from"].lower()
or any(search_lower in t.lower() for t in m["to"])
or search_lower in m["text"].lower()
]
# Sort newest first
filtered.sort(key=lambda m: m["sent_at"], reverse=True)
# Pagination
total = len(filtered)
start = (page - 1) * per_page
paginated = filtered[start:start + per_page]
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
return templates.TemplateResponse( return templates.TemplateResponse(
"message_list.html", "message_list.html",
{ {
"request": request, "request": request,
"messages": messages, "messages": paginated,
"msg_type": type, "current_type": msg_type, # For tabs/links
"current_user": current_user.username "current_search": search, # For preserving/clearing search
} "total": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"current_user": current_user.username,
},
) )

View File

@@ -1,13 +1,22 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header, Request
from typing import List from fastapi.responses import PlainTextResponse, Response, JSONResponse, StreamingResponse, RedirectResponse
from typing import List, Optional
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
import mimetypes import mimetypes
import logging
from traceback import format_exc
import base64
import traceback
from pydantic import BaseModel, model_validator
import re
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.database import DbDependency
from packetserver.server.objects import Object from packetserver.server.objects import Object
from pydantic import BaseModel from packetserver.server.users import User
router = APIRouter(prefix="/api/v1", tags=["objects"]) router = APIRouter(prefix="/api/v1", tags=["objects"])
@@ -22,21 +31,14 @@ class ObjectSummary(BaseModel):
modified_at: datetime modified_at: datetime
@router.get("/objects", response_model=List[ObjectSummary]) @router.get("/objects", response_model=List[ObjectSummary])
async def list_my_objects(current_user: HttpUser = Depends(get_current_http_user)): async def list_my_objects(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
from packetserver.runners.http_server import get_db_connection username = current_user.username.upper().strip() # ensure uppercase consistency
logging.debug(f"Listing objects for user {username}")
conn = get_db_connection()
root = conn.root()
username = current_user.username # uppercase callsign
core_objects = Object.get_objects_by_username(username, root)
# Sort newest first by created_at
core_objects.sort(key=lambda o: o.created_at, reverse=True)
user_objects = [] user_objects = []
for obj in core_objects: with db.transaction() as conn:
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) content_type, _ = mimetypes.guess_type(obj.name)
if content_type is None: if content_type is None:
content_type = "application/octet-stream" if obj.binary else "text/plain" 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 modified_at=obj.modified_at
)) ))
# Sort newest first
user_objects.sort(key=lambda x: x.created_at, reverse=True)
return user_objects 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
)

View 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)

View File

@@ -3,23 +3,23 @@ from fastapi import APIRouter, Depends
from packetserver.http.dependencies import get_current_http_user from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.http.database import DbDependency
router = APIRouter(prefix="/api/v1", tags=["auth"]) router = APIRouter(prefix="/api/v1", tags=["auth"])
@router.get("/profile") @router.get("/profile")
async def profile(current_user: HttpUser = Depends(get_current_http_user)): async def profile(db: DbDependency, current_user: HttpUser = Depends(get_current_http_user)):
username = current_user.username username = current_user.username
rf_enabled = current_user.is_rf_enabled(db)
from packetserver.runners.http_server import get_db_connection
conn = get_db_connection()
root = conn.root()
# Get main BBS User and safe dict # Get main BBS User and safe dict
with db.transaction() as conn:
root = conn.root()
main_users = root.get('users', {}) main_users = root.get('users', {})
bbs_user = main_users.get(username) bbs_user = main_users.get(username)
safe_profile = bbs_user.to_safe_dict() if bbs_user else {} safe_profile = bbs_user.to_safe_dict() if bbs_user else {}
rf_enabled = current_user.is_rf_enabled(conn)
return { return {
**safe_profile, **safe_profile,

View File

@@ -11,6 +11,7 @@ from packetserver.http.dependencies import get_current_http_user
from packetserver.http.auth import HttpUser from packetserver.http.auth import HttpUser
from packetserver.server.messages import Message from packetserver.server.messages import Message
from packetserver.common.util import is_valid_ax25_callsign from packetserver.common.util import is_valid_ax25_callsign
from packetserver.http.database import DbDependency
router = APIRouter(prefix="/api/v1", tags=["messages"]) router = APIRouter(prefix="/api/v1", tags=["messages"])
@@ -39,14 +40,15 @@ class SendMessageRequest(BaseModel):
@router.post("/messages") @router.post("/messages")
async def send_message( async def send_message(
db: DbDependency,
payload: SendMessageRequest, payload: SendMessageRequest,
current_user: HttpUser = Depends(get_current_http_user) current_user: HttpUser = Depends(get_current_http_user)
): ):
from packetserver.runners.http_server import get_db_connection is_rf_enabled = current_user.is_rf_enabled(db)
conn = get_db_connection() with db.transaction() as conn:
root = conn.root() root = conn.root()
if not current_user.is_rf_enabled(conn): if not is_rf_enabled:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="RF gateway access required to send messages" detail="RF gateway access required to send messages"
@@ -54,28 +56,51 @@ async def send_message(
username = current_user.username username = current_user.username
users_dict = root.get('users', {})
# Prepare recipients # Prepare recipients
to_list = payload.to to_list = [c.upper() for c in payload.to]
to_tuple = tuple(to_list) is_to_all = "ALL" in to_list
if "ALL" in to_list:
to_tuple = ("ALL",)
is_bulletin = "ALL" in to_list if is_to_all:
recipients = to_list if not is_bulletin else list(root.get('users', {}).keys()) # 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( new_msg = Message(
text=payload.text, text=payload.text,
msg_from=username, msg_from=username,
msg_to=to_tuple, msg_to=tuple(valid_recipients),
attachments=() attachments=()
) )
# Deliver to recipients + always sender (sent folder) # Deliver to valid recipients + always sender (sent folder)
messages_root = root.setdefault('messages', PersistentMapping()) messages_root = root.setdefault('messages', PersistentMapping())
delivered_to = set() 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 = messages_root.setdefault(recip, PersistentList())
mailbox.append(new_msg) mailbox.append(new_msg)
mailbox._p_changed = True mailbox._p_changed = True
@@ -84,11 +109,17 @@ async def send_message(
messages_root._p_changed = True messages_root._p_changed = True
transaction.commit() transaction.commit()
return { response = {
"status": "sent", "status": "sent",
"message_id": str(new_msg.msg_id), "message_id": str(new_msg.msg_id),
"from": username, "from": username,
"to": list(to_tuple), "to": list(valid_recipients),
"sent_at": new_msg.sent_at.isoformat() + "Z", "sent_at": new_msg.sent_at.isoformat() + "Z",
"recipients_delivered": len(delivered_to) "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

View File

@@ -3,20 +3,37 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
import base64
from .database import init_db, get_db, get_server_config_from_db
from .routers import public, profile, messages, send from .routers import public, profile, messages, send
from .logging import init_logging
init_logging()
BASE_DIR = Path(__file__).parent.resolve() BASE_DIR = Path(__file__).parent.resolve()
app = FastAPI( app = FastAPI(
title="PacketServer HTTP API", title="PacketServer HTTP API",
description="RESTful interface to the AX.25 packet radio BBS", description="RESTful interface to the AX.25 packet radio BBS",
version="0.1.0", version="0.5.0",
) )
# Define templates EARLY (before importing dashboard) # Define templates EARLY (before importing dashboard)
templates = Jinja2Templates(directory=BASE_DIR / "templates") templates = Jinja2Templates(directory=BASE_DIR / "templates")
def b64decode_filter(value: str) -> str:
try:
decoded_bytes = base64.b64decode(value)
# Assume UTF-8 text (common for job output/errors)
return decoded_bytes.decode('utf-8', errors='replace')
except Exception:
return "[Invalid base64 data]"
templates.env.filters["b64decode"] = b64decode_filter
from datetime import datetime, timezone from datetime import datetime, timezone
def timestamp_to_date(ts): def timestamp_to_date(ts):
@@ -39,6 +56,18 @@ from .routers import dashboard, bulletins
from .routers.message_detail import router as message_detail_router from .routers.message_detail import router as message_detail_router
from .routers.messages import html_router from .routers.messages import html_router
from .routers.objects import router as objects_router from .routers.objects import router as objects_router
from .routers import objects_html
from .routers.jobs import router as jobs_router
from .routers.jobs import dashboard_router as jobs_html_router
# initialize database
init_db()
db = get_db()
server_config = get_server_config_from_db(db)
templates.env.globals['server_name'] = server_config['server_name']
templates.env.globals['server_callsign'] = server_config['server_callsign']
templates.env.globals['motd'] = server_config['motd']
templates.env.globals['server_operator'] = server_config['operator']
# Include routers # Include routers
app.include_router(public.router) app.include_router(public.router)
@@ -51,5 +80,7 @@ app.include_router(bulletins.html_router)
app.include_router(message_detail_router) app.include_router(message_detail_router)
app.include_router(html_router) app.include_router(html_router)
app.include_router(objects_router) app.include_router(objects_router)
app.include_router(objects_html.router)
app.include_router(jobs_router)
app.include_router(jobs_html_router)

View File

@@ -3,22 +3,33 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}PacketServer Dashboard{% endblock %}</title> <title>{% block title %}{{ server_name }} Dashboard{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/css/bootstrap.min.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}">
</head> </head>
<body class="bg-light"> <body class="bg-light">
<nav class="navbar navbar-dark bg-primary mb-4"> <nav class="navbar navbar-dark bg-primary mb-4 py-3"> <!-- Added py-3 for a bit more vertical padding -->
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard') }}">PacketServer BBS</a> <!-- Server name on its own "line" (centered, larger, prominent) -->
<span class="navbar-text"> <div class="w-100 text-center mb-3">
<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> Logged in as: <strong>{{ current_user }}</strong>
{# Basic Auth note #}
<small class="text-light ms-3">(Close browser to logout)</small> <small class="text-light ms-3">(Close browser to logout)</small>
</span> </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="{{ 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="/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="/bulletins" class="btn btn-outline-light btn-sm me-2">Bulletins</a>
<a href="/objects" class="btn btn-outline-light btn-sm me-2">Objects</a>
<a href="/jobs" class="btn btn-outline-light btn-sm me-2">Jobs</a>
</div>
</div>
</div> </div>
</nav> </nav>
@@ -103,8 +114,13 @@
}); });
if (response.ok) { if (response.ok) {
const result = await response.json();
let msg = 'Message sent successfully!';
if (result.warning) {
msg += ' ' + result.warning;
}
status.className = 'alert alert-success'; status.className = 'alert alert-success';
status.textContent = 'Message sent successfully!'; status.textContent = msg;
status.style.display = 'block'; status.style.display = 'block';
composeForm.reset(); composeForm.reset();
setTimeout(() => { setTimeout(() => {

View File

@@ -15,5 +15,35 @@
<a href="/bulletins">← All Bulletins</a> | <a href="/bulletins">← All Bulletins</a> |
<a href="/dashboard">Dashboard</a> <a href="/dashboard">Dashboard</a>
</p> </p>
{% if bulletin.author == current_user %}
<div class="card mt-5 border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">Danger Zone</h5>
</div>
<div class="card-body">
<p class="card-text">Once you delete a bulletin, there is no going back. Please be certain.</p>
<button type="button" class="btn btn-danger"
onclick='deleteBulletin({{ bulletin.id }}, "{{ bulletin.subject | replace("'", "\\'") | replace('"', '\\"') }}")'>
Delete This Bulletin Permanently
</button>
</div>
</div>
<script>
async function deleteBulletin(id, subject) {
console.log("Delete clicked for bulletin " + id);
if (!confirm(`Permanently delete bulletin "${subject}"? This cannot be undone.`)) {
return;
}
const response = await fetch(`/api/v1/bulletins/${id}`, { method: 'DELETE' });
if (response.ok) {
window.location.href = '/bulletins';
} else {
const errorText = await response.text();
alert('Delete failed: ' + (errorText || 'Unknown error'));
}
}
</script>
{% endif %}
</body> </body>
</html> </html>

View File

@@ -1,27 +1,24 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Bulletins - {{ server_name }}{% endblock %}
<meta charset="UTF-8">
<title>Bulletins - PacketServer</title> {% block content %}
<link rel="stylesheet" href="{{ url_for('static', path='/css/style.css') }}"> <div class="container mt-4">
</head> <h1 class="mb-4">Bulletins</h1>
<body>
<h1>Bulletins</h1>
{% if current_user %} {% if current_user %}
<p><a href="/bulletins/new">Create New Bulletin</a></p> <p class="mb-4"><a href="/bulletins/new" class="btn btn-primary">Create New Bulletin</a></p>
{% else %} {% else %}
<p><em>Log in to create bulletins.</em></p> <p class="mb-4"><em>Log in to create bulletins.</em></p>
{% endif %} {% endif %}
{% if bulletins %} {% if bulletins %}
<ul> <ul class="list-unstyled">
{% for bull in bulletins %} {% for bull in bulletins %}
<li> <li class="mb-4 pb-4 border-bottom">
<strong><a href="/bulletins/{{ bull.id }}">{{ bull.subject }}</a></strong> <strong><a href="/bulletins/{{ bull.id }}" class="text-decoration-none">{{ bull.subject }}</a></strong>
<div class="meta">by {{ bull.author }} on {{ bull.created_at[:10] }}</div> <div class="text-muted small">by {{ bull.author }} on {{ bull.created_at[:10] }}</div>
<div class="preview">{{ bull.body[:200] }}{% if bull.body|length > 200 %}...{% endif %}</div> <div class="mt-2">{{ bull.body[:200] }}{% if bull.body|length > 200 %}...{% endif %}</div>
<hr>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -29,6 +26,6 @@
<p>No bulletins yet.</p> <p>No bulletins yet.</p>
{% endif %} {% endif %}
<p><a href="/dashboard">← Back to Dashboard</a></p> <p><a href="{{ url_for('dashboard') }}">← Back to Dashboard</a></p>
</body> </div>
</html> {% endblock %}

View File

@@ -101,8 +101,11 @@
credentials: 'include' // sends Basic Auth credentials: 'include' // sends Basic Auth
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const result = await response.json();
document.getElementById('composeAlert').innerHTML = '<div class="alert alert-success">Sent! ID: ' + data.message_id + '</div>'; let msg = 'Message sent!';
if (result.warning) msg += ` ${result.warning}`;
status.textContent = msg;
document.getElementById('composeAlert').innerHTML = '<div class="alert alert-success">' + msg + '</div>';
setTimeout(() => location.reload(), 1500); setTimeout(() => location.reload(), 1500);
} else { } else {
const err = await resp.json(); const err = await resp.json();

View 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 %}

View 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 %}

View 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 %}

View File

@@ -1,38 +1,107 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Messages - PacketServer{% endblock %} {% block title %}Messages{% endblock %}
{% block content %} {% block content %}
<h1>Messages</h1> <div class="container mt-4">
<div class="mb-4 text-end"> <h1>Messages</h1>
<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"> <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#composeModal">
Compose New Message Compose New Message
</button> </button>
</div> </div>
</div>
<div class="mb-3"> {% if total > 0 %}
<a href="?msg_type=received" class="btn btn-sm {% if msg_type == 'received' %}btn-primary{% else %}btn-outline-primary{% endif %}">Received</a> <p class="text-muted mb-3">
<a href="?msg_type=sent" class="btn btn-sm {% if msg_type == 'sent' %}btn-primary{% else %}btn-outline-primary{% endif %}">Sent</a> Showing {{ ((page-1)*per_page) + 1 }}{{ page*per_page if page*per_page < total else total }} of {{ total }} messages
<a href="?msg_type=all" class="btn btn-sm {% if msg_type == 'all' %}btn-primary{% else %}btn-outline-primary{% endif %}">All</a> </p>
</div> {% endif %}
{% if messages %} {% if messages %}
<ul class="message-list"> <ul class="list-group message-list">
{% for msg in messages %} {% for msg in messages %}
<li> <li class="list-group-item">
<strong><a href="/dashboard/message/{{ msg.id }}">{{ msg.text[:60] }}{% if msg.text|length > 60 %}...{% endif %}</a></strong> <strong>
{% if msg.has_attachments %}<span class="text-info"> (Attachments)</span>{% endif %} <a href="/dashboard/message/{{ msg.id }}">
{% if not msg.retrieved %}<span class="text-warning"> (Unread)</span>{% endif %} {{ msg.text[:80] }}{% if msg.text|length > 80 %}...{% endif %}
<span class="meta"> </a>
From: {{ msg.from }} | To: {{ msg.to | join(', ') }} | {{ msg.sent_at[:10] }} {{ msg.sent_at[11:19] }} </strong>
</span> {% if msg.has_attachments %}<span class="badge bg-info ms-2">Attachments</span>{% endif %}
<div class="preview">{{ msg.text[:200] }}{% if msg.text|length > 200 %}...{% endif %}</div> {% 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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p>No messages found.</p> <div class="alert alert-info mt-4">
{% endif %} {% 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 %} {% endblock %}

View 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 %}

View 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 %}

View File

@@ -1,5 +1,5 @@
"""Package runs arbitrary commands/jobs via different mechanisms.""" """Package runs arbitrary commands/jobs via different mechanisms."""
from typing import Union,Optional,Iterable,Self from typing import Union, Optional, Iterable, Self, Callable, List
from enum import Enum from enum import Enum
import datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -73,8 +73,8 @@ class Runner:
"""Abstract class to take arguments and run a job and track the status and results.""" """Abstract class to take arguments and run a job and track the status and results."""
def __init__(self, username: str, args: Union[str, list[str]], job_id: int, environment: Optional[dict] = None, def __init__(self, username: str, args: Union[str, list[str]], job_id: int, environment: Optional[dict] = None,
timeout_secs: str = 300, labels: Optional[list] = None, timeout_secs: str = 300, labels: Optional[list] = None,
files: list[RunnerFile] = None): files: list[RunnerFile] = None, notify_function: Callable = None):
self.files = [] self.files: List[RunnerFile] = []
if files is not None: if files is not None:
for f in files: for f in files:
self.files.append(f) self.files.append(f)
@@ -87,6 +87,7 @@ class Runner:
self.finished_at = None self.finished_at = None
self._result = (0,(b'', b'')) self._result = (0,(b'', b''))
self._artifact_archive = b'' self._artifact_archive = b''
self.notify_function = notify_function
if environment: if environment:
for key in environment: for key in environment:
self.env[key] = environment[key] self.env[key] = environment[key]
@@ -98,6 +99,10 @@ class Runner:
self.timeout_seconds = timeout_secs self.timeout_seconds = timeout_secs
self.created_at = datetime.datetime.now(datetime.UTC) self.created_at = datetime.datetime.now(datetime.UTC)
def notify(self):
if self.notify_function:
self.notify_function()
def __repr__(self): def __repr__(self):
return f"<{type(self).__name__}: {self.username}[{self.job_id}] - {self.status.name}>" return f"<{type(self).__name__}: {self.username}[{self.job_id}] - {self.status.name}>"
@@ -150,6 +155,8 @@ class Orchestrator:
def __init__(self): def __init__(self):
self.runners = [] self.runners = []
self.runner_lock = Lock() self.runner_lock = Lock()
self.listeners = []
self.started: bool = False
def get_finished_runners(self) -> list[Runner]: def get_finished_runners(self) -> list[Runner]:
return [r for r in self.runners if r.is_finished()] return [r for r in self.runners if r.is_finished()]
@@ -178,14 +185,21 @@ class Orchestrator:
files: list[RunnerFile] = None) -> Runner: files: list[RunnerFile] = None) -> Runner:
pass pass
def notify_listeners(self):
"""If any runners change status, call all listener functions."""
for func in self.listeners:
func()
def manage_lifecycle(self): def manage_lifecycle(self):
"""When called, updates runner statuses and performs any housekeeping.""" """When called, updates runner statuses and performs any housekeeping."""
pass pass
def start(self): def start(self):
"""Do any setup and then be ready to operate""" """Do any setup and then be ready to operate"""
self.started = True
pass pass
def stop(self): def stop(self):
"""Do any cleanup needed.""" """Do any cleanup needed."""
self.started = False
pass pass

View File

@@ -1,5 +1,6 @@
"""Uses podman to run jobs in containers.""" """Uses podman to run jobs in containers."""
import time import time
from collections.abc import Callable
from ZEO import client from ZEO import client
@@ -24,6 +25,7 @@ from packetserver import VERSION as packetserver_version
import re import re
from threading import Thread from threading import Thread
from io import BytesIO from io import BytesIO
from typing import Callable
env_splitter_rex = '''([a-zA-Z0-9]+)=([a-zA-Z0-9]*)''' env_splitter_rex = '''([a-zA-Z0-9]+)=([a-zA-Z0-9]*)'''
@@ -33,9 +35,9 @@ PodmanOptions = namedtuple("PodmanOptions", ["default_timeout", "max_timeout", "
class PodmanRunner(Runner): class PodmanRunner(Runner):
def __init__(self, username: str, args: Union[str, list[str]], job_id: int, container: Container, def __init__(self, username: str, args: Union[str, list[str]], job_id: int, container: Container,
environment: Optional[dict] = None, timeout_secs: str = 300, labels: Optional[list] = None, environment: Optional[dict] = None, timeout_secs: str = 300, labels: Optional[list] = None,
files: list[RunnerFile] = None): files: list[RunnerFile] = None, notify_function: Callable = None):
super().__init__(username, args, job_id, environment=environment, timeout_secs=timeout_secs, super().__init__(username, args, job_id, environment=environment, timeout_secs=timeout_secs,
labels=labels, files=files) labels=labels, files=files, notify_function=notify_function)
self._artifact_archive = b'' self._artifact_archive = b''
if not container.inspect()['State']['Running']: if not container.inspect()['State']['Running']:
raise ValueError(f"Container {container} is not in state Running.") raise ValueError(f"Container {container} is not in state Running.")
@@ -44,10 +46,13 @@ class PodmanRunner(Runner):
self.env['PACKETSERVER_JOBID'] = str(job_id) self.env['PACKETSERVER_JOBID'] = str(job_id)
self.job_path = os.path.join("/home", self.username, ".packetserver", str(job_id)) self.job_path = os.path.join("/home", self.username, ".packetserver", str(job_id))
self.archive_path = os.path.join("/artifact_output", f"{str(job_id)}.tar.gz") self.archive_path = os.path.join("/artifact_output", f"{str(job_id)}.tar.gz")
self.env['PACKETSERVER_JOB_HOME'] = self.job_path
self.env['PACKETSERVER_ARTIFACT_DIR'] = os.path.join(self.job_path,'artifacts')
def thread_runner(self): def thread_runner(self):
self.status = RunnerStatus.RUNNING self.status = RunnerStatus.RUNNING
logging.debug(f"Thread for runner {self.job_id} started. Command for {(type(self.args))}:\n{self.args}") logging.debug(f"Thread for runner {self.job_id} started. Command for {(type(self.args))}:\n{self.args}")
self.notify()
# run the exec call # run the exec call
if type(self.args) is str: if type(self.args) is str:
logging.debug(f"Running string: {self.args}") logging.debug(f"Running string: {self.args}")
@@ -60,6 +65,7 @@ class PodmanRunner(Runner):
logging.debug(str(res)) logging.debug(str(res))
# cleanup housekeeping # cleanup housekeeping
self.status = RunnerStatus.STOPPING self.status = RunnerStatus.STOPPING
self.notify()
self._result = res self._result = res
# run cleanup script # run cleanup script
logging.debug(f"Running cleanup script for {self.job_id}") logging.debug(f"Running cleanup script for {self.job_id}")
@@ -87,6 +93,7 @@ class PodmanRunner(Runner):
self.status = RunnerStatus.SUCCESSFUL self.status = RunnerStatus.SUCCESSFUL
else: else:
self.status = RunnerStatus.FAILED self.status = RunnerStatus.FAILED
self.notify()
@property @property
def has_artifacts(self) -> bool: def has_artifacts(self) -> bool:
@@ -162,7 +169,6 @@ class PodmanRunner(Runner):
class PodmanOrchestrator(Orchestrator): class PodmanOrchestrator(Orchestrator):
def __init__(self, uri: Optional[str] = None, options: Optional[PodmanOptions] = None): def __init__(self, uri: Optional[str] = None, options: Optional[PodmanOptions] = None):
super().__init__() super().__init__()
self.started = False
self.user_containers = {} self.user_containers = {}
self.manager_thread = None self.manager_thread = None
self._client = None self._client = None
@@ -236,7 +242,7 @@ class PodmanOrchestrator(Orchestrator):
def podman_start_user_container(self, username: str) -> Container: def podman_start_user_container(self, username: str) -> Container:
container_env = { container_env = {
"PACKETSERVER_VERSION": packetserver_version, "PACKETSERVER_VERSION": packetserver_version,
"PACKETSERVER_USER": username.strip().lower() "PACKETSERVER_USER": username.strip().lower(),
} }
logging.debug(f"Starting user container for {username} with command {podman_run_command}") logging.debug(f"Starting user container for {username} with command {podman_run_command}")
con = self.client.containers.create(self.opts.image_name, name=self.get_container_name(username), con = self.client.containers.create(self.opts.image_name, name=self.get_container_name(username),
@@ -411,7 +417,7 @@ class PodmanOrchestrator(Orchestrator):
self.touch_user_container(username) self.touch_user_container(username)
logging.debug(f"Queuing a runner on container {con}, with command '{args}' of type '{type(args)}'") logging.debug(f"Queuing a runner on container {con}, with command '{args}' of type '{type(args)}'")
runner = PodmanRunner(username, args, job_id, con, environment=environment, timeout_secs=timeout_secs, runner = PodmanRunner(username, args, job_id, con, environment=environment, timeout_secs=timeout_secs,
labels=labels, files=files) labels=labels, files=files, notify_function=lambda : self.notify_listeners())
self.runners.append(runner) self.runners.append(runner)
runner.start() runner.start()
return runner return runner

View File

@@ -13,14 +13,10 @@ import argparse
import sys import sys
import uvicorn import uvicorn
import ZODB.FileStorage
import ZODB.DB
import logging
from packetserver.http.server import app from packetserver.http.server import app
def main(): def main():
parser = argparse.ArgumentParser(description="Run the PacketServer HTTP API server") parser = argparse.ArgumentParser(description="Run the PacketServer HTTP API server")
parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)") parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8080, help="Port to listen on (default: 8080)") parser.add_argument("--port", type=int, default=8080, help="Port to listen on (default: 8080)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload during development") parser.add_argument("--reload", action="store_true", help="Enable auto-reload during development")

View File

@@ -14,12 +14,16 @@ import sys
import time import time
from getpass import getpass from getpass import getpass
import ax25 import ax25
import os.path
import os
import ZODB.FileStorage import ZODB.FileStorage
import ZODB.DB import ZODB.DB
import transaction import transaction
from persistent.mapping import PersistentMapping from persistent.mapping import PersistentMapping
os.environ['PS_APP_ZEO_FILE'] = "N/A"
# Import our HTTP package internals # Import our HTTP package internals
from packetserver.http.auth import HttpUser, ph # ph = PasswordHasher from packetserver.http.auth import HttpUser, ph # ph = PasswordHasher
@@ -42,6 +46,13 @@ def open_database(db_arg: str) -> ZODB.DB:
return ZODB.DB(storage) return ZODB.DB(storage)
def open_database_zeo_file(filename: str) -> ZODB.DB:
if os.path.isfile(filename):
return open_database(open(filename,'r').read().strip())
else:
raise FileNotFoundError("Must provide a filename to a zeo address.")
def get_or_create_http_users(root): def get_or_create_http_users(root):
if HTTP_USERS_KEY not in root: if HTTP_USERS_KEY not in root:
root[HTTP_USERS_KEY] = PersistentMapping() root[HTTP_USERS_KEY] = PersistentMapping()
@@ -55,7 +66,8 @@ def confirm(prompt: str) -> bool:
def main(): def main():
parser = argparse.ArgumentParser(description="Manage PacketServer HTTP API users") parser = argparse.ArgumentParser(description="Manage PacketServer HTTP API users")
parser.add_argument("--db", required=True, help="DB path (local /path/to/Data.fs) or ZEO (host:port)") parser.add_argument("--db", required=False, help="DB path (local /path/to/Data.fs) or ZEO (host:port)")
parser.add_argument("--zeo-file", required=False, help="zeo address file")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
# add # add
@@ -99,12 +111,16 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Open the database # Open the database
if args.db:
db = open_database(args.db) db = open_database(args.db)
connection = db.open() else:
root = connection.root() db = open_database_zeo_file(args.zeo_file)
try: try:
users_mapping = get_or_create_http_users(root) with db.transaction() as conn:
root = conn.root()
http_users_list = list(get_or_create_http_users(root).keys())
upper_callsign = lambda c: c.upper() upper_callsign = lambda c: c.upper()
@@ -117,7 +133,7 @@ def main():
print(f"Error: Trying to add valid callsign + ssid. Remove -<num> and add again.") print(f"Error: Trying to add valid callsign + ssid. Remove -<num> and add again.")
sys.exit(1) sys.exit(1)
if callsign in users_mapping: if callsign in http_users_list:
print(f"Error: HTTP user {callsign} already exists") print(f"Error: HTTP user {callsign} already exists")
sys.exit(1) sys.exit(1)
@@ -127,35 +143,41 @@ def main():
sys.exit(1) sys.exit(1)
# Create the HTTP-specific user # Create the HTTP-specific user
with db.transaction() as conn:
root = conn.root()
http_user = HttpUser(args.callsign, password) http_user = HttpUser(args.callsign, password)
users_mapping = get_or_create_http_users(conn.root())
users_mapping[callsign] = http_user users_mapping[callsign] = http_user
# Sync: create corresponding regular BBS user using proper write_new for UUID/uniqueness # Sync: create corresponding regular BBS user using proper write_new for UUID/uniqueness
from packetserver.server.users import User from packetserver.server.users import User
main_users = root.setdefault('users', PersistentMapping()) main_users = root.setdefault('users', PersistentMapping())
if callsign not in main_users: if callsign not in main_users:
User.write_new(main_users, args.callsign) # correct: pass mapping + callsign new_user = User(args.callsign)
print(f" → Also created regular BBS user {callsign} (with UUID)") new_user.write_new(conn.root())
print(f" → Also created regular BBS user {callsign}")
else: else:
print(f" → Regular BBS user {callsign} already exists") print(f" → Regular BBS user {callsign} already exists")
transaction.commit()
print(f"Created HTTP user {callsign}") print(f"Created HTTP user {callsign}")
elif args.command == "delete": elif args.command == "delete":
callsign = upper_callsign(args.callsign) callsign = upper_callsign(args.callsign)
if callsign not in users_mapping: if callsign not in http_users_list:
print(f"Error: User {callsign} not found") print(f"Error: User {callsign} not found")
sys.exit(1) sys.exit(1)
if not confirm(f"Delete HTTP user {callsign}?"): if not confirm(f"Delete HTTP user {callsign}?"):
sys.exit(0) sys.exit(0)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
del users_mapping[callsign] del users_mapping[callsign]
transaction.commit()
print(f"Deleted HTTP user {callsign}") print(f"Deleted HTTP user {callsign}")
elif args.command == "set-password": elif args.command == "set-password":
callsign = upper_callsign(args.callsign) callsign = upper_callsign(args.callsign)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign) user = users_mapping.get(callsign)
if not user: if not user:
print(f"Error: User {callsign} not found") print(f"Error: User {callsign} not found")
@@ -166,40 +188,45 @@ def main():
sys.exit(1) sys.exit(1)
user.password_hash = ph.hash(newpass) user.password_hash = ph.hash(newpass)
user._p_changed = True user._p_changed = True
transaction.commit()
print(f"Password updated for {callsign}") print(f"Password updated for {callsign}")
elif args.command == "enable": 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) callsign = upper_callsign(args.callsign)
user = users_mapping.get(callsign) user = users_mapping.get(callsign)
if not user: if not user:
print(f"Error: User {callsign} not found") print(f"Error: User {callsign} not found")
sys.exit(1) sys.exit(1)
user.enabled = True user.http_enabled = True
user._p_changed = True user._p_changed = True
transaction.commit()
print(f"HTTP access enabled for {callsign}") print(f"HTTP access enabled for {callsign}")
elif args.command == "disable": elif args.command == "disable":
callsign = upper_callsign(args.callsign) callsign = upper_callsign(args.callsign)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign) user = users_mapping.get(callsign)
if not user: if not user:
print(f"Error: User {callsign} not found") print(f"Error: User {callsign} not found")
sys.exit(1) sys.exit(1)
user.enabled = False user.http_enabled = False
user._p_changed = True user._p_changed = True
transaction.commit()
print(f"HTTP access disabled for {callsign}") print(f"HTTP access disabled for {callsign}")
elif args.command == "rf-enable": elif args.command == "rf-enable":
callsign = upper_callsign(args.callsign) callsign = upper_callsign(args.callsign)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
user = users_mapping.get(callsign) user = users_mapping.get(callsign)
if not user: if not user:
print(f"Error: User {callsign} not found") print(f"Error: User {callsign} not found")
sys.exit(1) sys.exit(1)
try: try:
user.set_rf_enabled(connection, True) user.set_rf_enabled(db, True)
transaction.commit()
print(f"RF gateway enabled for {callsign}") print(f"RF gateway enabled for {callsign}")
except ValueError as e: except ValueError as e:
print(f"Error: {e}") print(f"Error: {e}")
@@ -207,31 +234,39 @@ def main():
elif args.command == "rf-disable": elif args.command == "rf-disable":
callsign = upper_callsign(args.callsign) 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) user = users_mapping.get(callsign)
if not user: if not user:
print(f"Error: User {callsign} not found") print(f"Error: User {callsign} not found")
sys.exit(1) sys.exit(1)
user.set_rf_enabled(connection, False) user.set_rf_enabled(db, False)
transaction.commit()
print(f"RF gateway disabled for {callsign}") print(f"RF gateway disabled for {callsign}")
elif args.command == "list": elif args.command == "list":
if not users_mapping: if not http_users_list:
print("No HTTP users configured") print("No HTTP users configured")
else: else:
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(f"{'Callsign':<12} {'HTTP Enabled':<13} {'RF Enabled':<11} {'Created':<20} Last Login")
print("-" * 75) print("-" * 75)
for user in sorted(users_mapping.values(), key=lambda u: u.username): 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)) 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)) last = (time.strftime("%Y-%m-%d %H:%M", time.localtime(user.last_login))
if user.last_login else "-") 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}") print(f"{user.username:<12} {str(user.http_enabled):<13} {rf_status:<11} {created:<20} {last}")
elif args.command == "dump": elif args.command == "dump":
import json import json
callsign = upper_callsign(args.callsign) callsign = upper_callsign(args.callsign)
with db.transaction() as conn:
root = conn.root()
users_mapping = get_or_create_http_users(root)
http_user = users_mapping.get(callsign) http_user = users_mapping.get(callsign)
if not http_user: if not http_user:
print(f"Error: No HTTP user {callsign} found") print(f"Error: No HTTP user {callsign} found")
@@ -247,8 +282,8 @@ def main():
"http_user": { "http_user": {
"username": http_user.username, "username": http_user.username,
"http_enabled": http_user.http_enabled, "http_enabled": http_user.http_enabled,
"rf_enabled": http_user.is_rf_enabled(connection), "rf_enabled": http_user.is_rf_enabled(conn),
"blacklisted": not http_user.is_rf_enabled(connection), # explicit inverse "blacklisted": not http_user.is_rf_enabled(conn), # explicit inverse
"created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(http_user.created_at)), "created_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(http_user.created_at)),
"failed_attempts": http_user.failed_attempts, "failed_attempts": http_user.failed_attempts,
}, },
@@ -277,6 +312,8 @@ def main():
alphabet = string.ascii_letters + string.digits + "!@#$%^&*" alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
return ''.join(secrets.choice(alphabet) for _ in range(length)) return ''.join(secrets.choice(alphabet) for _ in range(length))
with db.transaction() as conn:
root = conn.root()
bbs_users = root.get('users', {}) bbs_users = root.get('users', {})
http_users = get_or_create_http_users(root) 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") print("Use 'set-password <call>' to set a known password before enabling login")
finally: finally:
connection.close()
db.close() db.close()

View File

@@ -3,7 +3,7 @@ import tempfile
import pe.app import pe.app
from packetserver.common import Response, Message, Request, PacketServerConnection, send_response, send_blank_response from packetserver.common import Response, Message, Request, PacketServerConnection, send_response, send_blank_response
from packetserver.server.constants import default_server_config from packetserver.server.constants import default_server_config, default_server_name
from packetserver.server.users import User from packetserver.server.users import User
from copy import deepcopy from copy import deepcopy
import ax25 import ax25
@@ -50,6 +50,7 @@ class Server:
self.check_job_queue = True self.check_job_queue = True
self.last_check_job_queue = datetime.datetime.now(datetime.UTC) self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
self.job_check_interval = 60 self.job_check_interval = 60
self.default_job_check_interval = 60
self.quick_job = False self.quick_job = False
if data_dir: if data_dir:
data_path = Path(data_dir) data_path = Path(data_dir)
@@ -74,6 +75,13 @@ class Server:
logging.debug("no config, writing blank default config") logging.debug("no config, writing blank default config")
conn.root.config = PersistentMapping(deepcopy(default_server_config)) conn.root.config = PersistentMapping(deepcopy(default_server_config))
conn.root.config['blacklist'] = PersistentList() conn.root.config['blacklist'] = PersistentList()
logging.debug(f"Setting server callsign in db to: {self.callsign}")
conn.root.server_callsign = self.callsign
for key in ['motd', 'operator']:
if key not in conn.root.config:
conn.root.config[key] = ""
if 'server_name' not in conn.root.config:
conn.root.config['server_name'] = default_server_name
if 'SYSTEM' not in conn.root.config['blacklist']: if 'SYSTEM' not in conn.root.config['blacklist']:
logging.debug("Adding 'SYSTEM' to blacklist in case someone feels like violating FCC rules.") logging.debug("Adding 'SYSTEM' to blacklist in case someone feels like violating FCC rules.")
conn.root.config['blacklist'].append('SYSTEM') conn.root.config['blacklist'].append('SYSTEM')
@@ -105,6 +113,7 @@ class Server:
if val in ['podman']: if val in ['podman']:
logging.debug(f"Enabling {val} orchestrator") logging.debug(f"Enabling {val} orchestrator")
self.orchestrator = get_orchestrator_from_config(conn.root.config['jobs_config']) self.orchestrator = get_orchestrator_from_config(conn.root.config['jobs_config'])
self.orchestrator.listeners.append(lambda : self.ping_job_queue())
self.app = pe.app.Application() self.app = pe.app.Application()
PacketServerConnection.receive_subscribers.append(lambda x: self.server_receiver(x)) PacketServerConnection.receive_subscribers.append(lambda x: self.server_receiver(x))
@@ -123,10 +132,11 @@ class Server:
self.last_check_job_queue = datetime.datetime.now(datetime.UTC) self.last_check_job_queue = datetime.datetime.now(datetime.UTC)
if self.quick_job: if self.quick_job:
logging.debug("Setting the final quick job timer.") logging.debug("Setting the final quick job timer.")
if self.default_job_check_interval > 5:
self.job_check_interval = 5 self.job_check_interval = 5
self.quick_job = False self.quick_job = False
else: else:
self.job_check_interval = 60 self.job_check_interval = self.default_job_check_interval
def server_connection_bouncer(self, conn: PacketServerConnection): def server_connection_bouncer(self, conn: PacketServerConnection):
logging.debug("new connection bouncer checking user status") logging.debug("new connection bouncer checking user status")
@@ -225,6 +235,13 @@ class Server:
self.ping_job_queue() self.ping_job_queue()
if (self.orchestrator is not None) and self.orchestrator.started and self.check_job_queue: if (self.orchestrator is not None) and self.orchestrator.started and self.check_job_queue:
with self.db.transaction() as storage: with self.db.transaction() as storage:
if 'job_check_interval' in storage.root.config:
try:
self.default_job_check_interval = int(storage.root.config['job_check_interval'])
if self.job_check_interval > self.default_job_check_interval:
self.job_check_interval = self.default_job_check_interval
except:
logging.warning(f"Invalid config value for 'job_check_interval'")
# queue as many jobs as possible # queue as many jobs as possible
while self.orchestrator.runners_available(): while self.orchestrator.runners_available():
if len(storage.root.job_queue) > 0: if len(storage.root.job_queue) > 0:

View File

@@ -1,6 +1,13 @@
default_server_name = "Packet Server BBS"
default_server_config = { default_server_config = {
"motd": "Welcome to this PacketServer BBS!", "motd": "Welcome to this PacketServer BBS!",
"operator": "placeholder", "operator": "email_callsign_name_whatever",
"max_message_length": 2000 "max_message_length": 2000,
"server_name": default_server_name
}
jobs_default = {
} }

View File

@@ -5,15 +5,17 @@ import persistent
import persistent.list import persistent.list
from persistent.mapping import PersistentMapping from persistent.mapping import PersistentMapping
import datetime import datetime
from typing import Self,Union,Optional,Tuple from typing import Self,Union,Optional,Tuple,List
from traceback import format_exc from traceback import format_exc
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
from packetserver.common.constants import no_values, yes_values from packetserver.common.constants import no_values, yes_values
from packetserver.server.db import get_user_db_json from packetserver.server.db import get_user_db_json
import ZODB import ZODB
from ZODB.Connection import Connection
from persistent.list import PersistentList from persistent.list import PersistentList
import logging import logging
from packetserver.server.users import user_authorized from packetserver.server.users import user_authorized, User
from packetserver.server.objects import Object
import gzip import gzip
import tarfile import tarfile
import time import time
@@ -23,6 +25,7 @@ from packetserver.runner import Orchestrator, Runner, RunnerStatus, RunnerFile
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
import base64 import base64
from uuid import UUID
class JobStatus(Enum): class JobStatus(Enum):
CREATED = 1 CREATED = 1
@@ -58,6 +61,37 @@ def get_new_job_id(root: PersistentMapping) -> int:
root['job_counter'] = current + 1 root['job_counter'] = current + 1
return current return current
def add_object_to_file_list(object_id: Union[str,UUID], file_list: List[RunnerFile], username: str, conn: Connection):
if type(object_id) is str:
object_id = UUID(object_id)
logging.debug("Adding an object to file list for new job.")
root = conn.root()
logging.debug("Got db root from transaction/connection object.")
obj = Object.get_object_by_uuid(object_id, root)
logging.debug(f"Looked up object {obj}")
if obj is None:
raise KeyError(f"Object '{object_id}' does not exist.")
if obj.private:
owner_uuid = obj.owner
owner = User.get_user_by_uuid(owner_uuid, root)
logging.debug(f"Looked up owner of object: {owner}")
if not (owner.username.lower() == username.lower()):
raise PermissionError(f"Specified object {object_id} not public and not owned by user.")
logging.debug("Checking paths now.")
unique_path = obj.name
runner_paths = []
for i in file_list:
runner_paths.append(i.destination_path)
suffix = 1
while unique_path in runner_paths:
unique_path = obj.name + f"_{suffix}"
suffix = suffix + 1
rf = RunnerFile(unique_path,data=obj.data_bytes)
file_list.append(rf)
class Job(persistent.Persistent): class Job(persistent.Persistent):
@classmethod @classmethod
def update_job_from_runner(cls, runner: Runner, db_root: PersistentMapping) -> True: def update_job_from_runner(cls, runner: Runner, db_root: PersistentMapping) -> True:
@@ -202,6 +236,7 @@ class Job(persistent.Persistent):
"return_code": self.return_code, "return_code": self.return_code,
"artifacts": [], "artifacts": [],
"status": self.status.name, "status": self.status.name,
"env": self.env,
"id": self.id "id": self.id
} }
if include_data: if include_data:
@@ -296,7 +331,7 @@ def handle_new_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB)
if type(req.payload['cmd']) not in [str, list]: if type(req.payload['cmd']) not in [str, list]:
send_blank_response(conn, req, 401, "job post must contain cmd key containing str or list[str]") send_blank_response(conn, req, 401, "job post must contain cmd key containing str or list[str]")
return return
files = [] files: List[RunnerFile] = []
if 'db' in req.payload: if 'db' in req.payload:
logging.debug(f"Fetching a user db as requested.") logging.debug(f"Fetching a user db as requested.")
try: try:
@@ -312,6 +347,11 @@ def handle_new_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB)
val = req.payload['files'][key] val = req.payload['files'][key]
if type(val) is bytes: if type(val) is bytes:
files.append(RunnerFile(key, data=val)) files.append(RunnerFile(key, data=val))
if 'objs' in req.payload:
if type(req.payload['objs']) is list:
with db.transaction() as db_connection:
for obj in req.payload['objs']:
add_object_to_file_list(obj, files, username, db_connection)
env = {} env = {}
if 'env' in req.payload: if 'env' in req.payload:
if type(req.payload['env']) is dict: if type(req.payload['env']) is dict:
@@ -360,6 +400,29 @@ def handle_job_post(req: Request, conn: PacketServerConnection, db: ZODB.DB):
else: else:
send_blank_response(conn, req, status_code=404) send_blank_response(conn, req, status_code=404)
def handle_job_delete(req: Request, conn: PacketServerConnection, db: ZODB.DB):
spl = [x for x in req.path.split("/") if x.strip() != ""]
if (len(spl) == 2) and (spl[1].isdigit()):
jid = int(spl[1])
logging.debug(f"Asked to delete job {jid}")
with db.transaction() as storage:
username = ax25.Address(conn.remote_callsign).call.upper().strip()
if jid in storage.user_jobs[username]:
logging.debug(f"User {username} is authorized to delete job {jid}")
if jid in storage.jobs:
del storage.jobs[jid]
storage.user_jobs[username].remove(jid)
logging.debug(f"Deleted job {jid}")
send_blank_response(conn, req, status_code=204, payload=f"Deleted job {jid}")
else:
if jid in storage.jobs:
logging.error(f"Job with no owner detected: {jid}")
send_blank_response(conn, req, status_code=500, payload="Job not owned by any user.")
else:
send_blank_response(conn, req, status_code=404)
else:
send_blank_response(conn, req, 400, payload="bad delete job request")
def job_root_handler(req: Request, conn: PacketServerConnection, db: ZODB.DB): def job_root_handler(req: Request, conn: PacketServerConnection, db: ZODB.DB):
logging.debug(f"{req} being processed by job_root_handler") logging.debug(f"{req} being processed by job_root_handler")
if not user_authorized(conn, db): if not user_authorized(conn, db):

View File

@@ -1,4 +1,5 @@
"""Server object storage system.""" """Server object storage system."""
import traceback
from copy import deepcopy from copy import deepcopy
import persistent import persistent
@@ -9,6 +10,7 @@ import datetime
from typing import Self,Union,Optional from typing import Self,Union,Optional
from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response from packetserver.common import PacketServerConnection, Request, Response, Message, send_response, send_blank_response
import ZODB import ZODB
from ZODB.Connection import Connection
import logging import logging
import uuid import uuid
from uuid import UUID from uuid import UUID
@@ -76,6 +78,10 @@ class Object(persistent.Persistent):
self._binary = False self._binary = False
self.touch() self.touch()
@property
def data_bytes(self):
return self._data
@property @property
def owner(self) -> Optional[UUID]: def owner(self) -> Optional[UUID]:
return self._owner return self._owner
@@ -96,12 +102,12 @@ class Object(persistent.Persistent):
logging.debug(f"chowning object {self} to user {username}") logging.debug(f"chowning object {self} to user {username}")
un = username.strip().upper() un = username.strip().upper()
old_owner_uuid = self._owner old_owner_uuid = self._owner
with db.transaction() as db: with db.transaction() as conn:
user = User.get_user_by_username(username, db.root()) user = User.get_user_by_username(username, conn.root())
old_owner = User.get_user_by_uuid(old_owner_uuid, db.root()) old_owner = User.get_user_by_uuid(old_owner_uuid, conn.root())
if user: if user:
logging.debug(f"new owner user exists: {user}") logging.debug(f"new owner user exists: {user}")
db.root.objects[self.uuid].owner = user.uuid conn.root.objects[self.uuid].owner = user.uuid
if old_owner_uuid: if old_owner_uuid:
if old_owner: if old_owner:
logging.debug(f"The object has an old owner user: {old_owner}") logging.debug(f"The object has an old owner user: {old_owner}")
@@ -114,20 +120,33 @@ class Object(persistent.Persistent):
raise KeyError(f"User '{un}' not found.") raise KeyError(f"User '{un}' not found.")
@classmethod @classmethod
def get_object_by_uuid(cls, obj: UUID, db_root: PersistentMapping): def get_object_by_uuid(cls, obj: UUID, db_root: PersistentMapping) -> Union[None,Self]:
return db_root['objects'].get(obj) return db_root['objects'].get(obj)
@classmethod @classmethod
def get_objects_by_username(cls, username: str, db: ZODB.DB) -> list[Self]: def get_objects_by_username(cls, username: str, db: Union[ZODB.DB,Connection]) -> list[Self]:
un = username.strip().upper() un = username.strip().upper()
objs = [] objs = []
with db.transaction() as db: if type(db) is Connection:
user = User.get_user_by_username(username, db.root()) conn = db
user = User.get_user_by_username(un, conn.root())
if user: if user:
uuids = user.object_uuids uuids = user.object_uuids
for u in uuids: for u in uuids:
try: try:
obj = cls.get_object_by_uuid(u, db) obj = cls.get_object_by_uuid(u, conn.root())
if obj:
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: if obj:
objs.append(obj) objs.append(obj)
except: except:
@@ -138,15 +157,24 @@ class Object(persistent.Persistent):
def uuid(self) -> Optional[UUID]: def uuid(self) -> Optional[UUID]:
return self._uuid return self._uuid
def write_new(self, db: ZODB.DB) -> UUID: def write_new(self, db: ZODB.DB, username: str = None) -> UUID:
if self.uuid: if self.uuid:
raise KeyError("Object already has UUID. Manually clear it to write it again.") raise KeyError("Object already has UUID. Manually clear it to write it again.")
self._uuid = uuid.uuid4() self._uuid = uuid.uuid4()
with db.transaction() as db:
while self.uuid in db.root.objects: with db.transaction() as conn:
while self.uuid in conn.root.objects:
self._uuid = uuid.uuid4() self._uuid = uuid.uuid4()
db.root.objects[self.uuid] = self conn.root.objects[self.uuid] = self
self.touch() self.touch()
logging.debug(f"New object assigned uuid {self.uuid}")
if username:
logging.debug(f"Attempting to assign new object to user: {username}")
try:
self.chown(username,db)
logging.debug(f"New object assigned to user: {username}")
except:
logging.warning(f"Unable to chown this object to user {username}: {traceback.format_exc()}")
return self.uuid return self.uuid
def to_dict(self, include_data: bool = True) -> dict: def to_dict(self, include_data: bool = True) -> dict:

View File

@@ -129,7 +129,7 @@ class User(persistent.Persistent):
uid = uuid.UUID(str(user_uuid)) uid = uuid.UUID(str(user_uuid))
for user in db_root['users']: for user in db_root['users']:
if uid == db_root['users'][user].uuid: if uid == db_root['users'][user].uuid:
return db_root['users'][user].uuid return db_root['users'][user]
except Exception: except Exception:
return None return None
return None return None