From 54e39cf02d09842919b61bf863caa367b3508d1e Mon Sep 17 00:00:00 2001 From: thevickypedia Date: Tue, 23 Jan 2024 16:58:34 -0600 Subject: [PATCH] Serve HTML responses for failed login and session-expiry Update docstrings and runbook --- docs/genindex.html | 54 +++++- docs/index.html | 263 ++++++++++++++++++++++----- docs/objects.inv | Bin 942 -> 1082 bytes docs/searchindex.js | 2 +- pystream/main.py | 27 ++- pystream/models/authenticator.py | 59 +++--- pystream/models/config.py | 44 ++++- pystream/models/squire.py | 3 +- pystream/routers/auth.py | 48 ++++- pystream/routers/basics.py | 28 ++- pystream/routers/video.py | 14 +- pystream/templates/index.html | 9 +- pystream/templates/land.html | 2 +- pystream/templates/session.html | 67 +++++++ pystream/templates/unauthorized.html | 65 +++++++ release_notes.rst | 4 +- 16 files changed, 579 insertions(+), 110 deletions(-) create mode 100644 pystream/templates/session.html create mode 100644 pystream/templates/unauthorized.html diff --git a/docs/genindex.html b/docs/genindex.html index 11df95a..0d75d77 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -100,6 +100,8 @@

E

  • EnvConfig (class in pystream.models.config)
  • EnvConfig.Config (class in pystream.models.config) +
  • +
  • error() (in module pystream.routers.basics)
  • extra (pystream.models.config.EnvConfig.Config attribute)
  • @@ -109,6 +111,8 @@

    E

    F

    @@ -152,6 +156,12 @@

    H

    +
    @@ -165,11 +175,9 @@

    I

    @@ -177,12 +185,18 @@

    I

    L

      +
    • remove_thumbnail() (in module pystream.models.squire) +
    • root() (in module pystream.routers.basics)
    • RootFilter (class in pystream.logger) @@ -362,18 +380,24 @@

      R

      S

      + - diff --git a/docs/index.html b/docs/index.html index e20c16c..abe1c2b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -63,6 +63,26 @@

      Stream-Localhost - A secured interface to stream videos

      Main Module

      +
      +
      +async pystream.main.redirect_exception_handler(request: Request, exception: RedirectException) JSONResponse
      +

      Custom exception handler to handle redirect.

      +
      +
      Parameters:
      +
        +
      • request – Takes the Request object as an argument.

      • +
      • exception – Takes the RedirectException object inherited from Exception as an argument.

      • +
      +
      +
      Returns:
      +

      Returns the JSONResponse with content, status code and cookie.

      +
      +
      Return type:
      +

      JSONResponse

      +
      +
      +
      +
      async pystream.main.startup_tasks() None
      @@ -93,18 +113,40 @@

      Models

      Authenticator

      -
      -async pystream.models.authenticator.verify(credentials: HTTPBasicCredentials) JSONResponse
      -

      Verifies authentication.

      +
      +async pystream.models.authenticator.failed_auth_counter(request: Request) None
      +

      Keeps track of failed login attempts from each host, and redirects if failed for 3 or more times.

      Parameters:
      -

      credentials – Credentials from client.

      +

      request – Takes the Request object as an argument.

      -
      Returns:
      -

      Returns JSON response with content and status code.

      +
      +
      + +
      +
      +async pystream.models.authenticator.verify_login(request: Request) JSONResponse
      +

      Verifies authentication.

      +
      +
      Returns:
      +

      Returns JSON response with content and status code.

      -
      Return type:
      -

      JSONResponse

      +
      Return type:
      +

      JSONResponse

      +
      +
      +
      + +
      +
      +async pystream.models.authenticator.verify_token(token: str) None
      +

      Decodes the JWT and validates the session token and expiration.

      +
      +
      Parameters:
      +

      token – JSON web token.

      +
      +
      Raises:
      +

      RedirectException

      @@ -114,7 +156,7 @@

      Models

      Config

      -class pystream.models.config.EnvConfig(_case_sensitive: bool | None = None, _env_prefix: str | None = None, _env_file: DotenvType | None = PosixPath('.'), _env_file_encoding: str | None = None, _env_nested_delimiter: str | None = None, _secrets_dir: str | Path | None = None, *, username: str, password: SecretStr, video_source: Path, video_host: IPv4Address = '127.0.0.1', video_port: int = 8000, file_formats: Sequence[str] = ('.mov', '.mp4'), workers: int = 1, website: Optional[List[str]] = [], auto_thumbnail: bool = True)
      +class pystream.models.config.EnvConfig(_case_sensitive: bool | None = None, _env_prefix: str | None = None, _env_file: DotenvType | None = PosixPath('.'), _env_file_encoding: str | None = None, _env_nested_delimiter: str | None = None, _secrets_dir: str | Path | None = None, *, username: str, password: SecretStr, secret: SecretStr, video_source: Path, video_host: IPv4Address = '127.0.0.1', video_port: int = 8000, session_duration: int = 3600, file_formats: Sequence[str] = ('.mov', '.mp4'), workers: int = 1, website: Optional[List[str]] = [], auto_thumbnail: bool = True)

      Configure all env vars and validate using pydantic to share across modules.

      >>> EnvConfig
       
      @@ -134,6 +176,11 @@

      Modelspassword: SecretStr

      +
      +
      +secret: SecretStr
      +
      +
      video_source: Path
      @@ -149,6 +196,11 @@

      Modelsvideo_port: int

      +
      +
      +session_duration: int
      +
      +
      file_formats: Sequence[str]
      @@ -211,7 +263,7 @@

      Models
      -class pystream.models.config.FileIO(*, index: str = 'index.html', list_files: str = 'list_files.html')
      +class pystream.models.config.FileIO(*, index: str = 'index.html', listing: str = 'list.html', landing: str = 'land.html')

      Loads all the files’ path required/created.

      >>> FileIO
       
      @@ -227,15 +279,20 @@

      Models

      -
      -list_files: str
      +
      +listing: str
      +
      + +
      +
      +landing: str

      -class pystream.models.config.Static(*, track: str = 'track', stream: str = 'stream', preview: str = 'preview', query_param: str = 'file', index_endpoint: str = '/index', logout_endpoint: str = '/logout', streaming_endpoint: str = '/video', chunk_size: int = 1048576, deletions: Set[PosixPath] = {})
      +class pystream.models.config.Static(*, track: str = 'track', stream: str = 'stream', preview: str = 'preview', query_param: str = 'file', home_endpoint: str = '/home', login_endpoint: str = '/login', logout_endpoint: str = '/logout', streaming_endpoint: str = '/video', chunk_size: int = 1048576, deletions: Set[PosixPath] = {}, session_token: str = 'AsZa9GJ8ajXIZbicS0kk4lBcSwpoWfm5')

      Object to store static values.

      >>> Static
       
      @@ -266,8 +323,13 @@

      Models

      -
      -index_endpoint: str
      +
      +home_endpoint: str
      +
      + +
      +
      +login_endpoint: str
      @@ -290,11 +352,16 @@

      Modelsdeletions: Set[PosixPath]

      +
      +
      +session_token: str
      +
      +
      -class pystream.models.config.Session(*, info: dict = {})
      +class pystream.models.config.Session(*, info: dict = {}, invalid: dict = {})

      Object to store session information.

      >>> Session
       
      @@ -309,6 +376,63 @@

      Modelsinfo: dict

      +
      +
      +invalid: dict
      +
      + + + +
      +
      +class pystream.models.config.WebToken(*, token: str, timestamp: int)
      +

      Object to store and validate JWT objects.

      +
      >>> WebToken
      +
      +
      +

      Create a new model by parsing and validating input data from keyword arguments.

      +

      Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be +validated to form a valid model.

      +

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

      +
      +
      +token: str
      +
      + +
      +
      +timestamp: int
      +
      + +
      + +
      +
      +exception pystream.models.config.RedirectException(location: str, detail: Optional[str] = '')
      +

      Custom RedirectException raised within the API since HTTPException doesn’t support returning HTML content.

      +
      >>> RedirectException
      +
      +
      +
      +

      See also

      +
        +
      • RedirectException allows the API to redirect on demand in cases where returning is not a solution.

      • +
      • There are alternatives to raise HTML content as an exception but none work with our use-case with JavaScript.

      • +
      • This way of exception handling comes handy for many unexpected scenarios.

      • +
      +
      +

      References

      +

      https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers

      +

      Instantiates the RedirectException object with the required parameters.

      +
      +
      Parameters:
      +
        +
      • location – Location for redirect.

      • +
      • detail – Reason for redirect.

      • +
      +
      +
      @@ -335,7 +459,7 @@

      Models
      -generate_thumbnails(interval: int = 1, output_dir: Optional[PosixPath] = None) bool
      +generate_thumbnails(interval: int = 1, output_dir: PosixPath = None) bool

      Generate thumbnails for a video file.

      Parameters:
      @@ -369,7 +493,7 @@

      Models
      -generate_preview(path: str, at_second: Optional[int] = None) bool
      +generate_preview(path: str, at_second: int = None) bool

      Generate preview image for a video.

      Parameters:
      @@ -559,6 +683,17 @@

      Models

      +
      +
      +async pystream.models.subtitles.vtt_to_srt(filename: PosixPath)
      +

      Convert a .vtt file to .srt for subtitles to be compatible with video-js.

      +
      +
      Parameters:
      +

      filename – Name of the srt file.

      +
      +
      +
      +

      Routers

      @@ -566,36 +701,59 @@

      Routers

      Authentication

      -
      -async pystream.routers.auth.index(request: Request, credentials: HTTPBasicCredentials = Depends(HTTPBasic)) TemplateResponse
      -

      Login request handler.

      +
      +async pystream.routers.auth.home_page(request: Request, session_token: str = Cookie(None)) TemplateResponse
      +

      Serves the home/index page for the UI.

      Parameters:
        -
      • request – Request class.

      • -
      • credentials – HTTPBasicCredentials for authentication.

      • +
      • request – Takes the Request object as an argument.

      • +
      • session_token – Session token set after verifying username and password.

      Returns:
      -

      Template response for listing page.

      +

      Returns the listing page for video streaming.

      Return type:
      -

      templates.TemplateResponse

      +

      TemplateResponse

      +
      +
      +
      + +
      +
      +async pystream.routers.auth.login(request: Request) JSONResponse
      +

      Authenticates the user input and returns a redirect response with the session token set as a cookie.

      +
      +
      Parameters:
      +

      request – Takes the Request object as an argument.

      +
      +
      Returns:
      +

      Returns the JSONResponse with content, status code and cookie.

      +
      +
      Return type:
      +

      JSONResponse

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

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

      +async pystream.routers.auth.logout(request: Request, session_token: str = Cookie(None)) Union[RedirectResponse, JSONResponse] +

      Terminates the user’s session by deleting the cookie and redirecting back to login page upon refresh.

      -
      Raises:
      +
      Parameters:
        -
      • HTTPException

      • -
      • 401 with a logout message.

      • +
      • request – Takes the Request object as an argument.

      • +
      • session_token – Session token set after verifying username and password.

      +
      Returns:
      +

      JSONResponse to delete the cookie and send logout confirmation, RedirectResponse to navigate to login page.

      +
      +
      Return type:
      +

      Union[RedirectResponse, JSONResponse]

      +
      @@ -618,7 +776,7 @@

      Routers
      -async pystream.routers.basics.root() RedirectResponse
      +async pystream.routers.basics.root(request: Request) RedirectResponse

      Reads the root request to render HTMl page.

      Returns:
      @@ -630,19 +788,36 @@

      Routers +
      +async pystream.routers.basics.error(detail: str = Cookie(None)) HTMLResponse
      +

      Endpoint to serve broken pages as HTML response.

      +
      +
      Parameters:
      +

      detail – Optional session related information.

      +
      +
      Returns:
      +

      Rendered HTML response with deleted cookie.

      +
      +
      Return type:
      +

      HTMLResponse

      +
      +
      +

      +

      Video

      -async pystream.routers.video.preview_loader(request: Request, img_path: str, credentials: HTTPBasicCredentials = Depends(HTTPBasic)) FileResponse
      +async pystream.routers.video.preview_loader(request: Request, img_path: str, session_token: str = Cookie(None)) FileResponse

      Returns the file for preview image.

      Parameters:
        -
      • request – Takes the Request class as an argument.

      • +
      • request – Takes the Request object as an argument.

      • img_path – Path of the image file that has to be rendered.

      • -
      • credentials – HTTPBasicCredentials for authentication.

      • +
      • session_token – Token setup for each session.

      Returns:
      @@ -656,14 +831,14 @@

      Routers
      -async pystream.routers.video.track_loader(request: Request, track_path: str, credentials: HTTPBasicCredentials = Depends(HTTPBasic)) FileResponse
      +async pystream.routers.video.track_loader(request: Request, track_path: str, session_token: str = Cookie(None)) FileResponse

      Returns the file for subtitles.

      Parameters:
        -
      • request – Takes the Request class as an argument.

      • +
      • request – Takes the Request object as an argument.

      • track_path – Path of the subtitle track that has to be rendered.

      • -
      • credentials – HTTPBasicCredentials for authentication.

      • +
      • session_token – Token setup for each session.

      Returns:
      @@ -677,18 +852,18 @@

      Routers
      -async pystream.routers.video.stream_video(request: Request, video_path: str, credentials: HTTPBasicCredentials = Depends(HTTPBasic)) TemplateResponse
      +async pystream.routers.video.stream_video(request: Request, video_path: str, session_token: str = Cookie(None)) TemplateResponse

      Returns the template for streaming page.

      Parameters:
        -
      • request – Takes the Request class as an argument.

      • +
      • request – Takes the Request object as an argument.

      • video_path – Path of the video file that has to be rendered.

      • -
      • credentials – HTTPBasicCredentials for authentication.

      • +
      • session_token – Token setup for each session.

      Returns:
      -

      Template response for streaming page.

      +

      Returns the listing page for video streaming.

      Return type:

      templates.TemplateResponse

      @@ -698,14 +873,14 @@

      Routers
      -async pystream.routers.video.video_endpoint(request: Request, range: Optional[str] = Header(None), credentials: HTTPBasicCredentials = Depends(HTTPBasic)) Union[RedirectResponse, StreamingResponse]
      +async pystream.routers.video.video_endpoint(request: Request, range: Optional[str] = Header(None), session_token: str = Cookie(None)) Union[RedirectResponse, StreamingResponse]

      Streams the video file by sending bytes using StreamingResponse.

      Parameters:
        -
      • request – Takes the Request class as an argument.

      • +
      • request – Takes the Request object as an argument.

      • range – Header information.

      • -
      • credentials – HTTPBasicCredentials for authentication.

      • +
      • session_token – Token setup for each session.

      Returns:
      diff --git a/docs/objects.inv b/docs/objects.inv index d587d94c4a55f65b19cfc1d27733243659a1978f..f65a1da604bc7ed3a5ad32d380e8ac2ab290780e 100644 GIT binary patch delta 979 zcmV;^11$Wm2f7H5b$^&qkJ~5^$KUfQEL~rNuCINou3KrPtFGFu?j8}w;}`{l3qy8u zpT1*=)1=z&2JlN1WB$K^0cHkIM~@D~$h6(rh{iT;HdvdZQFOv zb~9Zo*n_qBS!qmSsfG8=`%CTXBG#X^Bj8}WSHz=GCTE0me}CqnQw}6@c$P4svW5@B zbUNAWgOPLF8&;Vg0(SPva1?&;vy>307yEBKs&WN{m`fsFcEdm2`;BUk~TGdi&nYfv3ZclJ`&6b+yy;ARS;m#rrv!cfpBx{406EgEL~R zK2F!mR!^F&TZ!ataI$)S7dxp6W8EG~4>6SvaQ{z!41Y~J7CYBiW|}GpWp;H8<*}IY zaH?wVmZE0HoGYB0F^}mG<$LlZBv~P2-`R{=eRDnm6FxnPc^YoJLpLG2+ zn0Md1KvA|_3#GE}!6z}r(%HBHxg_jtgb_&PE`NE2ibB4JV-du`Xb*6G^~r#o<6Z8; zrl;A3PM96=0YsPW%Jh@~4k#UX^z_G1lzyRzg1`BrlcNMrm3aKhk~8*8T`FECiW&t*S?mm9{a@Tn5K6F+#+xJasi9MQsqoW^}s&Yd` zLAvtJ{k3R8&x)lrY3}F0o1fiI*oz*Cw@m$eo?868v28v!9%OLPQ9tr_FC;W*n^TI2 zPWKz~)C<<9<92zG@L!UX(>N~Pz9IX1q~c}pn754atXc~HrKstf`RH=rELSRqNCn?Np175)qFM_ zE5xI7^ivy3;@T+0>grJYxk$8oZ4cDB<`Z$Rw9T2}I9^~EsDE)UR+7RMj`B9xnzXYg zED0p#DO(VRJWH+tgF*%Bz>=;NxvX7yPcPQsb-T%%LG5g-mlI4Up}4i{?|a0lWAK4g zT#6vTt|gx=7*UGI>qQ_a!wnu<{dy_jD|vMxR;L?~+75yESAzG>V=0=?lathieqoh1 zrL=J;Wb1sdsDF57gYpP+&1-hDb4ce!j!$7Pus}^vNJ4yZzA0T^K=4-e#RvD;LFJ^u z`3sTI1z#7h?_kf0FwU=F>UH4^26w;nqgB$0IJm^JFd8X_xrqyS0>m*nWUpW=Xf{*1eWqj<^xot{iRTP`y9#^ zbL<$6TYpHhDF~}5c*S1fY%f=^nFR6ByEPnFj~S4Cq{!Xa^lx;bS9S@q1Jz`^GW|;c z4;=oGkUwzXBmt9H$l*#ZHUub-v6-~6AWddc6xnM9^1gCqzov7~r{{FU1yX!q=2$+| zy;VAy2Dj-jQ@K#6Yn(FKq>M+XRcl?JeT)7Bcz@?|CmrM9lsQM?F4s%rGS2)lVjI5p z6Uhd|j9M~-lnV~C`x%HpyBu_;MPTGKrl#~ZZ(Gb(niNpm-T&fbP?u?Hg(Z~kjsT73P@tD6d; z4nH0m9>?Bp6~Dg7Rog%tosKj1)H~LP&3xC9@b}5dX&wi?Cu9#B&g%A9ji7wp9Sc+2 Qb|tC*flGziKO&=i7w^xSZU6uP diff --git a/docs/searchindex.js b/docs/searchindex.js index cbba7a6..d59b1aa 100644 --- a/docs/searchindex.js +++ b/docs/searchindex.js @@ -1 +1 @@ -Search.setIndex({"docnames": ["README", "index"], "filenames": ["README.md", "index.rst"], "titles": ["Video Streaming", "Stream-Localhost - A secured interface to stream videos"], "terms": {"deploy": 0, "python": 0, "modul": 0, "via": 0, "authent": 0, "session": [0, 1], "us": [0, 1], "fastapi": [0, 1], "m": 0, "pip": 0, "localhost": 0, "import": 0, "asyncio": 0, "o": 0, "pystream": [0, 1], "__name__": 0, "__main__": 0, "kwarg": [0, 1], "dict": [0, 1], "usernam": [0, 1], "foo": 0, "password": [0, 1], "bar": 0, "video_sourc": [0, 1], "path": [0, 1], "join": 0, "expandus": 0, "download": 0, "add": [0, 1], "follow": 0, "host": [0, 1], "local": [0, 1], "ip": [0, 1], "address": [0, 1], "skip": 0, "127": [0, 1], "0": [0, 1], "1": [0, 1], "video_host": [0, 1], "util": 0, "get_local_ip": [0, 1], "run": [0, 1], "start": [0, 1], "bulb": 0, "environ": [0, 1], "can": 0, "load": [0, 1], "from": [0, 1], "ani": 0, "file": [0, 1], "filenam": [0, 1], "default": 0, "To": 0, "custom": 0, "set": [0, 1], "var": [0, 1], "env_fil": [0, 1], "kei": [0, 1], "its": [0, 1], "valu": [0, 1], "mandatori": 0, "choic": 0, "_": 0, "sourc": [0, 1], "underscor": 0, "ignor": [0, 1], "option": [0, 1], "port": 0, "number": [0, 1], "applic": 0, "8000": [0, 1], "format": 0, "sequenc": [0, 1], "support": 0, "mp4": [0, 1], "mov": [0, 1], "worker": [0, 1], "spin": 0, "up": 0, "uvicorn": 0, "server": 0, "websit": [0, 1], "list": [0, 1], "regex": 0, "cor": 0, "configur": [0, 1], "requir": [0, 1], "onli": [0, 1], "tunnel": 0, "cdn": 0, "auto": 0, "thumbnail": [0, 1], "boolean": [0, 1], "flag": [0, 1], "gener": [0, 1], "imag": 0, "preview": [0, 1], "true": [0, 1], "docstr": 0, "googl": 0, "style": 0, "convent": 0, "pep": 0, "8": 0, "isort": 0, "gitvers": 0, "revers": 0, "f": 0, "release_not": 0, "rst": 0, "t": 0, "pre": 0, "commit": 0, "ensur": 0, "pytest": 0, "valid": [0, 1], "hyperlink": 0, "all": [0, 1], "markdown": 0, "includ": [0, 1], "wiki": 0, "page": [0, 1], "sphinx": 0, "5": 0, "recommonmark": 0, "http": [0, 1], "org": 0, "project": 0, "thevickypedia": 0, "github": 0, "io": 0, "vignesh": 0, "rao": 0, "under": 0, "mit": 0, "instal": 1, "sampl": 1, "usag": 1, "code": 1, "standard": 1, "releas": 1, "note": 1, "lint": 1, "pypi": 1, "packag": 1, "runbook": 1, "licens": 1, "copyright": 1, "async": 1, "startup_task": 1, "none": 1, "task": 1, "need": 1, "dure": 1, "api": 1, "startup": 1, "shutdown_task": 1, "shutdown": 1, "starter": 1, "function": 1, "paramet": 1, "keyword": 1, "argument": 1, "env": 1, "verifi": 1, "credenti": 1, "httpbasiccredenti": 1, "jsonrespons": 1, "client": 1, "return": 1, "json": 1, "respons": 1, "content": 1, "statu": 1, "type": 1, "class": 1, "envconfig": 1, "_case_sensit": 1, "bool": 1, "_env_prefix": 1, "str": 1, "_env_fil": 1, "dotenvtyp": 1, "posixpath": 1, "_env_file_encod": 1, "_env_nested_delimit": 1, "_secrets_dir": 1, "secretstr": 1, "ipv4address": 1, "video_port": 1, "int": 1, "file_format": 1, "auto_thumbnail": 1, "pydant": 1, "share": 1, "across": 1, "creat": 1, "new": 1, "pars": 1, "input": 1, "data": 1, "rais": 1, "validationerror": 1, "pydantic_cor": 1, "cannot": 1, "form": 1, "__init__": 1, "__pydantic_self__": 1, "instead": 1, "more": 1, "common": 1, "self": 1, "first": 1, "arg": 1, "allow": 1, "field": 1, "name": 1, "variabl": 1, "env_prefix": 1, "extra": 1, "hide_input_in_error": 1, "classmethod": 1, "parse_video_host": 1, "string": 1, "notion": 1, "object": 1, "parse_websit": 1, "evalu": 1, "fileio": 1, "index": 1, "html": 1, "list_fil": 1, "static": 1, "track": 1, "query_param": 1, "index_endpoint": 1, "logout_endpoint": 1, "logout": 1, "streaming_endpoint": 1, "chunk_siz": 1, "1048576": 1, "delet": 1, "store": 1, "info": 1, "inform": 1, "alia": 1, "filepath": 1, "initi": 1, "captur": 1, "frame": 1, "particular": 1, "instanti": 1, "opencv": 1, "": 1, "videocaptur": 1, "generate_thumbnail": 1, "interv": 1, "output_dir": 1, "second": 1, "output": 1, "directori": 1, "success": 1, "failur": 1, "get_video_length": 1, "tupl": 1, "timedelta": 1, "get": 1, "calcul": 1, "length": 1, "datetim": 1, "generate_preview": 1, "at_second": 1, "time": 1, "which": 1, "should": 1, "log_connect": 1, "request": 1, "log": 1, "connect": 1, "devic": 1, "thi": 1, "avoid": 1, "multipl": 1, "when": 1, "same": 1, "differ": 1, "natural_sort_kei": 1, "union": 1, "sort": 1, "natur": 1, "wai": 1, "take": 1, "an": 1, "element": 1, "deriv": 1, "split": 1, "part": 1, "regular": 1, "express": 1, "get_dir_stream_cont": 1, "parent": 1, "subdir": 1, "insid": 1, "displai": 1, "login": 1, "subdirectori": 1, "within": 1, "exist": 1, "dictionari": 1, "pair": 1, "get_all_stream_cont": 1, "folder": 1, "contain": 1, "each": 1, "section": 1, "get_it": 1, "purepath": 1, "current": 1, "serv": 1, "previou": 1, "next": 1, "render": 1, "remove_thumbnail": 1, "img_path": 1, "trigger": 1, "timer": 1, "remov": 1, "send_bytes_range_request": 1, "file_obj": 1, "binaryio": 1, "start_rang": 1, "end_rang": 1, "asynciter": 1, "bytestr": 1, "send": 1, "chunk": 1, "rang": 1, "specif": 1, "rfc7233": 1, "byte": 1, "end": 1, "yield": 1, "iter": 1, "get_range_head": 1, "range_head": 1, "file_s": 1, "proce": 1, "header": 1, "size": 1, "range_requests_respons": 1, "file_path": 1, "streamingrespons": 1, "given": 1, "srt_to_vtt": 1, "convert": 1, "srt": 1, "vtt": 1, "compat": 1, "j": 1, "auth": 1, "depend": 1, "httpbasic": 1, "templaterespons": 1, "handler": 1, "templat": 1, "redirectrespons": 1, "401": 1, "out": 1, "user": 1, "httpexcept": 1, "messag": 1, "get_favicon": 1, "filerespons": 1, "favicon": 1, "ico": 1, "endpoint": 1, "robinhood": 1, "script": 1, "root": 1, "redirect": 1, "preview_load": 1, "ha": 1, "track_load": 1, "track_path": 1, "stream_video": 1, "video_path": 1, "video_endpoint": 1, "receiv": 1, "cooki": 1, "rootfilt": 1, "filter": 1, "while": 1, "preserv": 1, "other": 1, "access": 1, "200": 1, "ok": 1, "307": 1, "temporari": 1, "vid_nam": 1, "redund": 1, "pass": 1, "overrid": 1, "implement": 1, "subclass": 1, "The": 1, "method": 1, "record": 1, "examin": 1, "fals": 1, "discard": 1, "togeth": 1, "children": 1, "have": 1, "event": 1, "through": 1, "If": 1, "i": 1, "specifi": 1, "everi": 1, "logrecord": 1, "repres": 1, "someth": 1, "simpl": 1, "check": 1, "network": 1, "id": 1, "retriev": 1, "privat": 1, "machin": 1, "get_public_ip": 1, "extract": 1, "public": 1, "make": 1, "extern": 1, "search": 1}, "objects": {"pystream": [[1, 0, 0, "-", "logger"], [1, 0, 0, "-", "main"], [1, 0, 0, "-", "utils"]], "pystream.logger": [[1, 1, 1, "", "RootFilter"]], "pystream.logger.RootFilter": [[1, 2, 1, "", "filter"]], "pystream.main": [[1, 3, 1, "", "shutdown_tasks"], [1, 3, 1, "", "start"], [1, 3, 1, "", "startup_tasks"]], "pystream.models": [[1, 0, 0, "-", "authenticator"], [1, 0, 0, "-", "config"], [1, 0, 0, "-", "images"], [1, 0, 0, "-", "squire"], [1, 0, 0, "-", "stream"], [1, 0, 0, "-", "subtitles"]], "pystream.models.authenticator": [[1, 3, 1, "", "verify"]], "pystream.models.config": [[1, 1, 1, "", "EnvConfig"], [1, 1, 1, "", "FileIO"], [1, 1, 1, "", "Session"], [1, 1, 1, "", "Static"], [1, 4, 1, "", "env"]], "pystream.models.config.EnvConfig": [[1, 1, 1, "", "Config"], [1, 4, 1, "", "auto_thumbnail"], [1, 4, 1, "", "file_formats"], [1, 2, 1, "", "parse_video_host"], [1, 2, 1, "", "parse_website"], [1, 4, 1, "", "password"], [1, 4, 1, "", "username"], [1, 4, 1, "", "video_host"], [1, 4, 1, "", "video_port"], [1, 4, 1, "", "video_source"], [1, 4, 1, "", "website"], [1, 4, 1, "", "workers"]], "pystream.models.config.EnvConfig.Config": [[1, 4, 1, "", "env_file"], [1, 4, 1, "", "env_prefix"], [1, 4, 1, "", "extra"], [1, 4, 1, "", "hide_input_in_errors"]], "pystream.models.config.FileIO": [[1, 4, 1, "", "index"], [1, 4, 1, "", "list_files"]], "pystream.models.config.Session": [[1, 4, 1, "", "info"]], "pystream.models.config.Static": [[1, 4, 1, "", "chunk_size"], [1, 4, 1, "", "deletions"], [1, 4, 1, "", "index_endpoint"], [1, 4, 1, "", "logout_endpoint"], [1, 4, 1, "", "preview"], [1, 4, 1, "", "query_param"], [1, 4, 1, "", "stream"], [1, 4, 1, "", "streaming_endpoint"], [1, 4, 1, "", "track"]], "pystream.models.images": [[1, 1, 1, "", "Images"]], "pystream.models.images.Images": [[1, 2, 1, "", "generate_preview"], [1, 2, 1, "", "generate_thumbnails"], [1, 2, 1, "", "get_video_length"]], "pystream.models.squire": [[1, 3, 1, "", "get_all_stream_content"], [1, 3, 1, "", "get_dir_stream_content"], [1, 3, 1, "", "get_iter"], [1, 3, 1, "", "log_connection"], [1, 3, 1, "", "natural_sort_key"], [1, 3, 1, "", "remove_thumbnail"]], "pystream.models.stream": [[1, 3, 1, "", "get_range_header"], [1, 3, 1, "", "range_requests_response"], [1, 3, 1, "", "send_bytes_range_requests"]], "pystream.models.subtitles": [[1, 3, 1, "", "srt_to_vtt"]], "pystream.routers": [[1, 0, 0, "-", "auth"], [1, 0, 0, "-", "basics"], [1, 0, 0, "-", "video"]], "pystream.routers.auth": [[1, 3, 1, "", "index"], [1, 3, 1, "", "logout"]], "pystream.routers.basics": [[1, 3, 1, "", "get_favicon"], [1, 3, 1, "", "root"]], "pystream.routers.video": [[1, 3, 1, "", "preview_loader"], [1, 3, 1, "", "stream_video"], [1, 3, 1, "", "track_loader"], [1, 3, 1, "", "video_endpoint"]], "pystream.utils": [[1, 3, 1, "", "get_local_ip"], [1, 3, 1, "", "get_public_ip"]]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:method", "3": "py:function", "4": "py:attribute"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "method", "Python method"], "3": ["py", "function", "Python function"], "4": ["py", "attribute", "Python attribute"]}, "titleterms": {"video": [0, 1], "stream": [0, 1], "instal": 0, "sampl": 0, "usag": 0, "env": 0, "variabl": 0, "code": 0, "standard": 0, "releas": 0, "note": 0, "lint": 0, "pypi": 0, "packag": 0, "runbook": 0, "licens": 0, "copyright": 0, "localhost": 1, "A": 1, "secur": 1, "interfac": 1, "read": 1, "me": 1, "main": 1, "modul": 1, "model": 1, "authent": 1, "config": 1, "imag": 1, "squir": 1, "subtitl": 1, "router": 1, "basic": 1, "support": 1, "logger": 1, "util": 1, "indic": 1, "tabl": 1}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 6, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 56}}) \ No newline at end of file +Search.setIndex({"docnames": ["README", "index"], "filenames": ["README.md", "index.rst"], "titles": ["Video Streaming", "Stream-Localhost - A secured interface to stream videos"], "terms": {"deploy": 0, "python": 0, "modul": 0, "via": 0, "authent": 0, "session": [0, 1], "us": [0, 1], "fastapi": [0, 1], "m": 0, "pip": 0, "localhost": 0, "import": 0, "asyncio": 0, "o": 0, "pystream": [0, 1], "__name__": 0, "__main__": 0, "kwarg": [0, 1], "dict": [0, 1], "usernam": [0, 1], "foo": 0, "password": [0, 1], "bar": 0, "video_sourc": [0, 1], "path": [0, 1], "join": 0, "expandus": 0, "download": 0, "add": [0, 1], "follow": 0, "host": [0, 1], "local": [0, 1], "ip": [0, 1], "address": [0, 1], "skip": 0, "127": [0, 1], "0": [0, 1], "1": [0, 1], "video_host": [0, 1], "util": 0, "get_local_ip": [0, 1], "run": [0, 1], "start": [0, 1], "bulb": 0, "environ": [0, 1], "can": 0, "load": [0, 1], "from": [0, 1], "ani": 0, "file": [0, 1], "filenam": [0, 1], "default": 0, "To": 0, "custom": [0, 1], "set": [0, 1], "var": [0, 1], "env_fil": [0, 1], "kei": [0, 1], "its": [0, 1], "valu": [0, 1], "mandatori": 0, "choic": 0, "_": 0, "sourc": [0, 1], "underscor": 0, "ignor": [0, 1], "option": [0, 1], "port": 0, "number": [0, 1], "applic": 0, "8000": [0, 1], "format": 0, "sequenc": [0, 1], "support": 0, "mp4": [0, 1], "mov": [0, 1], "worker": [0, 1], "spin": 0, "up": 0, "uvicorn": 0, "server": 0, "websit": [0, 1], "list": [0, 1], "regex": 0, "cor": 0, "configur": [0, 1], "requir": [0, 1], "onli": [0, 1], "tunnel": 0, "cdn": 0, "auto": 0, "thumbnail": [0, 1], "boolean": [0, 1], "flag": [0, 1], "gener": [0, 1], "imag": 0, "preview": [0, 1], "true": [0, 1], "docstr": 0, "googl": 0, "style": 0, "convent": 0, "pep": 0, "8": 0, "isort": 0, "gitvers": 0, "revers": 0, "f": 0, "release_not": 0, "rst": 0, "t": [0, 1], "pre": 0, "commit": 0, "ensur": 0, "pytest": 0, "valid": [0, 1], "hyperlink": 0, "all": [0, 1], "markdown": 0, "includ": [0, 1], "wiki": 0, "page": [0, 1], "sphinx": 0, "5": 0, "recommonmark": 0, "http": [0, 1], "org": 0, "project": 0, "thevickypedia": 0, "github": 0, "io": 0, "vignesh": 0, "rao": 0, "under": 0, "mit": 0, "instal": 1, "sampl": 1, "usag": 1, "code": 1, "standard": 1, "releas": 1, "note": 1, "lint": 1, "pypi": 1, "packag": 1, "runbook": 1, "licens": 1, "copyright": 1, "async": 1, "redirect_exception_handl": 1, "request": 1, "except": 1, "redirectexcept": 1, "jsonrespons": 1, "handler": 1, "handl": 1, "redirect": 1, "paramet": 1, "take": 1, "object": 1, "an": 1, "argument": 1, "inherit": 1, "return": 1, "content": 1, "statu": 1, "cooki": 1, "type": 1, "startup_task": 1, "none": 1, "task": 1, "need": 1, "dure": 1, "api": 1, "startup": 1, "shutdown_task": 1, "shutdown": 1, "starter": 1, "function": 1, "keyword": 1, "env": 1, "failed_auth_count": 1, "keep": 1, "track": 1, "fail": 1, "login": 1, "attempt": 1, "each": 1, "3": 1, "more": 1, "time": 1, "verify_login": 1, "verifi": 1, "json": 1, "respons": 1, "verify_token": 1, "token": 1, "str": 1, "decod": 1, "jwt": 1, "expir": 1, "web": 1, "rais": 1, "class": 1, "envconfig": 1, "_case_sensit": 1, "bool": 1, "_env_prefix": 1, "_env_fil": 1, "dotenvtyp": 1, "posixpath": 1, "_env_file_encod": 1, "_env_nested_delimit": 1, "_secrets_dir": 1, "secretstr": 1, "secret": 1, "ipv4address": 1, "video_port": 1, "int": 1, "session_dur": 1, "3600": 1, "file_format": 1, "auto_thumbnail": 1, "pydant": 1, "share": 1, "across": 1, "creat": 1, "new": 1, "pars": 1, "input": 1, "data": 1, "validationerror": 1, "pydantic_cor": 1, "cannot": 1, "form": 1, "__init__": 1, "__pydantic_self__": 1, "instead": 1, "common": 1, "self": 1, "first": 1, "arg": 1, "allow": 1, "field": 1, "name": 1, "variabl": 1, "env_prefix": 1, "extra": 1, "hide_input_in_error": 1, "classmethod": 1, "parse_video_host": 1, "string": 1, "notion": 1, "parse_websit": 1, "evalu": 1, "fileio": 1, "index": 1, "html": 1, "land": 1, "static": 1, "query_param": 1, "home_endpoint": 1, "home": 1, "login_endpoint": 1, "logout_endpoint": 1, "logout": 1, "streaming_endpoint": 1, "chunk_siz": 1, "1048576": 1, "delet": 1, "session_token": 1, "asza9gj8ajxizbics0kk4lbcswpowfm5": 1, "store": 1, "info": 1, "invalid": 1, "inform": 1, "webtoken": 1, "timestamp": 1, "locat": 1, "detail": 1, "within": 1, "sinc": 1, "httpexcept": 1, "doesn": 1, "demand": 1, "case": 1, "where": 1, "i": 1, "solut": 1, "There": 1, "ar": 1, "altern": 1, "work": 1, "our": 1, "javascript": 1, "thi": 1, "wai": 1, "come": 1, "handi": 1, "mani": 1, "unexpect": 1, "scenario": 1, "refer": 1, "tiangolo": 1, "com": 1, "tutori": 1, "error": 1, "instanti": 1, "reason": 1, "alia": 1, "filepath": 1, "initi": 1, "captur": 1, "frame": 1, "particular": 1, "opencv": 1, "": 1, "videocaptur": 1, "generate_thumbnail": 1, "interv": 1, "output_dir": 1, "second": 1, "output": 1, "directori": 1, "success": 1, "failur": 1, "get_video_length": 1, "tupl": 1, "timedelta": 1, "get": 1, "calcul": 1, "length": 1, "datetim": 1, "generate_preview": 1, "at_second": 1, "which": 1, "should": 1, "log_connect": 1, "log": 1, "connect": 1, "devic": 1, "avoid": 1, "multipl": 1, "when": 1, "same": 1, "differ": 1, "natural_sort_kei": 1, "union": 1, "sort": 1, "natur": 1, "element": 1, "deriv": 1, "split": 1, "part": 1, "regular": 1, "express": 1, "get_dir_stream_cont": 1, "parent": 1, "subdir": 1, "insid": 1, "displai": 1, "subdirectori": 1, "exist": 1, "dictionari": 1, "pair": 1, "get_all_stream_cont": 1, "folder": 1, "contain": 1, "section": 1, "get_it": 1, "purepath": 1, "current": 1, "serv": 1, "previou": 1, "next": 1, "render": 1, "remove_thumbnail": 1, "img_path": 1, "trigger": 1, "timer": 1, "remov": 1, "send_bytes_range_request": 1, "file_obj": 1, "binaryio": 1, "start_rang": 1, "end_rang": 1, "asynciter": 1, "bytestr": 1, "send": 1, "chunk": 1, "rang": 1, "specif": 1, "rfc7233": 1, "byte": 1, "end": 1, "yield": 1, "iter": 1, "get_range_head": 1, "range_head": 1, "file_s": 1, "proce": 1, "header": 1, "size": 1, "range_requests_respons": 1, "file_path": 1, "streamingrespons": 1, "given": 1, "srt_to_vtt": 1, "convert": 1, "srt": 1, "vtt": 1, "compat": 1, "j": 1, "vtt_to_srt": 1, "auth": 1, "home_pag": 1, "templaterespons": 1, "ui": 1, "after": 1, "user": 1, "redirectrespons": 1, "termin": 1, "back": 1, "upon": 1, "refresh": 1, "confirm": 1, "navig": 1, "get_favicon": 1, "filerespons": 1, "favicon": 1, "ico": 1, "endpoint": 1, "robinhood": 1, "script": 1, "root": 1, "htmlrespons": 1, "broken": 1, "relat": 1, "preview_load": 1, "ha": 1, "setup": 1, "track_load": 1, "track_path": 1, "stream_video": 1, "video_path": 1, "templat": 1, "video_endpoint": 1, "receiv": 1, "rootfilt": 1, "filter": 1, "while": 1, "preserv": 1, "other": 1, "access": 1, "200": 1, "ok": 1, "307": 1, "temporari": 1, "vid_nam": 1, "redund": 1, "pass": 1, "overrid": 1, "implement": 1, "subclass": 1, "The": 1, "method": 1, "record": 1, "examin": 1, "fals": 1, "discard": 1, "togeth": 1, "children": 1, "have": 1, "event": 1, "through": 1, "If": 1, "specifi": 1, "everi": 1, "logrecord": 1, "out": 1, "repres": 1, "someth": 1, "simpl": 1, "check": 1, "network": 1, "id": 1, "retriev": 1, "privat": 1, "machin": 1, "get_public_ip": 1, "extract": 1, "public": 1, "make": 1, "extern": 1, "search": 1}, "objects": {"pystream": [[1, 0, 0, "-", "logger"], [1, 0, 0, "-", "main"], [1, 0, 0, "-", "utils"]], "pystream.logger": [[1, 1, 1, "", "RootFilter"]], "pystream.logger.RootFilter": [[1, 2, 1, "", "filter"]], "pystream.main": [[1, 3, 1, "", "redirect_exception_handler"], [1, 3, 1, "", "shutdown_tasks"], [1, 3, 1, "", "start"], [1, 3, 1, "", "startup_tasks"]], "pystream.models": [[1, 0, 0, "-", "authenticator"], [1, 0, 0, "-", "config"], [1, 0, 0, "-", "images"], [1, 0, 0, "-", "squire"], [1, 0, 0, "-", "stream"], [1, 0, 0, "-", "subtitles"]], "pystream.models.authenticator": [[1, 3, 1, "", "failed_auth_counter"], [1, 3, 1, "", "verify_login"], [1, 3, 1, "", "verify_token"]], "pystream.models.config": [[1, 1, 1, "", "EnvConfig"], [1, 1, 1, "", "FileIO"], [1, 5, 1, "", "RedirectException"], [1, 1, 1, "", "Session"], [1, 1, 1, "", "Static"], [1, 1, 1, "", "WebToken"], [1, 4, 1, "", "env"]], "pystream.models.config.EnvConfig": [[1, 1, 1, "", "Config"], [1, 4, 1, "", "auto_thumbnail"], [1, 4, 1, "", "file_formats"], [1, 2, 1, "", "parse_video_host"], [1, 2, 1, "", "parse_website"], [1, 4, 1, "", "password"], [1, 4, 1, "", "secret"], [1, 4, 1, "", "session_duration"], [1, 4, 1, "", "username"], [1, 4, 1, "", "video_host"], [1, 4, 1, "", "video_port"], [1, 4, 1, "", "video_source"], [1, 4, 1, "", "website"], [1, 4, 1, "", "workers"]], "pystream.models.config.EnvConfig.Config": [[1, 4, 1, "", "env_file"], [1, 4, 1, "", "env_prefix"], [1, 4, 1, "", "extra"], [1, 4, 1, "", "hide_input_in_errors"]], "pystream.models.config.FileIO": [[1, 4, 1, "", "index"], [1, 4, 1, "", "landing"], [1, 4, 1, "", "listing"]], "pystream.models.config.Session": [[1, 4, 1, "", "info"], [1, 4, 1, "", "invalid"]], "pystream.models.config.Static": [[1, 4, 1, "", "chunk_size"], [1, 4, 1, "", "deletions"], [1, 4, 1, "", "home_endpoint"], [1, 4, 1, "", "login_endpoint"], [1, 4, 1, "", "logout_endpoint"], [1, 4, 1, "", "preview"], [1, 4, 1, "", "query_param"], [1, 4, 1, "", "session_token"], [1, 4, 1, "", "stream"], [1, 4, 1, "", "streaming_endpoint"], [1, 4, 1, "", "track"]], "pystream.models.config.WebToken": [[1, 4, 1, "", "timestamp"], [1, 4, 1, "", "token"]], "pystream.models.images": [[1, 1, 1, "", "Images"]], "pystream.models.images.Images": [[1, 2, 1, "", "generate_preview"], [1, 2, 1, "", "generate_thumbnails"], [1, 2, 1, "", "get_video_length"]], "pystream.models.squire": [[1, 3, 1, "", "get_all_stream_content"], [1, 3, 1, "", "get_dir_stream_content"], [1, 3, 1, "", "get_iter"], [1, 3, 1, "", "log_connection"], [1, 3, 1, "", "natural_sort_key"], [1, 3, 1, "", "remove_thumbnail"]], "pystream.models.stream": [[1, 3, 1, "", "get_range_header"], [1, 3, 1, "", "range_requests_response"], [1, 3, 1, "", "send_bytes_range_requests"]], "pystream.models.subtitles": [[1, 3, 1, "", "srt_to_vtt"], [1, 3, 1, "", "vtt_to_srt"]], "pystream.routers": [[1, 0, 0, "-", "auth"], [1, 0, 0, "-", "basics"], [1, 0, 0, "-", "video"]], "pystream.routers.auth": [[1, 3, 1, "", "home_page"], [1, 3, 1, "", "login"], [1, 3, 1, "", "logout"]], "pystream.routers.basics": [[1, 3, 1, "", "error"], [1, 3, 1, "", "get_favicon"], [1, 3, 1, "", "root"]], "pystream.routers.video": [[1, 3, 1, "", "preview_loader"], [1, 3, 1, "", "stream_video"], [1, 3, 1, "", "track_loader"], [1, 3, 1, "", "video_endpoint"]], "pystream.utils": [[1, 3, 1, "", "get_local_ip"], [1, 3, 1, "", "get_public_ip"]]}, "objtypes": {"0": "py:module", "1": "py:class", "2": "py:method", "3": "py:function", "4": "py:attribute", "5": "py:exception"}, "objnames": {"0": ["py", "module", "Python module"], "1": ["py", "class", "Python class"], "2": ["py", "method", "Python method"], "3": ["py", "function", "Python function"], "4": ["py", "attribute", "Python attribute"], "5": ["py", "exception", "Python exception"]}, "titleterms": {"video": [0, 1], "stream": [0, 1], "instal": 0, "sampl": 0, "usag": 0, "env": 0, "variabl": 0, "code": 0, "standard": 0, "releas": 0, "note": 0, "lint": 0, "pypi": 0, "packag": 0, "runbook": 0, "licens": 0, "copyright": 0, "localhost": 1, "A": 1, "secur": 1, "interfac": 1, "read": 1, "me": 1, "main": 1, "modul": 1, "model": 1, "authent": 1, "config": 1, "imag": 1, "squir": 1, "subtitl": 1, "router": 1, "basic": 1, "support": 1, "logger": 1, "util": 1, "indic": 1, "tabl": 1}, "envversion": {"sphinx.domains.c": 2, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 6, "sphinx.domains.index": 1, "sphinx.domains.javascript": 2, "sphinx.domains.math": 2, "sphinx.domains.python": 3, "sphinx.domains.rst": 2, "sphinx.domains.std": 2, "sphinx": 56}}) \ No newline at end of file diff --git a/pystream/main.py b/pystream/main.py index b647c8d..c09832f 100644 --- a/pystream/main.py +++ b/pystream/main.py @@ -1,8 +1,9 @@ import os import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from pystream.logger import logger from pystream.models import config @@ -14,13 +15,35 @@ app.include_router(video.router) +# Exception handler for RedirectException +@app.exception_handler(config.RedirectException) +async def redirect_exception_handler(request: Request, exception: config.RedirectException) -> JSONResponse: + """Custom exception handler to handle redirect. + + Args: + request: Takes the ``Request`` object as an argument. + exception: Takes the ``RedirectException`` object inherited from ``Exception`` as an argument. + + Returns: + JSONResponse: + Returns the JSONResponse with content, status code and cookie. + """ + logger.info("Exception headers: %s", request.headers) + logger.info("Exception cookies: %s", request.cookies) + # fixme: Set conditional to return JSONResponse only if request.url.path matches config.static.login_endpoint + response = JSONResponse(content={"redirect_url": exception.location}, status_code=200) + if exception.detail: + response.set_cookie("detail", exception.detail.upper()) + return response + + async def startup_tasks() -> None: """Tasks that need to run during the API startup.""" logger.info('Setting CORS policy.') origins = ["http://localhost.com", "https://localhost.com"] origins.extend(config.env.website) origins.extend(map((lambda x: x + '/*'), config.env.website)) - app.add_middleware(CORSMiddleware, allow_origins=origins) + app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"]) async def shutdown_tasks() -> None: diff --git a/pystream/models/authenticator.py b/pystream/models/authenticator.py index 8260dce..b2e997b 100644 --- a/pystream/models/authenticator.py +++ b/pystream/models/authenticator.py @@ -3,24 +3,41 @@ import time import jwt -from fastapi import HTTPException, status +from fastapi import HTTPException, Request, status from fastapi.responses import JSONResponse +from pydantic import ValidationError from pystream.logger import logger from pystream.models import config -async def verify_login(credentials) -> JSONResponse: +async def failed_auth_counter(request: Request) -> None: + """Keeps track of failed login attempts from each host, and redirects if failed for 3 or more times. + + Args: + request: Takes the ``Request`` object as an argument. + """ + try: + config.session.invalid[request.client.host] += 1 + except KeyError: + config.session.invalid[request.client.host] = 1 + logger.info(config.session.invalid[request.client.host]) + if config.session.invalid[request.client.host] >= 3: + raise config.RedirectException(location="/error") + + +async def verify_login(request: Request) -> JSONResponse: """Verifies authentication. Returns: JSONResponse: Returns JSON response with content and status code. """ - decoded_auth = base64.b64decode(credentials).decode('utf-8') + decoded_auth = base64.b64decode(request.headers.get("authorization", "")).decode("utf-8") auth = bytes(decoded_auth, "utf-8").decode(encoding="unicode_escape") username, password = auth.split(':') if not username or not password: + await failed_auth_counter(request) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Username and password is required to proceed.", @@ -31,14 +48,11 @@ async def verify_login(credentials) -> JSONResponse: password_validation = secrets.compare_digest(password, config.env.password.get_secret_value()) if username_validation and password_validation: - return JSONResponse( - content={ - "authenticated": True - }, - status_code=200, - ) + config.session.invalid[request.client.host] = 0 + return JSONResponse(content={"authenticated": True}, status_code=200) logger.error("Incorrect username [%s] or password [%s]", username, password) + await failed_auth_counter(request) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", @@ -46,22 +60,23 @@ async def verify_login(credentials) -> JSONResponse: ) -async def verify_timestamp(timestamp: int): - # todo: include html files to specify this and redirect upon refresh or button click - if time.time() - timestamp > config.env.session_duration: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session has timed out") +async def verify_token(token: str) -> None: + """Decodes the JWT and validates the session token and expiration. + Args: + token: JSON web token. -async def verify_token(token: str): - go_home = HTTPException(status_code=status.HTTP_307_TEMPORARY_REDIRECT, - detail="Missing or invalid session token", - headers={"Location": "/"}) # redirect to root page for login + Raises: + RedirectException: + """ if not token: - raise go_home + raise config.RedirectException(location="/error", detail="Invalid session token") try: decoded = config.WebToken(**jwt.decode(jwt=token, key=config.env.secret.get_secret_value(), algorithms="HS256")) - except jwt.InvalidSignatureError as error: + except (jwt.InvalidSignatureError, ValidationError) as error: logger.error(error) - raise go_home - await verify_login(decoded.credentials) - await verify_timestamp(decoded.timestamp) + raise config.RedirectException(location="/error", detail="Invalid session token") + if not secrets.compare_digest(decoded.token, config.static.session_token): + raise config.RedirectException(location="/error", detail="Invalid session token") + if time.time() - decoded.timestamp > config.env.session_duration: + raise config.RedirectException(location="/error", detail="Session expired") diff --git a/pystream/models/config.py b/pystream/models/config.py index 65a536a..f2add62 100644 --- a/pystream/models/config.py +++ b/pystream/models/config.py @@ -1,13 +1,17 @@ import os import pathlib +import random import socket +import string from ipaddress import IPv4Address -from typing import List, Sequence, Set, Union +from typing import List, Optional, Sequence, Set, Union from pydantic import (BaseModel, DirectoryPath, Field, PositiveInt, SecretStr, field_validator) from pydantic_settings import BaseSettings +template_storage = os.path.join(pathlib.Path(__file__).parent.parent, "templates") + class EnvConfig(BaseSettings): """Configure all env vars and validate using ``pydantic`` to share across modules. @@ -87,6 +91,10 @@ class Static(BaseModel): streaming_endpoint: str = "/video" chunk_size: PositiveInt = 1024 * 1024 deletions: Set[pathlib.PosixPath] = set() + # todo: Allow multiple users, and create multiple session tokens during startup + # Use a single session token, since currently this project only allows one username and password + # Random string ensures, that users are forced to login when the server restarts + session_token: str = "".join(random.choices(string.ascii_letters + string.digits, k=32)) class Session(BaseModel): @@ -97,13 +105,45 @@ class Session(BaseModel): """ info: dict = {} + invalid: dict = {} class WebToken(BaseModel): - credentials: str + """Object to store and validate JWT objects. + + >>> WebToken + + """ + + token: str timestamp: int +class RedirectException(Exception): + """Custom ``RedirectException`` raised within the API since HTTPException doesn't support returning HTML content. + + >>> RedirectException + + See Also: + - RedirectException allows the API to redirect on demand in cases where returning is not a solution. + - There are alternatives to raise HTML content as an exception but none work with our use-case with JavaScript. + - This way of exception handling comes handy for many unexpected scenarios. + + References: + https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers + """ + + def __init__(self, location: str, detail: Optional[str] = ""): + """Instantiates the ``RedirectException`` object with the required parameters. + + Args: + location: Location for redirect. + detail: Reason for redirect. + """ + self.location = location + self.detail = detail + + env = EnvConfig fileio = FileIO() static = Static() diff --git a/pystream/models/squire.py b/pystream/models/squire.py index 1d53c47..837e2c7 100644 --- a/pystream/models/squire.py +++ b/pystream/models/squire.py @@ -9,8 +9,7 @@ from pystream.logger import logger from pystream.models import config - -templates = Jinja2Templates(directory=os.path.join(pathlib.Path(__file__).parent.parent, "templates")) +templates = Jinja2Templates(directory=config.template_storage) def log_connection(request: Request) -> None: diff --git a/pystream/routers/auth.py b/pystream/routers/auth.py index c9ac1bb..abf16f8 100644 --- a/pystream/routers/auth.py +++ b/pystream/routers/auth.py @@ -1,8 +1,9 @@ import time +from typing import Union import jwt -from fastapi import APIRouter, Request, Cookie, status -from fastapi.responses import RedirectResponse, JSONResponse +from fastapi import APIRouter, Cookie, Request, status +from fastapi.responses import JSONResponse, RedirectResponse from pystream.logger import logger from pystream.models import authenticator, config, squire @@ -11,7 +12,17 @@ @router.get("%s" % config.static.home_endpoint, response_model=None) -async def home_page(request: Request, session_token: str = Cookie(None)): +async def home_page(request: Request, session_token: str = Cookie(None)) -> squire.templates.TemplateResponse: + """Serves the home/index page for the UI. + + Args: + request: Takes the ``Request`` object as an argument. + session_token: Session token set after verifying username and password. + + Returns: + TemplateResponse: + Returns the listing page for video streaming. + """ squire.log_connection(request) await authenticator.verify_token(session_token) landing_page = squire.get_all_stream_content() @@ -23,23 +34,40 @@ async def home_page(request: Request, session_token: str = Cookie(None)): @router.post("%s" % config.static.login_endpoint, response_model=None) -async def login(request: Request): +async def login(request: Request) -> JSONResponse: + """Authenticates the user input and returns a redirect response with the session token set as a cookie. + + Args: + request: Takes the ``Request`` object as an argument. + + Returns: + JSONResponse: + Returns the JSONResponse with content, status code and cookie. + """ squire.log_connection(request) - authorization = request.headers.get('authorization') - await authenticator.verify_login(authorization) - # todo: instead of storing authorization to cookie - # create a db, assign a token to the user and set that token as cookie + await authenticator.verify_login(request) # Since JavaScript cannot handle RedirectResponse from FastAPI # Solution is to revert to Form, but that won't allow header auth and additional customization done by JavaScript response = JSONResponse(content={"redirect_url": config.static.home_endpoint}, status_code=status.HTTP_200_OK) - encoded_jwt = jwt.encode(payload={"credentials": authorization, "timestamp": int(time.time())}, + encoded_jwt = jwt.encode(payload={"token": config.static.session_token, "timestamp": int(time.time())}, key=config.env.secret.get_secret_value(), algorithm="HS256") response.set_cookie("session_token", encoded_jwt, httponly=True) return response @router.get("%s" % config.static.logout_endpoint, response_model=None) -async def logout(request: Request, session_token: str = Cookie(None)) -> RedirectResponse: +async def logout(request: Request, session_token: str = Cookie(None)) -> Union[RedirectResponse, JSONResponse]: + """Terminates the user's session by deleting the cookie and redirecting back to login page upon refresh. + + Args: + request: Takes the ``Request`` object as an argument. + session_token: Session token set after verifying username and password. + + Returns: + Union[RedirectResponse, JSONResponse]: + JSONResponse to delete the cookie and send logout confirmation, RedirectResponse to navigate to login page. + """ + # todo: serve an HTML page for logout as well if session_token: logger.info("%s logged out", request.client.host) if config.session.info.get(request.client.host): diff --git a/pystream/routers/basics.py b/pystream/routers/basics.py index d264dc9..f5ff8b6 100644 --- a/pystream/routers/basics.py +++ b/pystream/routers/basics.py @@ -1,7 +1,8 @@ import os -from fastapi import APIRouter, Request -from fastapi.responses import FileResponse, RedirectResponse +from fastapi import APIRouter, Cookie, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from jinja2 import Template from pystream.models import config, squire @@ -33,3 +34,26 @@ async def root(request: Request) -> RedirectResponse: name=config.fileio.index, context={"request": request, "signin": config.static.login_endpoint} ) + + +@router.get(path="/error", include_in_schema=False) +async def error(detail: str = Cookie(None)) -> HTMLResponse: + """Endpoint to serve broken pages as HTML response. + + Args: + detail: Optional session related information. + + Returns: + HTMLResponse: + Rendered HTML response with deleted cookie. + """ + with open(os.path.join(config.template_storage, "session.html")) as sess_file: + session_template: Template = Template(sess_file.read()) + with open(os.path.join(config.template_storage, "unauthorized.html")) as auth_file: + unauthorized_template: str = auth_file.read() + if detail: + response = HTMLResponse(session_template.render(reason=detail)) + else: + response = HTMLResponse(unauthorized_template) + response.delete_cookie("detail") + return response diff --git a/pystream/routers/video.py b/pystream/routers/video.py index 8aef366..b023dab 100644 --- a/pystream/routers/video.py +++ b/pystream/routers/video.py @@ -4,7 +4,7 @@ from typing import Optional, Union from urllib import parse as urlparse -from fastapi import APIRouter, Header, HTTPException, Request, status, Cookie +from fastapi import APIRouter, Cookie, Header, HTTPException, Request, status from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from pystream.logger import logger @@ -21,7 +21,7 @@ async def preview_loader(request: Request, """Returns the file for preview image. Args: - request: Takes the ``Request`` class as an argument. + request: Takes the ``Request`` object as an argument. img_path: Path of the image file that has to be rendered. session_token: Token setup for each session. @@ -46,7 +46,7 @@ async def track_loader(request: Request, """Returns the file for subtitles. Args: - request: Takes the ``Request`` class as an argument. + request: Takes the ``Request`` object as an argument. track_path: Path of the subtitle track that has to be rendered. session_token: Token setup for each session. @@ -66,13 +66,13 @@ async def stream_video(request: Request, """Returns the template for streaming page. Args: - request: Takes the ``Request`` class as an argument. + request: Takes the ``Request`` object as an argument. video_path: Path of the video file that has to be rendered. session_token: Token setup for each session. Returns: templates.TemplateResponse: - Template response for streaming page. + Returns the listing page for video streaming. """ await authenticator.verify_token(session_token) squire.log_connection(request) @@ -94,7 +94,7 @@ async def stream_video(request: Request, if pure_path.exists(): prev_, next_ = squire.get_iter(pure_path) attrs = { - "request": request, "title": video_path, + "request": request, "video_title": video_path, "home": config.static.home_endpoint, "logout": config.static.logout_endpoint, "path": f"{config.static.streaming_endpoint}?{config.static.query_param}={urlparse.quote(str(pure_path))}", "previous": prev_, "next": next_ @@ -131,7 +131,7 @@ async def video_endpoint(request: Request, """Streams the video file by sending bytes using StreamingResponse. Args: - request: Takes the ``Request`` class as an argument. + request: Takes the ``Request`` object as an argument. range: Header information. session_token: Token setup for each session. diff --git a/pystream/templates/index.html b/pystream/templates/index.html index acc8632..3a87e7f 100644 --- a/pystream/templates/index.html +++ b/pystream/templates/index.html @@ -52,6 +52,9 @@ label { margin-top: 10px; color: #000000; + font-size: large; + font-family: 'Courier New', sans-serif; + font-weight: normal; } input { @@ -61,11 +64,6 @@ border-radius: 5px; } - input:focus { - outline: none; - border-color: #4CAF50; - } - button { padding: 12px; background-color: #000000; @@ -141,6 +139,7 @@ // Manually handle the redirect window.location.href = data.redirect_url; } else { + console.log("Unhandled good response data") // Handle success if needed console.log(data); } diff --git a/pystream/templates/land.html b/pystream/templates/land.html index fda5722..9219eb3 100644 --- a/pystream/templates/land.html +++ b/pystream/templates/land.html @@ -103,7 +103,7 @@

      -

      {{ title }}

      +

      {{ video_title }}