mirror of
https://github.com/sequentialread/pow-captcha.git
synced 2025-03-30 15:08:29 +00:00
241 lines
6.5 KiB
Go
241 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
errors "git.sequentialread.com/forest/pkg-errors"
|
|
)
|
|
|
|
var httpClient *http.Client
|
|
var powAPIURL *url.URL
|
|
var powChallenges []string
|
|
|
|
var items []string
|
|
|
|
// 5 bits of difficulty, 1 in 2^6 (1 in 32) tries will succeed on average.
|
|
//
|
|
// 7 bits of difficulty would be ok for apps that are never used on mobile phones, 5 is better suited for mobile apps
|
|
const powDifficultyLevel = 7
|
|
|
|
func main() {
|
|
|
|
httpClient = &http.Client{
|
|
Timeout: time.Second * time.Duration(5),
|
|
}
|
|
|
|
apiToken := os.ExpandEnv("$BOT_DETERRENT_API_TOKEN")
|
|
if apiToken == "" {
|
|
panic(errors.New("can't start the app, the BOT_DETERRENT_API_TOKEN environment variable is required"))
|
|
}
|
|
|
|
var err error
|
|
powAPIURL, err = url.Parse("http://localhost:2370")
|
|
if err != nil {
|
|
panic(errors.New("can't start the app because can't parse powAPIURL"))
|
|
}
|
|
|
|
err = loadChallenges(apiToken)
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "can't start the app because could not loadChallenges():"))
|
|
}
|
|
|
|
_, err = os.ReadFile("index.html")
|
|
if err != nil {
|
|
panic(errors.Wrap(err, "can't start the app because can't open the template file. Are you in the right directory? "))
|
|
}
|
|
|
|
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("../static/"))))
|
|
|
|
http.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
|
|
|
|
// The user submitted a POST request, attempting to add a new item to the list
|
|
if request.Method == "POST" {
|
|
|
|
// Ask the bot deterrent server if the user's proof of work result is legit,
|
|
// and if not, return HTTP 400 Bad Request
|
|
err := request.ParseForm()
|
|
if err == nil {
|
|
err = validatePow(apiToken, request.Form.Get("challenge"), request.Form.Get("nonce"))
|
|
}
|
|
|
|
if err != nil {
|
|
responseWriter.WriteHeader(400)
|
|
responseWriter.Write([]byte(fmt.Sprintf("400 bad request: %s", err)))
|
|
return
|
|
}
|
|
|
|
// Validation passed, add the user's new item to the list
|
|
items = append(items, request.Form.Get("item"))
|
|
|
|
http.Redirect(responseWriter, request, "/", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// if it looks like we will run out of challenges soon, then kick off a goroutine to go get more in the background
|
|
// note that in a real application in production, you would want to use a lock or mutex to ensure that
|
|
// this only happens once if lots of requests come in at the same time
|
|
if len(powChallenges) > 0 && len(powChallenges) < 5 {
|
|
go loadChallenges(apiToken)
|
|
}
|
|
|
|
// if we somehow completely ran out of challenges, load more synchronously
|
|
if len(powChallenges) == 0 {
|
|
err = loadChallenges(apiToken)
|
|
if err != nil {
|
|
log.Printf("loading bot deterrent challenges failed: %v", err)
|
|
responseWriter.WriteHeader(500)
|
|
responseWriter.Write([]byte("bot deterrent api error"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// This gets & consumes the next challenge from the begining of the slice
|
|
challenge := powChallenges[0]
|
|
powChallenges = powChallenges[1:]
|
|
|
|
// render the page HTML & output the result to the web browser
|
|
htmlBytes, err := renderPageTemplate(challenge)
|
|
if err != nil {
|
|
log.Printf("renderPageTemplate(): %v", err)
|
|
responseWriter.WriteHeader(500)
|
|
responseWriter.Write([]byte("500 internal server error"))
|
|
return
|
|
}
|
|
responseWriter.Write(htmlBytes)
|
|
})
|
|
|
|
log.Println("📋 Todo List example application listening on port 8080")
|
|
|
|
err = http.ListenAndServe(":8080", nil)
|
|
|
|
// if got this far it means server crashed!
|
|
panic(err)
|
|
}
|
|
|
|
func renderPageTemplate(challenge string) ([]byte, error) {
|
|
|
|
// in a real application in production you would read the template file & parse it 1 time when the app starts
|
|
// I'm doing it for each request here just to make it easier to hack on it while its running 😇
|
|
indexHTMLTemplateString, err := ioutil.ReadFile("index.html")
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "can't open the template file. Are you in the right directory? ")
|
|
}
|
|
pageTemplate, err := template.New("master").Parse(string(indexHTMLTemplateString))
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "can't parse the template file: ")
|
|
}
|
|
|
|
// constructing an instance of an anonymous struct type to contain all the data
|
|
// that we need to pass to the template
|
|
pageData := struct {
|
|
Challenge string
|
|
Items []string
|
|
PowAPIURL string
|
|
}{
|
|
Challenge: challenge,
|
|
Items: items,
|
|
PowAPIURL: powAPIURL.String(),
|
|
}
|
|
var outputBuffer bytes.Buffer
|
|
err = pageTemplate.Execute(&outputBuffer, pageData)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "rendering page template failed: ")
|
|
}
|
|
|
|
return outputBuffer.Bytes(), nil
|
|
}
|
|
|
|
func loadChallenges(apiToken string) error {
|
|
|
|
query := url.Values{}
|
|
query.Add("difficultyLevel", strconv.Itoa(powDifficultyLevel))
|
|
|
|
loadURL := url.URL{
|
|
Scheme: powAPIURL.Scheme,
|
|
Host: powAPIURL.Host,
|
|
Path: filepath.Join(powAPIURL.Path, "GetChallenges"),
|
|
RawQuery: query.Encode(),
|
|
}
|
|
|
|
request, err := http.NewRequest("POST", loadURL.String(), nil)
|
|
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
response, err := httpClient.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
responseBytes, err := ioutil.ReadAll(response.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if response.StatusCode != 200 {
|
|
return fmt.Errorf(
|
|
"load proof of work bot deterrent challenges api returned http %d: %s",
|
|
response.StatusCode, string(responseBytes),
|
|
)
|
|
}
|
|
|
|
err = json.Unmarshal(responseBytes, &powChallenges)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(powChallenges) == 0 {
|
|
return errors.New("proof of work bot deterrent challenges api returned empty array")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validatePow(apiToken, challenge, nonce string) error {
|
|
query := url.Values{}
|
|
query.Add("challenge", challenge)
|
|
query.Add("nonce", nonce)
|
|
|
|
verifyURL := url.URL{
|
|
Scheme: powAPIURL.Scheme,
|
|
Host: powAPIURL.Host,
|
|
Path: filepath.Join(powAPIURL.Path, "Verify"),
|
|
RawQuery: query.Encode(),
|
|
}
|
|
|
|
request, err := http.NewRequest("POST", verifyURL.String(), nil)
|
|
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", apiToken))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
response, err := httpClient.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if response.StatusCode != 200 {
|
|
bodyString := "http read error"
|
|
bytez, err := io.ReadAll(response.Body)
|
|
if err == nil {
|
|
bodyString = string(bytez)
|
|
}
|
|
log.Printf("validation failed: HTTP %d: %s\n", response.StatusCode, bodyString)
|
|
return errors.New("PoW bot deterrent validation failed")
|
|
}
|
|
return nil
|
|
}
|