From 47887f00ad1efa237723f1f8056d04aa22707200 Mon Sep 17 00:00:00 2001 From: thevickypedia Date: Mon, 4 Dec 2023 14:14:07 -0600 Subject: [PATCH] Avoid bad request to streaming endpoint breaking the API Set reused string references to `Static` object Remove f-strings from logging Use sensible function names --- docs/genindex.html | 31 ++++++++++++++++------ docs/index.html | 40 +++++++++++++++++++++-------- docs/objects.inv | Bin 781 -> 803 bytes docs/searchindex.js | 2 +- pystream/models/config.py | 8 ++++-- pystream/models/ngrok.py | 5 ++-- pystream/models/squire.py | 13 +++++----- pystream/models/stream.py | 2 +- pystream/routers/auth.py | 16 +++++++----- pystream/routers/basics.py | 4 ++- pystream/routers/video.py | 33 ++++++++++++++---------- pystream/templates/index.html | 2 +- pystream/templates/list_files.html | 2 +- 13 files changed, 104 insertions(+), 54 deletions(-) diff --git a/docs/genindex.html b/docs/genindex.html index 5cae456..0bfaafd 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -51,6 +51,7 @@

Index

| M | N | P + | Q | R | S | U @@ -61,7 +62,7 @@

Index

C

@@ -101,19 +102,19 @@

F

G

@@ -284,6 +289,14 @@

P

+

Q

+ + +
+

R

@@ -331,7 +346,7 @@

U

V

    -
  • VAULT (pystream.models.config.Static attribute) +
  • vault (pystream.models.config.Static attribute)
  • verify() (in module pystream.models.authenticator)
  • diff --git a/docs/index.html b/docs/index.html index 3af8209..9307728 100644 --- a/docs/index.html +++ b/docs/index.html @@ -285,7 +285,7 @@

    Models
    -class pystream.models.config.Static(*, CHUNK_SIZE: int = 1048576, VAULT: str = 'stream')
    +class pystream.models.config.Static(*, vault: str = 'stream', query_param: str = 'file', login_endpoint: str = '/login', logout_endpoint: str = '/logout', streaming_endpoint: str = '/video', chunk_size: int = 1048576)

    Object to store static values.

    >>> Static
     
    @@ -296,13 +296,33 @@

    Models__init__ uses __pydantic_self__ instead of the more common self for the first arg to allow self as a field name.

    -
    -CHUNK_SIZE: int
    +
    +vault: str
    -
    -VAULT: str
    +
    +query_param: str
    +
    + +
    +
    +login_endpoint: str
    +
    + +
    +
    +logout_endpoint: str
    +
    + +
    +
    +streaming_endpoint: str
    +
    + +
    +
    +chunk_size: int

    @@ -377,8 +397,8 @@

    Models
    -
    -pystream.models.squire.get_dir_content(parent: PosixPath, subdir: str) List[Dict[str, str]]
    +
    +pystream.models.squire.get_dir_stream_content(parent: PosixPath, subdir: str) List[Dict[str, str]]

    Get the video files inside a particular directory.

    Parameters:
    @@ -397,8 +417,8 @@

    Models

    -
    -pystream.models.squire.get_stream_content() Dict[str, List[Dict[str, str]]]
    +
    +pystream.models.squire.get_all_stream_content() Dict[str, List[Dict[str, str]]]

    Get video files or folders that contain video files to be streamed.

    Returns:
    @@ -499,7 +519,7 @@

    Routers
    -async pystream.routers.auth.logout(request: Request)
    +async pystream.routers.auth.logout(request: Request) RedirectResponse

    Raises a 401 with no headers to log out the user.

    Raises:
    diff --git a/docs/objects.inv b/docs/objects.inv index 0e50804bdd1b208d93a3c79017a781dc3e542b50..1a454f712934e4841cd89a5fddd345321120f055 100644 GIT binary patch delta 695 zcmV;o0!aOh2BQX$cz@g=5QgvjD=f9wMa?z0s7<9vi5ewRuZXqBHd+jX88&f$eK9!c zu9Rj2_GM)Q&pRLBVb+dF4#dEe={j(g9WMvdcm>tBO6mq)SWkoclE$`QtL?^~670bk z`Y9D9xK;wOx;SP(hFJT?4nVzWK7a=yb&iMAdWIw%O;qc3O@GqpGX@giEhm9{{48i1 zP{@R*9yG~XkjcvBdptXUlkJ%2L2Yy^y8_b*WY_xe{Y~QBX8s3dYbh|$2OiD`Oj3f# z$(<)yCTAVA@?|Z-7jj}HtzI_3rM8~JF9+w0!==)?b7s%U>_IP;vSA~ijcbR7svMuJCw9s#<)F{9;8sA;pSKVcbqsD7ca5QlthXJ zpfG?(K%B~Eb_6pRLTF8)X%eB17g&xQt9!5a9OWNaVt%@Ar(&WTYow;7QYO@bD(Y!w)si&Q4L6DVseaW;{06I*eR zwb4bU!+)#EbvpJMB|b-R-fjM-l52VHb&kvKCnP*cm6hlS#QZ8#3&o|)u@ph;cS_c0 z$%pH!A9vRkQghjuy4qe;X(Xu6$brpL{{WwY$j ziln!_okEM9?6$h95NhwB3F|_(TM;@*GSysWVldKi6EgLVbvtffCzAYKN^$~=gQ+#R7n5; delta 673 zcmV;S0$%;228{-gcz@e442JLd6$Z9zg!S56(Pdb%b}O2-hg}54rfngP=!#UD{QJpL z+9g2OL~<^HE%ABzA(>M8EwKf)5*;=T*rMNFuCQ_)6kiH$D%gmYSKX&LHvL>oC;cHI z9uWDP?l{6_r-+J+efDEW)NkwxxW(!Nc%`&SiEywN#LDtO)ql>EtkE2C5CPXqRVjFsyBLcXA_#_Bi% zYt(A=lCFKZP=B^ad>*A9?U|Gaoh?V__W}#jJYELv5^^_^@6d4bEB!l;oCwBaJc~{f zhX#{CtjqjGg&k}J8dB&=m>_WD{ZAO8h`tfJI8s@h!dk^{MBmU4MZ$W zBE034A-)YMSY;ZJEvPD9=Tg0kC}82)0WtXp>d_EnX%U!8F18FL4`okOv*@?HjW@EC zvg~uiifEJJZ~*;lBDqkcOUem0l3@{Rwbmt?EZ6=OQ-cmiW=_lj+^x2JQ%Uu_JF@*| z=~18j-G4dOT~eU~5bE1usXD3q6i)%{-J;XwQSxDS_4RI65LcHKmc{g}!&m!tgZ->;vg=|_}6$GWTP None: """ config.env = config.EnvConfig(**kwargs) if public_url := get_tunnel(logger=logger): - logger.info(f"Already hosting {config.env.video_port} on {public_url}") + logger.info("Already hosting %d on %s", config.env.video_port, public_url) return from pyngrok import ngrok @@ -57,7 +57,8 @@ def run_tunnel(logger: Logger, **kwargs) -> None: sock.listen(1) connection = None - logger.info(f'Tunneling http://{config.env.video_host}:{config.env.video_port} through public URL: {public_url}') + logger.info('Tunneling http://%s:%s through public URL: %s', + config.env.video_host, config.env.video_port, public_url) try: connection, client_address = sock.accept() except KeyboardInterrupt: diff --git a/pystream/models/squire.py b/pystream/models/squire.py index 2ef3284..86d49cd 100644 --- a/pystream/models/squire.py +++ b/pystream/models/squire.py @@ -17,11 +17,11 @@ def log_connection(request: Request) -> None: """ if request.client.host not in config.session.info: config.session.info[request.client.host] = None - logger.info(f"Connection received from {request.client.host} via {request.headers.get('host')}") - logger.info(f"User agent: {request.headers.get('user-agent')}") + logger.info("Connection received from %s via %s", request.client.host, request.headers.get('host')) + logger.info("User agent: %s", request.headers.get('user-agent')) -def get_dir_content(parent: pathlib.PosixPath, subdir: str) -> List[Dict[str, str]]: +def get_dir_stream_content(parent: pathlib.PosixPath, subdir: str) -> List[Dict[str, str]]: """Get the video files inside a particular directory. Args: @@ -44,13 +44,14 @@ def get_dir_content(parent: pathlib.PosixPath, subdir: str) -> List[Dict[str, st return sorted(files, key=lambda x: x['name']) -def get_stream_content() -> Dict[str, List[Dict[str, str]]]: +def get_all_stream_content() -> Dict[str, List[Dict[str, str]]]: """Get video files or folders that contain video files to be streamed. Returns: Dict[str, List[str]]: Dictionary of files and directories with name and path as key-value pairs on each section. """ + # todo: Cache this with a background task updating the cache periodically structure = {'files': [], 'directories': []} file_sort_by = "len" dir_sort_by = "len" @@ -64,14 +65,14 @@ def get_stream_content() -> Dict[str, List[Dict[str, str]]]: if path := __path.replace(str(config.env.video_source), "").lstrip(os.path.sep): if path[0].isdigit(): dir_sort_by = "index" - entry = {"name": path, "path": os.path.join(config.static.VAULT, path)} + entry = {"name": path, "path": os.path.join(config.static.vault, path)} if entry in structure['directories']: continue structure['directories'].append(entry) else: if file_[0].isdigit(): file_sort_by = "index" - structure['files'].append({"name": file_, "path": os.path.join(config.static.VAULT, file_)}) + structure['files'].append({"name": file_, "path": os.path.join(config.static.vault, file_)}) if file_sort_by == "len": structure['files'] = sorted(structure['files'], key=lambda x: len(x['name'])) else: diff --git a/pystream/models/stream.py b/pystream/models/stream.py index b872d78..5afb53f 100644 --- a/pystream/models/stream.py +++ b/pystream/models/stream.py @@ -24,7 +24,7 @@ def send_bytes_range_requests(file_obj: BinaryIO, with file_obj as streamer: streamer.seek(start_range) while (pos := streamer.tell()) <= end_range: - read_size = min(config.static.CHUNK_SIZE, end_range + 1 - pos) + read_size = min(config.static.chunk_size, end_range + 1 - pos) yield streamer.read(read_size) diff --git a/pystream/routers/auth.py b/pystream/routers/auth.py index 1b2abf9..8b4037b 100644 --- a/pystream/routers/auth.py +++ b/pystream/routers/auth.py @@ -14,7 +14,7 @@ templates = Jinja2Templates(directory=os.path.join(pathlib.Path(__file__).parent.parent, "templates")) -@router.get("/login", response_model=None) +@router.get("%s" % config.static.login_endpoint, response_model=None) async def login(request: Request, credentials: HTTPBasicCredentials = Depends(security)) -> templates.TemplateResponse: """Login request handler. @@ -27,17 +27,19 @@ async def login(request: Request, templates.TemplateResponse: Template response for listing page. """ + # fixme: this is just an index landing page, so throw it out or throw it under root endpoint and kill all redirects await authenticator.verify(credentials) squire.log_connection(request) - content = squire.get_stream_content() + content = squire.get_all_stream_content() return templates.TemplateResponse( name=config.fileio.list_files, - context={"request": request, "files": content['files'], "directories": content['directories']} + context={"request": request, "files": content['files'], + "directories": content['directories'], "logout": config.static.logout_endpoint} ) -@router.get("/logout") -async def logout(request: Request): +@router.get("%s" % config.static.logout_endpoint, response_model=None) +async def logout(request: Request) -> RedirectResponse: """Raises a 401 with no headers to log out the user. Raises: @@ -49,7 +51,7 @@ async def logout(request: Request): if config.session.info.get(request.client.host): del config.session.info[request.client.host] else: - logger.warning(f"Session information for {request.client.host} was never stored.") + logger.warning("Session information for %s was never stored.", request.client.host) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Logged out successfully. Refresh the page to navigate to login.", @@ -57,4 +59,4 @@ async def logout(request: Request): ) else: logger.info("Redirecting connection from %s to login page", request.client.host) - return RedirectResponse(url="/login", headers=None) + return RedirectResponse(url=config.static.login_endpoint, headers=None) diff --git a/pystream/routers/basics.py b/pystream/routers/basics.py index b397810..6cb39ba 100644 --- a/pystream/routers/basics.py +++ b/pystream/routers/basics.py @@ -3,6 +3,8 @@ from fastapi import APIRouter from fastapi.responses import FileResponse, RedirectResponse +from pystream.models import config + router = APIRouter() @@ -26,4 +28,4 @@ async def root() -> RedirectResponse: RedirectResponse: Redirects to login page. """ - return RedirectResponse(url="/login", headers=None) + return RedirectResponse(url=config.static.login_endpoint, headers=None) diff --git a/pystream/routers/video.py b/pystream/routers/video.py index d3df42d..dcc214c 100644 --- a/pystream/routers/video.py +++ b/pystream/routers/video.py @@ -14,7 +14,7 @@ router = APIRouter() -@router.get("/%s/{video_path:path}" % config.static.VAULT, response_model=None) +@router.get("/%s/{video_path:path}" % config.static.vault, response_model=None) async def stream_video(request: Request, video_path: str, credentials: HTTPBasicCredentials = Depends(auth.security)) -> auth.templates.TemplateResponse: @@ -33,28 +33,30 @@ async def stream_video(request: Request, squire.log_connection(request) pure_path = config.env.video_source / video_path if pure_path.is_dir(): - # Use only the final dir in a path, since rest of it will be loaded in the login page itself + # Use only the final dir in the path, since rest of it will be loaded in the login page itself + # Not doing this will result in redundant path, like /home/GOT/season1/season1/episode1.mp4 resulting in 404 child_dir = pathlib.Path(video_path).parts[-1] return auth.templates.TemplateResponse( name=config.fileio.list_files, context={ "request": request, - "files": squire.get_dir_content(pure_path, child_dir), - "dir_name": child_dir, + "dir_name": child_dir, # For GOT/season1/episode1.mp4, this will just display 'season1' in landing page + "files": squire.get_dir_stream_content(pure_path, child_dir), + "logout": config.static.logout_endpoint } ) if pure_path.exists(): + src = f"{config.static.streaming_endpoint}?{config.static.query_param}={urllib.parse.quote(str(pure_path))}" return auth.templates.TemplateResponse( name=config.fileio.index, headers=None, - context={"request": request, "title": video_path, - "path": f"video?vid_name={urllib.parse.quote(str(pure_path))}"} + context={"request": request, "title": video_path, "path": src} ) else: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Video file {video_path!r} not found") # noinspection PyShadowingBuiltins -@router.get("/video", response_model=None) +@router.get("%s" % config.static.streaming_endpoint, response_model=None, include_in_schema=False) async def video_endpoint(request: Request, range: Optional[str] = Header(None), credentials: HTTPBasicCredentials = Depends(auth.security)) \ -> Union[RedirectResponse, StreamingResponse]: @@ -73,12 +75,15 @@ async def video_endpoint(request: Request, range: Optional[str] = Header(None), squire.log_connection(request) if not range or not range.startswith("bytes"): logger.info("/video endpoint accessed directly. Redirecting to login page.") - return RedirectResponse(url="/login", headers=None) - - if config.session.info.get(request.client.host) != request.query_params['vid_name']: - config.session.info[request.client.host] = request.query_params['vid_name'] - logger.info(f"Streaming: {request.query_params['vid_name']}") - + return RedirectResponse(url=config.static.login_endpoint, headers=None) + if not request.query_params.get(config.static.query_param): + raise HTTPException(status_code=status.HTTP_421_MISDIRECTED_REQUEST, + detail="Misdirected request, please route through the login page.") + # Check if the session is streaming the same video, if so - skip logging + if config.session.info.get(request.client.host) != request.query_params[config.static.query_param]: + config.session.info[request.client.host] = request.query_params[config.static.query_param] + logger.info("Streaming: %s", request.query_params[config.static.query_param]) return stream.range_requests_response( - range_header=range, file_path=os.path.join(config.env.video_source, request.query_params['vid_name']) + range_header=range, + file_path=os.path.join(config.env.video_source, request.query_params[config.static.query_param]) ) diff --git a/pystream/templates/index.html b/pystream/templates/index.html index 3a36fbd..aa48d45 100644 --- a/pystream/templates/index.html +++ b/pystream/templates/index.html @@ -86,7 +86,7 @@

    {{title}}

    let origin = window.location.origin; // Get the current origin using JavaScript // Construct the video source URL by combining origin and path - let videoSource = origin + '/' + path; + let videoSource = origin + path; // Set the source URL for the video element let videoElement = document.getElementById("video-source"); diff --git a/pystream/templates/list_files.html b/pystream/templates/list_files.html index 33fa4ec..471ffe1 100644 --- a/pystream/templates/list_files.html +++ b/pystream/templates/list_files.html @@ -114,7 +114,7 @@

    Directories