6 Commits

Author SHA1 Message Date
Adnan Maolood
9079be9019 Add ServeFile function 2020-10-27 13:32:48 -04:00
Adnan Maolood
79165833de Add (*ResponseWriter).WriteStatus function 2020-10-27 13:30:35 -04:00
Adnan Maolood
8ab4064841 Add NewRequestFromURL function 2020-10-27 13:27:52 -04:00
Adnan Maolood
c44f011b15 Remove (Text).HTML function 2020-10-26 12:49:16 -04:00
Adnan Maolood
192065d0e6 Add contributing instructions to README.md 2020-10-24 21:55:58 -04:00
Adnan Maolood
255eef9e31 Add import path to README.md 2020-10-24 16:58:35 -04:00
9 changed files with 236 additions and 142 deletions

View File

@@ -6,9 +6,19 @@ Package gemini implements the [Gemini protocol](https://gemini.circumlunar.space
It aims to provide an API similar to that of net/http to make it easy to develop Gemini clients and servers.
## Usage
import "git.sr.ht/~adnano/go-gemini"
## Examples
There are a few examples provided in the `examples` directory.
To run the examples:
There are a few examples provided in the examples directory.
To run an example:
go run examples/server.go
## Contributing
Send patches and questions to [~adnano/go-gemini-devel](https://lists.sr.ht/~adnano/go-gemini-devel).
Subscribe to release announcements on [~adnano/go-gemini-announce](https://lists.sr.ht/~adnano/go-gemini-announce).

View File

@@ -7,6 +7,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/url"
"os"
"time"
@@ -76,21 +77,23 @@ func sendRequest(req *gmi.Request) error {
case gmi.StatusClassInput:
fmt.Printf("%s: ", resp.Meta)
scanner.Scan()
req.URL.RawQuery = scanner.Text()
req.URL.RawQuery = url.QueryEscape(scanner.Text())
return sendRequest(req)
case gmi.StatusClassSuccess:
fmt.Print(string(resp.Body))
return nil
case gmi.StatusClassRedirect:
fmt.Println("Redirecting to", resp.Meta)
// Make the request to the same host
red, err := gmi.NewRequestTo(resp.Meta, req.Host)
target, err := url.Parse(resp.Meta)
if err != nil {
return err
}
// Handle relative redirects
red.URL = req.URL.ResolveReference(red.URL)
return sendRequest(red)
// TODO: Prompt the user if the redirect is to another domain.
redirect, err := gmi.NewRequestFromURL(req.URL.ResolveReference(target))
if err != nil {
return err
}
return sendRequest(redirect)
case gmi.StatusClassTemporaryFailure:
return fmt.Errorf("Temporary failure: %s", resp.Meta)
case gmi.StatusClassPermanentFailure:

90
examples/html.go Normal file
View File

@@ -0,0 +1,90 @@
// +build ignore
// This example illustrates a gemtext to HTML converter.
package main
import (
"fmt"
"html"
"strings"
"git.sr.ht/~adnano/go-gemini"
)
func main() {
text := gemini.Text{
gemini.LineHeading1("Hello, world!"),
gemini.LineText("This is a gemini text document."),
}
html := textToHTML(text)
fmt.Print(html)
}
// textToHTML returns the Gemini text response as HTML.
func textToHTML(text gemini.Text) string {
var b strings.Builder
var pre bool
var list bool
for _, l := range text {
if _, ok := l.(gemini.LineListItem); ok {
if !list {
list = true
fmt.Fprint(&b, "<ul>\n")
}
} else if list {
list = false
fmt.Fprint(&b, "</ul>\n")
}
switch l.(type) {
case gemini.LineLink:
link := l.(gemini.LineLink)
url := html.EscapeString(link.URL)
name := html.EscapeString(link.Name)
if name == "" {
name = url
}
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name)
case gemini.LinePreformattingToggle:
pre = !pre
if pre {
fmt.Fprint(&b, "<pre>\n")
} else {
fmt.Fprint(&b, "</pre>\n")
}
case gemini.LinePreformattedText:
text := string(l.(gemini.LinePreformattedText))
fmt.Fprintf(&b, "%s\n", html.EscapeString(text))
case gemini.LineHeading1:
text := string(l.(gemini.LineHeading1))
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(text))
case gemini.LineHeading2:
text := string(l.(gemini.LineHeading2))
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(text))
case gemini.LineHeading3:
text := string(l.(gemini.LineHeading3))
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(text))
case gemini.LineListItem:
text := string(l.(gemini.LineListItem))
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(text))
case gemini.LineQuote:
text := string(l.(gemini.LineQuote))
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(text))
case gemini.LineText:
text := string(l.(gemini.LineText))
if text == "" {
fmt.Fprint(&b, "<br>\n")
} else {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text))
}
}
}
if pre {
fmt.Fprint(&b, "</pre>\n")
}
if list {
fmt.Fprint(&b, "</ul>\n")
}
return b.String()
}

23
fs.go
View File

@@ -28,7 +28,7 @@ func (fsh fsHandler) Respond(w *ResponseWriter, r *Request) {
path := path.Clean(r.URL.Path)
f, err := fsh.Open(path)
if err != nil {
NotFound(w, r)
w.WriteStatus(StatusNotFound)
return
}
// Detect mimetype
@@ -58,6 +58,27 @@ type Dir string
// If the file is a directory, it tries to open the index file in that directory.
func (d Dir) Open(name string) (File, error) {
p := path.Join(string(d), name)
return openFile(p)
}
// ServeFile responds to the request with the contents of the named file
// or directory.
// TODO: Use io/fs.FS when available.
func ServeFile(w *ResponseWriter, fs FS, name string) {
f, err := fs.Open(name)
if err != nil {
w.WriteStatus(StatusNotFound)
return
}
// Detect mimetype
ext := filepath.Ext(name)
mimetype := mime.TypeByExtension(ext)
w.SetMimetype(mimetype)
// Copy file to response writer
io.Copy(w, f)
}
func openFile(p string) (File, error) {
f, err := os.OpenFile(p, os.O_RDONLY, 0644)
if err != nil {
return nil, err

View File

@@ -9,35 +9,80 @@ import (
)
// Status codes.
type Status int
const (
StatusInput = 10
StatusSensitiveInput = 11
StatusSuccess = 20
StatusRedirect = 30
StatusRedirectPermanent = 31
StatusTemporaryFailure = 40
StatusServerUnavailable = 41
StatusCGIError = 42
StatusProxyError = 43
StatusSlowDown = 44
StatusPermanentFailure = 50
StatusNotFound = 51
StatusGone = 52
StatusProxyRequestRefused = 53
StatusBadRequest = 59
StatusCertificateRequired = 60
StatusCertificateNotAuthorized = 61
StatusCertificateNotValid = 62
StatusInput Status = 10
StatusSensitiveInput Status = 11
StatusSuccess Status = 20
StatusRedirect Status = 30
StatusRedirectPermanent Status = 31
StatusTemporaryFailure Status = 40
StatusServerUnavailable Status = 41
StatusCGIError Status = 42
StatusProxyError Status = 43
StatusSlowDown Status = 44
StatusPermanentFailure Status = 50
StatusNotFound Status = 51
StatusGone Status = 52
StatusProxyRequestRefused Status = 53
StatusBadRequest Status = 59
StatusCertificateRequired Status = 60
StatusCertificateNotAuthorized Status = 61
StatusCertificateNotValid Status = 62
)
// Class returns the status class for this status code.
func (s Status) Class() StatusClass {
return StatusClass(s / 10)
}
// StatusMessage returns the status message corresponding to the provided
// status code.
// StatusMessage returns an empty string for input, successs, and redirect
// status codes.
func (s Status) Message() string {
switch s {
case StatusTemporaryFailure:
return "TemporaryFailure"
case StatusServerUnavailable:
return "Server unavailable"
case StatusCGIError:
return "CGI error"
case StatusProxyError:
return "Proxy error"
case StatusSlowDown:
return "Slow down"
case StatusPermanentFailure:
return "PermanentFailure"
case StatusNotFound:
return "Not found"
case StatusGone:
return "Gone"
case StatusProxyRequestRefused:
return "Proxy request refused"
case StatusBadRequest:
return "Bad request"
case StatusCertificateRequired:
return "Certificate required"
case StatusCertificateNotAuthorized:
return "Certificate not authorized"
case StatusCertificateNotValid:
return "Certificate not valid"
}
return ""
}
// Status code categories.
type StatusClass int
const (
StatusClassInput = 1
StatusClassSuccess = 2
StatusClassRedirect = 3
StatusClassTemporaryFailure = 4
StatusClassPermanentFailure = 5
StatusClassCertificateRequired = 6
StatusClassInput StatusClass = 1
StatusClassSuccess StatusClass = 2
StatusClassRedirect StatusClass = 3
StatusClassTemporaryFailure StatusClass = 4
StatusClassPermanentFailure StatusClass = 5
StatusClassCertificateRequired StatusClass = 6
)
// Errors.

View File

@@ -42,22 +42,27 @@ func hostname(host string) string {
return hostname
}
// NewRequest returns a new request. The host is inferred from the provided URL.
// NewRequest returns a new request. The host is inferred from the URL.
func NewRequest(rawurl string) (*Request, error) {
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
return NewRequestFromURL(u)
}
// NewRequestFromURL returns a new request for the given URL.
// The host is inferred from the URL.
func NewRequestFromURL(url *url.URL) (*Request, error) {
// If there is no port, use the default port of 1965
host := u.Host
if u.Port() == "" {
host := url.Host
if url.Port() == "" {
host += ":1965"
}
return &Request{
Host: host,
URL: u,
URL: url,
}, nil
}

View File

@@ -10,7 +10,7 @@ import (
// Response is a Gemini response.
type Response struct {
// Status represents the response status.
Status int
Status Status
// Meta contains more information related to the response status.
// For successful responses, Meta should contain the mimetype of the response.
@@ -37,11 +37,11 @@ func (resp *Response) read(r *bufio.Reader) error {
if err != nil {
return err
}
resp.Status = status
resp.Status = Status(status)
// Disregard invalid status codes
const minStatus, maxStatus = 1, 6
statusClass := status / 10
statusClass := resp.Status.Class()
if statusClass < minStatus || statusClass > maxStatus {
return ErrInvalidResponse
}
@@ -74,7 +74,7 @@ func (resp *Response) read(r *bufio.Reader) error {
}
// Read response body
if status/10 == StatusClassSuccess {
if resp.Status.Class() == StatusClassSuccess {
var err error
resp.Body, err = ioutil.ReadAll(r)
if err != nil {

View File

@@ -168,7 +168,12 @@ func (s *Server) respond(conn net.Conn) {
RemoteAddr: conn.RemoteAddr(),
TLS: conn.(*tls.Conn).ConnectionState(),
}
s.responder(req).Respond(w, req)
resp := s.responder(req)
if resp != nil {
resp.Respond(w, req)
} else {
w.WriteStatus(StatusNotFound)
}
}
w.b.Flush()
conn.Close()
@@ -184,7 +189,7 @@ func (s *Server) responder(r *Request) Responder {
return h
}
}
return ResponderFunc(NotFound)
return nil
}
// ResponseWriter is used by a Gemini handler to construct a Gemini response.
@@ -208,22 +213,27 @@ func newResponseWriter(conn net.Conn) *ResponseWriter {
// For successful responses, Meta should contain the mimetype of the response.
// For failure responses, Meta should contain a short description of the failure.
// Meta should not be longer than 1024 bytes.
func (w *ResponseWriter) WriteHeader(status int, meta string) {
func (w *ResponseWriter) WriteHeader(status Status, meta string) {
if w.wroteHeader {
return
}
w.b.WriteString(strconv.Itoa(status))
w.b.WriteString(strconv.Itoa(int(status)))
w.b.WriteByte(' ')
w.b.WriteString(meta)
w.b.Write(crlf)
// Only allow body to be written on successful status codes.
if status/10 == StatusClassSuccess {
if status.Class() == StatusClassSuccess {
w.bodyAllowed = true
}
w.wroteHeader = true
}
// WriteStatus writes the response header with the given status code.
func (w *ResponseWriter) WriteStatus(status Status) {
w.WriteHeader(status, status.Message())
}
// SetMimetype sets the mimetype that will be written for a successful response.
// The provided mimetype will only be used if Write is called without calling
// WriteHeader.
@@ -280,42 +290,20 @@ func SensitiveInput(w *ResponseWriter, r *Request, prompt string) (string, bool)
}
// Redirect replies to the request with a redirect to the given URL.
func Redirect(w *ResponseWriter, r *Request, url string) {
func Redirect(w *ResponseWriter, url string) {
w.WriteHeader(StatusRedirect, url)
}
// PermanentRedirect replies to the request with a permanent redirect to the given URL.
func PermanentRedirect(w *ResponseWriter, r *Request, url string) {
func PermanentRedirect(w *ResponseWriter, url string) {
w.WriteHeader(StatusRedirectPermanent, url)
}
// NotFound replies to the request with the NotFound status code.
func NotFound(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusNotFound, "Not found")
}
// Gone replies to the request with the Gone status code.
func Gone(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusGone, "Gone")
}
// CertificateRequired responds to the request with the CertificateRequired
// status code.
func CertificateRequired(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusCertificateRequired, "Certificate required")
}
// CertificateNotAuthorized responds to the request with
// the CertificateNotAuthorized status code.
func CertificateNotAuthorized(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusCertificateNotAuthorized, "Certificate not authorized")
}
// Certificate returns the request certificate. If one is not provided,
// it returns nil and responds with StatusCertificateRequired.
func Certificate(w *ResponseWriter, r *Request) (*x509.Certificate, bool) {
if len(r.TLS.PeerCertificates) == 0 {
CertificateRequired(w, r)
w.WriteStatus(StatusCertificateRequired)
return nil, false
}
return r.TLS.PeerCertificates[0], true
@@ -458,14 +446,14 @@ func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
// If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(path, r.URL); ok {
Redirect(w, r, u.String())
Redirect(w, u.String())
return
}
if path != r.URL.Path {
u := *r.URL
u.Path = path
Redirect(w, r, u.String())
Redirect(w, u.String())
return
}
@@ -474,7 +462,7 @@ func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
resp := mux.match(path)
if resp == nil {
NotFound(w, r)
w.WriteStatus(StatusNotFound)
return
}
resp.Respond(w, r)

68
text.go
View File

@@ -3,7 +3,6 @@ package gemini
import (
"bufio"
"fmt"
"html"
"io"
"strings"
)
@@ -151,70 +150,3 @@ func (t Text) String() string {
}
return b.String()
}
// HTML returns the Gemini text response as HTML.
func (t Text) HTML() string {
var b strings.Builder
var pre bool
var list bool
for _, l := range t {
if _, ok := l.(LineListItem); ok {
if !list {
list = true
fmt.Fprint(&b, "<ul>\n")
}
} else if list {
list = false
fmt.Fprint(&b, "</ul>\n")
}
switch l.(type) {
case LineLink:
link := l.(LineLink)
url := html.EscapeString(link.URL)
name := html.EscapeString(link.Name)
if name == "" {
name = url
}
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name)
case LinePreformattingToggle:
pre = !pre
if pre {
fmt.Fprint(&b, "<pre>\n")
} else {
fmt.Fprint(&b, "</pre>\n")
}
case LinePreformattedText:
text := string(l.(LinePreformattedText))
fmt.Fprintf(&b, "%s\n", html.EscapeString(text))
case LineHeading1:
text := string(l.(LineHeading1))
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(text))
case LineHeading2:
text := string(l.(LineHeading2))
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(text))
case LineHeading3:
text := string(l.(LineHeading3))
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(text))
case LineListItem:
text := string(l.(LineListItem))
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(text))
case LineQuote:
text := string(l.(LineQuote))
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(text))
case LineText:
text := string(l.(LineText))
if text == "" {
fmt.Fprint(&b, "<br>\n")
} else {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text))
}
}
}
if pre {
fmt.Fprint(&b, "</pre>\n")
}
if list {
fmt.Fprint(&b, "</ul>\n")
}
return b.String()
}