mirror of
https://gitlab.com/ric_harvey/MailHog.git
synced 2024-11-27 16:24:04 +00:00
Refactor to github.com/mailhog/{MailHog-Server,MailHog-UI}
This commit is contained in:
parent
09c9701511
commit
ebbae16589
21 changed files with 10 additions and 2620 deletions
|
@ -1,252 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
"github.com/ian-kent/Go-MailHog/data"
|
||||
"github.com/ian-kent/Go-MailHog/storage"
|
||||
"github.com/ian-kent/go-log/log"
|
||||
gotcha "github.com/ian-kent/gotcha/app"
|
||||
"github.com/ian-kent/gotcha/http"
|
||||
|
||||
"github.com/ian-kent/goose"
|
||||
)
|
||||
|
||||
type APIv1 struct {
|
||||
config *config.Config
|
||||
app *gotcha.App
|
||||
}
|
||||
|
||||
var stream *goose.EventStream
|
||||
|
||||
type ReleaseConfig struct {
|
||||
Email string
|
||||
Host string
|
||||
Port string
|
||||
}
|
||||
|
||||
func CreateAPIv1(conf *config.Config, app *gotcha.App) *APIv1 {
|
||||
log.Println("Creating API v1")
|
||||
apiv1 := &APIv1{
|
||||
config: conf,
|
||||
app: app,
|
||||
}
|
||||
|
||||
stream = goose.NewEventStream()
|
||||
r := app.Router
|
||||
|
||||
r.Get("/api/v1/messages/?", apiv1.messages)
|
||||
r.Delete("/api/v1/messages/?", apiv1.delete_all)
|
||||
r.Get("/api/v1/messages/(?P<id>[^/]+)/?", apiv1.message)
|
||||
r.Delete("/api/v1/messages/(?P<id>[^/]+)/?", apiv1.delete_one)
|
||||
r.Get("/api/v1/messages/(?P<id>[^/]+)/download/?", apiv1.download)
|
||||
r.Get("/api/v1/messages/(?P<id>[^/]+)/mime/part/(\\d+)/download/?", apiv1.download_part)
|
||||
r.Post("/api/v1/messages/(?P<id>[^/]+)/release/?", apiv1.release_one)
|
||||
r.Get("/api/v1/events/?", apiv1.eventstream)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case msg := <-apiv1.config.MessageChan:
|
||||
log.Println("Got message in APIv1 event stream")
|
||||
bytes, _ := json.MarshalIndent(msg, "", " ")
|
||||
json := string(bytes)
|
||||
log.Printf("Sending content: %s\n", json)
|
||||
apiv1.broadcast(json)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return apiv1
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) broadcast(json string) {
|
||||
log.Println("[APIv1] BROADCAST /api/v1/events")
|
||||
b := []byte(json)
|
||||
stream.Notify("data", b)
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) eventstream(session *http.Session) {
|
||||
log.Println("[APIv1] GET /api/v1/events")
|
||||
|
||||
stream.AddReceiver(session.Response.GetWriter())
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) messages(session *http.Session) {
|
||||
log.Println("[APIv1] GET /api/v1/messages")
|
||||
|
||||
// TODO start, limit
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
messages, _ := apiv1.config.Storage.(*storage.MongoDB).List(0, 1000)
|
||||
bytes, _ := json.Marshal(messages)
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
session.Response.Write(bytes)
|
||||
case *storage.InMemory:
|
||||
messages, _ := apiv1.config.Storage.(*storage.InMemory).List(0, 1000)
|
||||
bytes, _ := json.Marshal(messages)
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
session.Response.Write(bytes)
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
}
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) message(session *http.Session) {
|
||||
id := session.Stash["id"].(string)
|
||||
log.Printf("[APIv1] GET /api/v1/messages/%s\n", id)
|
||||
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
message, _ := apiv1.config.Storage.(*storage.MongoDB).Load(id)
|
||||
bytes, _ := json.Marshal(message)
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
session.Response.Write(bytes)
|
||||
case *storage.InMemory:
|
||||
message, _ := apiv1.config.Storage.(*storage.InMemory).Load(id)
|
||||
bytes, _ := json.Marshal(message)
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
session.Response.Write(bytes)
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
}
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) download(session *http.Session) {
|
||||
id := session.Stash["id"].(string)
|
||||
log.Printf("[APIv1] GET /api/v1/messages/%s\n", id)
|
||||
|
||||
session.Response.Headers.Add("Content-Type", "message/rfc822")
|
||||
session.Response.Headers.Add("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
message, _ := apiv1.config.Storage.(*storage.MongoDB).Load(id)
|
||||
for h, l := range message.Content.Headers {
|
||||
for _, v := range l {
|
||||
session.Response.Write([]byte(h + ": " + v + "\r\n"))
|
||||
}
|
||||
}
|
||||
session.Response.Write([]byte("\r\n" + message.Content.Body))
|
||||
case *storage.InMemory:
|
||||
message, _ := apiv1.config.Storage.(*storage.InMemory).Load(id)
|
||||
for h, l := range message.Content.Headers {
|
||||
for _, v := range l {
|
||||
session.Response.Write([]byte(h + ": " + v + "\r\n"))
|
||||
}
|
||||
}
|
||||
session.Response.Write([]byte("\r\n" + message.Content.Body))
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
}
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) download_part(session *http.Session) {
|
||||
id := session.Stash["id"].(string)
|
||||
part, _ := strconv.Atoi(session.Stash["part"].(string))
|
||||
log.Printf("[APIv1] GET /api/v1/messages/%s/mime/part/%d/download\n", id, part)
|
||||
|
||||
// TODO extension from content-type?
|
||||
|
||||
session.Response.Headers.Add("Content-Disposition", "attachment; filename=\""+id+"-part-"+strconv.Itoa(part)+"\"")
|
||||
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
message, _ := apiv1.config.Storage.(*storage.MongoDB).Load(id)
|
||||
for h, l := range message.MIME.Parts[part].Headers {
|
||||
for _, v := range l {
|
||||
session.Response.Headers.Add(h, v)
|
||||
}
|
||||
}
|
||||
session.Response.Write([]byte("\r\n" + message.MIME.Parts[part].Body))
|
||||
case *storage.InMemory:
|
||||
message, _ := apiv1.config.Storage.(*storage.InMemory).Load(id)
|
||||
for h, l := range message.MIME.Parts[part].Headers {
|
||||
for _, v := range l {
|
||||
session.Response.Headers.Add(h, v)
|
||||
}
|
||||
}
|
||||
session.Response.Write([]byte("\r\n" + message.MIME.Parts[part].Body))
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
}
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) delete_all(session *http.Session) {
|
||||
log.Println("[APIv1] POST /api/v1/messages")
|
||||
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
apiv1.config.Storage.(*storage.MongoDB).DeleteAll()
|
||||
case *storage.InMemory:
|
||||
apiv1.config.Storage.(*storage.InMemory).DeleteAll()
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) release_one(session *http.Session) {
|
||||
id := session.Stash["id"].(string)
|
||||
log.Printf("[APIv1] POST /api/v1/messages/%s/release\n", id)
|
||||
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
var msg = &data.Message{}
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
msg, _ = apiv1.config.Storage.(*storage.MongoDB).Load(id)
|
||||
case *storage.InMemory:
|
||||
msg, _ = apiv1.config.Storage.(*storage.InMemory).Load(id)
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
return
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(session.Request.Body())
|
||||
var cfg ReleaseConfig
|
||||
err := decoder.Decode(&cfg)
|
||||
if err != nil {
|
||||
log.Printf("Error decoding request body: %s", err)
|
||||
session.Response.Status = 500
|
||||
session.Response.Write([]byte("Error decoding request body"))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Releasing to %s (via %s:%s)", cfg.Email, cfg.Host, cfg.Port)
|
||||
log.Printf("Got message: %s", msg.ID)
|
||||
|
||||
bytes := make([]byte, 0)
|
||||
for h, l := range msg.Content.Headers {
|
||||
for _, v := range l {
|
||||
bytes = append(bytes, []byte(h+": "+v+"\r\n")...)
|
||||
}
|
||||
}
|
||||
bytes = append(bytes, []byte("\r\n"+msg.Content.Body)...)
|
||||
|
||||
err = smtp.SendMail(cfg.Host+":"+cfg.Port, nil, "nobody@"+apiv1.config.Hostname, []string{cfg.Email}, bytes)
|
||||
if err != nil {
|
||||
log.Printf("Failed to release message: %s", err)
|
||||
session.Response.Status = 500
|
||||
return
|
||||
}
|
||||
log.Printf("Message released successfully")
|
||||
}
|
||||
|
||||
func (apiv1 *APIv1) delete_one(session *http.Session) {
|
||||
id := session.Stash["id"].(string)
|
||||
log.Printf("[APIv1] POST /api/v1/messages/%s/delete\n", id)
|
||||
|
||||
session.Response.Headers.Add("Content-Type", "text/json")
|
||||
switch apiv1.config.Storage.(type) {
|
||||
case *storage.MongoDB:
|
||||
apiv1.config.Storage.(*storage.MongoDB).DeleteOne(id)
|
||||
case *storage.InMemory:
|
||||
apiv1.config.Storage.(*storage.InMemory).DeleteOne(id)
|
||||
default:
|
||||
session.Response.Status = 500
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/monkey"
|
||||
"github.com/ian-kent/Go-MailHog/data"
|
||||
"github.com/ian-kent/Go-MailHog/storage"
|
||||
"github.com/ian-kent/envconf"
|
||||
)
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
SMTPBindAddr: "0.0.0.0:1025",
|
||||
HTTPBindAddr: "0.0.0.0:8025",
|
||||
Hostname: "mailhog.example",
|
||||
MongoUri: "127.0.0.1:27017",
|
||||
MongoDb: "mailhog",
|
||||
MongoColl: "messages",
|
||||
StorageType: "memory",
|
||||
MessageChan: make(chan *data.Message),
|
||||
}
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
SMTPBindAddr string
|
||||
HTTPBindAddr string
|
||||
Hostname string
|
||||
MongoUri string
|
||||
MongoDb string
|
||||
MongoColl string
|
||||
StorageType string
|
||||
InviteJim bool
|
||||
Storage storage.Storage
|
||||
MessageChan chan *data.Message
|
||||
Assets func(asset string) ([]byte, error)
|
||||
Monkey monkey.ChaosMonkey
|
||||
}
|
||||
|
||||
var cfg = DefaultConfig()
|
||||
var jim = &monkey.Jim{}
|
||||
|
||||
func Configure() *Config {
|
||||
switch cfg.StorageType {
|
||||
case "memory":
|
||||
log.Println("Using in-memory storage")
|
||||
cfg.Storage = storage.CreateInMemory()
|
||||
case "mongodb":
|
||||
log.Println("Using MongoDB message storage")
|
||||
s := storage.CreateMongoDB(cfg.MongoUri, cfg.MongoDb, cfg.MongoColl)
|
||||
if s == nil {
|
||||
log.Println("MongoDB storage unavailable, reverting to in-memory storage")
|
||||
cfg.Storage = storage.CreateInMemory()
|
||||
} else {
|
||||
log.Println("Connected to MongoDB")
|
||||
cfg.Storage = s
|
||||
}
|
||||
default:
|
||||
log.Fatalf("Invalid storage type %s", cfg.StorageType)
|
||||
}
|
||||
|
||||
if cfg.InviteJim {
|
||||
jim.Configure(func(message string, args ...interface{}) {
|
||||
log.Printf(message, args...)
|
||||
})
|
||||
cfg.Monkey = jim
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func RegisterFlags() {
|
||||
flag.StringVar(&cfg.SMTPBindAddr, "smtpbindaddr", envconf.FromEnvP("MH_SMTP_BIND_ADDR", "0.0.0.0:1025").(string), "SMTP bind interface and port, e.g. 0.0.0.0:1025 or just :1025")
|
||||
flag.StringVar(&cfg.HTTPBindAddr, "httpbindaddr", envconf.FromEnvP("MH_HTTP_BIND_ADDR", "0.0.0.0:8025").(string), "HTTP bind interface and port, e.g. 0.0.0.0:8025 or just :8025")
|
||||
flag.StringVar(&cfg.Hostname, "hostname", envconf.FromEnvP("MH_HOSTNAME", "mailhog.example").(string), "Hostname for EHLO/HELO response, e.g. mailhog.example")
|
||||
flag.StringVar(&cfg.StorageType, "storage", envconf.FromEnvP("MH_STORAGE", "memory").(string), "Message storage: memory (default) or mongodb")
|
||||
flag.StringVar(&cfg.MongoUri, "mongouri", envconf.FromEnvP("MH_MONGO_URI", "127.0.0.1:27017").(string), "MongoDB URI, e.g. 127.0.0.1:27017")
|
||||
flag.StringVar(&cfg.MongoDb, "mongodb", envconf.FromEnvP("MH_MONGO_DB", "mailhog").(string), "MongoDB database, e.g. mailhog")
|
||||
flag.StringVar(&cfg.MongoColl, "mongocoll", envconf.FromEnvP("MH_MONGO_COLLECTION", "messages").(string), "MongoDB collection, e.g. messages")
|
||||
flag.BoolVar(&cfg.InviteJim, "invite-jim", envconf.FromEnvP("MH_INVITE_JIM", false).(bool), "Decide whether to invite Jim (beware, he causes trouble)")
|
||||
jim.RegisterFlags()
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/api"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/smtp"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-UI/assets"
|
||||
"github.com/ian-kent/Go-MailHog/http"
|
||||
"github.com/ian-kent/go-log/log"
|
||||
gotcha "github.com/ian-kent/gotcha/app"
|
||||
)
|
||||
|
||||
var conf *config.Config
|
||||
var exitCh chan int
|
||||
|
||||
func configure() {
|
||||
config.RegisterFlags()
|
||||
flag.Parse()
|
||||
conf = config.Configure()
|
||||
}
|
||||
|
||||
func main() {
|
||||
configure()
|
||||
|
||||
exitCh = make(chan int)
|
||||
cb := func(app *gotcha.App) {
|
||||
api.CreateAPIv1(conf, app)
|
||||
}
|
||||
go http.Listen(conf, assets.Asset, exitCh, cb)
|
||||
go smtp.Listen(conf, exitCh)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-exitCh:
|
||||
log.Printf("Received exit signal")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,105 +0,0 @@
|
|||
package monkey
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"math/rand"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/ian-kent/linkio"
|
||||
)
|
||||
|
||||
// Jim is a chaos monkey
|
||||
type Jim struct {
|
||||
disconnectChance float64
|
||||
acceptChance float64
|
||||
linkSpeedAffect float64
|
||||
linkSpeedMin float64
|
||||
linkSpeedMax float64
|
||||
rejectSenderChance float64
|
||||
rejectRecipientChance float64
|
||||
rejectAuthChance float64
|
||||
logf func(message string, args ...interface{})
|
||||
}
|
||||
|
||||
// RegisterFlags implements ChaosMonkey.RegisterFlags
|
||||
func (j *Jim) RegisterFlags() {
|
||||
flag.Float64Var(&j.disconnectChance, "jim-disconnect", 0.005, "Chance of disconnect")
|
||||
flag.Float64Var(&j.acceptChance, "jim-accept", 0.99, "Chance of accept")
|
||||
flag.Float64Var(&j.linkSpeedAffect, "jim-linkspeed-affect", 0.1, "Chance of affecting link speed")
|
||||
flag.Float64Var(&j.linkSpeedMin, "jim-linkspeed-min", 1024, "Minimum link speed (in bytes per second)")
|
||||
flag.Float64Var(&j.linkSpeedMax, "jim-linkspeed-max", 10240, "Maximum link speed (in bytes per second)")
|
||||
flag.Float64Var(&j.rejectSenderChance, "jim-reject-sender", 0.05, "Chance of rejecting a sender (MAIL FROM)")
|
||||
flag.Float64Var(&j.rejectRecipientChance, "jim-reject-recipient", 0.05, "Chance of rejecting a recipient (RCPT TO)")
|
||||
flag.Float64Var(&j.rejectAuthChance, "jim-reject-auth", 0.05, "Chance of rejecting authentication (AUTH)")
|
||||
}
|
||||
|
||||
// Configure implements ChaosMonkey.Configure
|
||||
func (j *Jim) Configure(logf func(string, ...interface{})) {
|
||||
j.logf = logf
|
||||
rand.Seed(time.Now().Unix())
|
||||
}
|
||||
|
||||
// Accept implements ChaosMonkey.Accept
|
||||
func (j *Jim) Accept(conn net.Conn) bool {
|
||||
if rand.Float64() > j.acceptChance {
|
||||
j.logf("Jim: Rejecting connection\n")
|
||||
return false
|
||||
}
|
||||
j.logf("Jim: Allowing connection\n")
|
||||
return true
|
||||
}
|
||||
|
||||
// LinkSpeed implements ChaosMonkey.LinkSpeed
|
||||
func (j *Jim) LinkSpeed() *linkio.Throughput {
|
||||
rand.Seed(time.Now().Unix())
|
||||
if rand.Float64() < j.linkSpeedAffect {
|
||||
lsDiff := j.linkSpeedMax - j.linkSpeedMin
|
||||
lsAffect := j.linkSpeedMin + (lsDiff * rand.Float64())
|
||||
f := linkio.Throughput(lsAffect) * linkio.BytePerSecond
|
||||
j.logf("Jim: Restricting throughput to %s\n", f)
|
||||
return &f
|
||||
}
|
||||
j.logf("Jim: Allowing unrestricted throughput")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidRCPT implements ChaosMonkey.ValidRCPT
|
||||
func (j *Jim) ValidRCPT(rcpt string) bool {
|
||||
if rand.Float64() < j.rejectRecipientChance {
|
||||
j.logf("Jim: Rejecting recipient %s\n", rcpt)
|
||||
return false
|
||||
}
|
||||
j.logf("Jim: Allowing recipient%s\n", rcpt)
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidMAIL implements ChaosMonkey.ValidMAIL
|
||||
func (j *Jim) ValidMAIL(mail string) bool {
|
||||
if rand.Float64() < j.rejectSenderChance {
|
||||
j.logf("Jim: Rejecting sender %s\n", mail)
|
||||
return false
|
||||
}
|
||||
j.logf("Jim: Allowing sender %s\n", mail)
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidAUTH implements ChaosMonkey.ValidAUTH
|
||||
func (j *Jim) ValidAUTH(mechanism string, args ...string) bool {
|
||||
if rand.Float64() < j.rejectAuthChance {
|
||||
j.logf("Jim: Rejecting authentication %s: %s\n", mechanism, args)
|
||||
return false
|
||||
}
|
||||
j.logf("Jim: Allowing authentication %s: %s\n", mechanism, args)
|
||||
return true
|
||||
}
|
||||
|
||||
// Disconnect implements ChaosMonkey.Disconnect
|
||||
func (j *Jim) Disconnect() bool {
|
||||
if rand.Float64() < j.disconnectChance {
|
||||
j.logf("Jim: Being nasty, kicking them off\n")
|
||||
return true
|
||||
}
|
||||
j.logf("Jim: Being nice, letting them stay\n")
|
||||
return false
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
package monkey
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/ian-kent/linkio"
|
||||
)
|
||||
|
||||
// ChaosMonkey should be implemented by chaos monkeys!
|
||||
type ChaosMonkey interface {
|
||||
RegisterFlags()
|
||||
Configure(func(string, ...interface{}))
|
||||
|
||||
// Accept is called for each incoming connection. Returning false closes the connection.
|
||||
Accept(conn net.Conn) bool
|
||||
// LinkSpeed sets the maximum connection throughput (in one direction)
|
||||
LinkSpeed() *linkio.Throughput
|
||||
|
||||
// ValidRCPT is called for the RCPT command. Returning false signals an invalid recipient.
|
||||
ValidRCPT(rcpt string) bool
|
||||
// ValidMAIL is called for the MAIL command. Returning false signals an invalid sender.
|
||||
ValidMAIL(mail string) bool
|
||||
// ValidAUTH is called after authentication. Returning false signals invalid authentication.
|
||||
ValidAUTH(mechanism string, args ...string) bool
|
||||
|
||||
// Disconnect is called after every read. Returning true will close the connection.
|
||||
Disconnect() bool
|
||||
}
|
|
@ -1,161 +0,0 @@
|
|||
package smtp
|
||||
|
||||
// http://www.rfc-editor.org/rfc/rfc5321.txt
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/monkey"
|
||||
"github.com/ian-kent/Go-MailHog/data"
|
||||
"github.com/ian-kent/Go-MailHog/storage"
|
||||
"github.com/ian-kent/linkio"
|
||||
"github.com/mailhog/smtp"
|
||||
)
|
||||
|
||||
// Session represents a SMTP session using net.TCPConn
|
||||
type Session struct {
|
||||
conn io.ReadWriteCloser
|
||||
proto *smtp.Protocol
|
||||
storage storage.Storage
|
||||
messageChan chan *data.Message
|
||||
remoteAddress string
|
||||
isTLS bool
|
||||
line string
|
||||
link *linkio.Link
|
||||
|
||||
reader io.Reader
|
||||
writer io.Writer
|
||||
monkey monkey.ChaosMonkey
|
||||
}
|
||||
|
||||
// Accept starts a new SMTP session using io.ReadWriteCloser
|
||||
func Accept(remoteAddress string, conn io.ReadWriteCloser, storage storage.Storage, messageChan chan *data.Message, hostname string, monkey monkey.ChaosMonkey) {
|
||||
defer conn.Close()
|
||||
|
||||
proto := smtp.NewProtocol()
|
||||
proto.Hostname = hostname
|
||||
var link *linkio.Link
|
||||
reader := io.Reader(conn)
|
||||
writer := io.Writer(conn)
|
||||
if monkey != nil {
|
||||
linkSpeed := monkey.LinkSpeed()
|
||||
if linkSpeed != nil {
|
||||
link = linkio.NewLink(*linkSpeed * linkio.BytePerSecond)
|
||||
reader = link.NewLinkReader(io.Reader(conn))
|
||||
writer = link.NewLinkWriter(io.Writer(conn))
|
||||
}
|
||||
}
|
||||
|
||||
session := &Session{conn, proto, storage, messageChan, remoteAddress, false, "", link, reader, writer, monkey}
|
||||
proto.LogHandler = session.logf
|
||||
proto.MessageReceivedHandler = session.acceptMessage
|
||||
proto.ValidateSenderHandler = session.validateSender
|
||||
proto.ValidateRecipientHandler = session.validateRecipient
|
||||
proto.ValidateAuthenticationHandler = session.validateAuthentication
|
||||
|
||||
session.logf("Starting session")
|
||||
session.Write(proto.Start())
|
||||
for session.Read() == true {
|
||||
if monkey != nil && monkey.Disconnect != nil && monkey.Disconnect() {
|
||||
session.conn.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
session.logf("Session ended")
|
||||
}
|
||||
|
||||
func (c *Session) validateAuthentication(mechanism string, args ...string) (errorReply *smtp.Reply, ok bool) {
|
||||
if c.monkey != nil {
|
||||
ok := c.monkey.ValidAUTH(mechanism, args...)
|
||||
if !ok {
|
||||
// FIXME better error?
|
||||
return smtp.ReplyUnrecognisedCommand(), false
|
||||
}
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func (c *Session) validateRecipient(to string) bool {
|
||||
if c.monkey != nil {
|
||||
ok := c.monkey.ValidRCPT(to)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Session) validateSender(from string) bool {
|
||||
if c.monkey != nil {
|
||||
ok := c.monkey.ValidMAIL(from)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Session) acceptMessage(msg *data.Message) (id string, err error) {
|
||||
c.logf("Storing message %s", msg.ID)
|
||||
id, err = c.storage.Store(msg)
|
||||
c.messageChan <- msg
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Session) logf(message string, args ...interface{}) {
|
||||
message = strings.Join([]string{"[SMTP %s]", message}, " ")
|
||||
args = append([]interface{}{c.remoteAddress}, args...)
|
||||
log.Printf(message, args...)
|
||||
}
|
||||
|
||||
// Read reads from the underlying net.TCPConn
|
||||
func (c *Session) Read() bool {
|
||||
buf := make([]byte, 1024)
|
||||
n, err := c.reader.Read(buf)
|
||||
|
||||
if n == 0 {
|
||||
c.logf("Connection closed by remote host\n")
|
||||
io.Closer(c.conn).Close() // not sure this is necessary?
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.logf("Error reading from socket: %s\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
text := string(buf[0:n])
|
||||
logText := strings.Replace(text, "\n", "\\n", -1)
|
||||
logText = strings.Replace(logText, "\r", "\\r", -1)
|
||||
c.logf("Received %d bytes: '%s'\n", n, logText)
|
||||
|
||||
c.line += text
|
||||
|
||||
for strings.Contains(c.line, "\n") {
|
||||
line, reply := c.proto.Parse(c.line)
|
||||
c.line = line
|
||||
|
||||
if reply != nil {
|
||||
c.Write(reply)
|
||||
if reply.Status == 221 {
|
||||
io.Closer(c.conn).Close()
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Write writes a reply to the underlying net.TCPConn
|
||||
func (c *Session) Write(reply *smtp.Reply) {
|
||||
lines := reply.Lines()
|
||||
for _, l := range lines {
|
||||
logText := strings.Replace(l, "\n", "\\n", -1)
|
||||
logText = strings.Replace(logText, "\r", "\\r", -1)
|
||||
c.logf("Sent %d bytes: '%s'", len(l), logText)
|
||||
c.writer.Write([]byte(l))
|
||||
}
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
package smtp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/data"
|
||||
"github.com/ian-kent/Go-MailHog/storage"
|
||||
)
|
||||
|
||||
type fakeRw struct {
|
||||
_read func(p []byte) (n int, err error)
|
||||
_write func(p []byte) (n int, err error)
|
||||
_close func() error
|
||||
}
|
||||
|
||||
func (rw *fakeRw) Read(p []byte) (n int, err error) {
|
||||
if rw._read != nil {
|
||||
return rw._read(p)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
func (rw *fakeRw) Close() error {
|
||||
if rw._close != nil {
|
||||
return rw._close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (rw *fakeRw) Write(p []byte) (n int, err error) {
|
||||
if rw._write != nil {
|
||||
return rw._write(p)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestAccept(t *testing.T) {
|
||||
Convey("Accept should handle a connection", t, func() {
|
||||
frw := &fakeRw{}
|
||||
mChan := make(chan *data.Message)
|
||||
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocketError(t *testing.T) {
|
||||
Convey("Socket errors should return from Accept", t, func() {
|
||||
frw := &fakeRw{
|
||||
_read: func(p []byte) (n int, err error) {
|
||||
return -1, errors.New("OINK")
|
||||
},
|
||||
}
|
||||
mChan := make(chan *data.Message)
|
||||
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcceptMessage(t *testing.T) {
|
||||
Convey("acceptMessage should be called", t, func() {
|
||||
mbuf := "EHLO localhost\nMAIL FROM:<test>\nRCPT TO:<test>\nDATA\nHi.\r\n.\r\nQUIT\n"
|
||||
var rbuf []byte
|
||||
frw := &fakeRw{
|
||||
_read: func(p []byte) (n int, err error) {
|
||||
if len(p) >= len(mbuf) {
|
||||
ba := []byte(mbuf)
|
||||
mbuf = ""
|
||||
for i, b := range ba {
|
||||
p[i] = b
|
||||
}
|
||||
return len(ba), nil
|
||||
}
|
||||
|
||||
ba := []byte(mbuf[0:len(p)])
|
||||
mbuf = mbuf[len(p):]
|
||||
for i, b := range ba {
|
||||
p[i] = b
|
||||
}
|
||||
return len(ba), nil
|
||||
},
|
||||
_write: func(p []byte) (n int, err error) {
|
||||
rbuf = append(rbuf, p...)
|
||||
return len(p), nil
|
||||
},
|
||||
_close: func() error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
mChan := make(chan *data.Message)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
handlerCalled := false
|
||||
go func() {
|
||||
handlerCalled = true
|
||||
<-mChan
|
||||
//FIXME breaks some tests (in drone.io)
|
||||
//m := <-mChan
|
||||
//So(m, ShouldNotBeNil)
|
||||
wg.Done()
|
||||
}()
|
||||
Accept("1.1.1.1:11111", frw, storage.CreateInMemory(), mChan, "localhost", nil)
|
||||
wg.Wait()
|
||||
So(handlerCalled, ShouldBeTrue)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAuthentication(t *testing.T) {
|
||||
Convey("validateAuthentication is always successful", t, func() {
|
||||
c := &Session{}
|
||||
|
||||
err, ok := c.validateAuthentication("OINK")
|
||||
So(err, ShouldBeNil)
|
||||
So(ok, ShouldBeTrue)
|
||||
|
||||
err, ok = c.validateAuthentication("OINK", "arg1")
|
||||
So(err, ShouldBeNil)
|
||||
So(ok, ShouldBeTrue)
|
||||
|
||||
err, ok = c.validateAuthentication("OINK", "arg1", "arg2")
|
||||
So(err, ShouldBeNil)
|
||||
So(ok, ShouldBeTrue)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRecipient(t *testing.T) {
|
||||
Convey("validateRecipient is always successful", t, func() {
|
||||
c := &Session{}
|
||||
|
||||
So(c.validateRecipient("OINK"), ShouldBeTrue)
|
||||
So(c.validateRecipient("foo@bar.mailhog"), ShouldBeTrue)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateSender(t *testing.T) {
|
||||
Convey("validateSender is always successful", t, func() {
|
||||
c := &Session{}
|
||||
|
||||
So(c.validateSender("OINK"), ShouldBeTrue)
|
||||
So(c.validateSender("foo@bar.mailhog"), ShouldBeTrue)
|
||||
})
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package smtp
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
)
|
||||
|
||||
func Listen(cfg *config.Config, exitCh chan int) *net.TCPListener {
|
||||
log.Printf("[SMTP] Binding to address: %s\n", cfg.SMTPBindAddr)
|
||||
ln, err := net.Listen("tcp", cfg.SMTPBindAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("[SMTP] Error listening on socket: %s\n", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
log.Printf("[SMTP] Error accepting connection: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if cfg.Monkey != nil {
|
||||
ok := cfg.Monkey.Accept(conn)
|
||||
if !ok {
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
go Accept(
|
||||
conn.(*net.TCPConn).RemoteAddr().String(),
|
||||
io.ReadWriteCloser(conn),
|
||||
cfg.Storage,
|
||||
cfg.MessageChan,
|
||||
cfg.Hostname,
|
||||
cfg.Monkey,
|
||||
)
|
||||
}
|
||||
}
|
1
MailHog-UI/assets/.gitignore
vendored
1
MailHog-UI/assets/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
assets.go
|
Binary file not shown.
Before Width: | Height: | Size: 673 B |
Binary file not shown.
Before Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
Before Width: | Height: | Size: 2.7 KiB |
|
@ -1,291 +0,0 @@
|
|||
var mailhogApp = angular.module('mailhogApp', []);
|
||||
|
||||
function guid() {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1);
|
||||
}
|
||||
return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
|
||||
s4() + '-' + s4() + s4() + s4();
|
||||
}
|
||||
|
||||
mailhogApp.controller('MailCtrl', function ($scope, $http, $sce, $timeout) {
|
||||
$scope.cache = {};
|
||||
$scope.previewAllHeaders = false;
|
||||
|
||||
$scope.eventsPending = {};
|
||||
$scope.eventCount = 0;
|
||||
$scope.eventDone = 0;
|
||||
$scope.eventFailed = 0;
|
||||
|
||||
$scope.hasEventSource = false;
|
||||
$scope.source = null;
|
||||
|
||||
$(function() {
|
||||
$scope.openStream();
|
||||
});
|
||||
|
||||
$scope.toggleStream = function() {
|
||||
$scope.source == null ? $scope.openStream() : $scope.closeStream();
|
||||
}
|
||||
$scope.openStream = function() {
|
||||
$scope.source = new EventSource('/api/v1/events');
|
||||
$scope.source.addEventListener('message', function(e) {
|
||||
$scope.$apply(function() {
|
||||
$scope.messages.push(JSON.parse(e.data));
|
||||
});
|
||||
}, false);
|
||||
$scope.source.addEventListener('open', function(e) {
|
||||
$scope.$apply(function() {
|
||||
$scope.hasEventSource = true;
|
||||
});
|
||||
}, false);
|
||||
$scope.source.addEventListener('error', function(e) {
|
||||
//if(e.readyState == EventSource.CLOSED) {
|
||||
$scope.$apply(function() {
|
||||
$scope.hasEventSource = false;
|
||||
});
|
||||
//}
|
||||
}, false);
|
||||
}
|
||||
$scope.closeStream = function() {
|
||||
$scope.source.close();
|
||||
$scope.source = null;
|
||||
$scope.hasEventSource = false;
|
||||
}
|
||||
|
||||
$scope.tryDecodeMime = function(str) {
|
||||
return unescapeFromMime(str)
|
||||
}
|
||||
|
||||
$scope.startEvent = function(name, args, glyphicon) {
|
||||
var eID = guid();
|
||||
//console.log("Starting event '" + name + "' with id '" + eID + "'")
|
||||
var e = {
|
||||
id: eID,
|
||||
name: name,
|
||||
started: new Date(),
|
||||
complete: false,
|
||||
failed: false,
|
||||
args: args,
|
||||
glyphicon: glyphicon,
|
||||
getClass: function() {
|
||||
// FIXME bit nasty
|
||||
if(this.failed) {
|
||||
return "bg-danger"
|
||||
}
|
||||
if(this.complete) {
|
||||
return "bg-success"
|
||||
}
|
||||
return "bg-warning"; // pending
|
||||
},
|
||||
done: function() {
|
||||
//delete $scope.eventsPending[eID]
|
||||
var e = this;
|
||||
e.complete = true;
|
||||
$scope.eventDone++;
|
||||
if(this.failed) {
|
||||
// console.log("Failed event '" + e.name + "' with id '" + eID + "'")
|
||||
} else {
|
||||
// console.log("Completed event '" + e.name + "' with id '" + eID + "'")
|
||||
$timeout(function() {
|
||||
e.remove();
|
||||
}, 10000);
|
||||
}
|
||||
},
|
||||
fail: function() {
|
||||
$scope.eventFailed++;
|
||||
this.failed = true;
|
||||
this.done();
|
||||
},
|
||||
remove: function() {
|
||||
// console.log("Deleted event '" + e.name + "' with id '" + eID + "'")
|
||||
if(e.failed) {
|
||||
$scope.eventFailed--;
|
||||
}
|
||||
delete $scope.eventsPending[eID];
|
||||
$scope.eventDone--;
|
||||
$scope.eventCount--;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
$scope.eventsPending[eID] = e;
|
||||
$scope.eventCount++;
|
||||
return e;
|
||||
}
|
||||
|
||||
$scope.messagesDisplayed = function() {
|
||||
return $('#messages-container table tbody tr').length
|
||||
}
|
||||
|
||||
$scope.refresh = function() {
|
||||
var e = $scope.startEvent("Loading messages", null, "glyphicon-download");
|
||||
$http.get('/api/v1/messages').success(function(data) {
|
||||
$scope.messages = data;
|
||||
e.done();
|
||||
});
|
||||
}
|
||||
$scope.refresh();
|
||||
|
||||
$scope.selectMessage = function(message) {
|
||||
if($scope.cache[message.ID]) {
|
||||
$scope.preview = $scope.cache[message.ID];
|
||||
reflow();
|
||||
} else {
|
||||
$scope.preview = message;
|
||||
var e = $scope.startEvent("Loading message", message.ID, "glyphicon-download-alt");
|
||||
$http.get('/api/v1/messages/' + message.ID).success(function(data) {
|
||||
$scope.cache[message.ID] = data;
|
||||
data.previewHTML = $sce.trustAsHtml($scope.getMessageHTML(data));
|
||||
$scope.preview = data;
|
||||
preview = $scope.cache[message.ID];
|
||||
reflow();
|
||||
e.done();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$scope.toggleHeaders = function(val) {
|
||||
$scope.previewAllHeaders = val;
|
||||
var t = window.setInterval(function() {
|
||||
if(val) {
|
||||
if($('#hide-headers').length) {
|
||||
window.clearInterval(t);
|
||||
reflow();
|
||||
}
|
||||
} else {
|
||||
if($('#show-headers').length) {
|
||||
window.clearInterval(t);
|
||||
reflow();
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
$scope.tryDecodeContent = function(message, content) {
|
||||
var charset = "UTF-8"
|
||||
if(message.Content.Headers["Content-Type"][0]) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
if(message.Content.Headers["Content-Transfer-Encoding"][0]) {
|
||||
if(message.Content.Headers["Content-Transfer-Encoding"][0] == "quoted-printable") {
|
||||
content = unescapeFromQuotedPrintable(content, charset)
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
$scope.getMessagePlain = function(message) {
|
||||
var l = $scope.findMatchingMIME(message, "text/plain");
|
||||
if(l != null && l !== "undefined") {
|
||||
return $scope.tryDecode(l);
|
||||
}
|
||||
return message.Content.Body;
|
||||
}
|
||||
|
||||
$scope.findMatchingMIME = function(part, mime) {
|
||||
// TODO cache results
|
||||
if(part.MIME) {
|
||||
for(var p in part.MIME.Parts) {
|
||||
if("Content-Type" in part.MIME.Parts[p].Headers) {
|
||||
if(part.MIME.Parts[p].Headers["Content-Type"].length > 0) {
|
||||
if(part.MIME.Parts[p].Headers["Content-Type"][0].match(mime + ";?.*")) {
|
||||
return part.MIME.Parts[p];
|
||||
} else if (part.MIME.Parts[p].Headers["Content-Type"][0].match(/multipart\/.*/)) {
|
||||
var f = $scope.findMatchingMIME(part.MIME.Parts[p], mime);
|
||||
if(f != null) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
$scope.hasHTML = function(message) {
|
||||
// TODO cache this
|
||||
var l = $scope.findMatchingMIME(message, "text/html");
|
||||
if(l != null && l !== "undefined") {
|
||||
return true
|
||||
}
|
||||
return false;
|
||||
}
|
||||
$scope.getMessageHTML = function(message) {
|
||||
var l = $scope.findMatchingMIME(message, "text/html");
|
||||
if(l != null && l !== "undefined") {
|
||||
return $scope.tryDecode(l);
|
||||
}
|
||||
return "<HTML not found>";
|
||||
}
|
||||
|
||||
$scope.tryDecode = function(l){
|
||||
if(l.Headers && l.Headers["Content-Type"] && l.Headers["Content-Transfer-Encoding"]){
|
||||
return $scope.tryDecodeContent({Content:l},l.Body.replace(/=[\r\n]+/gm,""));
|
||||
}else{
|
||||
return l.Body;
|
||||
}
|
||||
};
|
||||
$scope.date = function(timestamp) {
|
||||
return (new Date(timestamp)).toString();
|
||||
};
|
||||
|
||||
$scope.deleteAll = function() {
|
||||
$('#confirm-delete-all').modal('show');
|
||||
}
|
||||
|
||||
$scope.releaseOne = function(message) {
|
||||
$scope.releasing = message;
|
||||
$('#release-one').modal('show');
|
||||
}
|
||||
$scope.confirmReleaseMessage = function() {
|
||||
$('#release-one').modal('hide');
|
||||
var message = $scope.releasing;
|
||||
$scope.releasing = null;
|
||||
|
||||
var e = $scope.startEvent("Releasing message", message.ID, "glyphicon-share");
|
||||
|
||||
$http.post('/api/v1/messages/' + message.ID + '/release', {
|
||||
email: $('#release-message-email').val(),
|
||||
host: $('#release-message-smtp-host').val(),
|
||||
port: $('#release-message-smtp-port').val(),
|
||||
}).success(function() {
|
||||
e.done();
|
||||
}).error(function(err) {
|
||||
e.fail();
|
||||
e.error = err;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.getSource = function(message) {
|
||||
var source = "";
|
||||
$.each(message.Content.Headers, function(k, v) {
|
||||
source += k + ": " + v + "\n";
|
||||
});
|
||||
source += "\n";
|
||||
source += message.Content.Body;
|
||||
return source;
|
||||
}
|
||||
|
||||
$scope.deleteAllConfirm = function() {
|
||||
$('#confirm-delete-all').modal('hide');
|
||||
var e = $scope.startEvent("Deleting all messages", null, "glyphicon-remove-circle");
|
||||
$http.delete('/api/v1/messages').success(function() {
|
||||
$scope.refresh();
|
||||
$scope.preview = null;
|
||||
e.done()
|
||||
});
|
||||
}
|
||||
|
||||
$scope.deleteOne = function(message) {
|
||||
var e = $scope.startEvent("Deleting message", message.ID, "glyphicon-remove");
|
||||
$http.delete('/api/v1/messages/' + message.ID).success(function() {
|
||||
if($scope.preview._id == message._id) $scope.preview = null;
|
||||
$scope.refresh();
|
||||
e.done();
|
||||
});
|
||||
}
|
||||
});
|
|
@ -1,980 +0,0 @@
|
|||
// -*- coding: utf-8 -*-
|
||||
|
||||
// GO-MAILHOG: This file borrowed from http://0xcc.net/jsescape/strutil.js
|
||||
|
||||
// Utility functions for strings.
|
||||
//
|
||||
// Copyright (C) 2007 Satoru Takabayashi <satoru 0xcc.net>
|
||||
// All rights reserved. This is free software with ABSOLUTELY NO WARRANTY.
|
||||
// You can redistribute it and/or modify it under the terms of
|
||||
// the GNU General Public License version 2.
|
||||
|
||||
// NOTES:
|
||||
//
|
||||
// Surrogate pairs:
|
||||
//
|
||||
// 1st 0xD800 - 0xDBFF (high surrogate)
|
||||
// 2nd 0xDC00 - 0xDFFF (low surrogate)
|
||||
//
|
||||
// UTF-8 sequences:
|
||||
//
|
||||
// 0xxxxxxx
|
||||
// 110xxxxx 10xxxxxx
|
||||
// 1110xxxx 10xxxxxx 10xxxxxx
|
||||
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
|
||||
|
||||
var EQUAL_SIGN = 0x3D;
|
||||
var QUESTION_MARK = 0x3F;
|
||||
|
||||
// "あい" => [ 0x3042, 0x3044 ]
|
||||
function convertStringToUnicodeCodePoints(str) {
|
||||
var surrogate_1st = 0;
|
||||
var unicode_codes = [];
|
||||
for (var i = 0; i < str.length; ++i) {
|
||||
var utf16_code = str.charCodeAt(i);
|
||||
if (surrogate_1st != 0) {
|
||||
if (utf16_code >= 0xDC00 && utf16_code <= 0xDFFF) {
|
||||
var surrogate_2nd = utf16_code;
|
||||
var unicode_code = (surrogate_1st - 0xD800) * (1 << 10) + (1 << 16) +
|
||||
(surrogate_2nd - 0xDC00);
|
||||
unicode_codes.push(unicode_code);
|
||||
} else {
|
||||
// Malformed surrogate pair ignored.
|
||||
}
|
||||
surrogate_1st = 0;
|
||||
} else if (utf16_code >= 0xD800 && utf16_code <= 0xDBFF) {
|
||||
surrogate_1st = utf16_code;
|
||||
} else {
|
||||
unicode_codes.push(utf16_code);
|
||||
}
|
||||
}
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ] => [ 0x3042, 0x3044 ]
|
||||
function convertUtf8BytesToUnicodeCodePoints(utf8_bytes) {
|
||||
var unicode_codes = [];
|
||||
var unicode_code = 0;
|
||||
var num_followed = 0;
|
||||
for (var i = 0; i < utf8_bytes.length; ++i) {
|
||||
var utf8_byte = utf8_bytes[i];
|
||||
if (utf8_byte >= 0x100) {
|
||||
// Malformed utf8 byte ignored.
|
||||
} else if ((utf8_byte & 0xC0) == 0x80) {
|
||||
if (num_followed > 0) {
|
||||
unicode_code = (unicode_code << 6) | (utf8_byte & 0x3f);
|
||||
num_followed -= 1;
|
||||
} else {
|
||||
// Malformed UTF-8 sequence ignored.
|
||||
}
|
||||
} else {
|
||||
if (num_followed == 0) {
|
||||
unicode_codes.push(unicode_code);
|
||||
} else {
|
||||
// Malformed UTF-8 sequence ignored.
|
||||
}
|
||||
if (utf8_byte < 0x80){ // 1-byte
|
||||
unicode_code = utf8_byte;
|
||||
num_followed = 0;
|
||||
} else if ((utf8_byte & 0xE0) == 0xC0) { // 2-byte
|
||||
unicode_code = utf8_byte & 0x1f;
|
||||
num_followed = 1;
|
||||
} else if ((utf8_byte & 0xF0) == 0xE0) { // 3-byte
|
||||
unicode_code = utf8_byte & 0x0f;
|
||||
num_followed = 2;
|
||||
} else if ((utf8_byte & 0xF8) == 0xF0) { // 4-byte
|
||||
unicode_code = utf8_byte & 0x07;
|
||||
num_followed = 3;
|
||||
} else {
|
||||
// Malformed UTF-8 sequence ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (num_followed == 0) {
|
||||
unicode_codes.push(unicode_code);
|
||||
} else {
|
||||
// Malformed UTF-8 sequence ignored.
|
||||
}
|
||||
unicode_codes.shift(); // Trim the first element.
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// Helper function.
|
||||
function convertEscapedCodesToCodes(str, prefix, base, num_bits) {
|
||||
var parts = str.split(prefix);
|
||||
parts.shift(); // Trim the first element.
|
||||
var codes = [];
|
||||
var max = Math.pow(2, num_bits);
|
||||
for (var i = 0; i < parts.length; ++i) {
|
||||
var code = parseInt(parts[i], base);
|
||||
if (code >= 0 && code < max) {
|
||||
codes.push(code);
|
||||
} else {
|
||||
// Malformed code ignored.
|
||||
}
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
// r'\u3042\u3044' => [ 0x3042, 0x3044 ]
|
||||
// Note that the r '...' notation is borrowed from Python.
|
||||
function convertEscapedUtf16CodesToUtf16Codes(str) {
|
||||
return convertEscapedCodesToCodes(str, "\\u", 16, 16);
|
||||
}
|
||||
|
||||
// r'\U00003042\U00003044' => [ 0x3042, 0x3044 ]
|
||||
function convertEscapedUtf32CodesToUnicodeCodePoints(str) {
|
||||
return convertEscapedCodesToCodes(str, "\\U", 16, 32);
|
||||
}
|
||||
|
||||
// r'\xE3\x81\x82\xE3\x81\x84' => [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ]
|
||||
// r'\343\201\202\343\201\204' => [ 0343, 0201, 0202, 0343, 0201, 0204 ]
|
||||
function convertEscapedBytesToBytes(str, base) {
|
||||
var prefix = (base == 16 ? "\\x" : "\\");
|
||||
return convertEscapedCodesToCodes(str, prefix, base, 8);
|
||||
}
|
||||
|
||||
// "&#12354;&#12356;" => [ 0x3042, 0x3044 ]
|
||||
// "&#x3042;&#x3044;" => [ 0x3042, 0x3044 ]
|
||||
function convertNumRefToUnicodeCodePoints(str, base) {
|
||||
var num_refs = str.split(";");
|
||||
num_refs.pop(); // Trim the last element.
|
||||
var unicode_codes = [];
|
||||
for (var i = 0; i < num_refs.length; ++i) {
|
||||
var decimal_str = num_refs[i].replace(/^&#x?/, "");
|
||||
var unicode_code = parseInt(decimal_str, base);
|
||||
unicode_codes.push(unicode_code);
|
||||
}
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// [ 0x3042, 0x3044 ] => [ 0x3042, 0x3044 ]
|
||||
// [ 0xD840, 0xDC0B ] => [ 0x2000B ] // A surrogate pair.
|
||||
function convertUnicodeCodePointsToUtf16Codes(unicode_codes) {
|
||||
var utf16_codes = [];
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var unicode_code = unicode_codes[i];
|
||||
if (unicode_code < (1 << 16)) {
|
||||
utf16_codes.push(unicode_code);
|
||||
} else {
|
||||
var first = ((unicode_code - (1 << 16)) / (1 << 10)) + 0xD800;
|
||||
var second = (unicode_code % (1 << 10)) + 0xDC00;
|
||||
utf16_codes.push(first)
|
||||
utf16_codes.push(second)
|
||||
}
|
||||
}
|
||||
return utf16_codes;
|
||||
}
|
||||
|
||||
// 0x3042 => [ 0xE3, 0x81, 0x82 ]
|
||||
function convertUnicodeCodePointToUtf8Bytes(unicode_code, base) {
|
||||
var utf8_bytes = [];
|
||||
if (unicode_code < 0x80) { // 1-byte
|
||||
utf8_bytes.push(unicode_code);
|
||||
} else if (unicode_code < (1 << 11)) { // 2-byte
|
||||
utf8_bytes.push((unicode_code >>> 6) | 0xC0);
|
||||
utf8_bytes.push((unicode_code & 0x3F) | 0x80);
|
||||
} else if (unicode_code < (1 << 16)) { // 3-byte
|
||||
utf8_bytes.push((unicode_code >>> 12) | 0xE0);
|
||||
utf8_bytes.push(((unicode_code >> 6) & 0x3f) | 0x80);
|
||||
utf8_bytes.push((unicode_code & 0x3F) | 0x80);
|
||||
} else if (unicode_code < (1 << 21)) { // 4-byte
|
||||
utf8_bytes.push((unicode_code >>> 18) | 0xF0);
|
||||
utf8_bytes.push(((unicode_code >> 12) & 0x3F) | 0x80);
|
||||
utf8_bytes.push(((unicode_code >> 6) & 0x3F) | 0x80);
|
||||
utf8_bytes.push((unicode_code & 0x3F) | 0x80);
|
||||
}
|
||||
return utf8_bytes;
|
||||
}
|
||||
|
||||
// [ 0x3042, 0x3044 ] => [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ]
|
||||
function convertUnicodeCodePointsToUtf8Bytes(unicode_codes) {
|
||||
var utf8_bytes = [];
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var bytes = convertUnicodeCodePointToUtf8Bytes(unicode_codes[i]);
|
||||
utf8_bytes = utf8_bytes.concat(bytes);
|
||||
}
|
||||
return utf8_bytes;
|
||||
}
|
||||
|
||||
// 0xff => "ff"
|
||||
// 0xff => "377"
|
||||
function formatNumber(number, base, num_digits) {
|
||||
var str = number.toString(base).toUpperCase();
|
||||
for (var i = str.length; i < num_digits; ++i) {
|
||||
str = "0" + str;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
var BASE64 =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
function encodeBase64Helper(data) {
|
||||
var encoded = [];
|
||||
if (data.length == 1) {
|
||||
encoded.push(BASE64.charAt(data[0] >> 2));
|
||||
encoded.push(BASE64.charAt(((data[0] & 3) << 4)));
|
||||
encoded.push('=');
|
||||
encoded.push('=');
|
||||
} else if (data.length == 2) {
|
||||
encoded.push(BASE64.charAt(data[0] >> 2));
|
||||
encoded.push(BASE64.charAt(((data[0] & 3) << 4) |
|
||||
(data[1] >> 4)));
|
||||
encoded.push(BASE64.charAt(((data[1] & 0xF) << 2)));
|
||||
encoded.push('=');
|
||||
} else if (data.length == 3) {
|
||||
encoded.push(BASE64.charAt(data[0] >> 2));
|
||||
encoded.push(BASE64.charAt(((data[0] & 3) << 4) |
|
||||
(data[1] >> 4)));
|
||||
encoded.push(BASE64.charAt(((data[1] & 0xF) << 2) |
|
||||
(data[2] >> 6)));
|
||||
encoded.push(BASE64.charAt(data[2] & 0x3f));
|
||||
}
|
||||
return encoded.join('');
|
||||
}
|
||||
|
||||
// "44GC44GE" => [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ]
|
||||
function decodeBase64(encoded) {
|
||||
var decoded_bytes = [];
|
||||
var data_bytes = [];
|
||||
for (var i = 0; i < encoded.length; i += 4) {
|
||||
data_bytes.length = 0;
|
||||
for (var j = i; j < i + 4; ++j) {
|
||||
var letter = encoded.charAt(j);
|
||||
if (letter == "=" || letter == "") {
|
||||
break;
|
||||
}
|
||||
var data_byte = BASE64.indexOf(letter);
|
||||
if (data_byte >= 64) { // Malformed base64 data.
|
||||
break;
|
||||
}
|
||||
data_bytes.push(data_byte);
|
||||
}
|
||||
if (data_bytes.length == 1) {
|
||||
// Malformed base64 data.
|
||||
} else if (data_bytes.length == 2) { // 12-bit.
|
||||
decoded_bytes.push((data_bytes[0] << 2) | (data_bytes[1] >> 4));
|
||||
} else if (data_bytes.length == 3) { // 18-bit.
|
||||
decoded_bytes.push((data_bytes[0] << 2) | (data_bytes[1] >> 4));
|
||||
decoded_bytes.push(((data_bytes[1] & 0xF) << 4) | (data_bytes[2] >> 2));
|
||||
} else if (data_bytes.length == 4) { // 24-bit.
|
||||
decoded_bytes.push((data_bytes[0] << 2) | (data_bytes[1] >> 4));
|
||||
decoded_bytes.push(((data_bytes[1] & 0xF) << 4) | (data_bytes[2] >> 2));
|
||||
decoded_bytes.push(((data_bytes[2] & 0x3) << 6) | (data_bytes[3]));
|
||||
}
|
||||
}
|
||||
return decoded_bytes;
|
||||
}
|
||||
|
||||
// [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ] => "44GC44GE"
|
||||
function encodeBase64(data_bytes) {
|
||||
var encoded = '';
|
||||
for (var i = 0; i < data_bytes.length; i += 3) {
|
||||
var at_most_three_bytes = data_bytes.slice(i, i + 3);
|
||||
encoded += encodeBase64Helper(at_most_three_bytes);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decodeQuotedPrintableHelper(str, prefix) {
|
||||
var decoded_bytes = [];
|
||||
for (var i = 0; i < str.length;) {
|
||||
if (str.charAt(i) == prefix) {
|
||||
decoded_bytes.push(parseInt(str.substr(i + 1, 2), 16));
|
||||
i += 3;
|
||||
} else {
|
||||
decoded_bytes.push(str.charCodeAt(i));
|
||||
++i;
|
||||
}
|
||||
}
|
||||
return decoded_bytes;
|
||||
}
|
||||
|
||||
// "=E3=81=82=E3=81=84" => [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ]
|
||||
function decodeQuotedPrintable(str) {
|
||||
str = str.replace(/_/g, " ") // RFC 2047.
|
||||
return decodeQuotedPrintableHelper(str, "=");
|
||||
}
|
||||
|
||||
// "%E3%81%82%E3%81%84" => [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ]
|
||||
function decodeUrl(str) {
|
||||
return decodeQuotedPrintableHelper(str, "%");
|
||||
}
|
||||
|
||||
function encodeQuotedPrintableHelper(data_bytes, prefix, should_escape) {
|
||||
var encoded = '';
|
||||
var prefix_code = prefix.charCodeAt(0);
|
||||
for (var i = 0; i < data_bytes.length; ++i) {
|
||||
var data_byte = data_bytes[i];
|
||||
if (should_escape(data_byte)) {
|
||||
encoded += prefix + formatNumber(data_bytes[i], 16, 2);
|
||||
} else {
|
||||
encoded += String.fromCharCode(data_byte);
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
// [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ] => "=E3=81=82=E3=81=84"
|
||||
function encodeQuotedPrintable(data_bytes) {
|
||||
var should_escape = function(b) {
|
||||
return b < 32 || b > 126 || b == EQUAL_SIGN || b == QUESTION_MARK;
|
||||
};
|
||||
return encodeQuotedPrintableHelper(data_bytes, '=', should_escape);
|
||||
}
|
||||
|
||||
var URL_SAFE =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-";
|
||||
|
||||
// [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ] => "%E3%81%82%E3%81%84"
|
||||
function encodeUrl(data_bytes) {
|
||||
var should_escape = function(b) {
|
||||
return URL_SAFE.indexOf(String.fromCharCode(b)) == -1;
|
||||
};
|
||||
return encodeQuotedPrintableHelper(data_bytes, '%', should_escape);
|
||||
}
|
||||
|
||||
// [ 0x3042, 0x3044 ] => "あい"
|
||||
function convertUtf16CodesToString(utf16_codes) {
|
||||
var unescaped = '';
|
||||
for (var i = 0; i < utf16_codes.length; ++i) {
|
||||
unescaped += String.fromCharCode(utf16_codes[i]);
|
||||
}
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
// [ 0x3042, 0x3044 ] => "あい"
|
||||
function convertUnicodeCodePointsToString(unicode_codes) {
|
||||
var utf16_codes = convertUnicodeCodePointsToUtf16Codes(unicode_codes);
|
||||
return convertUtf16CodesToString(utf16_codes);
|
||||
}
|
||||
|
||||
function maybeInitMaps(encoded_maps, to_unicode_map, from_unicode_map) {
|
||||
if (to_unicode_map.is_initialized) {
|
||||
return;
|
||||
}
|
||||
var data_types = [ 'ROUNDTRIP', 'INPUT_ONLY', 'OUTPUT_ONLY' ];
|
||||
for (var i = 0; i < data_types.length; ++i) {
|
||||
var data_type = data_types[i];
|
||||
var encoded_data = encoded_maps[data_type];
|
||||
var data_bytes = decodeBase64(encoded_data);
|
||||
for (var j = 0; j < data_bytes.length; j += 4) {
|
||||
var local_code = (data_bytes[j] << 8) | data_bytes[j + 1];
|
||||
var unicode_code = (data_bytes[j + 2] << 8) | data_bytes[j + 3];
|
||||
if (i == 0 || i == 1) { // ROUNDTRIP or INPUT_ONLY
|
||||
to_unicode_map[local_code] = unicode_code;
|
||||
}
|
||||
if (i == 0 || i == 2) { // ROUNDTRIP or OUTPUT_ONLY
|
||||
from_unicode_map[unicode_code] = local_code;
|
||||
}
|
||||
}
|
||||
}
|
||||
to_unicode_map.is_initialized = true;
|
||||
}
|
||||
|
||||
var SJIS_TO_UNICODE = {}
|
||||
var UNICODE_TO_SJIS = {}
|
||||
// Requires: sjis_map.js should be loaded.
|
||||
function maybeInitSjisMaps() {
|
||||
maybeInitMaps(SJIS_MAP_ENCODED, SJIS_TO_UNICODE, UNICODE_TO_SJIS);
|
||||
}
|
||||
|
||||
var ISO88591_TO_UNICODE = {}
|
||||
var UNICODE_TO_ISO88591 = {}
|
||||
// Requires: iso88591_map.js should be loaded.
|
||||
function maybeInitIso88591Maps() {
|
||||
maybeInitMaps(ISO88591_MAP_ENCODED, ISO88591_TO_UNICODE,
|
||||
UNICODE_TO_ISO88591);
|
||||
}
|
||||
|
||||
function lookupMapWithDefault(map, key, default_value) {
|
||||
var value = map[key];
|
||||
if (!value) {
|
||||
value = default_value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// [ 0x3042, 0x3044 ] => [ 0x82, 0xA0, 0x82, 0xA2 ]
|
||||
function convertUnicodeCodePointsToSjisBytes(unicode_codes) {
|
||||
maybeInitSjisMaps();
|
||||
var sjis_bytes = [];
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var unicode_code = unicode_codes[i];
|
||||
var sjis_code = lookupMapWithDefault(UNICODE_TO_SJIS,
|
||||
unicode_code, QUESTION_MARK);
|
||||
if (sjis_code <= 0xFF) { // 1-byte character.
|
||||
sjis_bytes.push(sjis_code);
|
||||
} else {
|
||||
sjis_bytes.push(sjis_code >> 8);
|
||||
sjis_bytes.push(sjis_code & 0xFF);
|
||||
}
|
||||
}
|
||||
return sjis_bytes;
|
||||
}
|
||||
|
||||
// [ 0x3042, 0x3044 ] => [ 0xA4, 0xA2, 0xA4, 0xA4 ]
|
||||
function convertUnicodeCodePointsToEucJpBytes(unicode_codes) {
|
||||
maybeInitSjisMaps();
|
||||
var eucjp_bytes = [];
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var unicode_code = unicode_codes[i];
|
||||
var sjis_code = lookupMapWithDefault(UNICODE_TO_SJIS, unicode_code,
|
||||
QUESTION_MARK);
|
||||
if (sjis_code > 0xFF) { // Double byte character.
|
||||
var jis_code = convertSjisCodeToJisX208Code(sjis_code);
|
||||
var eucjp_code = jis_code | 0x8080;
|
||||
eucjp_bytes.push(eucjp_code >> 8);
|
||||
eucjp_bytes.push(eucjp_code & 0xFF);
|
||||
} else if (sjis_code >= 0x80) { // 8-bit character.
|
||||
eucjp_bytes.push(0x8E);
|
||||
eucjp_bytes.push(sjis_code);
|
||||
} else { // 7-bit character.
|
||||
eucjp_bytes.push(sjis_code);
|
||||
}
|
||||
}
|
||||
return eucjp_bytes;
|
||||
}
|
||||
|
||||
|
||||
function convertUnicodeCodePointsToIso88591Bytes(unicode_codes) {
|
||||
maybeInitIso88591Maps();
|
||||
var latin_bytes = [];
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var unicode_code = unicode_codes[i];
|
||||
var latin_code = lookupMapWithDefault(UNICODE_TO_ISO88591,
|
||||
unicode_code, QUESTION_MARK);
|
||||
latin_bytes.push(latin_code);
|
||||
}
|
||||
return latin_bytes;
|
||||
}
|
||||
|
||||
// [ 0x82, 0xA0, 0x82, 0xA2 ] => [ 0x3042, 0x3044 ]
|
||||
function convertSjisBytesToUnicodeCodePoints(sjis_bytes) {
|
||||
maybeInitSjisMaps();
|
||||
var unicode_codes = [];
|
||||
for (var i = 0; i < sjis_bytes.length;) {
|
||||
var sjis_code = -1;
|
||||
var sjis_byte = sjis_bytes[i];
|
||||
if ((sjis_byte >= 0x81 && sjis_byte <= 0x9F) ||
|
||||
(sjis_byte >= 0xE0 && sjis_byte <= 0xFC)) {
|
||||
++i;
|
||||
var sjis_byte2 = sjis_bytes[i];
|
||||
if ((sjis_byte2 >= 0x40 && sjis_byte2 <= 0x7E) ||
|
||||
(sjis_byte2 >= 0x80 && sjis_byte2 <= 0xFC)) {
|
||||
sjis_code = (sjis_byte << 8) | sjis_byte2;
|
||||
++i;
|
||||
}
|
||||
} else {
|
||||
sjis_code = sjis_byte;
|
||||
++i;
|
||||
}
|
||||
|
||||
var unicode_code = lookupMapWithDefault(SJIS_TO_UNICODE,
|
||||
sjis_code, QUESTION_MARK);
|
||||
unicode_codes.push(unicode_code);
|
||||
}
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
function convertIso88591BytesToUnicodeCodePoints(latin_bytes) {
|
||||
maybeInitIso88591Maps();
|
||||
var unicode_codes = [];
|
||||
for (var i = 0; i < latin_bytes.length; ++i) {
|
||||
var latin_code = latin_bytes[i];
|
||||
var unicode_code = lookupMapWithDefault(ISO88591_TO_UNICODE,
|
||||
latin_code, QUESTION_MARK);
|
||||
unicode_codes.push(unicode_code);
|
||||
}
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// 0x2422 => 0x82a0
|
||||
function convertJisX208CodeToSjisCode(jis_code) {
|
||||
var j1 = jis_code >> 8;
|
||||
var j2 = jis_code & 0xFF;
|
||||
// http://people.debian.org/~kubota/unicode-symbols-map2.html.ja
|
||||
var s1 = ((j1 - 1) >> 1) + ((j1 <= 0x5E) ? 0x71 : 0xB1);
|
||||
var s2 = j2 + ((j1 & 1) ? ((j2 < 0x60) ? 0x1F : 0x20) : 0x7E);
|
||||
return (s1 << 8) | s2;
|
||||
}
|
||||
|
||||
// 0x82a0 => 0x2422
|
||||
function convertSjisCodeToJisX208Code(sjis_code) {
|
||||
var s1 = sjis_code >> 8;
|
||||
var s2 = sjis_code & 0xFF;
|
||||
// http://people.debian.org/~kubota/unicode-symbols-map2.html.ja
|
||||
var j1 = (s1 << 1) - (s1 <= 0x9f ? 0xe0 : 0x160) - (s2 < 0x9f ? 1 : 0);
|
||||
var j2 = s2 - 0x1f - (s2 >= 0x7f ? 1 : 0) - (s2 >= 0x9f ? 0x5e : 0);
|
||||
return (j1 << 8) | j2;
|
||||
}
|
||||
|
||||
// [ 0x24, 0x22, 0x24, 0x24 ] => [ 0x82, 0xA0, 0x82, 0xA2 ]
|
||||
function convertJisX208BytesToSjisBytes(jis_bytes) {
|
||||
var sjis_bytes = [];
|
||||
for (var i = 0; i < jis_bytes.length; i += 2) {
|
||||
var jis_code = (jis_bytes[i] << 8) | jis_bytes[i + 1];
|
||||
var sjis_code = convertJisX208CodeToSjisCode(jis_code);
|
||||
sjis_bytes.push(sjis_code >> 8);
|
||||
sjis_bytes.push(sjis_code & 0xFF);
|
||||
}
|
||||
return sjis_bytes;
|
||||
}
|
||||
|
||||
// [ 0x82, 0xA0, 0x82, 0xA2 ] => [ 0x24, 0x22, 0x24, 0x24 ]
|
||||
function convertSjisBytesToJisX208Bytes(sjis_bytes) {
|
||||
var jis_bytes = [];
|
||||
for (var i = 0; i < sjis_bytes.length; i += 2) {
|
||||
var sjis_code = (sjis_bytes[i] << 8) | sjis_bytes[i + 1];
|
||||
var jis_code = convertSjisCodeToJisX208Code(sjis_code);
|
||||
jis_bytes.push(jis_code >> 8);
|
||||
jis_bytes.push(jis_code & 0xFF);
|
||||
}
|
||||
return jis_bytes;
|
||||
}
|
||||
|
||||
// Constants used in convertJisBytesToUnicodeCodePoints().
|
||||
var ASCII = 0;
|
||||
var JISX201 = 1;
|
||||
var JISX208 = 2;
|
||||
|
||||
// Map used in convertIso2022JpBytesToUnicodeCodePoints().
|
||||
var ESCAPE_SEQUENCE_TO_MODE = {
|
||||
"(B": ASCII,
|
||||
"(J": JISX201,
|
||||
"$B": JISX208,
|
||||
"$@": JISX208
|
||||
};
|
||||
|
||||
// Map used in convertUnicodeCodePointsToIso2022JpBytes().
|
||||
var MODE_TO_ESCAPE_SEQUENCE = {}
|
||||
MODE_TO_ESCAPE_SEQUENCE[ASCII] = "(B";
|
||||
MODE_TO_ESCAPE_SEQUENCE[JISX201] = "(J";
|
||||
MODE_TO_ESCAPE_SEQUENCE[JISX208] = "$B";
|
||||
|
||||
// [ 0x1B, 0x24, 0x42, 0x24, 0x22, 0x1B, 0x28, 0x42, ] => [ 0x3042 ]
|
||||
function convertIso2022JpBytesToUnicodeCodePoints(iso2022jp_bytes) {
|
||||
maybeInitSjisMaps();
|
||||
var flush = function(mode, data_bytes, output) {
|
||||
var unicode_codes = [];
|
||||
if (mode == ASCII) {
|
||||
unicode_codes = data_bytes;
|
||||
} else if (mode == JISX201) { // Might have half-width Katakana?
|
||||
unicode_codes = convertSjisBytesToUnicodeCodePoints(data_bytes);
|
||||
} else if (mode == JISX208) {
|
||||
var sjis_bytes = convertJisX208BytesToSjisBytes(data_bytes);
|
||||
unicode_codes = convertSjisBytesToUnicodeCodePoints(sjis_bytes);
|
||||
} else { // Unknown mode
|
||||
}
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
output.push(unicode_codes[i]);
|
||||
}
|
||||
data_bytes.length = 0; // Clear.
|
||||
}
|
||||
|
||||
var unicode_codes = [];
|
||||
var mode = ASCII;
|
||||
var current_data_bytes = [];
|
||||
for (var i = 0; i < iso2022jp_bytes.length;) {
|
||||
if (iso2022jp_bytes[i] == 0x1B) { // Mode is changed.
|
||||
flush(mode, current_data_bytes, unicode_codes);
|
||||
++i;
|
||||
var code = String.fromCharCode(iso2022jp_bytes[i],
|
||||
iso2022jp_bytes[i + 1]);
|
||||
mode = ESCAPE_SEQUENCE_TO_MODE[code];
|
||||
if (!mode) { // Unknown mode.
|
||||
mode = ASCII;
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
current_data_bytes.push(iso2022jp_bytes[i]);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
flush(mode, current_data_bytes, unicode_codes);
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// [ 0xA4, 0xA2, 0xA4, 0xA4 ] => [ 0x3042, 0x3044 ]
|
||||
function convertEucJpBytesToUnicodeCodePoints(eucjp_bytes) {
|
||||
maybeInitSjisMaps();
|
||||
var unicode_codes = [];
|
||||
for (var i = 0; i < eucjp_bytes.length;) {
|
||||
if (eucjp_bytes[i] >= 0x80 && (i + 1) < eucjp_bytes.length &&
|
||||
eucjp_bytes[i + 1] >= 0x80) {
|
||||
var eucjp_code = (eucjp_bytes[i] << 8) | eucjp_bytes[i + 1];
|
||||
var jis_code = eucjp_code & 0x7F7F;
|
||||
var sjis_code = convertJisX208CodeToSjisCode(jis_code);
|
||||
var unicode_code = lookupMapWithDefault(SJIS_TO_UNICODE,
|
||||
sjis_code, QUESTION_MARK);
|
||||
unicode_codes.push(unicode_code);
|
||||
i += 2;
|
||||
} else {
|
||||
if (eucjp_bytes[i] < 0x80) {
|
||||
unicode_codes.push(eucjp_bytes[i]);
|
||||
} else {
|
||||
// Ignore singleton 8-bit byte.
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// [ 0x3042 ] => [ 0x1B, 0x24, 0x42, 0x24, 0x22, 0x1B, 0x28, 0x42, ]
|
||||
function convertUnicodeCodePointsToIso2022JpBytes(unicode_codes) {
|
||||
maybeInitSjisMaps();
|
||||
var mode = ASCII;
|
||||
var maybeChangeMode = function(new_mode) {
|
||||
if (mode != new_mode) {
|
||||
mode = new_mode;
|
||||
var esc_as_string = MODE_TO_ESCAPE_SEQUENCE[mode];
|
||||
var esc_as_code_points = convertStringToUnicodeCodePoints(esc_as_string);
|
||||
iso2022jp_bytes.push(0x1B); // ESC code.
|
||||
iso2022jp_bytes = iso2022jp_bytes.concat(esc_as_code_points);
|
||||
}
|
||||
}
|
||||
var iso2022jp_bytes = [];
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var unicode_code = unicode_codes[i];
|
||||
var sjis_code = lookupMapWithDefault(UNICODE_TO_SJIS, unicode_code,
|
||||
QUESTION_MARK);
|
||||
if (sjis_code > 0xFF) { // Double byte character.
|
||||
var jis_code = convertSjisCodeToJisX208Code(sjis_code);
|
||||
maybeChangeMode(JISX208);
|
||||
iso2022jp_bytes.push(jis_code >> 8);
|
||||
iso2022jp_bytes.push(jis_code & 0xFF);
|
||||
} else if (sjis_code >= 0x80) { // 8-bit character.
|
||||
maybeChangeMode(JISX201);
|
||||
iso2022jp_bytes.push(sjis_code);
|
||||
} else { // 7-bit character.
|
||||
maybeChangeMode(ASCII);
|
||||
iso2022jp_bytes.push(sjis_code);
|
||||
}
|
||||
}
|
||||
maybeChangeMode(ASCII);
|
||||
return iso2022jp_bytes;
|
||||
}
|
||||
|
||||
var MIME_FULL_MATCH = /^=\?([^?]+)\?([BQ])\?([^?]+)\?=$/;
|
||||
var MIME_PARTIAL_MATCH = /^=\?([^?]+)\?([BQ])\?([^?]+)\?=/;
|
||||
|
||||
// "=?UTF-8?B?44GC?=" => true
|
||||
// "foobar" => false
|
||||
function isMimeEncodedString(str) {
|
||||
return str.match(MIME_FULL_MATCH) != null;
|
||||
}
|
||||
|
||||
// "=?UTF-8?B?44GC?=" => ["UTF-8", [0xE3, 0x81, 0x82]]
|
||||
// "=?UTF-8?Q?=E3=81=82?=" => ["UTF-8", [0xE3, 0x81, 0x82]]
|
||||
// "INVALID" => []
|
||||
function decodeMime(str) {
|
||||
var m = str.match(MIME_FULL_MATCH);
|
||||
if (m) {
|
||||
var char_encoding = m[1];
|
||||
// We don't need the language information preceded by '*'.
|
||||
char_encoding = char_encoding.replace(/\*.*$/, "")
|
||||
var mime_encoding = m[2];
|
||||
var mime_str = m[3];
|
||||
var decoded_bytes;
|
||||
if (mime_encoding == "B") {
|
||||
decoded_bytes = decodeBase64(mime_str);
|
||||
} else if (mime_encoding == "Q") {
|
||||
decoded_bytes = decodeQuotedPrintable(mime_str);
|
||||
}
|
||||
if (char_encoding != "" && decoded_bytes) {
|
||||
return [char_encoding, decoded_bytes]
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
var OUTPUT_CONVERTERS = {
|
||||
'ISO2022JP': convertUnicodeCodePointsToIso2022JpBytes,
|
||||
'ISO88591': convertUnicodeCodePointsToIso88591Bytes,
|
||||
'SHIFTJIS': convertUnicodeCodePointsToSjisBytes,
|
||||
'EUCJP': convertUnicodeCodePointsToEucJpBytes,
|
||||
'UTF8': convertUnicodeCodePointsToUtf8Bytes
|
||||
}
|
||||
|
||||
var INPUT_CONVERTERS = {
|
||||
'ISO2022JP': convertIso2022JpBytesToUnicodeCodePoints,
|
||||
'ISO88591': convertIso88591BytesToUnicodeCodePoints,
|
||||
'SHIFTJIS': convertSjisBytesToUnicodeCodePoints,
|
||||
'EUCJP': convertEucJpBytesToUnicodeCodePoints,
|
||||
'UTF8': convertUtf8BytesToUnicodeCodePoints
|
||||
}
|
||||
|
||||
function convertUnicodeCodePointsToBytes(unicode_codes, encoding) {
|
||||
var normalized_encoding = normalizeEncodingName(encoding);
|
||||
var convert_function = OUTPUT_CONVERTERS[normalized_encoding];
|
||||
if (convert_function) {
|
||||
return convert_function(unicode_codes);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function convertBytesToUnicodeCodePoints(data_bytes, encoding) {
|
||||
var normalized_encoding = normalizeEncodingName(encoding);
|
||||
var convert_function = INPUT_CONVERTERS[normalized_encoding];
|
||||
if (convert_function) {
|
||||
return convert_function(data_bytes);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 'あい' => r'\u3042\u3044'
|
||||
function escapeToUtf16(str) {
|
||||
var escaped = ''
|
||||
for (var i = 0; i < str.length; ++i) {
|
||||
var hex = str.charCodeAt(i).toString(16).toUpperCase();
|
||||
escaped += "\\u" + "0000".substr(hex.length) + hex;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// 'あい' => r'\U00003042\U00003044'
|
||||
function escapeToUtf32(str) {
|
||||
var escaped = ''
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
for (var i = 0; i <unicode_codes.length; ++i) {
|
||||
var hex = unicode_codes[i].toString(16).toUpperCase();
|
||||
escaped += "\\U" + "00000000".substr(hex.length) + hex;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// "あい" => "あい"
|
||||
// "あい" => "あい"
|
||||
function escapeToNumRef(str, base) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var escaped = ''
|
||||
var prefix = base == 10 ? '' : 'x';
|
||||
for (var i = 0; i < unicode_codes.length; ++i) {
|
||||
var code = unicode_codes[i].toString(base).toUpperCase();
|
||||
var num_ref = "&#" + prefix + code + ";"
|
||||
escaped += num_ref;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// "あい" => "l8je"
|
||||
function escapeToPunyCode(str) {
|
||||
var unicode_codes = convertStringToPunyCodes(str);
|
||||
return convertUnicodeCodePointsToString(unicode_codes);
|
||||
}
|
||||
|
||||
// [ 0xE3, 0x81, 0x82, 0xE3, 0x81, 0x84 ] => '\xE3\x81\x82\xE3\x81\x84'
|
||||
// [ 0343, 0201, 0202, 0343, 0201, 0204 ] => '\343\201\202\343\201\204'
|
||||
function convertBytesToEscapedString(data_bytes, base) {
|
||||
var escaped = '';
|
||||
for (var i = 0; i < data_bytes.length; ++i) {
|
||||
var prefix = (base == 16 ? "\\x" : "\\");
|
||||
var num_digits = base == 16 ? 2 : 3;
|
||||
var escaped_byte = prefix + formatNumber(data_bytes[i], base, num_digits)
|
||||
escaped += escaped_byte;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// "あい" => [0x6C, 0x38, 0x6A, 0x65] // "l8je"
|
||||
// Requires: punycode.js should be loaded.
|
||||
function convertStringToPunyCodes(str) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var puny_codes = [];
|
||||
var result = "";
|
||||
if (PunyCode.encode(unicode_codes, puny_codes)) {
|
||||
return puny_codes;
|
||||
}
|
||||
return unicode_codes;
|
||||
}
|
||||
|
||||
// [ 0x6C, 0x38, 0x6A, 0x65 ] => "あい"
|
||||
// Requires: punycode.js should be loaded.
|
||||
function convertPunyCodesToString(puny_codes) {
|
||||
var unicode_codes = [];
|
||||
if (PunyCode.decode(puny_codes, unicode_codes)) {
|
||||
return convertUnicodeCodePointsToString(unicode_codes);
|
||||
}
|
||||
return convertUnicodeCodePointsToString(puny_codes);
|
||||
}
|
||||
|
||||
// "あい" => r'\xE3\x81\x82\xE3\x81\x84' // UTF-8
|
||||
// "あい" => r'\343\201\202\343\201\204' // UTF-8
|
||||
function escapeToEscapedBytes(str, base, encoding) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var data_bytes = convertUnicodeCodePointsToBytes(unicode_codes, encoding);
|
||||
return convertBytesToEscapedString(data_bytes, base);
|
||||
}
|
||||
|
||||
// "あい" => "44GC44GE" // UTF-8
|
||||
function escapeToBase64(str, encoding) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var data_bytes = convertUnicodeCodePointsToBytes(unicode_codes, encoding);
|
||||
return encodeBase64(data_bytes);
|
||||
}
|
||||
|
||||
// "あい" => "=E3=81=82=E3=81=84" // UTF-8
|
||||
function escapeToQuotedPrintable(str, encoding) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var data_bytes = convertUnicodeCodePointsToBytes(unicode_codes, encoding);
|
||||
return encodeQuotedPrintable(data_bytes);
|
||||
}
|
||||
|
||||
// "あい" => "%E3%81%82%E3%81%84"
|
||||
function escapeToUrl(str, encoding) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var data_bytes = convertUnicodeCodePointsToBytes(unicode_codes, encoding);
|
||||
return encodeUrl(data_bytes);
|
||||
}
|
||||
|
||||
// "あい" => "=?UTF-8?B?44GC44GE?="
|
||||
// "あい" => "=?UTF-8?Q?=E3=81=82=E3=81=84?="
|
||||
function escapeToMime(str, mime_encoding, char_encoding) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
var data_bytes = convertUnicodeCodePointsToBytes(unicode_codes,
|
||||
char_encoding);
|
||||
if (str == "") {
|
||||
return "";
|
||||
}
|
||||
var escaped = "=?" + char_encoding + "?";
|
||||
if (mime_encoding == 'base64') {
|
||||
escaped += "B?";
|
||||
escaped += encodeBase64(data_bytes);
|
||||
} else {
|
||||
escaped += "Q?";
|
||||
escaped += encodeQuotedPrintable(data_bytes);
|
||||
}
|
||||
escaped += '?=';
|
||||
return escaped;
|
||||
}
|
||||
|
||||
// r'\u3042\u3044 => "あい"
|
||||
function unescapeFromUtf16(str) {
|
||||
var utf16_codes = convertEscapedUtf16CodesToUtf16Codes(str);
|
||||
return convertUtf16CodesToString(utf16_codes);
|
||||
}
|
||||
|
||||
// r'\U00003042\U00003044 => "あい"
|
||||
function unescapeFromUtf32(str) {
|
||||
var unicode_codes = convertEscapedUtf32CodesToUnicodeCodePoints(str);
|
||||
var utf16_codes = convertUnicodeCodePointsToUtf16Codes(unicode_codes);
|
||||
return convertUtf16CodesToString(utf16_codes);
|
||||
}
|
||||
|
||||
// r'\xE3\x81\x82\xE3\x81\x84' => "あい"
|
||||
// r'\343\201\202\343\201\204' => "あい"
|
||||
function unescapeFromEscapedBytes(str, base, encoding) {
|
||||
var data_bytes = convertEscapedBytesToBytes(str, base);
|
||||
var unicode_codes = convertBytesToUnicodeCodePoints(data_bytes, encoding);
|
||||
return convertUnicodeCodePointsToString(unicode_codes);
|
||||
}
|
||||
|
||||
|
||||
// "あい" => "あい"
|
||||
// "あい" => "あい"
|
||||
function unescapeFromNumRef(str, base) {
|
||||
var unicode_codes = convertNumRefToUnicodeCodePoints(str, base);
|
||||
return convertUnicodeCodePointsToString(unicode_codes);
|
||||
}
|
||||
|
||||
// "l8je" => "あい"
|
||||
function unescapeFromPunyCode(str) {
|
||||
var unicode_codes = convertStringToUnicodeCodePoints(str);
|
||||
return convertPunyCodesToString(unicode_codes);
|
||||
}
|
||||
|
||||
// "44GC44GE" => "あい"
|
||||
function unescapeFromBase64(str, encoding) {
|
||||
var decoded_bytes = decodeBase64(str);
|
||||
var unicode_codes = convertBytesToUnicodeCodePoints(decoded_bytes, encoding);
|
||||
return convertUnicodeCodePointsToString(unicode_codes);
|
||||
}
|
||||
|
||||
// "=E3=81=82=E3=81=84" => "あい"
|
||||
function unescapeFromQuotedPrintable(str, encoding) {
|
||||
var decoded_bytes = decodeQuotedPrintable(str);
|
||||
var unicode_bytes = convertBytesToUnicodeCodePoints(decoded_bytes, encoding);
|
||||
return convertUnicodeCodePointsToString(unicode_bytes);
|
||||
}
|
||||
|
||||
// "%E3%81%82%E3%81%84" => "あい"
|
||||
function unescapeFromUrl(str, encoding) {
|
||||
var decoded_bytes = decodeUrl(str);
|
||||
var unicode_bytes = convertBytesToUnicodeCodePoints(decoded_bytes, encoding);
|
||||
return convertUnicodeCodePointsToString(unicode_bytes);
|
||||
}
|
||||
|
||||
// " " => true
|
||||
// " \n" => true
|
||||
function isEmptyOrSequenceOfWhiteSpaces(str) {
|
||||
for (var i = 0; i < str.length; ++i) {
|
||||
var code = str.charCodeAt(i);
|
||||
if (!(code == 0x09 || // TAB
|
||||
code == 0x0A || // LF
|
||||
code == 0x0D || // CR
|
||||
code == 0x20)) { // SPACE
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// "=?UTF-8?B?*?= =?UTF-8?B?*?=" => ["=?UTF-8?B?*?=", "=?UTF-8?B?*?="]
|
||||
// "=?UTF-8?B?*?=FOO" => ["=?UTF-8?B?*?=", "FOO"]
|
||||
function splitMimeString(str) {
|
||||
var parts = [];
|
||||
var current = "";
|
||||
while (str != "") {
|
||||
var m = str.match(MIME_PARTIAL_MATCH)
|
||||
if (m) {
|
||||
if (!isEmptyOrSequenceOfWhiteSpaces(current)) {
|
||||
parts.push(current);
|
||||
}
|
||||
current = "";
|
||||
parts.push(m[0]);
|
||||
str = str.substr(m[0].length);
|
||||
} else {
|
||||
current += str.charAt(0);
|
||||
str = str.substr(1);
|
||||
}
|
||||
}
|
||||
if (!isEmptyOrSequenceOfWhiteSpaces(current)) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// "UTF-8" => "UTF8"
|
||||
// "Shift_JIS" => "SHIFTJIS"
|
||||
function normalizeEncodingName(encoding) {
|
||||
return encoding.toUpperCase().replace(/[_-]/g, "");
|
||||
}
|
||||
|
||||
// "=?UTF-8?B?44GC44GE?=" => "あい"
|
||||
// "=?Shift_JIS?B?gqCCog==?=" => "あい"
|
||||
// "=?ISO-2022-JP?B?GyRCJCIkJBsoQg==?=" => "あい"
|
||||
// "=?UTF-8?Q?=E3=81=82=E3=81=84?=" => "あい"
|
||||
// "=?Shift_JIS?Q?=82=A0=82=A2?=" => "あい"
|
||||
// "=?ISO-2022-JP?Q?=1B$B$"$$=1B(B?=" => "あい"
|
||||
function unescapeFromMime(str) {
|
||||
var parts = splitMimeString(str);
|
||||
var unescaped = "";
|
||||
for (var i = 0; i < parts.length; ++i) {
|
||||
if (isMimeEncodedString(parts[i])) {
|
||||
var pair = decodeMime(parts[i]);
|
||||
if (pair.length == 0) { // Malformed MIME string. Skip it.
|
||||
continue;
|
||||
}
|
||||
var encoding = normalizeEncodingName(pair[0]);
|
||||
var data_bytes = pair[1];
|
||||
var unicode_codes = convertBytesToUnicodeCodePoints(data_bytes,
|
||||
encoding);
|
||||
unescaped += convertUnicodeCodePointsToString(unicode_codes);
|
||||
} else {
|
||||
unescaped += parts[i];
|
||||
}
|
||||
}
|
||||
return unescaped;
|
||||
}
|
|
@ -1,244 +0,0 @@
|
|||
<style>
|
||||
.messages {
|
||||
padding-top: 51px;
|
||||
height: 350px;
|
||||
border-bottom: 1px solid #999;
|
||||
overflow: hidden;
|
||||
}
|
||||
#messages-container {
|
||||
height: 100%;
|
||||
}
|
||||
.preview #headers {
|
||||
border-bottom: 1px solid #DDDDDD;
|
||||
}
|
||||
.selected {
|
||||
background: #0066CC;
|
||||
color: #fff;
|
||||
}
|
||||
table td, table th {
|
||||
padding: 2px 4px 2px 8px !important;
|
||||
}
|
||||
#messages-container table tbody {
|
||||
overflow: auto;
|
||||
height: 260px;
|
||||
}
|
||||
#messages-container table tbody td {
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table thead {
|
||||
background: #eee;
|
||||
}
|
||||
#messages-container > table > thead > tr, #messages-container > table > tbody {
|
||||
display: block;
|
||||
}
|
||||
table th:not(:last-child) {
|
||||
border-right: 2px solid #DDD;
|
||||
}
|
||||
table#headers {
|
||||
margin-bottom: 1px;
|
||||
background: #eee;
|
||||
}
|
||||
#content .nav>li>a {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.preview #headers tbody td {
|
||||
width: 100%;
|
||||
}
|
||||
.preview #headers tbody th {
|
||||
white-space: nowrap;
|
||||
padding-right: 10px !important;
|
||||
padding-left: 10px !important;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
#preview-plain, #preview-source {
|
||||
white-space: pre;
|
||||
font-family: Courier New, Courier, System, fixed-width;
|
||||
}
|
||||
.preview .tab-content {
|
||||
padding: 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.mime-part {
|
||||
padding: 10px;
|
||||
}
|
||||
.ui-resizable-handle.ui-resizable-s {
|
||||
background: #aaa;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
var columns = [15,15,40,20,10];
|
||||
var reflow = function() {
|
||||
var remaining = 0;
|
||||
if($('.preview').length > 0) {
|
||||
remaining = $(window).height() - $('.preview .nav-tabs').offset().top
|
||||
} else {
|
||||
remaining = $('#messages-container').offset().top + $('#messages-container').height();
|
||||
}
|
||||
remaining -= $('.resize_bar').height();
|
||||
$('.preview .tab-content').height(remaining - 32)
|
||||
$('#messages-container table').height($('#messages-container').height())
|
||||
$('#messages-container table tbody').height($('#messages-container').height() - $('#messages-container table thead').height())
|
||||
|
||||
var $table = $('#messages-container table');
|
||||
var colWidth = [];
|
||||
for(var i in columns) {
|
||||
colWidth[i] = $table.innerWidth() / 100 * columns[i];
|
||||
}
|
||||
|
||||
$table.find('thead tr').children().each(function(i, v) { $(v).width(colWidth[i]); });
|
||||
$table.find('tbody tr:first').children().each(function(i, v) { $(v).width(colWidth[i]); });
|
||||
}
|
||||
$(function() {
|
||||
$(".messages").resizable({"handles":"s"});
|
||||
reflow();
|
||||
$(window).resize(function() {
|
||||
reflow();
|
||||
}).resize();
|
||||
})
|
||||
</script>
|
||||
<div class="modal fade" id="confirm-delete-all">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Delete all messages?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete all messages?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" ng-click="deleteAllConfirm()">Delete all messages</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="release-one">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Release message</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>To release this message, enter a recipient and SMTP server address:</p>
|
||||
<form role="form">
|
||||
<div class="form-group">
|
||||
<label for="release-message-email">Email address</label>
|
||||
<input type="email" autofocus class="form-control" id="release-message-email" placeholder="someone@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="release-message-smtp-host">SMTP server</label>
|
||||
<input type="text" class="form-control" id="release-message-smtp-host" placeholder="mail.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="release-message-smtp-port">SMTP port</label>
|
||||
<input type="number" class="form-control" id="release-message-smtp-port" value="25">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" ng-click="confirmReleaseMessage()">Release message</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages">
|
||||
<div id="messages-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Subject</th>
|
||||
<th>Received</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="message in messages | filter:searchText" ng-click="selectMessage(message)" ng-class="{ selected: message.ID == preview.ID }">
|
||||
<td>
|
||||
{{ message.From.Mailbox }}@{{ message.From.Domain }}
|
||||
</td>
|
||||
<td>
|
||||
<span ng-repeat="to in message.To">
|
||||
{{ to.Mailbox }}@{{ to.Domain }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ tryDecodeMime(message.Content.Headers["Subject"][0]) }}
|
||||
</td>
|
||||
<td>
|
||||
{{ date(message.Created) }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-default" title="Delete" ng-click="deleteOne(message)"><span class="glyphicon glyphicon-remove"></span></button>
|
||||
<a class="btn btn-xs btn-default" title="Download" href="/api/v1/messages/{{ message.Id }}/download"><span class="glyphicon glyphicon-save"></span></a>
|
||||
<button class="btn btn-xs btn-default" title="Release" ng-click="releaseOne(message)"><span class="glyphicon glyphicon-share"></span></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview" ng-if="preview">
|
||||
<table class="table" id="headers">
|
||||
<tbody ng-if="!previewAllHeaders">
|
||||
<tr>
|
||||
<th>From</th>
|
||||
<td>{{ tryDecodeMime(preview.Content.Headers["From"][0]) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Subject</th>
|
||||
<td><strong>{{ tryDecodeMime(preview.Content.Headers["Subject"][0]) }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>To</th>
|
||||
<td>
|
||||
<button id="show-headers" ng-click="toggleHeaders(true)" type="button" class="btn btn-default pull-right btn-xs">Show headers <span class="glyphicon glyphicon-chevron-down"></span></button>
|
||||
{{ tryDecodeMime(preview.Content.Headers["To"].join(', ')) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody ng-if="previewAllHeaders">
|
||||
<tr ng-repeat="(header, value) in preview.Content.Headers">
|
||||
<th>
|
||||
{{ tryDecodeMime(header) }}
|
||||
</th>
|
||||
<td>
|
||||
<button id="hide-headers" ng-if="$last" ng-click="toggleHeaders(false)" type="button" class="btn btn-default pull-right btn-xs">Hide headers <span class="glyphicon glyphicon-chevron-up"></span></button>
|
||||
<div ng-repeat="v in value">{{ v }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="content">
|
||||
<ul class="nav nav-tabs">
|
||||
<li ng-if="hasHTML(preview)" ng-class="{ active: hasHTML(preview) }"><a href="#preview-html" data-toggle="tab">HTML</a></li>
|
||||
<li ng-class="{ active: !hasHTML(preview) }"><a href="#preview-plain" data-toggle="tab">Plain text</a></li>
|
||||
<li><a href="#preview-source" data-toggle="tab">Source</a></li>
|
||||
<li ng-if="preview.MIME"><a href="#preview-mime" data-toggle="tab">MIME</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div ng-if="hasHTML(preview)" ng-class="{ active: hasHTML(preview) }" class="tab-pane" id="preview-html" ng-bind-html="preview.previewHTML"></div>
|
||||
<div class="tab-pane" ng-class="{ active: !hasHTML(preview) }" id="preview-plain">{{ getMessagePlain(preview) }}</div>
|
||||
<div class="tab-pane" id="preview-source">{{ getSource(preview) }}</div>
|
||||
<div class="tab-pane" id="preview-mime">
|
||||
<div ng-repeat="part in preview.MIME.Parts" class="mime-part">
|
||||
<a href="/api/v1/messages/{{ preview.Id }}/mime/part/{{ $index }}/download" type="button" class="btn btn-default btn-sm">
|
||||
<span class="glyphicon glyphicon-save"></span>
|
||||
Download
|
||||
</a>
|
||||
{{ part.Headers["Content-Type"][0] || "Unknown type" }} ({{ part.Size }} bytes)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,146 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html ng-app="mailhogApp">
|
||||
<head>
|
||||
<title>MailHog</title>
|
||||
<script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
|
||||
<script src="//code.jquery.com/ui/1.10.4/jquery-ui.min.js"></script>
|
||||
<link rel="stylesheet" href="//code.jquery.com/ui/1.10.4/themes/smoothness/jquery-ui.css">
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap-theme.min.css">
|
||||
<script src="//netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.2.15/angular.js"></script>
|
||||
<script src="/js/strutil.js"></script>
|
||||
<script src="/js/controllers.js"></script>
|
||||
<style>
|
||||
body, html { height: 100%; overflow: hidden; }
|
||||
.navbar {
|
||||
margin-bottom: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.navbar-header img {
|
||||
height: 35px;
|
||||
margin: 8px 0 0 5px;
|
||||
float: left;
|
||||
}
|
||||
.navbar-nav.navbar-right:last-child {
|
||||
margin-right: 0; /* bootstrap fix?! */
|
||||
}
|
||||
.ajax-loader {
|
||||
background: url('/images/ajax-loader.gif');
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
.ajax-event {
|
||||
padding: 5px;
|
||||
width: 225px;
|
||||
}
|
||||
.ajax-event h1 {
|
||||
font-size: 1em;
|
||||
padding: 2px;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.ajax-event h2 {
|
||||
font-size: 0.8em;
|
||||
padding: 2px;
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.ajax-event .glyphicon {
|
||||
float: left;
|
||||
padding: 1px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dropdown-menu .glyphicon {
|
||||
padding: 1px;
|
||||
margin-right: 5px;
|
||||
color: #666;
|
||||
}
|
||||
.dropdown-menu > li > a {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
/* http://stackoverflow.com/questions/18838964/add-bootstrap-glyphicon-to-input-box */
|
||||
.left-inner-addon {
|
||||
position: relative;
|
||||
}
|
||||
.left-inner-addon input {
|
||||
padding-left: 30px;
|
||||
}
|
||||
.left-inner-addon i {
|
||||
color: #aaa;
|
||||
position: absolute;
|
||||
padding: 9px 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ev_good {
|
||||
color: green;
|
||||
}
|
||||
.ev_bad {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body ng-controller="MailCtrl">
|
||||
<nav class="navbar navbar-default navbar-static-top" role="navigation">
|
||||
<div class="navbar-header">
|
||||
<img src="/images/hog.png">
|
||||
<a class="navbar-brand" href="#">MailHog</a>
|
||||
</div>
|
||||
<ul class="nav navbar-nav navbar-left navbar-subtext">
|
||||
<li>
|
||||
<a>[: .config.Hostname :]</a>
|
||||
</li>
|
||||
<li>
|
||||
<a>{{ messagesDisplayed() }} {{ searchText ? "of " + messages.length : "" }} messages</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<li ng-if="eventCount > 0" class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<span ng-if="eventCount > eventDone" class="ajax-loader"></span>
|
||||
{{ eventCount - eventDone }} pending<span ng-if="eventFailed > 0"> ({{ eventFailed}} failed)</span> <span ng-if="eventDone > 0"> ({{ eventDone}} complete)</span> <b class="caret"></b>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-class="e.getClass()" ng-repeat="(id, e) in eventsPending" class="ajax-event">
|
||||
<span class="glyphicon {{ e.glyphicon }}"></span>
|
||||
<button ng-if="e.failed" ng-click="e.remove()" class="btn btn-xs btn-danger pull-right"><span class="glyphicon glyphicon-remove"></span></button>
|
||||
<h1>{{ e.name }}</h1>
|
||||
<h2 ng-if="e.args">{{ e.args }}</h2>
|
||||
<h2 ng-if="e.error">{{ e.error }}</h2>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" title="Event stream connected" ng-click="toggleStream()">
|
||||
<span class="glyphicon glyphicon-asterisk {{ hasEventSource ? 'ev_good' : 'ev_bad' }}"></span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form class="navbar-form navbar-left" role="search">
|
||||
<div class="form-group left-inner-addon">
|
||||
<i class="glyphicon glyphicon-search"></i>
|
||||
<input ng-model="searchText" type="text" class="form-control" placeholder="Search">
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
<li class="dropdown">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Options <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#" ng-click="refresh()"><span class="glyphicon glyphicon-download"></span> Refresh</a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="#" ng-click="deleteAll()"><span class="glyphicon glyphicon-remove-circle"></span> Delete all messages</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a target="_blank" href="https://github.com/ian-kent/Go-MailHog"><img src="/images/github.png" style="width: 16px;" /> GitHub</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
[: .Content :]
|
||||
</body>
|
||||
</html>
|
|
@ -1,42 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-UI/assets"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-UI/web"
|
||||
"github.com/ian-kent/Go-MailHog/http"
|
||||
"github.com/ian-kent/go-log/log"
|
||||
gotcha "github.com/ian-kent/gotcha/app"
|
||||
)
|
||||
|
||||
var conf *config.Config
|
||||
var exitCh chan int
|
||||
|
||||
func configure() {
|
||||
config.RegisterFlags()
|
||||
flag.Parse()
|
||||
conf = config.Configure()
|
||||
}
|
||||
|
||||
func main() {
|
||||
configure()
|
||||
|
||||
// FIXME need to make API URL configurable
|
||||
|
||||
exitCh = make(chan int)
|
||||
cb := func(app *gotcha.App) {
|
||||
web.CreateWeb(conf, app)
|
||||
}
|
||||
go http.Listen(conf, assets.Asset, exitCh, cb)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-exitCh:
|
||||
log.Printf("Received exit signal")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
gotcha "github.com/ian-kent/gotcha/app"
|
||||
"github.com/ian-kent/gotcha/events"
|
||||
"github.com/ian-kent/gotcha/http"
|
||||
)
|
||||
|
||||
type Web struct {
|
||||
config *config.Config
|
||||
app *gotcha.App
|
||||
}
|
||||
|
||||
func CreateWeb(cfg *config.Config, app *gotcha.App) *Web {
|
||||
app.On(events.BeforeHandler, func(session *http.Session, next func()) {
|
||||
session.Stash["config"] = cfg
|
||||
next()
|
||||
})
|
||||
|
||||
r := app.Router
|
||||
|
||||
r.Get("/images/(?P<file>.*)", r.Static("assets/images/{{file}}"))
|
||||
r.Get("/js/(?P<file>.*)", r.Static("assets/js/{{file}}"))
|
||||
r.Get("/", Index)
|
||||
|
||||
app.Config.LeftDelim = "[:"
|
||||
app.Config.RightDelim = ":]"
|
||||
|
||||
return &Web{
|
||||
config: cfg,
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
|
||||
func Index(session *http.Session) {
|
||||
html, _ := session.RenderTemplate("index.html")
|
||||
|
||||
session.Stash["Page"] = "Browse"
|
||||
session.Stash["Content"] = template.HTML(html)
|
||||
session.Render("layout.html")
|
||||
}
|
|
@ -4,14 +4,14 @@ import (
|
|||
"flag"
|
||||
"os"
|
||||
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/api"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/smtp"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-UI/assets"
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-UI/web"
|
||||
"github.com/ian-kent/Go-MailHog/http"
|
||||
"github.com/ian-kent/go-log/log"
|
||||
gotcha "github.com/ian-kent/gotcha/app"
|
||||
"github.com/mailhog/MailHog-Server/api"
|
||||
"github.com/mailhog/MailHog-Server/config"
|
||||
"github.com/mailhog/MailHog-Server/smtp"
|
||||
"github.com/mailhog/MailHog-UI/assets"
|
||||
"github.com/mailhog/MailHog-UI/web"
|
||||
)
|
||||
|
||||
var conf *config.Config
|
||||
|
|
15
Makefile
15
Makefile
|
@ -1,19 +1,10 @@
|
|||
DEPS = $(go list -f '{{range .TestImports}}{{.}} {{end}}' ./...)
|
||||
|
||||
all: deps bindata fmt combined
|
||||
all: deps fmt combined
|
||||
|
||||
combined:
|
||||
go install ./MailHog
|
||||
|
||||
server:
|
||||
go install ./MailHog-Server
|
||||
|
||||
ui:
|
||||
go install ./MailHog-UI
|
||||
|
||||
bindata:
|
||||
go-bindata -o MailHog-UI/assets/assets.go -pkg assets -prefix MailHog-UI/ MailHog-UI/assets/...
|
||||
|
||||
release: release-deps
|
||||
gox -output="build/{{.Dir}}_{{.OS}}_{{.Arch}}" ./MailHog
|
||||
|
||||
|
@ -21,6 +12,8 @@ fmt:
|
|||
go fmt ./...
|
||||
|
||||
deps:
|
||||
go get github.com/mailhog/MailHog-Server
|
||||
go get github.com/mailhog/MailHog-UI
|
||||
go get github.com/ian-kent/gotcha/gotcha
|
||||
go get github.com/ian-kent/go-log/log
|
||||
go get github.com/ian-kent/envconf
|
||||
|
@ -38,4 +31,4 @@ test-deps:
|
|||
release-deps:
|
||||
go get github.com/mitchellh/gox
|
||||
|
||||
.PNONY: all combined server ui bindata release fmt test-deps release-deps
|
||||
.PNONY: all combined release fmt deps test-deps release-deps
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"github.com/ian-kent/Go-MailHog/MailHog-Server/config"
|
||||
"github.com/ian-kent/go-log/log"
|
||||
gotcha "github.com/ian-kent/gotcha/app"
|
||||
"github.com/mailhog/MailHog-Server/config"
|
||||
)
|
||||
|
||||
func Listen(cfg *config.Config, Asset func(string) ([]byte, error), exitCh chan int, registerCallback func(*gotcha.App)) {
|
||||
|
|
Loading…
Reference in a new issue