Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(endpoints): Added new endpoints #16

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/tools v0.19.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/aeden/traceroute v0.0.0-20210211061815-03f5f7cb7908
github.com/jarcoal/httpmock v1.3.1
github.com/miekg/dns v1.1.59
github.com/stretchr/testify v1.9.0
golang.org/x/net v0.25.0
golang.org/x/sys v0.20.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
Expand All @@ -53,6 +55,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
Expand Down Expand Up @@ -87,6 +91,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
Expand Down
171 changes: 171 additions & 0 deletions handlers/archives.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package handlers

import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)

const archiveAPIURL = "https://web.archive.org/cdx/search/cdx"

func convertTimestampToDate(timestamp string) (time.Time, error) {
year, err := strconv.Atoi(timestamp[0:4])
if err != nil {
return time.Time{}, err
}
month, err := strconv.Atoi(timestamp[4:6])
if err != nil {
return time.Time{}, err
}
day, err := strconv.Atoi(timestamp[6:8])
if err != nil {
return time.Time{}, err
}
hour, err := strconv.Atoi(timestamp[8:10])
if err != nil {
return time.Time{}, err
}
minute, err := strconv.Atoi(timestamp[10:12])
if err != nil {
return time.Time{}, err
}
second, err := strconv.Atoi(timestamp[12:14])
if err != nil {
return time.Time{}, err
}
return time.Date(year, time.Month(month), day, hour, minute, second, 0, time.UTC), nil
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a small thing, but I'd probably put the minuteIndex, hourIndex, dayIndex, etc as variables, to avoid magic numbers. Because, for example hour, err := strconv.Atoi(timestamp[8:10]) is a bit hard to read.

const (
	yearIndex          = 0
	monthIndex         = 4
	dayIndex           = 6
	hourIndex          = 8
	minuteIndex        = 10
	secondIndex        = 12
	timestampLength    = 14
	averagePageSizeDiv = 100
)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The golang way :)

mask := "20060102150405"
return time.Parse(mask, timestamp)


func countPageChanges(results [][]string) int {
prevDigest := ""
changeCount := -1
for _, curr := range results {
if curr[2] != prevDigest {
prevDigest = curr[2]
changeCount++
}
}
return changeCount
}

func getAveragePageSize(scans [][]string) int {
totalSize := 0
for _, scan := range scans {
size, err := strconv.Atoi(scan[3])
if err != nil {
continue
}
totalSize += size
}
return totalSize / len(scans)
}

func getScanFrequency(firstScan, lastScan time.Time, totalScans, changeCount int) map[string]float64 {
formatToTwoDecimal := func(num float64) float64 {
Copy link
Collaborator

@kynrai kynrai Jun 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can use

fmt.Sprintf("%.2f", num)

return math.Round(num*100) / 100
}

dayFactor := lastScan.Sub(firstScan).Hours() / 24
daysBetweenScans := formatToTwoDecimal(dayFactor / float64(totalScans))
daysBetweenChanges := formatToTwoDecimal(dayFactor / float64(changeCount))
scansPerDay := formatToTwoDecimal(float64(totalScans-1) / dayFactor)
changesPerDay := formatToTwoDecimal(float64(changeCount) / dayFactor)

// Handle NaN values
if math.IsNaN(daysBetweenScans) {
daysBetweenScans = 0
}
if math.IsNaN(daysBetweenChanges) {
daysBetweenChanges = 0
}
if math.IsNaN(scansPerDay) {
scansPerDay = 0
}
if math.IsNaN(changesPerDay) {
changesPerDay = 0
}

return map[string]float64{
"daysBetweenScans": daysBetweenScans,
"daysBetweenChanges": daysBetweenChanges,
"scansPerDay": scansPerDay,
"changesPerDay": changesPerDay,
}
}

func getWaybackData(url string) (map[string]interface{}, error) {
cdxUrl := fmt.Sprintf("%s?url=%s&output=json&fl=timestamp,statuscode,digest,length,offset", archiveAPIURL, url)

resp, err := http.Get(cdxUrl)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes requests just hang, so we should include a timeout to prevent that

if err != nil {
return nil, err
}
defer resp.Body.Close()

var data [][]string
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return nil, err
}

if len(data) <= 1 {
return map[string]interface{}{
"skipped": "Site has never before been archived via the Wayback Machine",
}, nil
}

// Remove the header row
data = data[1:]

firstScan, err := convertTimestampToDate(data[0][0])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to do a length check, before accessing data[0] and data[len(data)-1] to avoid potential panics?

if err != nil {
return nil, err
}
lastScan, err := convertTimestampToDate(data[len(data)-1][0])
if err != nil {
return nil, err
}
totalScans := len(data)
changeCount := countPageChanges(data)

return map[string]interface{}{
"firstScan": firstScan.Format(time.RFC3339),
"lastScan": lastScan.Format(time.RFC3339),
"totalScans": totalScans,
"changeCount": changeCount,
"averagePageSize": getAveragePageSize(data),
"scanFrequency": getScanFrequency(firstScan, lastScan, totalScans, changeCount),
"scans": data,
"scanUrl": url,
}, nil
}

func HandleArchives() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urlParam := r.URL.Query().Get("url")
if urlParam == "" {
http.Error(w, "missing 'url' parameter", http.StatusBadRequest)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget, if there's an error anywhere, we need to still respond with JSON, like { error: "Missing 'url' parameter" }. Same goes for the other error responses below.

return
}

if !strings.HasPrefix(urlParam, "http://") && !strings.HasPrefix(urlParam, "https://") {
urlParam = "http://" + urlParam
}

data, err := getWaybackData(urlParam)
if err != nil {
http.Error(w, fmt.Sprintf("Error fetching Wayback data: %v", err), http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(data)
if err != nil {
http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
}
})
}
158 changes: 158 additions & 0 deletions handlers/mail-config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package handlers

import (
"errors"
"net/http"
"net/url"
"strings"

"github.com/miekg/dns"
)

func ResolveMx(domain string) ([]*dns.MX, int, error) {
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeMX)
r, _, err := c.Exchange(m, "8.8.8.8:53")
if err != nil {
return nil, dns.RcodeServerFailure, err
}
if r.Rcode != dns.RcodeSuccess {
return nil, r.Rcode, &dns.Error{}
}
var mxRecords []*dns.MX
for _, ans := range r.Answer {
if mx, ok := ans.(*dns.MX); ok {
mxRecords = append(mxRecords, mx)
}
}
if len(mxRecords) == 0 {
return nil, dns.RcodeNameError, nil
}
return mxRecords, dns.RcodeSuccess, nil
}

func ResolveTxt(domain string) ([]string, int, error) {
c := new(dns.Client)
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeTXT)
r, _, err := c.Exchange(m, "8.8.8.8:53")
if err != nil {
return nil, dns.RcodeServerFailure, err
}
if r.Rcode != dns.RcodeSuccess {
return nil, r.Rcode, &dns.Error{}
}
var txtRecords []string
for _, ans := range r.Answer {
if txt, ok := ans.(*dns.TXT); ok {
txtRecords = append(txtRecords, txt.Txt...)
}
}
if len(txtRecords) == 0 {
return nil, dns.RcodeNameError, nil
}
return txtRecords, dns.RcodeSuccess, nil
}

func HandleMailConfig() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urlParam := r.URL.Query().Get("url")
if urlParam == "" {
JSONError(w, errors.New("URL parameter is required"), http.StatusBadRequest)
return
}

if !strings.HasPrefix(urlParam, "http://") && !strings.HasPrefix(urlParam, "https://") {
urlParam = "http://" + urlParam
}

parsedURL, err := url.Parse(urlParam)
if err != nil {
JSONError(w, errors.New("Invalid URL"), http.StatusBadRequest)
return
}
domain := parsedURL.Hostname()
if domain == "" {
domain = parsedURL.Path
}

mxRecords, rcode, err := ResolveMx(domain)
if err != nil {
JSONError(w, err, http.StatusInternalServerError)
return
}

if rcode == dns.RcodeNameError || rcode == dns.RcodeServerFailure {
JSON(w, map[string]string{"skipped": "No mail server in use on this domain"}, http.StatusOK)
return
}

txtRecords, rcode, err := ResolveTxt(domain)
if err != nil {
JSONError(w, err, http.StatusInternalServerError)
return
}

if rcode == dns.RcodeNameError || rcode == dns.RcodeServerFailure {
JSON(w, map[string]string{"skipped": "No mail server in use on this domain"}, http.StatusOK)
return
}

emailTxtRecords := filterEmailTxtRecords(txtRecords)
mailServices := identifyMailServices(emailTxtRecords, mxRecords)

JSON(w, map[string]interface{}{
"mxRecords": mxRecords,
"txtRecords": emailTxtRecords,
"mailServices": mailServices,
}, http.StatusOK)
})
}

func filterEmailTxtRecords(records []string) []string {
var emailTxtRecords []string
for _, record := range records {
if strings.HasPrefix(record, "v=spf1") ||
strings.HasPrefix(record, "v=DKIM1") ||
strings.HasPrefix(record, "v=DMARC1") ||
strings.HasPrefix(record, "protonmail-verification=") ||
strings.HasPrefix(record, "google-site-verification=") ||
strings.HasPrefix(record, "MS=") ||
strings.HasPrefix(record, "zoho-verification=") ||
strings.HasPrefix(record, "titan-verification=") ||
strings.Contains(record, "bluehost.com") {
emailTxtRecords = append(emailTxtRecords, record)
}
}
return emailTxtRecords
}

func identifyMailServices(emailTxtRecords []string, mxRecords []*dns.MX) []map[string]string {
var mailServices []map[string]string
for _, record := range emailTxtRecords {
if strings.HasPrefix(record, "protonmail-verification=") {
mailServices = append(mailServices, map[string]string{"provider": "ProtonMail", "value": strings.Split(record, "=")[1]})
} else if strings.HasPrefix(record, "google-site-verification=") {
mailServices = append(mailServices, map[string]string{"provider": "Google Workspace", "value": strings.Split(record, "=")[1]})
} else if strings.HasPrefix(record, "MS=") {
mailServices = append(mailServices, map[string]string{"provider": "Microsoft 365", "value": strings.Split(record, "=")[1]})
} else if strings.HasPrefix(record, "zoho-verification=") {
mailServices = append(mailServices, map[string]string{"provider": "Zoho", "value": strings.Split(record, "=")[1]})
} else if strings.HasPrefix(record, "titan-verification=") {
mailServices = append(mailServices, map[string]string{"provider": "Titan", "value": strings.Split(record, "=")[1]})
} else if strings.Contains(record, "bluehost.com") {
mailServices = append(mailServices, map[string]string{"provider": "BlueHost", "value": record})
}
}

for _, mx := range mxRecords {
if strings.Contains(mx.Mx, "yahoodns.net") {
mailServices = append(mailServices, map[string]string{"provider": "Yahoo", "value": mx.Mx})
} else if strings.Contains(mx.Mx, "mimecast.com") {
mailServices = append(mailServices, map[string]string{"provider": "Mimecast", "value": mx.Mx})
}
}

return mailServices
}
Loading