jtp.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. package jtp
  2. import (
  3. "regexp"
  4. "errors"
  5. "crypto/tls"
  6. "net"
  7. "net/url"
  8. "bufio"
  9. "fmt"
  10. "strings"
  11. "encoding/json"
  12. )
  13. var dialer = &tls.Dialer{
  14. NetDialer: &net.Dialer{},
  15. }
  16. var mediaTypeRegexp = regexp.MustCompile(`(?s)^(([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)/([!#$%&'*+\-.^_\x60|~a-zA-Z0-9]+)).*$`)
  17. var statusLineRegexp = regexp.MustCompile(`^HTTP/1\.[0-9] ([0-9]{3}).*\n$`)
  18. var contentTypeRegexp = regexp.MustCompile(`^(?i:content-type):[ \t\r]*(.*?)[ \t\r]*\n$`)
  19. var locationRegexp = regexp.MustCompile(`^(?i:location):[ \t\r]*(.*?)[ \t\r]*\n$`)
  20. /*
  21. I send an HTTP/1.0 request to ensure the server doesn't respond
  22. with chunked transfer encoding.
  23. See: https://httpwg.org/specs/rfc9110.html
  24. */
  25. /*
  26. link
  27. the url being requested
  28. maxRedirects
  29. the maximum number of redirects to take
  30. */
  31. func Get(link *url.URL, accept string, tolerated []string, maxRedirects uint) (map[string]any, error) {
  32. if link.Scheme != "https" {
  33. return nil, errors.New(link.Scheme + " is not supported in requests, only https")
  34. }
  35. port := link.Port()
  36. if port == "" {
  37. port = "443"
  38. }
  39. // TODO: link.Host may work instead of needing net.JoinHostPort
  40. hostport := net.JoinHostPort(link.Hostname(), port)
  41. connection, err := dialer.Dial("tcp", hostport)
  42. if err != nil {
  43. return nil, err
  44. }
  45. _, err = connection.Write([]byte(
  46. "GET " + link.RequestURI() + " HTTP/1.0\r\n" +
  47. "Host: " + link.Host + "\r\n" +
  48. "Accept: " + accept + "\r\n" +
  49. "\r\n",
  50. ))
  51. if err != nil {
  52. return nil, errors.Join(err, connection.Close())
  53. }
  54. buf := bufio.NewReader(connection)
  55. statusLine, err := buf.ReadString('\n')
  56. if err != nil {
  57. return nil, errors.Join(
  58. fmt.Errorf("failed to parse HTTP status line: %w", err),
  59. connection.Close(),
  60. )
  61. }
  62. status, err := parseStatusLine(statusLine)
  63. if err != nil {
  64. return nil, errors.Join(err, connection.Close())
  65. }
  66. if strings.HasPrefix(status, "3") {
  67. location, err := findLocation(buf, link)
  68. if err != nil {
  69. return nil, errors.Join(err, connection.Close())
  70. }
  71. if maxRedirects == 0 {
  72. return nil, errors.Join(
  73. errors.New("Received " + status + " but max redirects has already been reached"),
  74. connection.Close(),
  75. )
  76. }
  77. if err := connection.Close(); err != nil {
  78. return nil, err
  79. }
  80. return Get(location, accept, tolerated, maxRedirects - 1)
  81. }
  82. if status != "200" && status != "201" && status != "202" && status != "203" {
  83. return nil, errors.Join(
  84. errors.New("received invalid status " + status),
  85. connection.Close(),
  86. )
  87. }
  88. err = validateHeaders(buf, tolerated)
  89. if err != nil {
  90. return nil, errors.Join(err, connection.Close())
  91. }
  92. var dictionary map[string]any
  93. err = json.NewDecoder(buf).Decode(&dictionary)
  94. if err != nil {
  95. return nil, errors.Join(
  96. fmt.Errorf("failed to parse JSON: %w", err),
  97. connection.Close(),
  98. )
  99. }
  100. if err := connection.Close(); err != nil {
  101. return nil, err
  102. }
  103. return dictionary, nil
  104. }
  105. func parseStatusLine(text string) (string, error) {
  106. matches := statusLineRegexp.FindStringSubmatch(text)
  107. if len(matches) != 2 {
  108. return "", errors.New("Received invalid status line: " + text)
  109. }
  110. return matches[1], nil
  111. }
  112. func parseContentType(text string) (*MediaType, bool, error) {
  113. matches := contentTypeRegexp.FindStringSubmatch(text)
  114. if len(matches) != 2 {
  115. return nil, false, nil
  116. }
  117. mediaType, err := ParseMediaType(matches[1])
  118. if err != nil {
  119. return nil, true, err
  120. }
  121. return mediaType, true, nil
  122. }
  123. func parseLocation(text string, baseLink *url.URL) (link *url.URL, isLocationLine bool, err error) {
  124. matches := locationRegexp.FindStringSubmatch(text)
  125. if len(matches) != 2 {
  126. return nil, false, nil
  127. }
  128. reference, err := url.Parse(matches[1])
  129. if err != nil {
  130. return nil, true, err
  131. }
  132. return baseLink.ResolveReference(reference), true, nil
  133. }
  134. func validateHeaders(buf *bufio.Reader, tolerated []string) error {
  135. contentTypeValidated := false
  136. for {
  137. line, err := buf.ReadString('\n')
  138. if err != nil {
  139. return err
  140. }
  141. if line == "\r\n" || line == "\n" {
  142. break
  143. }
  144. mediaType, isContentTypeLine, err := parseContentType(line)
  145. if err != nil {
  146. return err
  147. }
  148. if !isContentTypeLine {
  149. continue
  150. }
  151. if mediaType.Matches(tolerated) {
  152. contentTypeValidated = true
  153. } else {
  154. return errors.New("Response contains invalid content type " + mediaType.Full)
  155. }
  156. }
  157. if !contentTypeValidated {
  158. return errors.New("Response did not contain a content type")
  159. }
  160. return nil
  161. }
  162. func findLocation(buf *bufio.Reader, baseLink *url.URL) (*url.URL, error) {
  163. for {
  164. line, err := buf.ReadString('\n')
  165. if err != nil {
  166. return nil, err
  167. }
  168. if line == "\r\n" || line == "\n" {
  169. break
  170. }
  171. location, isLocationLine, err := parseLocation(line, baseLink)
  172. if err != nil {
  173. return nil, err
  174. }
  175. if !isLocationLine {
  176. continue
  177. }
  178. return location, nil
  179. }
  180. return nil, errors.New("Location is not present in headers")
  181. }
  182. type MediaType struct {
  183. Supertype string
  184. Subtype string
  185. /* Full omits the parameters */
  186. Full string
  187. }
  188. func ParseMediaType(text string) (*MediaType, error) {
  189. matches := mediaTypeRegexp.FindStringSubmatch(text)
  190. if len(matches) != 4 {
  191. return nil, errors.New(text + " is not a valid media type")
  192. }
  193. return &MediaType{
  194. Supertype: matches[2],
  195. Subtype: matches[3],
  196. Full: matches[1],
  197. }, nil
  198. }
  199. func (m *MediaType) Matches(mediaTypes []string) bool {
  200. for _, mediaType := range mediaTypes {
  201. if m.Full == mediaType {
  202. return true
  203. }
  204. }
  205. return false
  206. }