Skip to content

Instantly share code, notes, and snippets.

@bencrowder
Created October 29, 2020 12:45
Show Gist options
  • Save bencrowder/45f185b3473c50e9733e1df834c8c0cf to your computer and use it in GitHub Desktop.
Save bencrowder/45f185b3473c50e9733e1df834c8c0cf to your computer and use it in GitHub Desktop.
// Gate is a small web app for quick text entry along with forwarding entered
// text to other endpoints (which I use heavily in my personal workflow). I've
// only done a little coding in Go so far, so I'm sure this code is probably
// not as idiomatic as I'd like. (I'd also ordinarily split this up into
// multiple files. And do several things differently. Hindsight!)
package main
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/flosch/pongo2"
"gopkg.in/yaml.v2"
)
const configFilename = "actions.yaml"
// Action used in config
type Action struct {
Slug string
Label string
Payload string
Callback string
URL string
FullSlug string
}
// Config is what the YAML gets unmarshalled into
type Config struct {
Apps map[string]struct {
Host string
URL string
Field string
Callback string
Actions []Action
}
Board []struct {
Label string
App string
Groups []struct {
Actions []string
}
}
Port string
Basepath string
Auth struct {
Username string
Password string
Sesame string
}
}
// ConfigChunk is used for the config for a slug/app
type ConfigChunk struct {
URL string
Callback string
PayloadTemplate string
Field string
}
// SectionGroup is used for board sections
type SectionGroup struct {
Actions []Action
}
// BoardSection is used for the display of the actions
type BoardSection struct {
Label string
Groups []SectionGroup
}
// BoardSections is the list of sections
type BoardSections []BoardSection
// Response is for what we respond with
type Response struct {
Status string
Success bool
Message string
Callback string
}
var config Config
var actions map[string]Action
func loadYaml() (data []byte, err error) {
data, err = ioutil.ReadFile(configFilename)
return data, err
}
func (config *Config) loadConfigForSlug(target string, app string, slug string) (chunk ConfigChunk, err error) {
chunk = ConfigChunk{}
appConfig := config.Apps[app]
url := appConfig.URL
callback := appConfig.Callback
chunk.Field = appConfig.Field
host := appConfig.Host
var selectedAction Action
for key, action := range actions {
if target == key {
selectedAction = action
break
}
}
if selectedAction.Slug == "" {
// Didn't find the action
return ConfigChunk{}, errors.New("Action not found: " + target)
}
// Get URL if present on action
if selectedAction.URL != "" {
url = selectedAction.URL
}
// Get callback if present on action
if selectedAction.Callback != "" {
callback = selectedAction.Callback
}
// Replace host
chunk.URL = strings.Replace(url, "{host}", host, 1)
chunk.Callback = strings.Replace(callback, "{host}", host, 1)
chunk.PayloadTemplate = selectedAction.Payload
return chunk, nil
}
// loadConfig loads the configuration YAML into the config object
func loadConfig() (config Config) {
yamlData, err := loadYaml()
if err != nil {
log.Fatalf("Error loading YAML: %v", err)
}
config = Config{}
err = yaml.Unmarshal([]byte(yamlData), &config)
if err != nil {
log.Fatalf("error: %v", err)
}
// Load things into the map first
actions = make(map[string]Action)
for appKey, app := range config.Apps {
for _, action := range app.Actions {
slug := "@" + appKey + "." + action.Slug
action.FullSlug = slug
actions[slug] = action
}
}
// Update basepath
baseURL := "/"
if config.Basepath != "/" {
baseURL = config.Basepath + "/"
}
config.Basepath = baseURL
return config
}
func parseSlugLine(slugLine string) (app string, slug string, err error) {
// Turns @liszt.projects/next into "liszt", "projects/next"
data := strings.Split(slugLine, ".")
app = strings.Trim(data[0], "@")
slug = data[1]
if app == "" || slug == "" {
err = errors.New("App or slug incorrectly formed")
} else {
err = nil
}
return app, slug, err
}
func (chunk *ConfigChunk) postPayload(payload string) (Response, error) {
// Process payload via template
payloadData := strings.Replace(chunk.PayloadTemplate, "{payload}", payload, 1)
// POST to the target
resp, err := http.PostForm(
chunk.URL,
url.Values{
chunk.Field: {payloadData},
},
)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return Response{}, err
}
response := Response{}
response.Status = resp.Status
if resp.Status == "200 OK" {
response.Success = true
} else {
return Response{}, errors.New(string(body[:]))
}
response.Callback = chunk.Callback
return response, nil
}
func processPayloadHandler(w http.ResponseWriter, r *http.Request) {
var target = ""
var payload = ""
if !isAuthenticated(r) {
// Make sure we're authenticated
username, password, ok := r.BasicAuth()
if !ok {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, "{\"error\": \"Not authenticated\"}")
return
}
if username != config.Auth.Username {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, "{\"error\": \"Bad person\"}")
return
}
if password != config.Auth.Password {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, "{\"error\": \"Bad secrets\"}")
return
}
}
if r.Method == "POST" {
target = r.FormValue("target")
payload = r.FormValue("payload")
} else {
return
}
savePayload(target, payload)
if target == "" || payload == "" {
w.WriteHeader(http.StatusNotAcceptable)
fmt.Fprintf(w, "{\"error\": \"Missing parameters\"}")
return
}
app, slug, err := parseSlugLine(target)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "{\"error\": \"%v\"}", err)
return
}
chunk, err := config.loadConfigForSlug(target, app, slug)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "{\"error\": \"%v\"}", err)
return
}
response, err := chunk.postPayload(payload)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "{\"error\": \"%v\"}", err)
return
}
responseJSON, err := json.Marshal(response)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "{\"error\": \"%v\"}", err)
return
}
fmt.Fprintf(w, string(responseJSON))
}
func savePayload(target string, payload string) {
baseDir := "payloads/"
// Make sure the directory exists
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
os.Mkdir(baseDir, 0700)
}
// Prep the filename
t := time.Now()
year := t.Format("2006")
yearPath := baseDir + year
if _, err := os.Stat(yearPath); os.IsNotExist(err) {
os.Mkdir(yearPath, 0700)
}
month := t.Format("01")
monthPath := yearPath + "/" + month
if _, err := os.Stat(monthPath); os.IsNotExist(err) {
os.Mkdir(monthPath, 0700)
}
formattedDate := t.Format("2006-01-02-150405.000")
filename := formattedDate + ".text"
// Concatenate the target and payload
output := target + "\n" + payload
// Write out the contents of the payload
ioutil.WriteFile(monthPath+"/"+filename, []byte(output), 0600)
}
func indexPageHandler(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, config.Basepath+"login", http.StatusFound)
return
}
data, err := ioutil.ReadFile("templates/index.html")
if err != nil {
fmt.Fprintf(w, "Template error: \"%v\"", err)
return
}
tpl, err := pongo2.FromString(string(data))
if err != nil {
fmt.Fprintf(w, "Template error: \"%v\"", err)
return
}
out, err := tpl.Execute(pongo2.Context{"config": config})
if err != nil {
fmt.Fprintf(w, "Template error: \"%v\"", err)
return
}
fmt.Fprintf(w, out)
}
func md5Hash(s string) string {
hasher := md5.New()
hasher.Write([]byte(s))
return hex.EncodeToString(hasher.Sum(nil))
}
func isAuthenticated(r *http.Request) bool {
cookie, err := r.Cookie("gatecookie")
if err != nil {
return false
}
return cookie.Value == "redacted"
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
var sesame string
if r.Method == "POST" {
sesame = r.FormValue("sesame")
} else {
return
}
if sesame == "" {
fmt.Fprintf(w, "{\"error\": \"Missing parameters\"}")
return
}
// Hash all the things
hashedKey := md5Hash(config.Auth.Sesame)
hashedSesame := md5Hash(sesame)
// Invalid sesame
if hashedSesame != hashedKey {
fmt.Fprintf(w, "{\"error\": \"Sorry, denied\"}")
return
}
// Matches, so set cookie and redirect
expiration := time.Now().Add(365 * 24 * time.Hour)
cookie := http.Cookie{Name: "gatecookie", Value: "redacted", Path: config.Basepath, Expires: expiration}
http.SetCookie(w, &cookie)
http.Redirect(w, r, config.Basepath, http.StatusFound)
}
func loginPageHandler(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadFile("templates/login.html")
if err != nil {
fmt.Fprintf(w, "{\"error\": \"%v\"}", err)
return
}
tpl, err := pongo2.FromString(string(data))
if err != nil {
fmt.Fprintf(w, "Template error: \"%v\"", err)
return
}
out, err := tpl.Execute(pongo2.Context{"config": config})
if err != nil {
fmt.Fprintf(w, "Template error: \"%v\"", err)
return
}
fmt.Fprintf(w, out)
}
func main() {
config = loadConfig()
http.HandleFunc(config.Basepath+"api/process", processPayloadHandler)
http.HandleFunc(config.Basepath+"api/login", loginHandler)
http.HandleFunc(config.Basepath+"login", loginPageHandler)
http.HandleFunc(config.Basepath, indexPageHandler)
http.Handle(config.Basepath+"static/", http.StripPrefix(config.Basepath+"static/", http.FileServer(http.Dir("static"))))
log.Fatal(http.ListenAndServe(":"+config.Port, nil))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment