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) +}