diff --git a/src/frontend/static/js/pow.js b/src/frontend/static/js/pow.js
index bbe8660..876d029 100644
--- a/src/frontend/static/js/pow.js
+++ b/src/frontend/static/js/pow.js
@@ -37,7 +37,7 @@ function startPow() {
spinner.classList.add('hidden');
- const url = `/api/v1/pow/verify/${id}/${nonce}`;
+ const url = `/api/g/pow/verify/${id}/${nonce}`;
const response = await fetch(url);
const data = await response.json();
@@ -47,11 +47,11 @@ function startPow() {
element.classList.add('bg-lime-700');
element.classList.add('disabled');
text.innerText = '✅ Verified!';
-
+
// Show submit button
const submit = document.querySelector('#submit-btn');
submit.classList.remove('hidden');
-
+
// Set pow-nonce form input value to nonce
const nonceInput = document.querySelector('#pow-nonce');
nonceInput.value = nonce;
diff --git a/src/server/api_generic.go b/src/server/api_generic.go
new file mode 100644
index 0000000..a7bdcca
--- /dev/null
+++ b/src/server/api_generic.go
@@ -0,0 +1,179 @@
+package server
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/golang/freetype"
+ "github.com/golang/freetype/truetype"
+ "github.com/kataras/iris/v12"
+ "github.com/rs/zerolog/log"
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/gofont/gomonobold"
+
+ "pluja.dev/kycnot.me/database"
+)
+
+func (s *Server) handleVerifyPow(c iris.Context) {
+ // Get id, nonce and proof from the request
+ id := c.Params().Get("id")
+ nonce := c.Params().Get("nonce")
+
+ // Verify the proof of work
+ valid := s.PowChallenger.PowVerifyProof(id, nonce)
+
+ // Return the result
+ c.JSON(iris.Map{
+ "valid": valid,
+ "status": iris.StatusOK,
+ })
+}
+
+// Proxies the request to the logo URL of a service by its ID
+func (s *Server) handleApiPicture(c iris.Context) {
+ id := c.Params().Get("id")
+
+ service, err := database.Pb.GetServiceById(id)
+ if err != nil {
+ log.Error().Err(err).Msg("Could not get service")
+ respondWithPlaceholder(c, "?")
+ return
+ }
+
+ if service.LogoURL == "" {
+ log.Debug().Msgf("Image %s not found in cache", service.ID)
+ respondWithPlaceholder(c, service.Name)
+ return
+ }
+
+ if imageData, err := s.Cache.Get(fmt.Sprintf("img-%s", service.ID)); err == nil {
+ log.Debug().Msgf("Found image %s in cache", service.ID)
+ ctt, _ := s.Cache.Get(fmt.Sprintf("ctt-%s", service.ID))
+ c.ContentType(string(ctt))
+ c.StatusCode(iris.StatusOK)
+ c.Write(imageData)
+ return
+ }
+
+ log.Debug().Msgf("Image %s not found in cache", service.ID)
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", service.LogoURL, nil)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to create HTTP request")
+ return
+ }
+
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.3")
+ req.Header.Set("Referer", service.Urls[0])
+ resp, err := client.Do(req)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ log.Error().Err(err).Msg("Could not get image")
+ respondWithPlaceholder(c, service.Name)
+ return
+ }
+ defer resp.Body.Close()
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ log.Error().Err(err).Msg("Could not read response body")
+ respondWithPlaceholder(c, service.Name)
+ return
+ }
+
+ if bodyBytes == nil {
+ log.Error().Msg("Could not read response body")
+ respondWithPlaceholder(c, service.Name)
+ return
+ }
+
+ s.Cache.Set(fmt.Sprintf("img-%s", service.ID), bodyBytes)
+ s.Cache.Set(fmt.Sprintf("ctt-%s", service.ID), []byte(resp.Header.Get("Content-Type")))
+
+ c.ContentType(resp.Header.Get("Content-Type"))
+ c.StatusCode(iris.StatusOK)
+ c.Write(bodyBytes)
+}
+
+func respondWithPlaceholder(c iris.Context, serviceName string) {
+ log.Debug().Msgf("Generating placeholder for %s", serviceName)
+ const width, height = 250, 250
+ const margin = 15
+ firstLetter := strings.ToUpper(string(serviceName[0]))
+
+ // Create an image
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+
+ // Set background to black
+ draw.Draw(img, img.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
+
+ // Load the monospace font
+ f, err := truetype.Parse(gomonobold.TTF)
+ if err != nil {
+ log.Error().Err(err).Msg("could not parse font")
+ return
+ }
+
+ // Initialize freetype context
+ ctx := freetype.NewContext()
+ ctx.SetDPI(72)
+ ctx.SetFont(f)
+
+ // Parse hex color and set text color
+ var r, g, b uint8
+ hexColor := "#84cc16"
+ _, err = fmt.Sscanf(hexColor, "#%02x%02x%02x", &r, &g, &b)
+ if err != nil {
+ log.Error().Err(err).Msg("Invalid hex color")
+ return
+ }
+ textColor := color.RGBA{R: r, G: g, B: b, A: 255}
+ ctx.SetSrc(image.NewUniform(textColor))
+
+ ctx.SetClip(img.Bounds())
+ ctx.SetDst(img)
+
+ // Dynamically calculate the maximum font size
+ var fontSize float64
+ var textWidth, textHeight int
+ for fontSize = 205; fontSize > 10; fontSize -= 0.5 {
+ ctx.SetFontSize(fontSize)
+ opts := truetype.Options{Size: fontSize}
+ face := truetype.NewFace(f, &opts)
+ textWidth = font.MeasureString(face, firstLetter).Round()
+ ascent, descent := face.Metrics().Ascent, face.Metrics().Descent
+ textHeight = (ascent + descent).Ceil()
+
+ if textWidth <= width-margin*2 && textHeight <= height-margin*2 {
+ break
+ }
+ }
+
+ // Calculate position for the text
+ x := (width - textWidth) / 2
+ y := (height-textHeight)/2 + textHeight - int(ctx.PointToFixed(fontSize*0.2)>>6)
+ pt := freetype.Pt(x, y)
+
+ // Draw the text
+ if _, err := ctx.DrawString(firstLetter, pt); err != nil {
+ log.Error().Err(err).Msg("Failed to draw string")
+ return
+ }
+
+ // Encode to PNG
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ log.Error().Err(err).Msg("Failed to encode image")
+ return
+ }
+
+ c.ContentType("image/png")
+ c.StatusCode(iris.StatusOK)
+ c.Write(buf.Bytes())
+}
diff --git a/src/server/api_v1.go b/src/server/api_v1.go
new file mode 100644
index 0000000..4310949
--- /dev/null
+++ b/src/server/api_v1.go
@@ -0,0 +1,23 @@
+package server
+
+import (
+ "strings"
+
+ "github.com/kataras/iris/v12"
+ "github.com/rs/zerolog/log"
+ "pluja.dev/kycnot.me/database"
+)
+
+func (s *Server) handleApiService(c iris.Context) {
+ serviceName := strings.ToLower(c.Params().Get("name"))
+
+ service, err := database.Pb.GetServiceByNameOrUrl(serviceName)
+ if err != nil {
+ log.Error().Err(err).Msgf("Could not get service %v from database", serviceName)
+ c.HTML("
%s
", err.Error())
+ return
+ }
+
+ // Return the service as JSON
+ c.JSON(service)
+}
diff --git a/src/server/server.go b/src/server/server.go
index c8cee6b..a98c650 100644
--- a/src/server/server.go
+++ b/src/server/server.go
@@ -12,6 +12,7 @@ import (
"github.com/dustin/go-humanize"
"github.com/iris-contrib/middleware/cors"
"github.com/kataras/iris/v12"
+ "github.com/kataras/iris/v12/middleware/rate"
"github.com/rs/zerolog/log"
"pluja.dev/kycnot.me/config"
@@ -101,14 +102,24 @@ func (s *Server) RegisterRoutes() {
s.Router.Post("/new/service", s.handlePostRequestServiceForm)
// API routes
- api_v1 := s.Router.Party("/api/v1")
- api_v1.Get("/pow/verify/{id}/{nonce:int}", s.handleVerifyPow)
- api_v1.Get("/picture/{id}", s.handleApiPicture)
+ genericApi := s.Router.Party("/api/g")
+ {
+ genericApi.Get("/pow/verify/{id}/{nonce:int}", s.handleVerifyPow)
+ genericApi.Get("/picture/{id}", s.handleApiPicture)
+ }
+
+ v1Api := s.Router.Party("/api/v1")
+ {
+ limitV1 := rate.Limit(rate.Every(1*time.Minute), 5, rate.PurgeEvery(time.Minute, 5*time.Minute))
+ v1Api.Use(limitV1)
+ v1Api.Get("/service/{name:string}", s.handleApiService)
+ v1Api.Get("/service/{name:string}/summary", s.handleScoreSummary)
+ }
}
func (s *Server) RegisterViews() {
// Use blocks as the templating engine
- blocks := iris.Blocks("./frontend/templates", ".gohtml")
+ blocks := iris.Blocks("./frontend/templates", ".html")
if config.Conf.Dev {
blocks.Reload(true)
}
diff --git a/src/server/web_handlers.go b/src/server/web_handlers.go
new file mode 100644
index 0000000..4fed4aa
--- /dev/null
+++ b/src/server/web_handlers.go
@@ -0,0 +1,344 @@
+package server
+
+import (
+ "fmt"
+ "math/rand"
+ "strings"
+
+ "github.com/kataras/iris/v12"
+ "github.com/rs/zerolog/log"
+
+ "pluja.dev/kycnot.me/config"
+ "pluja.dev/kycnot.me/database"
+ "pluja.dev/kycnot.me/utils"
+)
+
+func (s *Server) handleIndex(c iris.Context) {
+ nokycPf := []string{
+ "KYC-Free Crypto Freedom.",
+ "Goodbye KYC, hello privacy.",
+ "KYC is a scam. Don't fall for it.",
+ }
+
+ t := c.URLParam("t")
+ q := c.URLParam("q")
+
+ // Currencies
+ xmr := c.URLParam("xmr")
+ btc := c.URLParam("btc")
+ ln := c.URLParam("ln")
+ cash := c.URLParam("cash")
+ fiat := c.URLParam("fiat")
+
+ filters := []string{"(listed=true && pending=false)"}
+
+ if t != "" && t != "all" {
+ filters = append(filters, fmt.Sprintf("(type='%v')", t))
+ }
+
+ if q != "" {
+ filters = append(
+ filters,
+ fmt.Sprintf("(name~'%v' || description~'%v' || tags~'%v')", q, q, q),
+ )
+ }
+
+ if xmr != "" {
+ filters = append(filters, fmt.Sprintf("(xmr=%v)", xmr == "on"))
+ }
+ if btc != "" {
+ filters = append(filters, fmt.Sprintf("(btc=%v)", btc == "on"))
+ }
+ if ln != "" {
+ filters = append(filters, fmt.Sprintf("(lightning=%v)", ln == "on"))
+ }
+ if cash != "" {
+ filters = append(filters, fmt.Sprintf("(cash=%v)", cash == "on"))
+ }
+ if fiat != "" {
+ filters = append(filters, fmt.Sprintf("(fiat=%v)", fiat == "on"))
+ }
+
+ services, err := database.Pb.GetServices(
+ fmt.Sprintf("(%v)", strings.Join(filters, " && ")),
+ "-score,@random",
+ )
+
+ if err != nil {
+ log.Error().Err(err).Msg("Could not get services from Pocketbase")
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ c.ViewLayout("main")
+ data := iris.Map{
+ "Title": "Home",
+ "Filters": map[string]string{
+ "Type": t,
+ "Query": q,
+ "Xmr": xmr,
+ "Btc": btc,
+ "Ln": ln,
+ "Cash": cash,
+ "Fiat": fiat,
+ },
+ "Announcement": *database.Pb.GetAnnouncement(),
+ "Current": "index",
+ "Services": services,
+ "RandomPitch": nokycPf[rand.Intn(len(nokycPf))],
+ }
+ if err := c.View("index", data); err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+}
+
+func (s *Server) handleService(c iris.Context) {
+ serviceName := strings.ToLower(c.Params().Get("name"))
+
+ service, err := database.Pb.GetServiceByNameOrUrl(serviceName)
+ if err != nil {
+ log.Error().Err(err).Msgf("Could not get service %v from database", serviceName)
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ // TODO: Compute score in background when needed!
+ // Update score in background
+ //upd := c.URLParam("update", "")
+ //if upd == "true" {
+ // go utils.UpdateScore(service)
+ //}
+ //utils.ComputeScore(service)
+
+ c.ViewLayout("main")
+ data := iris.Map{
+ "Title": fmt.Sprintf("%v | Service", serviceName),
+ "Service": service,
+ "Attributes": service.Expand["attributes"],
+ }
+ if err := c.View("service", data); err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+}
+
+func (s *Server) handleScoreSummary(c iris.Context) {
+ serviceName := strings.ToLower(c.Params().Get("name"))
+
+ service, err := database.Pb.GetServiceByNameOrUrl(serviceName)
+ if err != nil {
+ log.Error().Err(err).Msgf("Could not get service %v from database", serviceName)
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ summary := utils.ScoreSummary(service)
+
+ c.Text(summary)
+}
+
+func (s *Server) handleAttribute(c iris.Context) {
+ attributeId := strings.ToLower(c.Params().Get("id"))
+
+ // Get service from database by name
+ attribute, err := database.Pb.GetAttribute(attributeId)
+ if err != nil {
+ log.Error().Err(err).Msgf("Could not get attribute %v from database", attributeId)
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ // Get all services that have this attribute
+ services, err := database.Pb.GetServices(
+ fmt.Sprintf("(attributes~'%v')", attributeId),
+ "score",
+ )
+ if err != nil {
+ log.Error().Err(err).Msgf("Could not get services with attribute %v from database", attributeId)
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ c.ViewLayout("main")
+ data := iris.Map{
+ "Title": fmt.Sprintf("%v", attribute.Title),
+ "Attribute": attribute,
+ "Services": services,
+ }
+ if err := c.View("attribute", data); err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+}
+
+func (s *Server) handleRequestServiceForm(c iris.Context) {
+ challenge, id, difficulty, err := s.PowChallenger.PowGenerateChallenge(16)
+ if err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ attributes, err := database.Pb.GetAttributes("", "-rating")
+ if err != nil {
+ log.Error().Err(err).Msg("Could not get attributes from database")
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ c.ViewLayout("main")
+ data := iris.Map{
+ "Title": "Request a service",
+ "Current": "request",
+ "Pow": map[string]interface{}{
+ "Challenge": challenge,
+ "Difficulty": difficulty,
+ "Id": id,
+ },
+ "Attributes": attributes,
+ "Error": c.URLParam("error"),
+ "Message": c.URLParam("message"),
+ }
+ if err := c.View("request_service", data); err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+}
+
+func (s *Server) handleAbout(c iris.Context) {
+ c.ViewLayout("main")
+ data := iris.Map{
+ "Title": "About",
+ "Current": "about",
+ "Btc": config.Conf.Donations.Btc,
+ "Xmr": config.Conf.Donations.Xmr,
+ "Lnn": config.Conf.Donations.Lnn,
+ }
+ if err := c.View("about", data); err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+}
+
+func (s *Server) handlePending(c iris.Context) {
+ services, err := database.Pb.GetServices("pending=true", "score")
+ if err != nil {
+ log.Error().Err(err).Msg("Could not get services from database")
+ c.HTML("%s
", err.Error())
+ return
+ }
+
+ c.ViewLayout("main")
+ data := iris.Map{
+ "Title": "Pending",
+ "Services": services,
+ }
+ if err := c.View("pending", data); err != nil {
+ c.HTML("%s
", err.Error())
+ return
+ }
+}
+
+type RequestFormData struct {
+ Type string `form:"type"`
+ Category string `form:"category"`
+ Name string `form:"name"`
+ Description string `form:"description"`
+ Urls string `form:"urls"`
+ LogoUrl string `form:"logo_url"`
+ TosUrls string `form:"tos_urls"`
+ OnionUrls string `form:"onion_urls"`
+ Tags string `form:"tags"`
+ Xmr bool `form:"xmr"`
+ Btc bool `form:"btc"`
+ Ln bool `form:"ln"`
+ Fiat bool `form:"fiat"`
+ Cash bool `form:"cash"`
+ KYCLevel int `form:"kyc_level"`
+ Attributes []string `form:"attributes"`
+ PowNonce string `form:"pow-nonce"`
+ PowId string `form:"pow-id"`
+}
+
+func (s *Server) handlePostRequestServiceForm(c iris.Context) {
+ log.Info().Msg("Handling request service form")
+ log.Debug().Msg("Handling request service form")
+ var data RequestFormData
+ if err := c.ReadForm(&data); err != nil {
+ log.Debug().Err(err).Msg("Could not parse form data")
+ c.Redirect("/request/service?error=Invalid%20Form", iris.StatusSeeOther)
+ return
+ }
+
+ log.Printf("Nonce: %v, ID: %v", data.PowNonce, data.PowId)
+ if !s.PowChallenger.PowVerifyProof(data.PowId, data.PowNonce) {
+ log.Debug().Msg("Invalid PoW")
+ c.Redirect("/request/service?error=Invalid%20Captcha", iris.StatusSeeOther)
+ return
+ }
+
+ if data.KYCLevel < 0 || data.KYCLevel >= 4 {
+ log.Debug().Msgf("Invalid KYC Level value: %v", c.FormValue("kyc_level"))
+ c.Redirect("/request/service?error=Invalid%20KYC%20Level", iris.StatusSeeOther)
+ return
+ }
+
+ if len(data.Attributes) < 3 {
+ log.Debug().Msgf("Invalid number of attributes: %v", len(data.Attributes))
+ c.Redirect("/request/service?error=You%20must%20select%20at%20least%203%20attributes", iris.StatusSeeOther)
+ return
+ }
+
+ var service database.Service
+ // Parse tags
+ ts := strings.ReplaceAll(data.Tags, " ", ",")
+ // Remove trailing commas
+ ts = strings.TrimSuffix(ts, ",")
+ // Remove duplicate commas
+ ts = strings.ReplaceAll(ts, ",,", ",")
+ // Remove leading commas
+ ts = strings.TrimPrefix(ts, ",")
+ // Convert to lowercase
+ ts = strings.ToLower(ts)
+ // Create tags array
+ tags := strings.Split(ts, ",")
+
+ service.Tags = tags
+ service.Name = strings.ToLower(data.Name)
+ service.Description = data.Description
+ service.LogoURL = utils.UrlParser(data.LogoUrl)
+ service.Urls = utils.UrlListParser(data.Urls)
+ service.TosUrls = utils.UrlListParser(data.TosUrls)
+ service.OnionUrls = utils.UrlListParser(data.OnionUrls)
+ service.Xmr = data.Xmr
+ service.Btc = data.Btc
+ service.Lightning = data.Ln
+ service.Fiat = data.Fiat
+ service.Cash = data.Cash
+ service.Pending = true
+ service.Listed = false
+ service.Type = data.Type
+
+ if service.Type == "service" {
+ service.Category = data.Category
+ } else {
+ service.Category = ""
+ }
+
+ service.KycLevel = data.KYCLevel
+ service.Attributes = data.Attributes
+
+ service.Score = utils.ComputeScore(&service)
+
+ // Save service to database
+ err := database.Pb.CreateService(service)
+ if err != nil {
+ log.Error().Err(err).Msg("Could not save service to database")
+ c.Redirect("/request/service?error=internal-error", iris.StatusSeeOther)
+ return
+ }
+
+ // TODO: Update score in background
+ // go utils.UpdateScore(sv)
+ c.Redirect("/request/service?message=Success!", iris.StatusSeeOther)
+}