rpc.py 4.14 KB
Newer Older
1
import hashlib
2
import re
3

Kevin Morris's avatar
Kevin Morris committed
4
from http import HTTPStatus
5
from typing import List, Optional
6
from urllib.parse import unquote
7

8
9
10
import orjson

from fastapi import APIRouter, Query, Request, Response
11
from fastapi.responses import JSONResponse
12

13
from aurweb import defaults
Kevin Morris's avatar
Kevin Morris committed
14
from aurweb.ratelimit import check_ratelimit
15
16
17
18
19
from aurweb.rpc import RPC

router = APIRouter()


20
21
def parse_args(request: Request):
    """ Handle legacy logic of 'arg' and 'arg[]' query parameter handling.
22

23
24
25
    When 'arg' appears as the last argument given to the query string,
    that argument is used by itself as one single argument, regardless
    of any more 'arg' or 'arg[]' parameters supplied before it.
26

27
28
29
    When 'arg[]' appears as the last argument given to the query string,
    we iterate from last to first and build a list of arguments until
    we hit an 'arg'.
30

31
32
33
    TODO: This handling should be addressed in v6 of the RPC API. This
    was most likely a bi-product of legacy handling of versions 1-4
    which we no longer support.
34

35
36
37
38
39
40
41
42
43
    :param request: FastAPI request
    :returns: List of deduced arguments
    """
    # Create a list of (key, value) pairs of the given 'arg' and 'arg[]'
    # query parameters from last to first.
    query = list(reversed(unquote(request.url.query).split("&")))
    parts = [
        e.split("=", 1) for e in query if e.startswith(("arg=", "arg[]="))
    ]
44

45
46
47
48
49
    args = []
    if parts:
        # If we found 'arg' and/or 'arg[]' arguments, we begin processing
        # the set of arguments depending on the last key found.
        last = parts[0][0]
50

51
52
53
54
55
56
57
58
59
60
        if last == "arg":
            # If the last key was 'arg', then it is our sole argument.
            args.append(parts[0][1])
        else:
            # Otherwise, it must be 'arg[]', so traverse backward
            # until we reach a non-'arg[]' key.
            for key, value in parts:
                if key != last:
                    break
                args.append(value)
61

62
    return args
63
64


65
66
67
JSONP_EXPR = re.compile(r'^[a-zA-Z0-9()_.]{1,128}$')


68
69
@router.get("/rpc")
async def rpc(request: Request,
70
71
72
73
              v: Optional[int] = Query(default=None),
              type: Optional[str] = Query(default=None),
              by: Optional[str] = Query(default=defaults.RPC_SEARCH_BY),
              arg: Optional[str] = Query(default=None),
74
75
              args: Optional[List[str]] = Query(default=[], alias="arg[]"),
              callback: Optional[str] = Query(default=None)):
76

77
78
79
    # Create a handle to our RPC class.
    rpc = RPC(version=v, type=type)

Kevin Morris's avatar
Kevin Morris committed
80
81
82
83
84
    # If ratelimit was exceeded, return a 429 Too Many Requests.
    if check_ratelimit(request):
        return JSONResponse(rpc.error("Rate limit reached"),
                            status_code=int(HTTPStatus.TOO_MANY_REQUESTS))

85
86
87
88
89
90
91
92
93
94
    # If `callback` was provided, produce a text/javascript response
    # valid for the jsonp callback. Otherwise, by default, return
    # application/json containing `output`.
    content_type = "application/json"
    if callback:
        if not re.match(JSONP_EXPR, callback):
            return rpc.error("Invalid callback name.")

        content_type = "text/javascript"

95
96
    # Prepare list of arguments for input. If 'arg' was given, it'll
    # be a list with one element.
97
    arguments = parse_args(request)
98
    data = rpc.handle(by=by, args=arguments)
99
100
101

    # Serialize `data` into JSON in a sorted fashion. This way, our
    # ETag header produced below will never end up changed.
102
    content = orjson.dumps(data, option=orjson.OPT_SORT_KEYS)
103
104
105

    # Produce an md5 hash based on `output`.
    md5 = hashlib.md5()
106
    md5.update(content)
107
108
109
110
    etag = md5.hexdigest()

    # The ETag header expects quotes to surround any identifier.
    # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
111
112
    headers = {
        "Content-Type": content_type,
113
        "ETag": f'"{etag}"'
114
    }
115

116
117
118
119
120
    if_none_match = request.headers.get("If-None-Match", str())
    if if_none_match and if_none_match.strip("\t\n\r\" ") == etag:
        return Response(headers=headers,
                        status_code=int(HTTPStatus.NOT_MODIFIED))

121
122
123
    if callback:
        content = f"/**/{callback}({content.decode()})"

124
    return Response(content, headers=headers)