0
0
mirror of https://github.com/rls-moe/nyx synced 2024-11-13 22:12:24 +00:00

Improved stability, improved spam algorithm, fixed some bugs

This commit is contained in:
Tim Schuster 2017-03-15 09:13:15 +01:00
parent afd9ae71cf
commit 19d0e9282d
No known key found for this signature in database
GPG Key ID: F9E27097EFB77F61
37 changed files with 2099 additions and 255 deletions

View File

@ -1,21 +1,139 @@
package admin
import (
"encoding/json"
"fmt"
"github.com/tidwall/buntdb"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"net/http"
"strings"
"time"
)
func handleCleanup(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if sess == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
if sess.CAttr("mode") != "admin" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
fmt.Println("Beginning cleanup...")
db := middle.GetDB(r)
err := db.Update(func(tx *buntdb.Tx) error {
var delKeys = []string{}
err := db.View(func(tx *buntdb.Tx) error {
var err error
tx.AscendKeys("*", func(key, value string) bool {
keyType := detectType(key)
if keyType == "thread" {
var host string
host, err = resources.GetHostnameFromKey(key)
if err != nil {
fmt.Printf("Error: %s", err)
return false
}
var thread = &resources.Thread{}
err = json.Unmarshal([]byte(value), thread)
if err != nil {
fmt.Printf("Error: %s", err)
return false
}
threadTime := resources.DateFromId(thread.ID)
dur := threadTime.Sub(time.Now())
if dur > time.Hour*24*7 {
fmt.Printf("Sched %s for deletion: expired\n", key)
delKeys = append(delKeys, key)
return true
}
err = resources.FillReplies(tx, host, thread)
if err != nil {
fmt.Printf("Error: %s", err)
return false
}
if len(thread.GetReplies()) == 0 {
fmt.Printf("Sched %s for deletion: empty\n", key)
delKeys = append(delKeys, key)
return true
}
if _, err := resources.GetReply(tx, host, thread.Board, thread.ID, thread.StartReply); err == buntdb.ErrNotFound {
fmt.Printf("Sched %s for delection: main reply dead\n", key)
delKeys = append(delKeys, key)
return true
}
} else if keyType == "reply" {
var host string
host, err = resources.GetHostnameFromKey(key)
if err != nil {
fmt.Printf("Error: %s", err)
return false
}
var reply = &resources.Reply{}
err = json.Unmarshal([]byte(value), reply)
if err != nil {
fmt.Printf("Error: %s", err)
return false
}
replyTime := resources.DateFromId(reply.ID)
dur := replyTime.Sub(time.Now())
if dur > time.Hour*24*7 {
fmt.Printf("Sched %s for deletion: expired\n", key)
delKeys = append(delKeys, key)
return true
}
if val, ok := reply.Metadata["deleted"]; ok && val == "yes" {
fmt.Printf("Sched %s for deletion: deleted\n", key)
delKeys = append(delKeys, key)
return true
}
if err := resources.TestThread(tx, host, reply.Board, reply.Thread); err == buntdb.ErrNotFound {
fmt.Printf("Sched %s for deletion: missing parent %d: %s\n", key, reply.Thread, err)
delKeys = append(delKeys, key)
return true
}
}
return true
})
/* Insert cleanup codes here */
return err
})
fmt.Println("Removing sched' entries")
db.Update(func(tx *buntdb.Tx) error {
for _, v := range delKeys {
fmt.Printf("Deleting %s\n", v)
tx.Delete(v)
}
return nil
})
fmt.Println("Shrinking DB")
err = db.Shrink()
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
fmt.Println("Finished Cleanup")
http.Redirect(w, r, "/admin/panel.html", http.StatusSeeOther)
}
func detectType(key string) string {
if strings.Contains(key, "/jack/") {
if strings.HasSuffix(key, "/board-data") {
return "board"
}
if strings.HasSuffix(key, "/thread") {
return "thread"
}
if strings.HasSuffix(key, "/reply-data") {
return "reply"
}
return "system"
}
return "none"
}

74
http/admin/delpost.go Normal file
View File

@ -0,0 +1,74 @@
package admin
import (
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"strconv"
"go.rls.moe/nyx/resources"
"fmt"
"net/http"
"github.com/tidwall/buntdb"
)
func handleDelPost(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if sess == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
if sess.CAttr("mode") != "admin" && sess.CAttr("mode") != "mod" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
err := r.ParseForm()
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
rid, err := strconv.Atoi(r.FormValue("reply_id"))
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
trid, err := strconv.Atoi(r.FormValue("thread_id"))
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
board := r.FormValue("board")
if sess.CAttr("mode") == "mod" && sess.CAttr("board") != board {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Not on this board"))
return
}
db := middle.GetDB(r)
err = db.Update(func(tx *buntdb.Tx) error {
reply, err := resources.GetReply(tx, r.Host, board, trid, rid)
if err != nil {
return err
}
reply.Text = "[deleted]"
reply.Metadata["deleted"] = "yes"
reply.Image = nil
reply.Thumbnail = nil
err = resources.UpdateReply(tx, r.Host, board, reply)
if err != nil {
return err
}
return nil
})
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s/%d/thread.html", board, trid), http.StatusSeeOther)
}

View File

@ -2,17 +2,12 @@ package admin
import (
"bytes"
"fmt"
"github.com/GeertJohan/go.rice"
"github.com/icza/session"
"github.com/pressly/chi"
"github.com/tidwall/buntdb"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"html/template"
"net/http"
"strconv"
"time"
)
@ -27,8 +22,9 @@ var riceConf = rice.Config{
var box = riceConf.MustFindBox("http/admin/res/")
var (
panelTmpl = template.New("admin/panel")
loginTmpl = template.New("admin/login")
panelTmpl = template.New("admin/panel")
loginTmpl = template.New("admin/login")
statusTmpl = template.New("admin/status")
)
func init() {
@ -41,6 +37,10 @@ func init() {
if err != nil {
panic(err)
}
statusTmpl, err = statusTmpl.Parse(box.MustString("status.html"))
if err != nil {
panic(err)
}
}
// Router sets up the Administration Panel
@ -50,8 +50,12 @@ func AdminRouter(r chi.Router) {
r.Get("/index.html", serveLogin)
r.Get("/panel.html", servePanel)
r.Post("/new_board.sh", handleNewBoard)
r.Post("/cleanup.sh", handleCleanup)
r.Post("/login.sh", handleLogin)
r.Post("/logout.sh", handleLogout)
r.Post("/new_admin.sh", handleNewAdmin)
r.Post("/del_admin.sh", handleDelAdmin)
r.Get("/status.sh", serveStatus)
}
// Router sets up moderation functions
@ -60,69 +64,6 @@ func ModRouter(r chi.Router) {
r.Post("/del_reply.sh", handleDelPost)
}
func handleDelPost(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if sess == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
if sess.CAttr("mode") != "admin" && sess.CAttr("mode") != "mod" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
err := r.ParseForm()
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
rid, err := strconv.Atoi(r.FormValue("reply_id"))
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
trid, err := strconv.Atoi(r.FormValue("thread_id"))
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
board := r.FormValue("board")
if sess.CAttr("mode") == "mod" && sess.CAttr("board") != board {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Not on this board"))
return
}
db := middle.GetDB(r)
err = db.Update(func(tx *buntdb.Tx) error {
reply, err := resources.GetReply(tx, r.Host, board, trid, rid)
if err != nil {
return err
}
reply.Text = "[deleted]"
reply.Metadata["deleted"] = "yes"
reply.Image = nil
reply.Thumbnail = nil
err = resources.UpdateReply(tx, r.Host, board, reply)
if err != nil {
return err
}
return nil
})
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s/%d/thread.html", board, trid), http.StatusSeeOther)
}
func serveLogin(w http.ResponseWriter, r *http.Request) {
dat := bytes.NewBuffer([]byte{})
err := loginTmpl.Execute(dat, middle.GetBaseCtx(r))
@ -150,41 +91,3 @@ func servePanel(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "panel.html", time.Now(),
bytes.NewReader(dat.Bytes()))
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if sess == nil {
http.Redirect(w, r, "/admin/index.html", http.StatusSeeOther)
}
session.Remove(sess, w)
http.Redirect(w, r, "/admin/index.html", http.StatusSeeOther)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
errw.ErrorWriter(err, w, r)
}
db := middle.GetDB(r)
var admin = &resources.AdminPass{}
err = db.View(func(tx *buntdb.Tx) error {
var err error
admin, err = resources.GetAdmin(tx, r.FormValue("id"))
return err
})
if err != nil {
err = errw.MakeErrorWithTitle("Access Denied", "User or Password Invalid")
errw.ErrorWriter(err, w, r)
}
err = admin.VerifyLogin(r.FormValue("pass"))
if err != nil {
err = errw.MakeErrorWithTitle("Access Denied", "User or Password Invalid")
errw.ErrorWriter(err, w, r)
}
sess := session.NewSessionOptions(&session.SessOptions{
CAttrs: map[string]interface{}{"mode": "admin"},
})
session.Add(sess, w)
http.Redirect(w, r, "/admin/panel.html", http.StatusSeeOther)
}

50
http/admin/login.go Normal file
View File

@ -0,0 +1,50 @@
package admin
import (
"github.com/icza/session"
"github.com/tidwall/buntdb"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"net/http"
)
func handleLogout(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if sess == nil {
http.Redirect(w, r, "/admin/index.html", http.StatusSeeOther)
}
session.Remove(sess, w)
http.Redirect(w, r, "/admin/index.html", http.StatusSeeOther)
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
errw.ErrorWriter(err, w, r)
}
db := middle.GetDB(r)
var admin = &resources.AdminPass{}
err = db.View(func(tx *buntdb.Tx) error {
var err error
admin, err = resources.GetAdmin(tx, r.FormValue("id"))
return err
})
if err != nil {
err = errw.MakeErrorWithTitle("Access Denied", "User or Password Invalid")
errw.ErrorWriter(err, w, r)
return
}
err = admin.VerifyLogin(r.FormValue("pass"))
if err != nil {
err = errw.MakeErrorWithTitle("Access Denied", "User or Password Invalid")
errw.ErrorWriter(err, w, r)
return
}
sess := session.NewSessionOptions(&session.SessOptions{
CAttrs: map[string]interface{}{"mode": "admin"},
})
session.Add(sess, w)
http.Redirect(w, r, "/admin/panel.html", http.StatusSeeOther)
}

92
http/admin/newadmin.go Normal file
View File

@ -0,0 +1,92 @@
package admin
import (
"github.com/tidwall/buntdb"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"net/http"
)
func handleDelAdmin(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if !middle.IsAdminSession(sess) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
err := r.ParseForm()
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
db := middle.GetDB(r)
adminID := r.FormValue("adminid")
if len(adminID) > 255 {
errw.ErrorWriter(errw.MakeErrorWithTitle("Too long", "The ID of the administrator is too long"), w, r)
return
}
if len(adminID) < 4 {
errw.ErrorWriter(errw.MakeErrorWithTitle("Too short", "The ID of the administrator is too short"), w, r)
return
}
if err = db.Update(func(tx *buntdb.Tx) error {
return resources.DelAdmin(tx, adminID)
}); err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.Redirect(w, r, "/admin/panel.html", http.StatusSeeOther)
}
func handleNewAdmin(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if !middle.IsAdminSession(sess) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
err := r.ParseForm()
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
db := middle.GetDB(r)
var admin = &resources.AdminPass{}
admin.ID = r.FormValue("adminid")
if len(admin.ID) > 255 {
errw.ErrorWriter(errw.MakeErrorWithTitle("Too long", "The ID of the administrator is too long"), w, r)
return
}
if len(admin.ID) < 4 {
errw.ErrorWriter(errw.MakeErrorWithTitle("Too short", "The ID of the administrator is too short"), w, r)
return
}
if len(r.FormValue("adminpass")) > 255 {
errw.ErrorWriter(errw.MakeErrorWithTitle("Too long", "The Password of the administrator is too long"), w, r)
return
}
if len(r.FormValue("adminpass")) < 12 {
errw.ErrorWriter(errw.MakeErrorWithTitle("Too short", "The Password of the administrator is too short"), w, r)
return
}
admin.HashLogin(r.FormValue("adminpass"))
if err = db.Update(func(tx *buntdb.Tx) error {
return resources.NewAdmin(tx, admin)
}); err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.Redirect(w, r, "/admin/panel.html", http.StatusSeeOther)
}

View File

@ -11,12 +11,7 @@ import (
func handleNewBoard(w http.ResponseWriter, r *http.Request) {
sess := middle.GetSession(r)
if sess == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
if sess.CAttr("mode") != "admin" {
if !middle.IsAdminSession(sess) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return

View File

@ -19,20 +19,53 @@
<input type="submit" value="Logout" />
</form>
</div>
<div class="panel boardmgr">
<form method="POST" action="/admin/new_board.sh">
<input
type="hidden"
name="csrf_token"
value="{{ .CSRFToken }}" />
<input type="text" placeholder="shortname" name="shortname"/>
<input type="text" placeholder="longname" name="longname"/>
<input type="submit" value="Create Board" />
<input type="reset" value="Reset" />
<br clear="left" /><hr />
<div class="postarea">
<form id="postform1" action="/admin/new_board.sh" method="POST">
<table>
<tbody>
<tr>
<td class="postblock">
Action
</td>
<td>
New Board
<input
type="hidden"
name="csrf_token"
value="{{ .CSRFToken }}" />
</td>
</tr>
<tr>
<td class="postblock">
Short Name
</td>
<td>
<input type="text" placeholder="shortname" name="shortname"/>
</td>
</tr>
<tr>
<td class="postblock">
Long Name
</td>
<td>
<input type="text" placeholder="longname" name="longname"/>
</td>
</tr>
<tr>
<td class="postblock"></td>
<td>
<input type="submit" value="Create Board" />
<input type="reset" value="Reset" />
</td>
</tr>
</tbody>
</table>
</form>
</div>
<br clear="left" /><hr />
<div class="postarea">
<form id="postform1" action="/admin/new_admin.sh" method="POST">
<form id="postform2" action="/admin/new_admin.sh" method="POST">
<table>
<tbody>
<tr>
@ -53,8 +86,10 @@
</td>
<td>
<input type="text"
minlength="8"
name="id" />
minlength="4"
maxlength="255"
name="adminid"
required />
</td>
</tr>
<tr>
@ -65,7 +100,83 @@
<input type="password"
minlength="12"
maxlength="255"
name="id" />
name="adminpass"
required />
</td>
</tr>
<tr>
<td class="postblock"></td>
<td>
<input type="submit" value="Create Admin ID" />
<input type="reset" value="Reset" />
</td>
</tr>
</tbody>
</table>
</form>
</div>
<br clear="left" /><hr />
<div class="postarea">
<form id="postform3" action="/admin/del_admin.sh" method="POST">
<table>
<tbody>
<tr>
<td class="postblock">
Action
</td>
<td>
Delete Administrator
<input
type="hidden"
name="csrf_token"
value="{{ .CSRFToken }}" />
</td>
</tr>
<tr>
<td class="postblock">
ID
</td>
<td>
<input type="text"
minlength="4"
maxlength="255"
name="adminid"
required />
</td>
</tr>
<tr>
<td class="postblock"></td>
<td>
<input type="submit" value="Delete Admin ID" />
<input type="reset" value="Reset" />
</td>
</tr>
</tbody>
</table>
</form>
</div>
<br clear="left" /><hr />
<div class="postarea">
<form id="postform4" action="/admin/cleanup.sh" method="POST">
<table>
<tbody>
<tr>
<td class="postblock">
Action
</td>
<td>
Cleanup Database
<input
type="hidden"
name="csrf_token"
value="{{ .CSRFToken }}" />
</td>
</tr>
<tr>
<td class="postblock"></td>
<td>
<input type="submit" value="Start Cleanup" />
<input type="reset" value="Reset" />
</td>
</tr>
</tbody>

125
http/admin/res/status.html Normal file
View File

@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Config.Site.Title}} Status</title>
<link rel="stylesheet" href="/@/style.css">
<link rel="stylesheet" href="/@/custom.css">
<link rel="stylesheet" href="/@/admin.css">
</head>
<body>
<div class="banner logo">
<h2>Runtime Statistics</h2>
</div>
<br/>
<br/>
<div class="postarea">
<form id="postform1" action="/admin/new_board.sh" method="POST">
<table>
<tbody>
<tr><td class="postblock">
Start Time
</td><td>
{{.Uptime.human}}
</td></tr>
<tr><td class="postblock">
Uptime (Precise)
</td><td>
{{ .Uptime.hours | printf "%.4f"}} hours<br/>
{{ .Uptime.seconds | printf "%.4f"}} seconds
</td></tr>
<tr><td class="postblock">
Num. CPU
</td><td>
{{.GC.numcpu}}
</td></tr>
<tr><td class="postblock">
Num. GoRoutines
</td><td>
{{.GC.numgor}}
</td></tr>
<tr><td class="postblock">
Go Version<br/>
Arch/OS/Compiler
</td><td>
{{.GC.version}}<br/>
{{.GC.arch}} / {{.GC.os}} / {{.GC.compiler}}
</td></tr>
<tr><td class="postblock">
Current Allocs
</td><td>
{{.GC.memory.alloc}}
</td></tr>
<tr><td class="postblock">
Cumulative Allocs
</td><td>
{{.GC.memory.calloc}}
</td></tr>
<tr><td class="postblock">
Used Sys Memory
</td><td>
{{.GC.memory.sysmem}}
</td></tr>
<tr><td class="postblock">
Pointer Lookups
</td><td>
{{.GC.memory.lookups}}
</td></tr>
<tr><td class="postblock">
MAllocs
</td><td>
{{.GC.memory.mallocs}}
</td></tr>
<tr><td class="postblock">
MFrees
</td><td>
{{.GC.memory.frees}}
</td></tr>
<tr><td class="postblock">
Live Objects
</td><td>
{{.GC.memory.liveobj}}
</td></tr>
<tr><td class="postblock">
Heap Allocated
</td><td>
{{.GC.memory.heapalloc}}
</td></tr>
<tr><td class="postblock">
Heap Released
</td><td>
{{.GC.memory.heaprelease}}
</td></tr>
<tr><td class="postblock">
GC Metadata
</td><td>
{{.GC.memory.gcmeta}}
</td></tr>
<tr><td class="postblock">
GC Pause
</td><td>
{{.GC.memory.pause}}
</td></tr>
<tr><td class="postblock">
GC Invokations
</td><td>
{{.GC.memory.gctimes}}
</td></tr>
<tr><td class="postblock">
GC Forced
</td><td>
{{.GC.memory.fgctimes}}
</td></tr>
<tr><td class="postblock">
GC CPU Usage
</td><td>
{{.GC.memory.cpufrac}}
</td></tr>
</tbody>
</table>
</form>
</div>
<br clear="left" /><hr />
</body>
</html>

87
http/admin/status.go Normal file
View File

@ -0,0 +1,87 @@
package admin
import (
"bytes"
"fmt"
"github.com/dustin/go-humanize"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"net/http"
"runtime"
"sync"
"time"
)
var memStat = map[string]interface{}{}
var memStatLock = new(sync.RWMutex)
var startTime = time.Now().UTC()
func init() {
go func() {
update()
ticker := time.Tick(time.Second * 10)
for _ = range ticker {
update()
}
}()
}
func update() {
memStatLock.Lock()
defer memStatLock.Unlock()
memStat["Uptime"] = uptime()
memStat["GC"] = gcStat()
}
func uptime() map[string]interface{} {
return map[string]interface{}{
"human": humanize.Time(startTime),
"hours": time.Now().Sub(startTime).Hours(),
"seconds": time.Now().Sub(startTime).Seconds(),
}
}
func gcStat() map[string]interface{} {
m := &runtime.MemStats{}
runtime.ReadMemStats(m)
mem := map[string]interface{}{}
mem["alloc"] = fmt.Sprintf("%.5f GiB", float64(m.Alloc)/1024/1024/1024)
mem["calloc"] = fmt.Sprintf("%.5f GiB", float64(m.TotalAlloc)/1024/1024/1024)
mem["sysmem"] = fmt.Sprintf("%.5f MiB", float64(m.Sys)/1024/1024)
mem["lookups"] = fmt.Sprintf("× %d", m.Lookups)
mem["mallocs"] = fmt.Sprintf("× %d", m.Mallocs)
mem["frees"] = fmt.Sprintf("× %d", m.Frees)
mem["liveobj"] = fmt.Sprintf("× %d", m.Mallocs-m.Frees)
mem["heapalloc"] = fmt.Sprintf("%.5f MiB", float64(m.HeapSys)/1024/1024)
mem["heaprelease"] = fmt.Sprintf("%.5f MiB", float64(m.HeapReleased)/1024/1024)
mem["gcmeta"] = fmt.Sprintf("%.5f MiB", float64(m.GCSys)/1024/1024)
mem["pause"] = fmt.Sprintf("%.5f min", float64(m.PauseTotalNs)/1000/1000/1000/60)
mem["gctimes"] = fmt.Sprintf("× %d", m.NumGC)
mem["fgctimes"] = fmt.Sprintf("× %d", m.NumForcedGC)
mem["cpufrac"] = fmt.Sprintf("%.5f %%", m.GCCPUFraction*100)
return map[string]interface{}{
"numcpu": runtime.NumCPU(),
"numgor": runtime.NumGoroutine(),
"version": runtime.Version(),
"arch": runtime.GOARCH,
"os": runtime.GOOS,
"compiler": runtime.Compiler,
"memory": mem,
}
}
func serveStatus(w http.ResponseWriter, r *http.Request) {
memStatLock.RLock()
defer memStatLock.RUnlock()
ctx := middle.GetBaseCtx(r)
ctx["Uptime"] = memStat["Uptime"]
ctx["GC"] = memStat["GC"]
dat := bytes.NewBuffer([]byte{})
err := statusTmpl.Execute(dat, ctx)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.ServeContent(w, r, "panel.html", time.Now(),
bytes.NewReader(dat.Bytes()))
}

View File

@ -18,18 +18,21 @@ func serveBoard(w http.ResponseWriter, r *http.Request) {
ctx := middle.GetBaseCtx(r)
err := db.View(func(tx *buntdb.Tx) error {
bName := chi.URLParam(r, "board")
log.Println("Getting board")
b, err := resources.GetBoard(tx, r.Host, bName)
if err != nil {
return err
}
ctx["Board"] = b
log.Println("Listing Threads...")
threads, err := resources.ListThreads(tx, r.Host, bName)
if err != nil {
return err
}
log.Println("Number of Thread on board: ", len(threads))
log.Println("Filling threads")
for k := range threads {
err := resources.FillReplies(tx, r.Host, threads[k])
if err != nil {

View File

@ -27,9 +27,6 @@ var box = riceConf.MustFindBox("http/board/res/")
var (
tmpls = template.New("base")
//dirTmpl = template.New("board/dir")
//boardTmpl = template.New("board/board")
//threadTmpl = template.New("board/thread")
hdlFMap = template.FuncMap{
"renderText": resources.OperateReplyText,
@ -53,6 +50,10 @@ var (
"formatDate": func(date time.Time) string {
return date.Format("02 Jan 06 15:04:05")
},
"isAdminSession": middle.IsAdminSession,
"isModSession": middle.IsModSession,
"captchaProb": resources.CaptchaProb,
"percentFloat": func(in float64) float64 { return in * 100 },
}
)
@ -93,6 +94,7 @@ func Router(r chi.Router) {
func serveThumb(w http.ResponseWriter, r *http.Request) {
dat := bytes.NewBuffer([]byte{})
var date time.Time
db := middle.GetDB(r)
err := db.View(func(tx *buntdb.Tx) error {
bName := chi.URLParam(r, "board")
@ -113,17 +115,19 @@ func serveThumb(w http.ResponseWriter, r *http.Request) {
if err != nil {
return err
}
date = resources.DateFromId(reply.ID)
return nil
})
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.ServeContent(w, r, "thumb.png", time.Now(), bytes.NewReader(dat.Bytes()))
http.ServeContent(w, r, "thumb.png", date, bytes.NewReader(dat.Bytes()))
}
func serveFullImage(w http.ResponseWriter, r *http.Request) {
dat := bytes.NewBuffer([]byte{})
var date time.Time
db := middle.GetDB(r)
err := db.View(func(tx *buntdb.Tx) error {
bName := chi.URLParam(r, "board")
@ -144,13 +148,14 @@ func serveFullImage(w http.ResponseWriter, r *http.Request) {
if err != nil {
return err
}
date = resources.DateFromId(reply.ID)
return nil
})
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
http.ServeContent(w, r, "image.png", time.Now(), bytes.NewReader(dat.Bytes()))
http.ServeContent(w, r, "image.png", date, bytes.NewReader(dat.Bytes()))
}
func serveDir(w http.ResponseWriter, r *http.Request) {

61
http/board/imageparser.go Normal file
View File

@ -0,0 +1,61 @@
package board
import (
"bytes"
"github.com/nfnt/resize"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/resources"
"image"
"image/png"
"io"
"log"
"mime/multipart"
"net/http"
)
func parseImage(reply *resources.Reply, file multipart.File, hdr *multipart.FileHeader, err error) error {
if err != nil && err != http.ErrMissingFile {
return err
}
if err == http.ErrMissingFile {
return nil
}
cfg, _, err := image.DecodeConfig(file)
if err != nil {
return err
}
if cfg.Height > 8000 || cfg.Width > 8000 {
log.Println("Somebody tried to detonate the memory!")
return errw.MakeErrorWithTitle("Too large", "Your upload was too large")
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return err
}
img, _, err := image.Decode(file)
if err != nil {
return err
}
if img.Bounds().Dx() > 8000 || img.Bounds().Dy() > 8000 {
log.Println("Somebody tried to detonate the memory!")
return errw.MakeErrorWithTitle("Too large", "Your upload was too large")
}
thumb := resize.Thumbnail(128, 128, img, resize.Lanczos3)
imgBuf := bytes.NewBuffer([]byte{})
err = png.Encode(imgBuf, thumb)
if err != nil {
return err
}
log.Printf("Thumb has size %d KiB", imgBuf.Len()/1024)
reply.Thumbnail = make([]byte, imgBuf.Len())
copy(reply.Thumbnail, imgBuf.Bytes())
imgBuf.Reset()
err = png.Encode(imgBuf, img)
if err != nil {
return err
}
log.Printf("Image has size %d KiB", imgBuf.Len()/1024)
reply.Image = make([]byte, imgBuf.Len())
copy(reply.Image, imgBuf.Bytes())
return nil
}

View File

@ -1,20 +1,15 @@
package board
import (
"bytes"
"fmt"
"github.com/nfnt/resize"
"github.com/pressly/chi"
"github.com/tidwall/buntdb"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"net/http"
"strconv"
)
func handleNewReply(w http.ResponseWriter, r *http.Request) {
@ -23,7 +18,7 @@ func handleNewReply(w http.ResponseWriter, r *http.Request) {
errw.ErrorWriter(err, w, r)
return
}
err = r.ParseMultipartForm(10 * 1024 * 1024)
err = r.ParseMultipartForm(4 * 1024 * 1024)
if err != nil {
errw.ErrorWriter(err, w, r)
return
@ -39,66 +34,16 @@ func handleNewReply(w http.ResponseWriter, r *http.Request) {
var reply = &resources.Reply{}
reply.Board = chi.URLParam(r, "board")
tid, err := strconv.Atoi(chi.URLParam(r, "thread"))
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
reply.Thread = tid
reply.Text = r.FormValue("text")
if len(reply.Text) > 10000 {
errw.ErrorWriter(errw.MakeErrorWithTitle("I'm sorry but I can't do that", "These are too many characters"), w, r)
return
}
if len(reply.Text) < 5 {
errw.ErrorWriter(errw.MakeErrorWithTitle("I'm sorry but I can't do that", "These are not enough characters"), w, r)
return
}
if score, err := resources.SpamScore(reply.Text); err != nil || !resources.CaptchaPass(score) {
err = parseReply(r, reply)
if err == trollThrottle {
http.Redirect(w, r,
fmt.Sprintf("/%s/%s/thread.html?err=trollthrottle",
chi.URLParam(r, "board"), chi.URLParam(r, "thread")),
http.StatusSeeOther)
return
}
{
file, _, err := r.FormFile("image")
if err != nil && err != http.ErrMissingFile {
errw.ErrorWriter(err, w, r)
return
} else if err != http.ErrMissingFile {
img, _, err := image.Decode(file)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
thumb := resize.Thumbnail(128, 128, img, resize.Lanczos3)
imgBuf := bytes.NewBuffer([]byte{})
err = png.Encode(imgBuf, img)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
fmt.Println("Image has size ", len(imgBuf.Bytes()))
reply.Image = imgBuf.Bytes()
imgBuf = bytes.NewBuffer([]byte{})
err = png.Encode(imgBuf, thumb)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
reply.Thumbnail = imgBuf.Bytes()
}
}
reply.Metadata = map[string]string{}
if r.FormValue("tripcode") != "" {
reply.Metadata["trip"] = resources.CalcTripCode(r.FormValue("tripcode"))
} else {
reply.Metadata["trip"] = "Anonymous"
} else if err != nil {
errw.ErrorWriter(err, w, r)
return
}
db := middle.GetDB(r)

View File

@ -1,16 +1,12 @@
package board
import (
"bytes"
"fmt"
"github.com/nfnt/resize"
"github.com/pressly/chi"
"github.com/tidwall/buntdb"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"image"
"image/png"
"net/http"
)
@ -37,59 +33,18 @@ func handleNewThread(w http.ResponseWriter, r *http.Request) {
var thread = &resources.Thread{}
var mainReply = &resources.Reply{}
mainReply.Board = chi.URLParam(r, "board")
thread.Board = chi.URLParam(r, "board")
mainReply.Text = r.FormValue("text")
if len(mainReply.Text) > 10000 {
errw.ErrorWriter(errw.MakeErrorWithTitle("I'm sorry but I can't do that", "These are too many characters"), w, r)
return
}
if len(mainReply.Text) < 5 {
errw.ErrorWriter(errw.MakeErrorWithTitle("I'm sorry but I can't do that", "These are not enough characters"), w, r)
return
}
if score, err := resources.SpamScore(mainReply.Text); err != nil || !resources.CaptchaPass(score) {
err = parseReply(r, mainReply)
if err == trollThrottle {
http.Redirect(w, r,
fmt.Sprintf("/%s/board.html?err=trollthrottle",
chi.URLParam(r, "board")),
http.StatusSeeOther)
return
}
{
file, _, err := r.FormFile("image")
if err != nil && err != http.ErrMissingFile {
errw.ErrorWriter(err, w, r)
return
} else if err != http.ErrMissingFile {
img, _, err := image.Decode(file)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
thumb := resize.Thumbnail(128, 128, img, resize.Lanczos3)
imgBuf := bytes.NewBuffer([]byte{})
err = png.Encode(imgBuf, img)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
fmt.Println("Image has size ", len(imgBuf.Bytes()))
mainReply.Image = imgBuf.Bytes()
imgBuf = bytes.NewBuffer([]byte{})
err = png.Encode(imgBuf, thumb)
if err != nil {
errw.ErrorWriter(err, w, r)
return
}
mainReply.Thumbnail = imgBuf.Bytes()
}
}
mainReply.Metadata = map[string]string{}
if r.FormValue("tripcode") != "" {
mainReply.Metadata["trip"] = resources.CalcTripCode(r.FormValue("tripcode"))
} else if err != nil {
errw.ErrorWriter(err, w, r)
return
}
db := middle.GetDB(r)

72
http/board/replyparser.go Normal file
View File

@ -0,0 +1,72 @@
package board
import (
"errors"
"fmt"
"github.com/pressly/chi"
"go.rls.moe/nyx/http/errw"
"go.rls.moe/nyx/http/middle"
"go.rls.moe/nyx/resources"
"net/http"
"strconv"
)
var trollThrottle = errors.New("Troll throttle")
func parseReply(r *http.Request, reply *resources.Reply) error {
reply.Board = chi.URLParam(r, "board")
reply.Text = r.FormValue("text")
if tidStr := chi.URLParam(r, "thread"); tidStr != "" {
var err error
reply.Thread, err = strconv.Atoi(tidStr)
if err != nil {
return err
}
}
if len(reply.Text) > 10000 {
return errw.MakeErrorWithTitle(
"I'm sorry but I can't do that",
"There are too many characters")
}
if len(reply.Text) < 5 {
return errw.MakeErrorWithTitle(
"I'm sorry but I can't do that",
"There are not enough characters")
}
reply.Metadata = map[string]string{}
spamScore, err := resources.SpamScore(reply.Text)
if err != nil {
return err
}
reply.Metadata["spamscore"] = fmt.Sprintf("%.6f", spamScore)
reply.Metadata["captchaprob"] = fmt.Sprintf("%.2f", resources.CaptchaProb(spamScore)*100)
if !resources.CaptchaPass(spamScore) {
return trollThrottle
}
file, hdr, err := r.FormFile("image")
err = parseImage(reply, file, hdr, err)
if err != nil {
return err
}
if r.FormValue("tripcode") != "" {
reply.Metadata["trip"] = resources.CalcTripCode(r.FormValue("tripcode"))
}
if middle.IsModSession(middle.GetSession(r)) {
if r.FormValue("modpost") != "" {
reply.Metadata["modpost"] = "yes"
}
if middle.IsAdminSession(middle.GetSession(r)) {
if r.FormValue("adminpost") != "" {
reply.Metadata["adminpost"] = "yes"
}
}
}
return nil
}

View File

@ -78,6 +78,23 @@
</td>
</tr>
{{ end }}
{{ if (isModSession .Session) }}
<tr>
<td class="postblock">
Mod Post
</td>
<td>
<label>
<input type="checkbox" name="modpost"/>Mark as Mod Post
</label>
{{ if (isAdminSession .Session) }}
<label>
<input type="checkbox" name="adminpost"/>Mark as Admin Post
</label>
{{ end }}
</td>
</tr>
{{ end }}
<tr>
<td class="postblock">
@ -100,6 +117,12 @@
{{ else }}
Anonymous
{{ end }}
{{ if .Reply.Metadata.modpost }}
(Mod)
{{ end }}
{{ if .Reply.Metadata.adminpost }}
[Admin]
{{ end }}
</span></label>
<span class="date">{{dateFromID .Reply.ID | formatDate}}</span>
{{ if .Session }}
@ -126,7 +149,15 @@
{{ end }}
{{ end }}
<span>
{{printf "[SpamScore: %f]" (rateSpam .Reply.Text) }}
{{ if not .Reply.Metadata.spamscore }}
{{ $score := (rateSpam .Reply.Text) }}
{{printf "[SpamScore: %f]" $score }}
{{printf "[Captcha: %.3f%%]" (percentFloat (captchaProb $score)) }}
{{printf "[OLD]"}}
{{ else }}
{{ printf "[SpamScore: %s]" .Reply.Metadata.spamscore }}
{{ printf "[Captcha: %s %%]" .Reply.Metadata.captchaprob }}
{{ end }}
</span>
<span class="reflink">
<a href="/{{.Boardlink}}/{{.ThreadID}}/thread.html">No.{{.Reply.ID}}</a>

15
http/middle/limitsize.go Normal file
View File

@ -0,0 +1,15 @@
package middle
import (
"go.rls.moe/nyx/config"
"net/http"
)
func LimitSize(c *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
next.ServeHTTP(w, r)
})
}
}

View File

@ -13,3 +13,26 @@ func init() {
func GetSession(r *http.Request) session.Session {
return session.Get(r)
}
func IsAdminSession(sess session.Session) bool {
if sess == nil {
return false
}
if sess.CAttr("mode") == "admin" {
return true
}
return false
}
func IsModSession(sess session.Session) bool {
if sess == nil {
return false
}
if IsAdminSession(sess) {
return true
}
if sess.CAttr("mode") == "mod" {
return true
}
return false
}

View File

@ -10,6 +10,7 @@ import (
"go.rls.moe/nyx/http/board"
"go.rls.moe/nyx/http/middle"
"net/http"
"time"
)
var riceConf = rice.Config{
@ -27,6 +28,7 @@ func Start(config *config.Config) {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.CloseNotify)
r.Use(middle.LimitSize(config))
r.Use(middleware.DefaultCompress)
r.Use(middle.ConfigCtx(config))
@ -50,5 +52,13 @@ func Start(config *config.Config) {
r.Group(board.Router)
fmt.Println("Setup Complete, Starting Web Server...")
http.ListenAndServe(config.ListenOn, r)
srv := &http.Server{
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
Handler: r,
Addr: config.ListenOn,
MaxHeaderBytes: 1 * 1024 * 1024,
}
srv.ListenAndServe()
}

View File

@ -19,8 +19,19 @@ func (a *AdminPass) HashLogin(pass string) error {
return err
}
func (a *AdminPass) VerifyLogin(pass string) error {
var err error
func (a *AdminPass) VerifyLogin(pass string) (err error) {
defer func() {
if r := recover(); r != nil {
var ok bool
err, ok = r.(error)
if !ok {
err = fmt.Errorf("pkg: %v", r)
}
}
}()
if a == nil {
return errors.New("no login")
}
err = passlib.VerifyNoUpgrade(pass, a.Password)
return err
}

View File

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"github.com/tidwall/buntdb"
"regexp"
"strings"
)
@ -19,6 +20,16 @@ const (
adminPassPath = "/jack/./pass/admin/%s/admin-data"
)
func GetHostnameFromKey(key string) (string, error) {
regex := regexp.MustCompile(`^/jack/(.+)/(board|pass)`)
res := regex.FindStringSubmatch(key)
if len(res) != 3 {
fmt.Printf("Found %d keys: %s", len(res), res)
return "", errors.New("Could not find host in key")
}
return unescapeString(res[1]), nil
}
func InitialSetup(db *buntdb.DB) error {
return db.Update(func(tx *buntdb.Tx) error {
if _, err := tx.Get(setup); err != nil {
@ -112,3 +123,17 @@ func escapeString(in string) string {
in = strings.Replace(in, "<", ".arrow-right.", -1)
return in
}
func unescapeString(in string) string {
in = strings.Replace(in, ".arrow-right.", "<", -1)
in = strings.Replace(in, ".arrow-left.", ">", -1)
in = strings.Replace(in, ".quote.", ">>", -1)
in = strings.Replace(in, ".at.", "@", -1)
in = strings.Replace(in, ".slash.", "/", -1)
in = strings.Replace(in, ".ask.", "?", -1)
in = strings.Replace(in, ".star.", "*", -1)
in = strings.Replace(in, ".backslash.", "\\", -1)
in = strings.Replace(in, ".minus.", "-", -1)
in = strings.Replace(in, ".dot.", ".", -1)
return in
}

View File

@ -2,9 +2,9 @@ package resources
import (
"compress/flate"
"fmt"
"html/template"
"io"
"log"
"math"
"math/rand"
"strings"
@ -34,6 +34,7 @@ var (
"nazi",
"beemovie",
"bee movie",
"honey",
}
)
@ -56,7 +57,13 @@ func SpamScore(spam string) (float64, error) {
blScore += float64(strings.Count(spam, v))
}
score := float64(len(spam)) / float64(counter.p)
lines := strings.Count(spam, "\n")
if lines == 0 {
lines = 1
}
score := float64(len(spam)*lines) / float64(counter.p)
return (score * blScore) / 100, nil
}
@ -70,15 +77,19 @@ func (b *byteCounter) Write(p []byte) (n int, err error) {
return len(p), nil
}
func CaptchaPass(spamScore float64) bool {
chance := math.Max(
func CaptchaProb(spamScore float64) float64 {
return math.Max(
passScoreLimitMin,
math.Min(
passScoreReactive*math.Atan(
passScoreAggressive*spamScore,
),
passScoreLimitMax))
}
func CaptchaPass(spamScore float64) bool {
chance := CaptchaProb(spamScore)
take := rand.Float64()
fmt.Printf("Chance: %f, Take %f", chance, take)
log.Printf("Chance: %f, Take %f\n", chance, take)
return take > chance
}

View File

@ -91,6 +91,21 @@ func GetThread(tx *buntdb.Tx, host, board string, id int) (*Thread, error) {
}
ret.intReply, err = GetReply(tx, host, board, id, ret.StartReply)
if err != nil && err == buntdb.ErrNotFound {
ret.intReply = &Reply{
Board: ret.Board,
Thread: ret.ID,
ID: -1,
Image: nil,
Thumbnail: nil,
Metadata: map[string]string{
"deleted": "not found",
},
Text: "[not found]",
}
} else if err != nil {
return nil, err
}
return ret, nil
}
@ -132,7 +147,22 @@ func ListThreads(tx *buntdb.Tx, host, board string) ([]*Thread, error) {
}
thread.intReply, err = GetReply(tx, host, board, thread.ID, thread.StartReply)
if err != nil {
return false
if err == buntdb.ErrNotFound {
err = nil
thread.intReply = &Reply{
Board: thread.Board,
Thread: thread.ID,
ID: -1,
Image: nil,
Thumbnail: nil,
Metadata: map[string]string{
"deleted": "not found",
},
Text: "[not found]",
}
} else {
return false
}
}
threadList = append(threadList, thread)

21
vendor/github.com/dustin/go-humanize/LICENSE generated vendored Normal file
View File

@ -0,0 +1,21 @@
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<http://www.opensource.org/licenses/mit-license.php>

92
vendor/github.com/dustin/go-humanize/README.markdown generated vendored Normal file
View File

@ -0,0 +1,92 @@
# Humane Units [![Build Status](https://travis-ci.org/dustin/go-humanize.svg?branch=master)](https://travis-ci.org/dustin/go-humanize) [![GoDoc](https://godoc.org/github.com/dustin/go-humanize?status.svg)](https://godoc.org/github.com/dustin/go-humanize)
Just a few functions for helping humanize times and sizes.
`go get` it as `github.com/dustin/go-humanize`, import it as
`"github.com/dustin/go-humanize"`, use it as `humanize`.
See [godoc](https://godoc.org/github.com/dustin/go-humanize) for
complete documentation.
## Sizes
This lets you take numbers like `82854982` and convert them to useful
strings like, `83 MB` or `79 MiB` (whichever you prefer).
Example:
```go
fmt.Printf("That file is %s.", humanize.Bytes(82854982)) // That file is 83 MB.
```
## Times
This lets you take a `time.Time` and spit it out in relative terms.
For example, `12 seconds ago` or `3 days from now`.
Example:
```go
fmt.Printf("This was touched %s.", humanize.Time(someTimeInstance)) // This was touched 7 hours ago.
```
Thanks to Kyle Lemons for the time implementation from an IRC
conversation one day. It's pretty neat.
## Ordinals
From a [mailing list discussion][odisc] where a user wanted to be able
to label ordinals.
0 -> 0th
1 -> 1st
2 -> 2nd
3 -> 3rd
4 -> 4th
[...]
Example:
```go
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193)) // You are my 193rd best friend.
```
## Commas
Want to shove commas into numbers? Be my guest.
0 -> 0
100 -> 100
1000 -> 1,000
1000000000 -> 1,000,000,000
-100000 -> -100,000
Example:
```go
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491)) // You owe $6,582,491.
```
## Ftoa
Nicer float64 formatter that removes trailing zeros.
```go
fmt.Printf("%f", 2.24) // 2.240000
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
fmt.Printf("%f", 2.0) // 2.000000
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
```
## SI notation
Format numbers with [SI notation][sinotation].
Example:
```go
humanize.SI(0.00000000223, "M") // 2.23 nM
```
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix

31
vendor/github.com/dustin/go-humanize/big.go generated vendored Normal file
View File

@ -0,0 +1,31 @@
package humanize
import (
"math/big"
)
// order of magnitude (to a max order)
func oomm(n, b *big.Int, maxmag int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
if mag == maxmag && maxmag >= 0 {
break
}
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}
// total order of magnitude
// (same as above, but with no upper limit)
func oom(n, b *big.Int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}

173
vendor/github.com/dustin/go-humanize/bigbytes.go generated vendored Normal file
View File

@ -0,0 +1,173 @@
package humanize
import (
"fmt"
"math/big"
"strings"
"unicode"
)
var (
bigIECExp = big.NewInt(1024)
// BigByte is one byte in bit.Ints
BigByte = big.NewInt(1)
// BigKiByte is 1,024 bytes in bit.Ints
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
// BigMiByte is 1,024 k bytes in bit.Ints
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
// BigGiByte is 1,024 m bytes in bit.Ints
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
// BigTiByte is 1,024 g bytes in bit.Ints
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
// BigPiByte is 1,024 t bytes in bit.Ints
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
// BigEiByte is 1,024 p bytes in bit.Ints
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
// BigZiByte is 1,024 e bytes in bit.Ints
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
// BigYiByte is 1,024 z bytes in bit.Ints
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
)
var (
bigSIExp = big.NewInt(1000)
// BigSIByte is one SI byte in big.Ints
BigSIByte = big.NewInt(1)
// BigKByte is 1,000 SI bytes in big.Ints
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
// BigMByte is 1,000 SI k bytes in big.Ints
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
// BigGByte is 1,000 SI m bytes in big.Ints
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
// BigTByte is 1,000 SI g bytes in big.Ints
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
// BigPByte is 1,000 SI t bytes in big.Ints
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
// BigEByte is 1,000 SI p bytes in big.Ints
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
// BigZByte is 1,000 SI e bytes in big.Ints
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
// BigYByte is 1,000 SI z bytes in big.Ints
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
)
var bigBytesSizeTable = map[string]*big.Int{
"b": BigByte,
"kib": BigKiByte,
"kb": BigKByte,
"mib": BigMiByte,
"mb": BigMByte,
"gib": BigGiByte,
"gb": BigGByte,
"tib": BigTiByte,
"tb": BigTByte,
"pib": BigPiByte,
"pb": BigPByte,
"eib": BigEiByte,
"eb": BigEByte,
"zib": BigZiByte,
"zb": BigZByte,
"yib": BigYiByte,
"yb": BigYByte,
// Without suffix
"": BigByte,
"ki": BigKiByte,
"k": BigKByte,
"mi": BigMiByte,
"m": BigMByte,
"gi": BigGiByte,
"g": BigGByte,
"ti": BigTiByte,
"t": BigTByte,
"pi": BigPiByte,
"p": BigPByte,
"ei": BigEiByte,
"e": BigEByte,
"z": BigZByte,
"zi": BigZiByte,
"y": BigYByte,
"yi": BigYiByte,
}
var ten = big.NewInt(10)
func humanateBigBytes(s, base *big.Int, sizes []string) string {
if s.Cmp(ten) < 0 {
return fmt.Sprintf("%d B", s)
}
c := (&big.Int{}).Set(s)
val, mag := oomm(c, base, len(sizes)-1)
suffix := sizes[mag]
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// BigBytes produces a human readable representation of an SI size.
//
// See also: ParseBigBytes.
//
// BigBytes(82854982) -> 83 MB
func BigBytes(s *big.Int) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}
return humanateBigBytes(s, bigSIExp, sizes)
}
// BigIBytes produces a human readable representation of an IEC size.
//
// See also: ParseBigBytes.
//
// BigIBytes(82854982) -> 79 MiB
func BigIBytes(s *big.Int) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
return humanateBigBytes(s, bigIECExp, sizes)
}
// ParseBigBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See also: BigBytes, BigIBytes.
//
// ParseBigBytes("42 MB") -> 42000000, nil
// ParseBigBytes("42 mib") -> 44040192, nil
func ParseBigBytes(s string) (*big.Int, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
val := &big.Rat{}
_, err := fmt.Sscanf(num, "%f", val)
if err != nil {
return nil, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bigBytesSizeTable[extra]; ok {
mv := (&big.Rat{}).SetInt(m)
val.Mul(val, mv)
rv := &big.Int{}
rv.Div(val.Num(), val.Denom())
return rv, nil
}
return nil, fmt.Errorf("unhandled size name: %v", extra)
}

143
vendor/github.com/dustin/go-humanize/bytes.go generated vendored Normal file
View File

@ -0,0 +1,143 @@
package humanize
import (
"fmt"
"math"
"strconv"
"strings"
"unicode"
)
// IEC Sizes.
// kibis of bits
const (
Byte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
// SI Sizes.
const (
IByte = 1
KByte = IByte * 1000
MByte = KByte * 1000
GByte = MByte * 1000
TByte = GByte * 1000
PByte = TByte * 1000
EByte = PByte * 1000
)
var bytesSizeTable = map[string]uint64{
"b": Byte,
"kib": KiByte,
"kb": KByte,
"mib": MiByte,
"mb": MByte,
"gib": GiByte,
"gb": GByte,
"tib": TiByte,
"tb": TByte,
"pib": PiByte,
"pb": PByte,
"eib": EiByte,
"eb": EByte,
// Without suffix
"": Byte,
"ki": KiByte,
"k": KByte,
"mi": MiByte,
"m": MByte,
"gi": GiByte,
"g": GByte,
"ti": TiByte,
"t": TByte,
"pi": PiByte,
"p": PByte,
"ei": EiByte,
"e": EByte,
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// Bytes produces a human readable representation of an SI size.
//
// See also: ParseBytes.
//
// Bytes(82854982) -> 83 MB
func Bytes(s uint64) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(s, 1000, sizes)
}
// IBytes produces a human readable representation of an IEC size.
//
// See also: ParseBytes.
//
// IBytes(82854982) -> 79 MiB
func IBytes(s uint64) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanateBytes(s, 1024, sizes)
}
// ParseBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See Also: Bytes, IBytes.
//
// ParseBytes("42 MB") -> 42000000, nil
// ParseBytes("42 mib") -> 44040192, nil
func ParseBytes(s string) (uint64, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bytesSizeTable[extra]; ok {
f *= float64(m)
if f >= math.MaxUint64 {
return 0, fmt.Errorf("too large: %v", s)
}
return uint64(f), nil
}
return 0, fmt.Errorf("unhandled size name: %v", extra)
}

108
vendor/github.com/dustin/go-humanize/comma.go generated vendored Normal file
View File

@ -0,0 +1,108 @@
package humanize
import (
"bytes"
"math"
"math/big"
"strconv"
"strings"
)
// Comma produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Comma(834142) -> 834,142
func Comma(v int64) string {
sign := ""
// minin64 can't be negated to a usable value, so it has to be special cased.
if v == math.MinInt64 {
return "-9,223,372,036,854,775,808"
}
if v < 0 {
sign = "-"
v = 0 - v
}
parts := []string{"", "", "", "", "", "", ""}
j := len(parts) - 1
for v > 999 {
parts[j] = strconv.FormatInt(v%1000, 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
v = v / 1000
j--
}
parts[j] = strconv.Itoa(int(v))
return sign + strings.Join(parts[j:], ",")
}
// Commaf produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Commaf(834142.32) -> 834,142.32
func Commaf(v float64) string {
buf := &bytes.Buffer{}
if v < 0 {
buf.Write([]byte{'-'})
v = 0 - v
}
comma := []byte{','}
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}
// BigComma produces a string form of the given big.Int in base 10
// with commas after every three orders of magnitude.
func BigComma(b *big.Int) string {
sign := ""
if b.Sign() < 0 {
sign = "-"
b.Abs(b)
}
athousand := big.NewInt(1000)
c := (&big.Int{}).Set(b)
_, m := oom(c, athousand)
parts := make([]string, m+1)
j := len(parts) - 1
mod := &big.Int{}
for b.Cmp(athousand) >= 0 {
b.DivMod(b, athousand, mod)
parts[j] = strconv.FormatInt(mod.Int64(), 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
j--
}
parts[j] = strconv.Itoa(int(b.Int64()))
return sign + strings.Join(parts[j:], ",")
}

40
vendor/github.com/dustin/go-humanize/commaf.go generated vendored Normal file
View File

@ -0,0 +1,40 @@
// +build go1.6
package humanize
import (
"bytes"
"math/big"
"strings"
)
// BigCommaf produces a string form of the given big.Float in base 10
// with commas after every three orders of magnitude.
func BigCommaf(v *big.Float) string {
buf := &bytes.Buffer{}
if v.Sign() < 0 {
buf.Write([]byte{'-'})
v.Abs(v)
}
comma := []byte{','}
parts := strings.Split(v.Text('f', -1), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}

23
vendor/github.com/dustin/go-humanize/ftoa.go generated vendored Normal file
View File

@ -0,0 +1,23 @@
package humanize
import "strconv"
func stripTrailingZeros(s string) string {
offset := len(s) - 1
for offset > 0 {
if s[offset] == '.' {
offset--
break
}
if s[offset] != '0' {
break
}
offset--
}
return s[:offset+1]
}
// Ftoa converts a float to a string with no trailing zeros.
func Ftoa(num float64) string {
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
}

8
vendor/github.com/dustin/go-humanize/humanize.go generated vendored Normal file
View File

@ -0,0 +1,8 @@
/*
Package humanize converts boring ugly numbers to human-friendly strings and back.
Durations can be turned into strings such as "3 days ago", numbers
representing sizes like 82854982 into useful strings like, "83 MB" or
"79 MiB" (whichever you prefer).
*/
package humanize

192
vendor/github.com/dustin/go-humanize/number.go generated vendored Normal file
View File

@ -0,0 +1,192 @@
package humanize
/*
Slightly adapted from the source to fit go-humanize.
Author: https://github.com/gorhill
Source: https://gist.github.com/gorhill/5285193
*/
import (
"math"
"strconv"
)
var (
renderFloatPrecisionMultipliers = [...]float64{
1,
10,
100,
1000,
10000,
100000,
1000000,
10000000,
100000000,
1000000000,
}
renderFloatPrecisionRounders = [...]float64{
0.5,
0.05,
0.005,
0.0005,
0.00005,
0.000005,
0.0000005,
0.00000005,
0.000000005,
0.0000000005,
}
)
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
// * thousands separator
// * decimal separator
// * decimal precision
//
// Usage: s := RenderFloat(format, n)
// The format parameter tells how to render the number n.
//
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
//
// Examples of format strings, given n = 12345.6789:
// "#,###.##" => "12,345.67"
// "#,###." => "12,345"
// "#,###" => "12345,678"
// "#\u202F###,##" => "12345,68"
// "#.###,###### => 12.345,678900
// "" (aka default format) => 12,345.67
//
// The highest precision allowed is 9 digits after the decimal symbol.
// There is also a version for integer number, FormatInteger(),
// which is convenient for calls within template.
func FormatFloat(format string, n float64) string {
// Special cases:
// NaN = "NaN"
// +Inf = "+Infinity"
// -Inf = "-Infinity"
if math.IsNaN(n) {
return "NaN"
}
if n > math.MaxFloat64 {
return "Infinity"
}
if n < -math.MaxFloat64 {
return "-Infinity"
}
// default format
precision := 2
decimalStr := "."
thousandStr := ","
positiveStr := ""
negativeStr := "-"
if len(format) > 0 {
format := []rune(format)
// If there is an explicit format directive,
// then default values are these:
precision = 9
thousandStr = ""
// collect indices of meaningful formatting directives
formatIndx := []int{}
for i, char := range format {
if char != '#' && char != '0' {
formatIndx = append(formatIndx, i)
}
}
if len(formatIndx) > 0 {
// Directive at index 0:
// Must be a '+'
// Raise an error if not the case
// index: 0123456789
// +0.000,000
// +000,000.0
// +0000.00
// +0000
if formatIndx[0] == 0 {
if format[formatIndx[0]] != '+' {
panic("RenderFloat(): invalid positive sign directive")
}
positiveStr = "+"
formatIndx = formatIndx[1:]
}
// Two directives:
// First is thousands separator
// Raise an error if not followed by 3-digit
// 0123456789
// 0.000,000
// 000,000.00
if len(formatIndx) == 2 {
if (formatIndx[1] - formatIndx[0]) != 4 {
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
}
thousandStr = string(format[formatIndx[0]])
formatIndx = formatIndx[1:]
}
// One directive:
// Directive is decimal separator
// The number of digit-specifier following the separator indicates wanted precision
// 0123456789
// 0.00
// 000,0000
if len(formatIndx) == 1 {
decimalStr = string(format[formatIndx[0]])
precision = len(format) - formatIndx[0] - 1
}
}
}
// generate sign part
var signStr string
if n >= 0.000000001 {
signStr = positiveStr
} else if n <= -0.000000001 {
signStr = negativeStr
n = -n
} else {
signStr = ""
n = 0.0
}
// split number into integer and fractional parts
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
// generate integer part string
intStr := strconv.FormatInt(int64(intf), 10)
// add thousand separator if required
if len(thousandStr) > 0 {
for i := len(intStr); i > 3; {
i -= 3
intStr = intStr[:i] + thousandStr + intStr[i:]
}
}
// no fractional part, we can leave now
if precision == 0 {
return signStr + intStr
}
// generate fractional part
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
// may need padding
if len(fracStr) < precision {
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
}
return signStr + intStr + decimalStr + fracStr
}
// FormatInteger produces a formatted number as string.
// See FormatFloat.
func FormatInteger(format string, n int) string {
return FormatFloat(format, float64(n))
}

25
vendor/github.com/dustin/go-humanize/ordinals.go generated vendored Normal file
View File

@ -0,0 +1,25 @@
package humanize
import "strconv"
// Ordinal gives you the input number in a rank/ordinal format.
//
// Ordinal(3) -> 3rd
func Ordinal(x int) string {
suffix := "th"
switch x % 10 {
case 1:
if x%100 != 11 {
suffix = "st"
}
case 2:
if x%100 != 12 {
suffix = "nd"
}
case 3:
if x%100 != 13 {
suffix = "rd"
}
}
return strconv.Itoa(x) + suffix
}

113
vendor/github.com/dustin/go-humanize/si.go generated vendored Normal file
View File

@ -0,0 +1,113 @@
package humanize
import (
"errors"
"math"
"regexp"
"strconv"
)
var siPrefixTable = map[float64]string{
-24: "y", // yocto
-21: "z", // zepto
-18: "a", // atto
-15: "f", // femto
-12: "p", // pico
-9: "n", // nano
-6: "µ", // micro
-3: "m", // milli
0: "",
3: "k", // kilo
6: "M", // mega
9: "G", // giga
12: "T", // tera
15: "P", // peta
18: "E", // exa
21: "Z", // zetta
24: "Y", // yotta
}
var revSIPrefixTable = revfmap(siPrefixTable)
// revfmap reverses the map and precomputes the power multiplier
func revfmap(in map[float64]string) map[string]float64 {
rv := map[string]float64{}
for k, v := range in {
rv[v] = math.Pow(10, k)
}
return rv
}
var riParseRegex *regexp.Regexp
func init() {
ri := `^([\-0-9.]+)\s?([`
for _, v := range siPrefixTable {
ri += v
}
ri += `]?)(.*)`
riParseRegex = regexp.MustCompile(ri)
}
// ComputeSI finds the most appropriate SI prefix for the given number
// and returns the prefix along with the value adjusted to be within
// that prefix.
//
// See also: SI, ParseSI.
//
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
func ComputeSI(input float64) (float64, string) {
if input == 0 {
return 0, ""
}
mag := math.Abs(input)
exponent := math.Floor(logn(mag, 10))
exponent = math.Floor(exponent/3) * 3
value := mag / math.Pow(10, exponent)
// Handle special case where value is exactly 1000.0
// Should return 1 M instead of 1000 k
if value == 1000.0 {
exponent += 3
value = mag / math.Pow(10, exponent)
}
value = math.Copysign(value, input)
prefix := siPrefixTable[exponent]
return value, prefix
}
// SI returns a string with default formatting.
//
// SI uses Ftoa to format float value, removing trailing zeros.
//
// See also: ComputeSI, ParseSI.
//
// e.g. SI(1000000, "B") -> 1 MB
// e.g. SI(2.2345e-12, "F") -> 2.2345 pF
func SI(input float64, unit string) string {
value, prefix := ComputeSI(input)
return Ftoa(value) + " " + prefix + unit
}
var errInvalid = errors.New("invalid input")
// ParseSI parses an SI string back into the number and unit.
//
// See also: SI, ComputeSI.
//
// e.g. ParseSI("2.2345 pF") -> (2.2345e-12, "F", nil)
func ParseSI(input string) (float64, string, error) {
found := riParseRegex.FindStringSubmatch(input)
if len(found) != 4 {
return 0, "", errInvalid
}
mag := revSIPrefixTable[found[2]]
unit := found[3]
base, err := strconv.ParseFloat(found[1], 64)
return base * mag, unit, err
}

117
vendor/github.com/dustin/go-humanize/times.go generated vendored Normal file
View File

@ -0,0 +1,117 @@
package humanize
import (
"fmt"
"math"
"sort"
"time"
)
// Seconds-based time units
const (
Day = 24 * time.Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
LongTime = 37 * Year
)
// Time formats a time into a relative string.
//
// Time(someT) -> "3 weeks ago"
func Time(then time.Time) string {
return RelTime(then, time.Now(), "ago", "from now")
}
// A RelTimeMagnitude struct contains a relative time point at which
// the relative format of time will switch to a new format string. A
// slice of these in ascending order by their "D" field is passed to
// CustomRelTime to format durations.
//
// The Format field is a string that may contain a "%s" which will be
// replaced with the appropriate signed label (e.g. "ago" or "from
// now") and a "%d" that will be replaced by the quantity.
//
// The DivBy field is the amount of time the time difference must be
// divided by in order to display correctly.
//
// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
// DivBy should be time.Minute so whatever the duration is will be
// expressed in minutes.
type RelTimeMagnitude struct {
D time.Duration
Format string
DivBy time.Duration
}
var defaultMagnitudes = []RelTimeMagnitude{
{time.Second, "now", time.Second},
{2 * time.Second, "1 second %s", 1},
{time.Minute, "%d seconds %s", time.Second},
{2 * time.Minute, "1 minute %s", 1},
{time.Hour, "%d minutes %s", time.Minute},
{2 * time.Hour, "1 hour %s", 1},
{Day, "%d hours %s", time.Hour},
{2 * Day, "1 day %s", 1},
{Week, "%d days %s", Day},
{2 * Week, "1 week %s", 1},
{Month, "%d weeks %s", Week},
{2 * Month, "1 month %s", 1},
{Year, "%d months %s", Month},
{18 * Month, "1 year %s", 1},
{2 * Year, "2 years %s", 1},
{LongTime, "%d years %s", Year},
{math.MaxInt64, "a long while %s", 1},
}
// RelTime formats a time into a relative string.
//
// It takes two times and two labels. In addition to the generic time
// delta string (e.g. 5 minutes), the labels are used applied so that
// the label corresponding to the smaller time is applied.
//
// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
func RelTime(a, b time.Time, albl, blbl string) string {
return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
}
// CustomRelTime formats a time into a relative string.
//
// It takes two times two labels and a table of relative time formats.
// In addition to the generic time delta string (e.g. 5 minutes), the
// labels are used applied so that the label corresponding to the
// smaller time is applied.
func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
lbl := albl
diff := b.Sub(a)
if a.After(b) {
lbl = blbl
diff = a.Sub(b)
}
n := sort.Search(len(magnitudes), func(i int) bool {
return magnitudes[i].D >= diff
})
if n >= len(magnitudes) {
n = len(magnitudes) - 1
}
mag := magnitudes[n]
args := []interface{}{}
escaped := false
for _, ch := range mag.Format {
if escaped {
switch ch {
case 's':
args = append(args, lbl)
case 'd':
args = append(args, diff/mag.DivBy)
}
escaped = false
} else {
escaped = ch == '%'
}
}
return fmt.Sprintf(mag.Format, args...)
}

6
vendor/vendor.json vendored
View File

@ -38,6 +38,12 @@
"revision": "9e952142169c3cd6268c6482a3a61c121536aca2",
"revisionTime": "2015-07-28T12:50:59Z"
},
{
"checksumSHA1": "rhLUtXvcmouYuBwOq9X/nYKzvNg=",
"path": "github.com/dustin/go-humanize",
"revision": "259d2a102b871d17f30e3cd9881a642961a1e486",
"revisionTime": "2017-02-28T07:34:54Z"
},
{
"checksumSHA1": "6defOlYtxIqheaUEG/cWWouQnIU=",
"path": "github.com/hlandau/passlib",