You've already forked speedtest-go
Migrate code from Go branch to new repo
This commit is contained in:
178
results/stats.go
Normal file
178
results/stats.go
Normal file
@ -0,0 +1,178 @@
|
||||
package results
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/librespeed/speedtest/config"
|
||||
"github.com/librespeed/speedtest/database"
|
||||
"github.com/librespeed/speedtest/database/schema"
|
||||
)
|
||||
|
||||
type StatsData struct {
|
||||
NoPassword bool
|
||||
LoggedIn bool
|
||||
Data []schema.TelemetryData
|
||||
}
|
||||
|
||||
func Stats(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t, err := template.New("template").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to parse template: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
conf := config.LoadedConfig()
|
||||
var data StatsData
|
||||
|
||||
if conf.StatsPassword == "PASSWORD" {
|
||||
data.NoPassword = true
|
||||
}
|
||||
|
||||
if !data.NoPassword {
|
||||
op := r.FormValue("op")
|
||||
c, _ := r.Cookie("logged")
|
||||
|
||||
if c != nil && c.Value == "true" {
|
||||
if op == "logout" {
|
||||
cookie := &http.Cookie{
|
||||
Name: "logged",
|
||||
Value: "false",
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, "/stats", http.StatusTemporaryRedirect)
|
||||
} else {
|
||||
data.LoggedIn = true
|
||||
|
||||
id := r.FormValue("id")
|
||||
switch id {
|
||||
case "L100":
|
||||
stats, err := database.DB.FetchLast100()
|
||||
if err != nil {
|
||||
log.Errorf("Error fetching data from database: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Data = stats
|
||||
case "":
|
||||
default:
|
||||
stat, err := database.DB.FetchByUUID(id)
|
||||
if err != nil {
|
||||
log.Errorf("Error fetching data from database: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Data = append(data.Data, *stat)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if op == "login" {
|
||||
password := r.FormValue("password")
|
||||
if password == conf.StatsPassword {
|
||||
cookie := &http.Cookie{
|
||||
Name: "logged",
|
||||
Value: "true",
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, "/stats", http.StatusTemporaryRedirect)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := t.Execute(w, data); err != nil {
|
||||
log.Errorf("Error executing template: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>LibreSpeed - Stats</title>
|
||||
<style type="text/css">
|
||||
html,body{
|
||||
margin:0;
|
||||
padding:0;
|
||||
border:none;
|
||||
width:100%; min-height:100%;
|
||||
}
|
||||
html{
|
||||
background-color: hsl(198,72%,35%);
|
||||
font-family: "Segoe UI","Roboto",sans-serif;
|
||||
}
|
||||
body{
|
||||
background-color:#FFFFFF;
|
||||
box-sizing:border-box;
|
||||
width:100%;
|
||||
max-width:70em;
|
||||
margin:4em auto;
|
||||
box-shadow:0 1em 6em #00000080;
|
||||
padding:1em 1em 4em 1em;
|
||||
border-radius:0.4em;
|
||||
}
|
||||
h1,h2,h3,h4,h5,h6{
|
||||
font-weight:300;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
h1{
|
||||
text-align:center;
|
||||
}
|
||||
table{
|
||||
margin:2em 0;
|
||||
width:100%;
|
||||
}
|
||||
table, tr, th, td {
|
||||
border: 1px solid #AAAAAA;
|
||||
}
|
||||
th {
|
||||
width: 6em;
|
||||
}
|
||||
td {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LibreSpeed - Stats</h1>
|
||||
{{ if .NoPassword }}
|
||||
Please set statistics_password in settings.toml to enable access.
|
||||
{{ else if .LoggedIn }}
|
||||
<form action="stats" method="GET"><input type="hidden" name="op" value="logout" /><input type="submit" value="Logout" /></form>
|
||||
<form action="stats" method="GET">
|
||||
<h3>Search test results</h6>
|
||||
<input type="hidden" name="op" value="id" />
|
||||
<input type="text" name="id" id="id" placeholder="Test ID" value=""/>
|
||||
<input type="submit" value="Find" />
|
||||
<input type="submit" onclick="document.getElementById('id').value='L100'" value="Show last 100 tests" />
|
||||
</form>
|
||||
|
||||
{{ range $i, $v := .Data }}
|
||||
<table>
|
||||
<tr><th>Test ID</th><td>{{ $v.UUID }}</td></tr>
|
||||
<tr><th>Date and time</th><td>{{ $v.Timestamp }}</td></tr>
|
||||
<tr><th>IP and ISP Info</th><td>{{ $v.IPAddress }}<br/>{{ $v.ISPInfo }}</td></tr>
|
||||
<tr><th>User agent and locale</th><td>{{ $v.UserAgent }}<br/>{{ $v.Language }}</td></tr>
|
||||
<tr><th>Download speed</th><td>{{ $v.Download }}</td></tr>
|
||||
<tr><th>Upload speed</th><td>{{ $v.Upload }}</td></tr>
|
||||
<tr><th>Ping</th><td>{{ $v.Ping }}</td></tr>
|
||||
<tr><th>Jitter</th><td>{{ $v.Jitter }}</td></tr>
|
||||
<tr><th>Log</th><td>{{ $v.Log }}</td></tr>
|
||||
<tr><th>Extra info</th><td>{{ $v.Extra }}</td></tr>
|
||||
</table>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<form action="stats?op=login" method="POST">
|
||||
<h3>Login</h3>
|
||||
<input type="password" name="password" placeholder="Password" value=""/>
|
||||
<input type="submit" value="Login" />
|
||||
</form>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>`
|
340
results/telemetry.go
Normal file
340
results/telemetry.go
Normal file
@ -0,0 +1,340 @@
|
||||
package results
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/librespeed/speedtest/config"
|
||||
"github.com/librespeed/speedtest/database"
|
||||
"github.com/librespeed/speedtest/database/schema"
|
||||
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/oklog/ulid/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/image/font"
|
||||
)
|
||||
|
||||
const (
|
||||
watermark = "LibreSpeed"
|
||||
|
||||
labelMS = " ms"
|
||||
labelMbps = "Mbit/s"
|
||||
labelPing = "Ping"
|
||||
labelJitter = "Jitter"
|
||||
labelDownload = "Download"
|
||||
labelUpload = "Upload"
|
||||
)
|
||||
|
||||
var (
|
||||
ipv4Regex = regexp.MustCompile(`(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`)
|
||||
ipv6Regex = regexp.MustCompile(`(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))`)
|
||||
hostnameRegex = regexp.MustCompile(`"hostname":"([^\\\\"]|\\\\")*"`)
|
||||
|
||||
fontLight, fontBold *truetype.Font
|
||||
labelFace, valueFace, smallLabelFace, orgFace, watermarkFace font.Face
|
||||
|
||||
canvasWidth, canvasHeight = 800, 600
|
||||
dpi = 150.0
|
||||
colorLabel = image.NewUniform(color.RGBA{40, 40, 40, 255})
|
||||
colorDownload = image.NewUniform(color.RGBA{96, 96, 170, 255})
|
||||
colorUpload = image.NewUniform(color.RGBA{96, 96, 96, 255})
|
||||
colorPing = image.NewUniform(color.RGBA{170, 96, 96, 255})
|
||||
colorJitter = image.NewUniform(color.RGBA{170, 96, 96, 255})
|
||||
colorMeasure = image.NewUniform(color.RGBA{40, 40, 40, 255})
|
||||
colorISP = image.NewUniform(color.RGBA{40, 40, 40, 255})
|
||||
colorWatermark = image.NewUniform(color.RGBA{160, 160, 160, 255})
|
||||
colorSeparator = image.NewUniform(color.RGBA{192, 192, 192, 255})
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ProcessedString string `json:"processedString"`
|
||||
RawISPInfo IPInfoResponse `json:"rawIspInfo"`
|
||||
}
|
||||
|
||||
type IPInfoResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Hostname string `json:"hostname"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
Location string `json:"loc"`
|
||||
Organization string `json:"org"`
|
||||
Postal string `json:"postal"`
|
||||
Timezone string `json:"timezone"`
|
||||
Readme string `json:"readme"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
// changed to use Noto Sans instead of OpenSans, due to issue:
|
||||
// https://github.com/golang/freetype/issues/8
|
||||
if b, err := ioutil.ReadFile("assets/NotoSansDisplay-Light.ttf"); err != nil {
|
||||
log.Fatalf("Error opening NotoSansDisplay-Light font: %s", err)
|
||||
} else {
|
||||
f, err := freetype.ParseFont(b)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing NotoSansDisplay-Light font: %s", err)
|
||||
}
|
||||
fontLight = f
|
||||
}
|
||||
|
||||
if b, err := ioutil.ReadFile("assets/NotoSansDisplay-Medium.ttf"); err != nil {
|
||||
log.Fatalf("Error opening NotoSansDisplay-Medium font: %s", err)
|
||||
} else {
|
||||
f, err := freetype.ParseFont(b)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing NotoSansDisplay-Medium font: %s", err)
|
||||
}
|
||||
fontBold = f
|
||||
}
|
||||
|
||||
labelFace = truetype.NewFace(fontBold, &truetype.Options{
|
||||
Size: 26,
|
||||
DPI: dpi,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
|
||||
valueFace = truetype.NewFace(fontLight, &truetype.Options{
|
||||
Size: 36,
|
||||
DPI: dpi,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
|
||||
smallLabelFace = truetype.NewFace(fontBold, &truetype.Options{
|
||||
Size: 20,
|
||||
DPI: dpi,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
|
||||
orgFace = truetype.NewFace(fontBold, &truetype.Options{
|
||||
Size: 16,
|
||||
DPI: dpi,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
|
||||
watermarkFace = truetype.NewFace(fontLight, &truetype.Options{
|
||||
Size: 14,
|
||||
DPI: dpi,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
}
|
||||
|
||||
func Record(w http.ResponseWriter, r *http.Request) {
|
||||
ipAddr, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
userAgent := r.UserAgent()
|
||||
language := r.Header.Get("Accept-Language")
|
||||
|
||||
ispInfo := r.FormValue("ispinfo")
|
||||
download := r.FormValue("dl")
|
||||
upload := r.FormValue("ul")
|
||||
ping := r.FormValue("ping")
|
||||
jitter := r.FormValue("jitter")
|
||||
logs := r.FormValue("log")
|
||||
extra := r.FormValue("extra")
|
||||
|
||||
if config.LoadedConfig().RedactIP {
|
||||
ipAddr = "0.0.0.0"
|
||||
ipv4Regex.ReplaceAllString(ispInfo, "0.0.0.0")
|
||||
ipv4Regex.ReplaceAllString(logs, "0.0.0.0")
|
||||
ipv6Regex.ReplaceAllString(ispInfo, "0.0.0.0")
|
||||
ipv6Regex.ReplaceAllString(logs, "0.0.0.0")
|
||||
hostnameRegex.ReplaceAllString(ispInfo, `"hostname":"REDACTED"`)
|
||||
hostnameRegex.ReplaceAllString(logs, `"hostname":"REDACTED"`)
|
||||
}
|
||||
|
||||
var record schema.TelemetryData
|
||||
record.IPAddress = ipAddr
|
||||
if ispInfo == "" {
|
||||
record.ISPInfo = "{}"
|
||||
} else {
|
||||
record.ISPInfo = ispInfo
|
||||
}
|
||||
record.Extra = extra
|
||||
record.UserAgent = userAgent
|
||||
record.Language = language
|
||||
record.Download = download
|
||||
record.Upload = upload
|
||||
record.Ping = ping
|
||||
record.Jitter = jitter
|
||||
record.Log = logs
|
||||
|
||||
t := time.Now()
|
||||
entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0)
|
||||
uuid := ulid.MustNew(ulid.Timestamp(t), entropy)
|
||||
record.UUID = uuid.String()
|
||||
|
||||
err := database.DB.Insert(&record)
|
||||
if err != nil {
|
||||
log.Errorf("Error inserting into database: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte("id " + uuid.String())); err != nil {
|
||||
log.Errorf("Error writing ID to telemetry request: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func DrawPNG(w http.ResponseWriter, r *http.Request) {
|
||||
uuid := r.FormValue("id")
|
||||
record, err := database.DB.FetchByUUID(uuid)
|
||||
if err != nil {
|
||||
log.Errorf("Error querying database: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var result Result
|
||||
if err := json.Unmarshal([]byte(record.ISPInfo), &result); err != nil {
|
||||
log.Errorf("Error parsing ISP info: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rectangle{
|
||||
Min: image.Point{},
|
||||
Max: image.Point{
|
||||
X: canvasWidth,
|
||||
Y: canvasHeight,
|
||||
},
|
||||
})
|
||||
|
||||
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
|
||||
drawer := &font.Drawer{
|
||||
Dst: canvas,
|
||||
Face: labelFace,
|
||||
}
|
||||
|
||||
drawer.Src = colorLabel
|
||||
|
||||
// labels
|
||||
p := drawer.MeasureString(labelPing)
|
||||
x := canvasWidth/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight/10)
|
||||
drawer.DrawString(labelPing)
|
||||
|
||||
p = drawer.MeasureString(labelJitter)
|
||||
x = canvasWidth*3/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight/10)
|
||||
drawer.DrawString(labelJitter)
|
||||
|
||||
p = drawer.MeasureString(labelDownload)
|
||||
x = canvasWidth/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight/2)
|
||||
drawer.DrawString(labelDownload)
|
||||
|
||||
p = drawer.MeasureString(labelUpload)
|
||||
x = canvasWidth*3/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight/2)
|
||||
drawer.DrawString(labelUpload)
|
||||
|
||||
drawer.Face = smallLabelFace
|
||||
drawer.Src = colorMeasure
|
||||
p = drawer.MeasureString(labelMbps)
|
||||
x = canvasWidth/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*8/10)
|
||||
drawer.DrawString(labelMbps)
|
||||
|
||||
p = drawer.MeasureString(labelMbps)
|
||||
x = canvasWidth*3/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*8/10)
|
||||
drawer.DrawString(labelMbps)
|
||||
|
||||
msLength := drawer.MeasureString(labelMS)
|
||||
|
||||
// ping value
|
||||
drawer.Face = valueFace
|
||||
pingValue := strings.Split(record.Ping, ".")[0]
|
||||
p = drawer.MeasureString(pingValue)
|
||||
|
||||
x = canvasWidth/4 - (p.Round()+msLength.Round())/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*11/40)
|
||||
drawer.Src = colorPing
|
||||
drawer.DrawString(pingValue)
|
||||
x = x + p.Round()
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*11/40)
|
||||
drawer.Src = colorMeasure
|
||||
drawer.Face = smallLabelFace
|
||||
drawer.DrawString(labelMS)
|
||||
|
||||
// jitter value
|
||||
drawer.Face = valueFace
|
||||
jitterValue := strings.Split(record.Jitter, ".")[0]
|
||||
p = drawer.MeasureString(jitterValue)
|
||||
x = canvasWidth*3/4 - (p.Round()+msLength.Round())/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*11/40)
|
||||
drawer.Src = colorJitter
|
||||
drawer.DrawString(jitterValue)
|
||||
drawer.Face = smallLabelFace
|
||||
x = x + p.Round()
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*11/40)
|
||||
drawer.Src = colorMeasure
|
||||
drawer.DrawString(labelMS)
|
||||
|
||||
// download value
|
||||
drawer.Face = valueFace
|
||||
p = drawer.MeasureString(record.Download)
|
||||
x = canvasWidth/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*27/40)
|
||||
drawer.Src = colorDownload
|
||||
drawer.DrawString(record.Download)
|
||||
|
||||
// upload value
|
||||
p = drawer.MeasureString(record.Upload)
|
||||
x = canvasWidth*3/4 - p.Round()/2
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight*27/40)
|
||||
drawer.Src = colorUpload
|
||||
drawer.DrawString(record.Upload)
|
||||
|
||||
// watermark
|
||||
ctx := freetype.NewContext()
|
||||
ctx.SetFont(fontLight)
|
||||
ctx.SetFontSize(14)
|
||||
ctx.SetDPI(dpi)
|
||||
ctx.SetHinting(font.HintingFull)
|
||||
|
||||
drawer.Face = watermarkFace
|
||||
drawer.Src = colorWatermark
|
||||
p = drawer.MeasureString(watermark)
|
||||
x = canvasWidth - p.Round() - 5
|
||||
drawer.Dot = freetype.Pt(x, canvasHeight-10)
|
||||
drawer.DrawString(watermark)
|
||||
|
||||
// separator
|
||||
for i := canvas.Bounds().Min.X; i < canvas.Bounds().Max.X; i++ {
|
||||
canvas.Set(i, canvasHeight-ctx.PointToFixed(14).Round()-10, colorSeparator)
|
||||
}
|
||||
|
||||
// ISP info
|
||||
drawer.Face = orgFace
|
||||
drawer.Src = colorISP
|
||||
drawer.Dot = freetype.Pt(6, canvasHeight-ctx.PointToFixed(14).Round()-15)
|
||||
if result.RawISPInfo.Organization != "" {
|
||||
removeRegexp := regexp.MustCompile(`AS\d+\s`)
|
||||
org := removeRegexp.ReplaceAllString(result.RawISPInfo.Organization, "")
|
||||
if result.RawISPInfo.Country != "" {
|
||||
org += ", " + result.RawISPInfo.Country
|
||||
}
|
||||
drawer.DrawString(org)
|
||||
} else {
|
||||
drawer.DrawString(result.ProcessedString)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", "inline; filename="+uuid+".png")
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
if err := png.Encode(w, canvas); err != nil {
|
||||
log.Errorf("Failed to output image to HTTP client: %s", err)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user