├── .gitignore ├── LICENSE ├── README.md ├── cmd └── zns │ └── main.go ├── go.mod ├── go.sum ├── http.go ├── misc └── systemd │ ├── zns-tcp.socket │ ├── zns-udp.socket │ └── zns.service ├── pay.go ├── ticket.go ├── ticket_test.go └── web ├── app.js ├── index.html ├── qrcode.js └── style.css /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | cmd/zns/zns 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 go-kiss 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZNS - Simple DoH server in Go 2 | 3 | ## 安装 4 | 5 | ```bash 6 | go install github.com/taoso/zns/cmd/zns@latest 7 | ``` 8 | 9 | ## 运行 10 | 11 | ```bash 12 | zns -free -tls-hosts zns.example.org -root /var/www/html 13 | ``` 14 | 15 | ## 原理 16 | 17 | 18 | 19 | ## 星标趋势 20 | 21 | 22 | 23 | 24 | 25 | Star History Chart 26 | 27 | 28 | -------------------------------------------------------------------------------- /cmd/zns/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/quic-go/quic-go/http3" 15 | "github.com/taoso/zns" 16 | "golang.org/x/crypto/acme/autocert" 17 | ) 18 | 19 | var tlsCert string 20 | var tlsKey string 21 | var tlsHosts string 22 | var h12, h3 string 23 | var upstream string 24 | var dbPath string 25 | var price int 26 | var free bool 27 | var root string 28 | 29 | func listen() (lnH12 net.Listener, lnH3 net.PacketConn, err error) { 30 | if os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()) { 31 | if os.Getenv("LISTEN_FDS") != "2" { 32 | panic("LISTEN_FDS should be 2") 33 | } 34 | names := strings.Split(os.Getenv("LISTEN_FDNAMES"), ":") 35 | for i, name := range names { 36 | switch name { 37 | case "h12": 38 | f := os.NewFile(uintptr(i+3), "https port") 39 | lnH12, err = net.FileListener(f) 40 | case "h3": 41 | f := os.NewFile(uintptr(i+3), "quic port") 42 | lnH3, err = net.FilePacketConn(f) 43 | } 44 | } 45 | } else { 46 | if h12 != "" { 47 | lnH12, err = net.Listen("tcp", h12) 48 | if err != nil { 49 | return 50 | } 51 | } 52 | if h3 != "" { 53 | lnH3, err = net.ListenPacket("udp", h3) 54 | } 55 | } 56 | return 57 | } 58 | 59 | func main() { 60 | flag.StringVar(&tlsCert, "tls-cert", "", "File path of TLS certificate") 61 | flag.StringVar(&tlsKey, "tls-key", "", "File path of TLS key") 62 | flag.StringVar(&tlsHosts, "tls-hosts", "", "Host name for ACME") 63 | flag.StringVar(&h12, "h12", ":443", "Listen address for http1 and h2") 64 | flag.StringVar(&h3, "h3", ":443", "Listen address for h3") 65 | flag.StringVar(&upstream, "upstream", "https://doh.pub/dns-query", "DoH upstream URL") 66 | flag.StringVar(&dbPath, "db", "", "File path of Sqlite database") 67 | flag.StringVar(&root, "root", ".", "Root path of static files") 68 | flag.IntVar(&price, "price", 1024, "Traffic price MB/Yuan") 69 | flag.BoolVar(&free, "free", false, `Whether allow free access. 70 | If not free, you should set the following environment variables: 71 | - ALIPAY_APP_ID 72 | - ALIPAY_PRIVATE_KEY 73 | - ALIPAY_PUBLIC_KEY 74 | `) 75 | 76 | flag.Parse() 77 | 78 | var tlsCfg *tls.Config 79 | if tlsHosts != "" { 80 | acm := autocert.Manager{ 81 | Prompt: autocert.AcceptTOS, 82 | Cache: autocert.DirCache(os.Getenv("HOME") + "/.autocert"), 83 | HostPolicy: autocert.HostWhitelist(strings.Split(tlsHosts, ",")...), 84 | } 85 | 86 | tlsCfg = acm.TLSConfig() 87 | } else { 88 | tlsCfg = &tls.Config{} 89 | certs, err := tls.LoadX509KeyPair(tlsCert, tlsKey) 90 | if err != nil { 91 | panic(err) 92 | } 93 | tlsCfg.Certificates = []tls.Certificate{certs} 94 | } 95 | 96 | lnH12, lnH3, err := listen() 97 | if err != nil { 98 | panic(err) 99 | } 100 | 101 | var pay zns.Pay 102 | var repo zns.TicketRepo 103 | if free { 104 | repo = zns.FreeTicketRepo{} 105 | } else { 106 | repo = zns.NewTicketRepo(dbPath) 107 | pay = zns.NewPay( 108 | os.Getenv("ALIPAY_APP_ID"), 109 | os.Getenv("ALIPAY_PRIVATE_KEY"), 110 | os.Getenv("ALIPAY_PUBLIC_KEY"), 111 | ) 112 | } 113 | 114 | h := &zns.Handler{Upstream: upstream, Repo: repo, Root: http.Dir(root)} 115 | th := &zns.TicketHandler{MBpCNY: price, Pay: pay, Repo: repo} 116 | 117 | mux := http.NewServeMux() 118 | mux.Handle("/dns/{token}", h) 119 | mux.Handle("/ticket/", th) 120 | mux.Handle("/ticket/{token}", th) 121 | mux.Handle("/", http.FileServer(h.Root)) 122 | 123 | if lnH3 != nil { 124 | p := lnH3.LocalAddr().(*net.UDPAddr).Port 125 | h.AltSvc = fmt.Sprintf(`h3=":%d"`, p) 126 | th.AltSvc = h.AltSvc 127 | 128 | h3 := http3.Server{Handler: mux, TLSConfig: tlsCfg} 129 | go h3.Serve(lnH3) 130 | } 131 | 132 | lnTLS := tls.NewListener(lnH12, tlsCfg) 133 | if err = http.Serve(lnTLS, mux); err != nil { 134 | log.Fatal(err) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/taoso/zns 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/go-kiss/sqlx v0.0.0-20250514141631-7be2cb31cba2 9 | github.com/miekg/dns v1.1.66 10 | github.com/quic-go/quic-go v0.51.0 11 | github.com/smartwalle/alipay/v3 v3.2.25 12 | github.com/stretchr/testify v1.10.0 13 | golang.org/x/crypto v0.38.0 14 | modernc.org/sqlite v1.37.1 15 | ) 16 | 17 | require ( 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/dustin/go-humanize v1.0.1 // indirect 20 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 21 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect 22 | github.com/google/uuid v1.6.0 // indirect 23 | github.com/jmoiron/sqlx v1.4.0 // indirect 24 | github.com/mattn/go-isatty v0.0.20 // indirect 25 | github.com/ncruces/go-strftime v0.1.9 // indirect 26 | github.com/onsi/ginkgo/v2 v2.23.4 // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | github.com/quic-go/qpack v0.5.1 // indirect 29 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 30 | github.com/smartwalle/ncrypto v1.0.4 // indirect 31 | github.com/smartwalle/ngx v1.0.10 // indirect 32 | github.com/smartwalle/nsign v1.0.9 // indirect 33 | go.uber.org/automaxprocs v1.6.0 // indirect 34 | go.uber.org/mock v0.5.2 // indirect 35 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 36 | golang.org/x/mod v0.24.0 // indirect 37 | golang.org/x/net v0.40.0 // indirect 38 | golang.org/x/sync v0.14.0 // indirect 39 | golang.org/x/sys v0.33.0 // indirect 40 | golang.org/x/text v0.25.0 // indirect 41 | golang.org/x/tools v0.33.0 // indirect 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | modernc.org/libc v1.65.8 // indirect 44 | modernc.org/mathutil v1.7.1 // indirect 45 | modernc.org/memory v1.11.0 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 6 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 7 | github.com/go-kiss/sqlx v0.0.0-20250514141631-7be2cb31cba2 h1:UtYDAuXfuWHBZtzE97OEHokPKvwV+JP7J6POk+S2L8Q= 8 | github.com/go-kiss/sqlx v0.0.0-20250514141631-7be2cb31cba2/go.mod h1:ifMW4IAa4MhViphziwc/Onp3r1XPjAYkPHkUtDMaYeY= 9 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 10 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 11 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 12 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 13 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 14 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 15 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4= 18 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= 19 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 20 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 22 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 23 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 24 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 25 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 26 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 27 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 28 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 29 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 30 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 31 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 32 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 33 | github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= 34 | github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= 35 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 36 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 37 | github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= 38 | github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= 39 | github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= 40 | github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 44 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 45 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 46 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 47 | github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc= 48 | github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= 49 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 50 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 51 | github.com/smartwalle/alipay/v3 v3.2.25 h1:cRDN+fpDWTVHnuHIF/vsJETskRXS/S+fDOdAkzXmV/Q= 52 | github.com/smartwalle/alipay/v3 v3.2.25/go.mod h1:lVqFiupPf8YsAXaq5JXcwqnOUC2MCF+2/5vub+RlagE= 53 | github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8= 54 | github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk= 55 | github.com/smartwalle/ngx v1.0.10 h1:L0bHJ+SD4gTY+RxCYGAY866WxsDniW8n0yEesk3OWCw= 56 | github.com/smartwalle/ngx v1.0.10/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= 57 | github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= 58 | github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= 59 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 60 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 61 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 62 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 63 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= 64 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 65 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 66 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 67 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 68 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 69 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 70 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 71 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 72 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 73 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 74 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 75 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 77 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 78 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 79 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 80 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 81 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 82 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 83 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 86 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 88 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= 90 | modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 91 | modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= 92 | modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 93 | modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= 94 | modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 95 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 96 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 97 | modernc.org/libc v1.65.8 h1:7PXRJai0TXZ8uNA3srsmYzmTyrLoHImV5QxHeni108Q= 98 | modernc.org/libc v1.65.8/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= 99 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 100 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 101 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 102 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 103 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 104 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 105 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 106 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 107 | modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= 108 | modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= 109 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 110 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 111 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 112 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 113 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package zns 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/netip" 10 | 11 | "github.com/miekg/dns" 12 | ) 13 | 14 | type Handler struct { 15 | Upstream string 16 | Repo TicketRepo 17 | AltSvc string 18 | Root http.Dir 19 | } 20 | 21 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 22 | if h.AltSvc != "" { 23 | w.Header().Set("Alt-Svc", h.AltSvc) 24 | } 25 | 26 | token := r.PathValue("token") 27 | if token == "" { 28 | http.Error(w, "invalid token", http.StatusUnauthorized) 29 | return 30 | } 31 | 32 | ts, err := h.Repo.List(token, 1) 33 | if err != nil { 34 | http.Error(w, "invalid token", http.StatusInternalServerError) 35 | return 36 | } 37 | if len(ts) == 0 || ts[0].Bytes <= 0 { 38 | http.Error(w, "invalid token", http.StatusUnauthorized) 39 | return 40 | } 41 | 42 | var question []byte 43 | if r.Method == http.MethodGet { 44 | q := r.URL.Query().Get("dns") 45 | if q == "" { 46 | f, err := h.Root.Open("/index.html") 47 | if err != nil { 48 | http.Error(w, err.Error(), http.StatusInternalServerError) 49 | return 50 | } 51 | io.Copy(w, f) 52 | return 53 | } 54 | question, err = base64.RawURLEncoding.DecodeString(q) 55 | } else { 56 | question, err = io.ReadAll(r.Body) 57 | r.Body.Close() 58 | } 59 | if err != nil { 60 | http.Error(w, err.Error(), http.StatusBadRequest) 61 | return 62 | } 63 | 64 | var m dns.Msg 65 | if err := m.Unpack(question); err != nil { 66 | http.Error(w, err.Error(), http.StatusBadRequest) 67 | return 68 | } 69 | 70 | var hasSubnet bool 71 | if e := m.IsEdns0(); e != nil { 72 | for _, o := range e.Option { 73 | if o.Option() == dns.EDNS0SUBNET { 74 | a := o.(*dns.EDNS0_SUBNET).Address[:2] 75 | // skip empty subnet like 0.0.0.0/0 76 | if !bytes.HasPrefix(a, []byte{0, 0}) { 77 | hasSubnet = true 78 | } 79 | break 80 | } 81 | } 82 | } 83 | 84 | if !hasSubnet { 85 | ip, err := netip.ParseAddrPort(r.RemoteAddr) 86 | if err != nil { 87 | http.Error(w, err.Error(), http.StatusInternalServerError) 88 | return 89 | } 90 | addr := ip.Addr() 91 | opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}} 92 | ecs := &dns.EDNS0_SUBNET{Code: dns.EDNS0SUBNET} 93 | var bits int 94 | if addr.Is4() { 95 | bits = 24 96 | ecs.Family = 1 97 | } else { 98 | bits = 48 99 | ecs.Family = 2 100 | } 101 | ecs.SourceNetmask = uint8(bits) 102 | p := netip.PrefixFrom(addr, bits) 103 | ecs.Address = net.IP(p.Masked().Addr().AsSlice()) 104 | opt.Option = append(opt.Option, ecs) 105 | m.Extra = []dns.RR{opt} 106 | } 107 | 108 | if question, err = m.Pack(); err != nil { 109 | http.Error(w, err.Error(), http.StatusBadRequest) 110 | return 111 | } 112 | 113 | resp, err := http.Post(h.Upstream, "application/dns-message", bytes.NewReader(question)) 114 | if err != nil { 115 | http.Error(w, err.Error(), http.StatusInternalServerError) 116 | return 117 | } 118 | defer resp.Body.Close() 119 | 120 | answer, err := io.ReadAll(resp.Body) 121 | if err != nil { 122 | http.Error(w, err.Error(), http.StatusInternalServerError) 123 | return 124 | } 125 | 126 | if err = h.Repo.Cost(token, len(question)+len(answer)); err != nil { 127 | http.Error(w, err.Error(), http.StatusUnauthorized) 128 | return 129 | } 130 | 131 | w.Header().Add("content-type", "application/dns-message") 132 | w.Write(answer) 133 | } 134 | -------------------------------------------------------------------------------- /misc/systemd/zns-tcp.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=zns tcp socket 3 | 4 | [Socket] 5 | ListenStream=443 6 | BindIPv6Only=both 7 | FileDescriptorName=h12 8 | Service=zns.service 9 | 10 | [Install] 11 | WantedBy=sockets.target 12 | -------------------------------------------------------------------------------- /misc/systemd/zns-udp.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=zns udp socket 3 | 4 | [Socket] 5 | ListenDatagram=443 6 | BindIPv6Only=both 7 | FileDescriptorName=h3 8 | Service=zns.service 9 | 10 | [Install] 11 | WantedBy=sockets.target 12 | -------------------------------------------------------------------------------- /misc/systemd/zns.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=zns service 3 | After=network.target 4 | Requires=zns-tcp.socket zns-udp.socket 5 | 6 | [Service] 7 | LimitNOFILE=8192 8 | EnvironmentFile=/usr/local/etc/zns/env 9 | ExecStart=/usr/local/bin/zns -root /var/lib/zns/www -tls-hosts zns.nu.mk -db /var/lib/zns/zns.db 10 | ExecReload=/bin/kill -HUP $MAINPID 11 | User=www-data 12 | Group=www-data 13 | KillMode=process 14 | Restart=on-failure 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /pay.go: -------------------------------------------------------------------------------- 1 | package zns 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/smartwalle/alipay/v3" 9 | ) 10 | 11 | type Pay interface { 12 | NewQR(order Order, notifyURL string) (string, error) 13 | OnPay(req *http.Request) (Order, error) 14 | } 15 | 16 | func NewPay(appID, privateKey, publicKey string) Pay { 17 | client, err := alipay.New(appID, privateKey, true) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | if err = client.LoadAliPayPublicKey(publicKey); err != nil { 23 | panic(err) 24 | } 25 | 26 | return aliPay{ali: client} 27 | } 28 | 29 | type Order struct { 30 | OrderNo string 31 | Amount string 32 | TradeNo string 33 | } 34 | 35 | type aliPay struct { 36 | ali *alipay.Client 37 | } 38 | 39 | func (p aliPay) NewQR(order Order, notifyURL string) (string, error) { 40 | r, err := p.ali.TradePreCreate(context.TODO(), alipay.TradePreCreate{ 41 | Trade: alipay.Trade{ 42 | NotifyURL: notifyURL, 43 | Subject: "ZNS Ticket", 44 | OutTradeNo: order.OrderNo, 45 | TotalAmount: order.Amount, 46 | TimeoutExpress: "15m", 47 | }, 48 | }) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | if r.Code != alipay.CodeSuccess { 54 | return "", fmt.Errorf("TradePreCreate error: %w", err) 55 | } 56 | 57 | return r.QRCode, nil 58 | } 59 | 60 | func (p aliPay) OnPay(req *http.Request) (o Order, err error) { 61 | n, err := p.ali.GetTradeNotification(req) 62 | if err != nil { 63 | return 64 | } 65 | 66 | o.OrderNo = n.OutTradeNo 67 | o.TradeNo = n.TradeNo 68 | o.Amount = n.ReceiptAmount 69 | 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /ticket.go: -------------------------------------------------------------------------------- 1 | package zns 2 | 3 | import ( 4 | "crypto/rand" 5 | "database/sql" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/go-kiss/sqlx" 15 | "modernc.org/sqlite" 16 | ) 17 | 18 | type Ticket struct { 19 | ID int `db:"id" json:"id"` 20 | Token string `db:"token" json:"-"` 21 | Bytes int `db:"bytes" json:"bytes"` 22 | TotalBytes int `db:"total_bytes" json:"total_bytes"` 23 | PayOrder string `db:"pay_order" json:"pay_order"` 24 | BuyOrder string `db:"buy_order" json:"buy_order"` 25 | 26 | Created time.Time `db:"created" json:"created"` 27 | Updated time.Time `db:"updated" json:"updated"` 28 | Expires time.Time `db:"expires" json:"expires"` 29 | } 30 | 31 | func (_ *Ticket) KeyName() string { return "id" } 32 | func (_ *Ticket) TableName() string { return "tickets" } 33 | func (t *Ticket) Schema() string { 34 | return "CREATE TABLE IF NOT EXISTS " + t.TableName() + `( 35 | ` + t.KeyName() + ` INTEGER PRIMARY KEY AUTOINCREMENT, 36 | token TEXT, 37 | bytes INTEGER, 38 | total_bytes INTEGER, 39 | pay_order TEXT, 40 | buy_order TEXT, 41 | created DATETIME, 42 | updated DATETIME, 43 | expires DATETIME 44 | ); 45 | CREATE INDEX IF NOT EXISTS t_token_expires ON ` + t.TableName() + `(token, expires); 46 | CREATE UNIQUE INDEX IF NOT EXISTS t_pay_order ON ` + t.TableName() + `(pay_order);` 47 | } 48 | 49 | type TicketRepo interface { 50 | // New create and save one Ticket 51 | New(token string, bytes int, trade string, order string) error 52 | // Cost decreases bytes of one Ticket 53 | Cost(token string, bytes int) error 54 | // List fetches all current Tickets with bytes available. 55 | List(token string, limit int) ([]Ticket, error) 56 | } 57 | 58 | func NewTicketRepo(path string) TicketRepo { 59 | db, err := sqlx.Connect("sqlite", path) 60 | if err != nil { 61 | panic(err) 62 | } 63 | db.SetMaxOpenConns(1) 64 | r := sqliteTicketReop{db: db} 65 | r.Init() 66 | return r 67 | } 68 | 69 | type FreeTicketRepo struct{} 70 | 71 | func (r FreeTicketRepo) New(token string, bytes int, trade, order string) error { 72 | return nil 73 | } 74 | 75 | func (r FreeTicketRepo) Cost(token string, bytes int) error { 76 | return nil 77 | } 78 | 79 | func (r FreeTicketRepo) List(token string, limit int) ([]Ticket, error) { 80 | return []Ticket{{Bytes: 100}}, nil 81 | } 82 | 83 | type sqliteTicketReop struct { 84 | db *sqlx.DB 85 | } 86 | 87 | func (r sqliteTicketReop) Init() { 88 | if _, err := r.db.Exec((*Ticket).Schema(nil)); err != nil { 89 | panic(err) 90 | } 91 | } 92 | 93 | func (r sqliteTicketReop) New(token string, bytes int, trade, order string) error { 94 | now := time.Now() 95 | 96 | ts, err := r.List(token, 1) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | t := Ticket{ 102 | Token: token, 103 | Bytes: bytes, 104 | TotalBytes: bytes, 105 | PayOrder: order, 106 | BuyOrder: trade, 107 | Created: now, 108 | Updated: now, 109 | } 110 | 111 | gb := bytes / 1024 / 1024 / 1024 112 | if gb == 0 { 113 | gb = 1 114 | } 115 | d := 30 * 24 * time.Hour * time.Duration(gb) 116 | if len(ts) == 1 && ts[0].Expires.After(now) { 117 | t.Expires = ts[0].Expires.Add(d) 118 | } else { 119 | t.Expires = now.Add(d) 120 | } 121 | 122 | _, err = r.db.Insert(&t) 123 | 124 | se := &sqlite.Error{} 125 | // constraint failed: UNIQUE constraint failed 126 | if errors.As(err, &se) && se.Code() == 2067 { 127 | return nil 128 | } 129 | 130 | return err 131 | } 132 | 133 | func (r sqliteTicketReop) Cost(token string, bytes int) error { 134 | now := time.Now() 135 | 136 | sql := "update " + (*Ticket).TableName(nil) + 137 | " set bytes = bytes - ? where id in (select id from " + (*Ticket).TableName(nil) + 138 | " where token = ? and expires > ? order by id asc limit 1) and bytes >= ?" 139 | 140 | _r, err := r.db.Exec(sql, bytes, token, now, bytes) 141 | if err != nil { 142 | return err 143 | } 144 | n, err := _r.RowsAffected() 145 | if err != nil { 146 | return err 147 | } 148 | if n == 1 { 149 | return nil 150 | } 151 | 152 | return r.costSlow(token, bytes) 153 | } 154 | 155 | func (r sqliteTicketReop) costSlow(token string, bytes int) error { 156 | q := "select * from " + (*Ticket).TableName(nil) + 157 | " where token = ? and bytes > 0 and expires > ?" + 158 | " order by id asc" 159 | var ts []Ticket 160 | if err := r.db.Select(&ts, q, token, time.Now()); err != nil { 161 | return err 162 | } 163 | 164 | if len(ts) == 0 { 165 | return sql.ErrNoRows 166 | } 167 | 168 | var i int 169 | var t Ticket 170 | for i, t = range ts { 171 | if t.Bytes >= bytes { 172 | ts[i].Bytes -= bytes 173 | bytes = 0 174 | break 175 | } else { 176 | bytes -= t.Bytes 177 | ts[i].Bytes = 0 178 | } 179 | } 180 | 181 | if bytes > 0 { 182 | ts[i].Bytes -= bytes 183 | } 184 | 185 | if i == 0 { 186 | t := ts[i] 187 | t.Updated = time.Now() 188 | _, err := r.db.Update(&t) 189 | return err 190 | } 191 | 192 | tx, err := r.db.Beginx() 193 | if err != nil { 194 | return err 195 | } 196 | 197 | for ; i >= 0; i-- { 198 | t := ts[i] 199 | t.Updated = time.Now() 200 | _, err := tx.Update(&t) 201 | if err != nil { 202 | tx.Rollback() 203 | return err 204 | } 205 | } 206 | return tx.Commit() 207 | } 208 | 209 | func (r sqliteTicketReop) List(token string, limit int) (tickets []Ticket, err error) { 210 | sql := "select * from " + (*Ticket).TableName(nil) + 211 | " where token = ? order by id desc limit ?" 212 | err = r.db.Select(&tickets, sql, token, limit) 213 | return 214 | } 215 | 216 | type TicketHandler struct { 217 | MBpCNY int 218 | Pay Pay 219 | Repo TicketRepo 220 | AltSvc string 221 | } 222 | 223 | func (h *TicketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 224 | if h.AltSvc != "" { 225 | w.Header().Set("Alt-Svc", h.AltSvc) 226 | } 227 | 228 | if r.Method == http.MethodGet { 229 | token := r.PathValue("token") 230 | ts, err := h.Repo.List(token, 10) 231 | if err != nil { 232 | http.Error(w, err.Error(), http.StatusInternalServerError) 233 | return 234 | } 235 | w.Header().Add("content-type", "application/json") 236 | json.NewEncoder(w).Encode(ts) 237 | return 238 | } 239 | 240 | if r.URL.Query().Get("buy") != "" { 241 | req := struct { 242 | Token string `json:"token"` 243 | Cents int `json:"cents"` 244 | }{} 245 | defer r.Body.Close() 246 | err := json.NewDecoder(r.Body).Decode(&req) 247 | if err != nil { 248 | http.Error(w, err.Error(), http.StatusBadRequest) 249 | return 250 | } 251 | 252 | if req.Cents < 100 { 253 | http.Error(w, "cents must > 100", http.StatusBadRequest) 254 | return 255 | } 256 | 257 | if req.Token == "" { 258 | b := make([]byte, 16) 259 | _, err := rand.Read(b) 260 | if err != nil { 261 | http.Error(w, err.Error(), http.StatusInternalServerError) 262 | return 263 | } 264 | req.Token = base64.RawURLEncoding.EncodeToString(b) 265 | } 266 | 267 | now := time.Now().Format(time.RFC3339) 268 | yuan := strconv.FormatFloat(float64(req.Cents)/100, 'f', 2, 64) 269 | o := Order{ 270 | OrderNo: req.Token + "@" + now, 271 | Amount: yuan, 272 | } 273 | 274 | notify := "https://" + r.Host + r.URL.Path 275 | qr, err := h.Pay.NewQR(o, notify) 276 | if err != nil { 277 | http.Error(w, err.Error(), http.StatusInternalServerError) 278 | return 279 | } 280 | 281 | w.Header().Add("content-type", "application/json") 282 | json.NewEncoder(w).Encode(struct { 283 | QR string `json:"qr"` 284 | Token string `json:"token"` 285 | Order string `json:"order"` 286 | }{QR: qr, Token: req.Token, Order: o.OrderNo}) 287 | } else { 288 | o, err := h.Pay.OnPay(r) 289 | if err != nil { 290 | http.Error(w, err.Error(), http.StatusBadRequest) 291 | return 292 | } 293 | 294 | i := strings.Index(o.OrderNo, "@") 295 | token := o.OrderNo[:i] 296 | 297 | yuan, err := strconv.ParseFloat(o.Amount, 64) 298 | if err != nil { 299 | http.Error(w, err.Error(), http.StatusBadRequest) 300 | return 301 | } 302 | 303 | bytes := int(yuan * float64(h.MBpCNY) * 1024 * 1024) 304 | 305 | err = h.Repo.New(token, bytes, o.OrderNo, o.TradeNo) 306 | if err != nil { 307 | http.Error(w, err.Error(), http.StatusInternalServerError) 308 | return 309 | } 310 | 311 | w.Write([]byte("success")) 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /ticket_test.go: -------------------------------------------------------------------------------- 1 | package zns 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestRepo(t *testing.T) { 11 | r := NewTicketRepo(":memory:") 12 | 13 | err := r.New("foo", 100, "buy-1", "pay-1") 14 | assert.Nil(t, err) 15 | 16 | ts, err := r.List("foo", 2) 17 | assert.Nil(t, err) 18 | assert.Equal(t, 1, len(ts)) 19 | assert.Equal(t, "foo", ts[0].Token) 20 | assert.Equal(t, 100, ts[0].Bytes) 21 | assert.Equal(t, 100, ts[0].TotalBytes) 22 | assert.Equal(t, "pay-1", ts[0].PayOrder) 23 | 24 | n := time.Now() 25 | 26 | assert.True(t, ts[0].Expires.Before(n.Add(30*24*time.Hour))) 27 | assert.True(t, ts[0].Expires.After(n.Add(29*24*time.Hour))) 28 | assert.Equal(t, ts[0].Created, ts[0].Updated) 29 | assert.Equal(t, n.Truncate(time.Second), ts[0].Created.Truncate(time.Second)) 30 | 31 | err = r.Cost("foo", 50) 32 | assert.Nil(t, err) 33 | 34 | ts, err = r.List("foo", 2) 35 | assert.Nil(t, err) 36 | assert.Equal(t, 1, len(ts)) 37 | assert.Equal(t, 50, ts[0].Bytes) 38 | 39 | err = r.New("foo", 30, "buy-2", "pay-2") 40 | assert.Nil(t, err) 41 | 42 | ts, err = r.List("foo", 2) 43 | assert.Nil(t, err) 44 | assert.Equal(t, 2, len(ts)) 45 | assert.True(t, ts[0].Expires.Equal(ts[1].Expires.Add(30*24*time.Hour))) 46 | 47 | err = r.New("foo", 40, "buy-3", "pay-3") 48 | assert.Nil(t, err) 49 | 50 | err = r.Cost("foo", 110) 51 | assert.Nil(t, err) 52 | 53 | ts, err = r.List("foo", 4) 54 | assert.Nil(t, err) 55 | assert.Equal(t, 3, len(ts)) 56 | assert.Equal(t, 10, ts[0].Bytes) 57 | assert.Equal(t, 0, ts[1].Bytes) 58 | assert.Equal(t, 0, ts[2].Bytes) 59 | 60 | err = r.Cost("foo", 20) 61 | assert.Nil(t, err) 62 | 63 | ts, err = r.List("foo", 1) 64 | assert.Nil(t, err) 65 | assert.Equal(t, -10, ts[0].Bytes) 66 | 67 | err = r.New("foo", 40, "buy-4", "pay-4") 68 | assert.Nil(t, err) 69 | 70 | err = r.New("foo", 10, "buy-5", "pay-5") 71 | assert.Nil(t, err) 72 | 73 | err = r.Cost("foo", 65) 74 | assert.Nil(t, err) 75 | 76 | ts, err = r.List("foo", 1) 77 | assert.Nil(t, err) 78 | assert.Equal(t, -15, ts[0].Bytes) 79 | 80 | err = r.New("foo", 3*1024*1024*1024, "buy-6", "pay-6") 81 | assert.Nil(t, err) 82 | 83 | ts, err = r.List("foo", 2) 84 | assert.Nil(t, err) 85 | assert.Equal(t, 3*1024*1024*1024, ts[0].Bytes) 86 | assert.True(t, ts[0].Expires.Equal(ts[1].Expires.Add(3*30*24*time.Hour))) 87 | } 88 | 89 | func TestRepoSlow(t *testing.T) { 90 | r := NewTicketRepo(":memory:") 91 | 92 | err := r.New("foo", 10, "buy-1", "pay-1") 93 | assert.Nil(t, err) 94 | 95 | err = r.New("foo", 30, "buy-2", "pay-2") 96 | assert.Nil(t, err) 97 | 98 | err = r.Cost("foo", 20) 99 | assert.Nil(t, err) 100 | 101 | ts, err := r.List("foo", 3) 102 | assert.Nil(t, err) 103 | assert.Equal(t, 2, len(ts)) 104 | assert.Equal(t, 20, ts[0].Bytes) 105 | assert.Equal(t, 0, ts[1].Bytes) 106 | } 107 | -------------------------------------------------------------------------------- /web/app.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const urlParams = new URLSearchParams(location.search); 3 | let token = urlParams.get('token'); 4 | if (token) { 5 | document.location.href = '//' + document.domain + '/dns/' + token 6 | return 7 | } 8 | 9 | let m = document.location.pathname.match(/^\/dns\/(.+)$/) 10 | token = (m && m[1]) || "" 11 | 12 | $ = document.querySelector.bind(document); 13 | 14 | $('#pay').onclick = (e) => { 15 | const y = $('#cents'); 16 | const cents = Math.trunc(y.value * 100); 17 | if (cents < 100) { 18 | alert('最低一元钱起购'); 19 | y.focus(); 20 | return; 21 | } 22 | fetch('/ticket/?buy=1', { 23 | method: 'POST', 24 | headers: { 25 | 'content-type': 'application/json', 26 | }, 27 | body: JSON.stringify({cents: cents, token: token}), 28 | }).then((resp) => { 29 | resp.json().then((d) => { 30 | let qrcode = new QRCode($('#qr'), { width: 100, height: 100, useSVG: true }); 31 | qrcode.makeCode(d.qr); 32 | 33 | let countdownX; 34 | let orderLoading = false; 35 | let countDownDate = new Date().getTime() + (15 * 60 * 1000); 36 | let updateCountdownX = () => { 37 | let now = new Date().getTime(); 38 | let distance = countDownDate - now; 39 | 40 | if (distance < 0) { 41 | clearInterval(countdownX); 42 | $('#qr').innerHTML = ''; 43 | $('#qr-msg').innerHTML = "订单已失效"; 44 | } 45 | 46 | let minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); 47 | let seconds = Math.floor((distance % (1000 * 60)) / 1000); 48 | 49 | $('#qr-msg').innerHTML = minutes.toString().padStart(2, '0') + ":" + seconds.toString().padStart(2, '0'); 50 | 51 | return seconds; 52 | }; 53 | updateCountdownX(); 54 | countdownX = setInterval(function() { 55 | let seconds = updateCountdownX(); 56 | 57 | if (seconds%5 === 0 && !orderLoading) { 58 | orderLoading = true; 59 | fetch(`/ticket/${d.token}`).then((resp) => { 60 | resp.json().then((tickets) => { 61 | if (!tickets) return; 62 | if (tickets[0].buy_order != d.order) return; 63 | document.location = `/dns/${d.token}` 64 | }); 65 | }).finally(() => { 66 | orderLoading = false; 67 | });; 68 | } 69 | }, 1000); 70 | }); 71 | }); 72 | } 73 | 74 | fetch(`/ticket/${token}`).then((resp) => { 75 | resp.json().then((tickets) => { 76 | if (!tickets) return; 77 | _ = document.createElement.bind(document); 78 | t = $('#tickets'); 79 | t.style.display = 'table'; 80 | 81 | $('#qr-msg').innerHTML = `你的专属 DoH 链接🔗 (请勿在互联网上传播!)
https://${document.domain}/dns/${token}
有问题请加电报群 https://t.me/letszns
`; 82 | let keyName = { 83 | "id": "记录编号", 84 | "bytes": "剩余流量", 85 | "total_bytes": "已购流量", 86 | "pay_order": "支付订单", 87 | "buy_order": "业务订单", 88 | "created": "创建时间", 89 | "updated": "更新时间", 90 | "expires": "过期时间", 91 | }; 92 | tickets.forEach((ticket) => { 93 | let isTime = ["created", "updated", "expires"]; 94 | for ([key, value] of Object.entries(ticket)) { 95 | let tr = _('tr'); 96 | let th = _('th'); 97 | th.innerText = keyName[key] || key; 98 | let td = _('td'); 99 | if (isTime.includes(key)) { 100 | value = new Date(Date.parse(value)); 101 | value = value.toLocaleString(); 102 | } 103 | td.innerText = value; 104 | tr.appendChild(th); 105 | tr.appendChild(td); 106 | t.appendChild(tr); 107 | } 108 | let tr = _('tr'); 109 | tr.appendChild(_('hr')); 110 | t.appendChild(tr); 111 | }); 112 | }); 113 | }); 114 | })() 115 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 14 | 15 | 16 |

ZNS

17 |
18 |
🔒保护隐私
19 |
仅保存订单号、余额等必要信息,不记录 DNS 查询行为。
20 |
🌐智能解析
21 |
基于 DoH + EDNS Client Subnet (ECS) 技术,海外域名无污染,国内域名无延迟。支持 DNS over HTTP/3 (DoH3) 协议!
22 |
👨‍💻开源社区
23 |
代码全部开源,MIT 授权协议,自由使用。
24 |
💰按量付费
25 |
一元 1GB 流量,30天有效;两元 2G 流量,60 天有效;以此类推。连续充值过期时间顺延。
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /web/qrcode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 3 | * - Using the 'QRCode for Javascript library' 4 | * - Fixed dataset of 'QRCode for Javascript library' for support full-spec. 5 | * - this library has no dependencies. 6 | * 7 | * @author davidshimjs 8 | * @see http://www.d-project.com/ 9 | * @see http://jeromeetienne.github.com/jquery-qrcode/ 10 | */ 11 | var QRCode; 12 | 13 | (function () { 14 | //--------------------------------------------------------------------- 15 | // QRCode for JavaScript 16 | // 17 | // Copyright (c) 2009 Kazuhiko Arase 18 | // 19 | // URL: http://www.d-project.com/ 20 | // 21 | // Licensed under the MIT license: 22 | // http://www.opensource.org/licenses/mit-license.php 23 | // 24 | // The word "QR Code" is registered trademark of 25 | // DENSO WAVE INCORPORATED 26 | // http://www.denso-wave.com/qrcode/faqpatent-e.html 27 | // 28 | //--------------------------------------------------------------------- 29 | function QR8bitByte(data) { 30 | this.mode = QRMode.MODE_8BIT_BYTE; 31 | this.data = data; 32 | this.parsedData = []; 33 | 34 | // Added to support UTF-8 Characters 35 | for (var i = 0, l = this.data.length; i < l; i++) { 36 | var byteArray = []; 37 | var code = this.data.charCodeAt(i); 38 | 39 | if (code > 0x10000) { 40 | byteArray[0] = 0xF0 | ((code & 0x1C0000) >>> 18); 41 | byteArray[1] = 0x80 | ((code & 0x3F000) >>> 12); 42 | byteArray[2] = 0x80 | ((code & 0xFC0) >>> 6); 43 | byteArray[3] = 0x80 | (code & 0x3F); 44 | } else if (code > 0x800) { 45 | byteArray[0] = 0xE0 | ((code & 0xF000) >>> 12); 46 | byteArray[1] = 0x80 | ((code & 0xFC0) >>> 6); 47 | byteArray[2] = 0x80 | (code & 0x3F); 48 | } else if (code > 0x80) { 49 | byteArray[0] = 0xC0 | ((code & 0x7C0) >>> 6); 50 | byteArray[1] = 0x80 | (code & 0x3F); 51 | } else { 52 | byteArray[0] = code; 53 | } 54 | 55 | this.parsedData.push(byteArray); 56 | } 57 | 58 | this.parsedData = Array.prototype.concat.apply([], this.parsedData); 59 | 60 | if (this.parsedData.length != this.data.length) { 61 | this.parsedData.unshift(191); 62 | this.parsedData.unshift(187); 63 | this.parsedData.unshift(239); 64 | } 65 | } 66 | 67 | QR8bitByte.prototype = { 68 | getLength: function (buffer) { 69 | return this.parsedData.length; 70 | }, 71 | write: function (buffer) { 72 | for (var i = 0, l = this.parsedData.length; i < l; i++) { 73 | buffer.put(this.parsedData[i], 8); 74 | } 75 | } 76 | }; 77 | 78 | function QRCodeModel(typeNumber, errorCorrectLevel) { 79 | this.typeNumber = typeNumber; 80 | this.errorCorrectLevel = errorCorrectLevel; 81 | this.modules = null; 82 | this.moduleCount = 0; 83 | this.dataCache = null; 84 | this.dataList = []; 85 | } 86 | 87 | QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null;},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col);} 88 | return this.modules[row][col];},getModuleCount:function(){return this.moduleCount;},make:function(){this.makeImpl(false,this.getBestMaskPattern());},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row=7){this.setupTypeNumber(test);} 90 | if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList);} 91 | this.mapData(this.dataCache,maskPattern);},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if((0<=r&&r<=6&&(c==0||c==6))||(0<=c&&c<=6&&(r==0||r==6))||(2<=r&&r<=4&&2<=c&&c<=4)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i;}} 92 | return pattern;},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row>i)&1)==1);this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod;} 98 | for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod;}},setupTypeInfo:function(test,maskPattern){var data=(this.errorCorrectLevel<<3)|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<6){this.modules[i][8]=mod;}else if(i<8){this.modules[i+1][8]=mod;}else{this.modules[this.moduleCount-15+i][8]=mod;}} 99 | for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<8){this.modules[8][this.moduleCount-i-1]=mod;}else if(i<9){this.modules[8][15-i-1+1]=mod;}else{this.modules[8][15-i-1]=mod;}} 100 | this.modules[this.moduleCount-8][8]=(!test);},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex>>bitIndex)&1)==1);} 101 | var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark;} 102 | this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7;}}} 103 | row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break;}}}}};QRCodeModel.PAD0=0xEC;QRCodeModel.PAD1=0x11;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer();for(var i=0;itotalDataCount*8){throw new Error("code length overflow. (" 106 | +buffer.getLengthInBits() 107 | +">" 108 | +totalDataCount*8 109 | +")");} 110 | if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4);} 111 | while(buffer.getLengthInBits()%8!=0){buffer.putBit(false);} 112 | while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break;} 113 | buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break;} 114 | buffer.put(QRCodeModel.PAD1,8);} 115 | return QRCodeModel.createBytes(buffer,rsBlocks);};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r=0)?modPoly.get(modIndex):0;}} 117 | var totalCodeCount=0;for(var i=0;i=0){d^=(QRUtil.G15<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)));} 121 | return((data<<10)|d)^QRUtil.G15_MASK;},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=(QRUtil.G18<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)));} 122 | return(data<<12)|d;},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1;} 123 | return digit;},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1];},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return(i*j)%2+(i*j)%3==0;case QRMaskPattern.PATTERN110:return((i*j)%2+(i*j)%3)%2==0;case QRMaskPattern.PATTERN111:return((i*j)%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern);}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i5){lostPoint+=(3+sameCount-5);}}} 129 | for(var row=0;row=256){n-=255;} 136 | return QRMath.EXP_TABLE[n];},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<>>(7-index%8))&1)==1;},put:function(num,length){for(var i=0;i>>(length-i-1))&1)==1);}},getLengthInBits:function(){return this.length;},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0);} 151 | if(bit){this.buffer[bufIndex]|=(0x80>>>(this.length%8));} 152 | this.length++;}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]]; 153 | 154 | function _isSupportCanvas() { 155 | return typeof CanvasRenderingContext2D != "undefined"; 156 | } 157 | 158 | // android 2.x doesn't support Data-URI spec 159 | function _getAndroid() { 160 | var android = false; 161 | var sAgent = navigator.userAgent; 162 | 163 | if (/android/i.test(sAgent)) { // android 164 | android = true; 165 | var aMat = sAgent.toString().match(/android ([0-9]\.[0-9])/i); 166 | 167 | if (aMat && aMat[1]) { 168 | android = parseFloat(aMat[1]); 169 | } 170 | } 171 | 172 | return android; 173 | } 174 | 175 | var svgDrawer = (function() { 176 | 177 | var Drawing = function (el, htOption) { 178 | this._el = el; 179 | this._htOption = htOption; 180 | }; 181 | 182 | Drawing.prototype.draw = function (oQRCode) { 183 | var _htOption = this._htOption; 184 | var _el = this._el; 185 | var nCount = oQRCode.getModuleCount(); 186 | var nWidth = Math.floor(_htOption.width / nCount); 187 | var nHeight = Math.floor(_htOption.height / nCount); 188 | 189 | this.clear(); 190 | 191 | function makeSVG(tag, attrs) { 192 | var el = document.createElementNS('http://www.w3.org/2000/svg', tag); 193 | for (var k in attrs) 194 | if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]); 195 | return el; 196 | } 197 | 198 | var svg = makeSVG("svg" , {'viewBox': '0 0 ' + String(nCount) + " " + String(nCount), 'width': '100%', 'height': '100%', 'fill': _htOption.colorLight}); 199 | svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); 200 | _el.appendChild(svg); 201 | 202 | svg.appendChild(makeSVG("rect", {"fill": _htOption.colorLight, "width": "100%", "height": "100%"})); 203 | svg.appendChild(makeSVG("rect", {"fill": _htOption.colorDark, "width": "1", "height": "1", "id": "template"})); 204 | 205 | for (var row = 0; row < nCount; row++) { 206 | for (var col = 0; col < nCount; col++) { 207 | if (oQRCode.isDark(row, col)) { 208 | var child = makeSVG("use", {"x": String(col), "y": String(row)}); 209 | child.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template") 210 | svg.appendChild(child); 211 | } 212 | } 213 | } 214 | }; 215 | Drawing.prototype.clear = function () { 216 | while (this._el.hasChildNodes()) 217 | this._el.removeChild(this._el.lastChild); 218 | }; 219 | return Drawing; 220 | })(); 221 | 222 | var useSVG = document.documentElement.tagName.toLowerCase() === "svg"; 223 | 224 | // Drawing in DOM by using Table tag 225 | var Drawing = useSVG ? svgDrawer : !_isSupportCanvas() ? (function () { 226 | var Drawing = function (el, htOption) { 227 | this._el = el; 228 | this._htOption = htOption; 229 | }; 230 | 231 | /** 232 | * Draw the QRCode 233 | * 234 | * @param {QRCode} oQRCode 235 | */ 236 | Drawing.prototype.draw = function (oQRCode) { 237 | var _htOption = this._htOption; 238 | var _el = this._el; 239 | var nCount = oQRCode.getModuleCount(); 240 | var nWidth = Math.floor(_htOption.width / nCount); 241 | var nHeight = Math.floor(_htOption.height / nCount); 242 | var aHTML = ['']; 243 | 244 | for (var row = 0; row < nCount; row++) { 245 | aHTML.push(''); 246 | 247 | for (var col = 0; col < nCount; col++) { 248 | aHTML.push(''); 249 | } 250 | 251 | aHTML.push(''); 252 | } 253 | 254 | aHTML.push('
'); 255 | _el.innerHTML = aHTML.join(''); 256 | 257 | // Fix the margin values as real size. 258 | var elTable = _el.childNodes[0]; 259 | var nLeftMarginTable = (_htOption.width - elTable.offsetWidth) / 2; 260 | var nTopMarginTable = (_htOption.height - elTable.offsetHeight) / 2; 261 | 262 | if (nLeftMarginTable > 0 && nTopMarginTable > 0) { 263 | elTable.style.margin = nTopMarginTable + "px " + nLeftMarginTable + "px"; 264 | } 265 | }; 266 | 267 | /** 268 | * Clear the QRCode 269 | */ 270 | Drawing.prototype.clear = function () { 271 | this._el.innerHTML = ''; 272 | }; 273 | 274 | return Drawing; 275 | })() : (function () { // Drawing in Canvas 276 | function _onMakeImage() { 277 | this._elImage.src = this._elCanvas.toDataURL("image/png"); 278 | this._elImage.style.display = "block"; 279 | this._elCanvas.style.display = "none"; 280 | } 281 | 282 | // Android 2.1 bug workaround 283 | // http://code.google.com/p/android/issues/detail?id=5141 284 | if (this._android && this._android <= 2.1) { 285 | var factor = 1 / window.devicePixelRatio; 286 | var drawImage = CanvasRenderingContext2D.prototype.drawImage; 287 | CanvasRenderingContext2D.prototype.drawImage = function (image, sx, sy, sw, sh, dx, dy, dw, dh) { 288 | if (("nodeName" in image) && /img/i.test(image.nodeName)) { 289 | for (var i = arguments.length - 1; i >= 1; i--) { 290 | arguments[i] = arguments[i] * factor; 291 | } 292 | } else if (typeof dw == "undefined") { 293 | arguments[1] *= factor; 294 | arguments[2] *= factor; 295 | arguments[3] *= factor; 296 | arguments[4] *= factor; 297 | } 298 | 299 | drawImage.apply(this, arguments); 300 | }; 301 | } 302 | 303 | /** 304 | * Check whether the user's browser supports Data URI or not 305 | * 306 | * @private 307 | * @param {Function} fSuccess Occurs if it supports Data URI 308 | * @param {Function} fFail Occurs if it doesn't support Data URI 309 | */ 310 | function _safeSetDataURI(fSuccess, fFail) { 311 | var self = this; 312 | self._fFail = fFail; 313 | self._fSuccess = fSuccess; 314 | 315 | // Check it just once 316 | if (self._bSupportDataURI === null) { 317 | var el = document.createElement("img"); 318 | var fOnError = function() { 319 | self._bSupportDataURI = false; 320 | 321 | if (self._fFail) { 322 | self._fFail.call(self); 323 | } 324 | }; 325 | var fOnSuccess = function() { 326 | self._bSupportDataURI = true; 327 | 328 | if (self._fSuccess) { 329 | self._fSuccess.call(self); 330 | } 331 | }; 332 | 333 | el.onabort = fOnError; 334 | el.onerror = fOnError; 335 | el.onload = fOnSuccess; 336 | el.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; // the Image contains 1px data. 337 | return; 338 | } else if (self._bSupportDataURI === true && self._fSuccess) { 339 | self._fSuccess.call(self); 340 | } else if (self._bSupportDataURI === false && self._fFail) { 341 | self._fFail.call(self); 342 | } 343 | }; 344 | 345 | /** 346 | * Drawing QRCode by using canvas 347 | * 348 | * @constructor 349 | * @param {HTMLElement} el 350 | * @param {Object} htOption QRCode Options 351 | */ 352 | var Drawing = function (el, htOption) { 353 | this._bIsPainted = false; 354 | this._android = _getAndroid(); 355 | 356 | this._htOption = htOption; 357 | this._elCanvas = document.createElement("canvas"); 358 | this._elCanvas.width = htOption.width; 359 | this._elCanvas.height = htOption.height; 360 | el.appendChild(this._elCanvas); 361 | this._el = el; 362 | this._oContext = this._elCanvas.getContext("2d"); 363 | this._bIsPainted = false; 364 | this._elImage = document.createElement("img"); 365 | this._elImage.alt = "Scan me!"; 366 | this._elImage.style.display = "none"; 367 | this._el.appendChild(this._elImage); 368 | this._bSupportDataURI = null; 369 | }; 370 | 371 | /** 372 | * Draw the QRCode 373 | * 374 | * @param {QRCode} oQRCode 375 | */ 376 | Drawing.prototype.draw = function (oQRCode) { 377 | var _elImage = this._elImage; 378 | var _oContext = this._oContext; 379 | var _htOption = this._htOption; 380 | 381 | var nCount = oQRCode.getModuleCount(); 382 | var nWidth = _htOption.width / nCount; 383 | var nHeight = _htOption.height / nCount; 384 | var nRoundedWidth = Math.round(nWidth); 385 | var nRoundedHeight = Math.round(nHeight); 386 | 387 | _elImage.style.display = "none"; 388 | this.clear(); 389 | 390 | for (var row = 0; row < nCount; row++) { 391 | for (var col = 0; col < nCount; col++) { 392 | var bIsDark = oQRCode.isDark(row, col); 393 | var nLeft = col * nWidth; 394 | var nTop = row * nHeight; 395 | _oContext.strokeStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight; 396 | _oContext.lineWidth = 1; 397 | _oContext.fillStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight; 398 | _oContext.fillRect(nLeft, nTop, nWidth, nHeight); 399 | 400 | // 안티 앨리어싱 방지 처리 401 | _oContext.strokeRect( 402 | Math.floor(nLeft) + 0.5, 403 | Math.floor(nTop) + 0.5, 404 | nRoundedWidth, 405 | nRoundedHeight 406 | ); 407 | 408 | _oContext.strokeRect( 409 | Math.ceil(nLeft) - 0.5, 410 | Math.ceil(nTop) - 0.5, 411 | nRoundedWidth, 412 | nRoundedHeight 413 | ); 414 | } 415 | } 416 | 417 | this._bIsPainted = true; 418 | }; 419 | 420 | /** 421 | * Make the image from Canvas if the browser supports Data URI. 422 | */ 423 | Drawing.prototype.makeImage = function () { 424 | if (this._bIsPainted) { 425 | _safeSetDataURI.call(this, _onMakeImage); 426 | } 427 | }; 428 | 429 | /** 430 | * Return whether the QRCode is painted or not 431 | * 432 | * @return {Boolean} 433 | */ 434 | Drawing.prototype.isPainted = function () { 435 | return this._bIsPainted; 436 | }; 437 | 438 | /** 439 | * Clear the QRCode 440 | */ 441 | Drawing.prototype.clear = function () { 442 | this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height); 443 | this._bIsPainted = false; 444 | }; 445 | 446 | /** 447 | * @private 448 | * @param {Number} nNumber 449 | */ 450 | Drawing.prototype.round = function (nNumber) { 451 | if (!nNumber) { 452 | return nNumber; 453 | } 454 | 455 | return Math.floor(nNumber * 1000) / 1000; 456 | }; 457 | 458 | return Drawing; 459 | })(); 460 | 461 | /** 462 | * Get the type by string length 463 | * 464 | * @private 465 | * @param {String} sText 466 | * @param {Number} nCorrectLevel 467 | * @return {Number} type 468 | */ 469 | function _getTypeNumber(sText, nCorrectLevel) { 470 | var nType = 1; 471 | var length = _getUTF8Length(sText); 472 | 473 | for (var i = 0, len = QRCodeLimitLength.length; i <= len; i++) { 474 | var nLimit = 0; 475 | 476 | switch (nCorrectLevel) { 477 | case QRErrorCorrectLevel.L : 478 | nLimit = QRCodeLimitLength[i][0]; 479 | break; 480 | case QRErrorCorrectLevel.M : 481 | nLimit = QRCodeLimitLength[i][1]; 482 | break; 483 | case QRErrorCorrectLevel.Q : 484 | nLimit = QRCodeLimitLength[i][2]; 485 | break; 486 | case QRErrorCorrectLevel.H : 487 | nLimit = QRCodeLimitLength[i][3]; 488 | break; 489 | } 490 | 491 | if (length <= nLimit) { 492 | break; 493 | } else { 494 | nType++; 495 | } 496 | } 497 | 498 | if (nType > QRCodeLimitLength.length) { 499 | throw new Error("Too long data"); 500 | } 501 | 502 | return nType; 503 | } 504 | 505 | function _getUTF8Length(sText) { 506 | var replacedText = encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g, 'a'); 507 | return replacedText.length + (replacedText.length != sText ? 3 : 0); 508 | } 509 | 510 | /** 511 | * @class QRCode 512 | * @constructor 513 | * @example 514 | * new QRCode(document.getElementById("test"), "http://jindo.dev.naver.com/collie"); 515 | * 516 | * @example 517 | * var oQRCode = new QRCode("test", { 518 | * text : "http://naver.com", 519 | * width : 128, 520 | * height : 128 521 | * }); 522 | * 523 | * oQRCode.clear(); // Clear the QRCode. 524 | * oQRCode.makeCode("http://map.naver.com"); // Re-create the QRCode. 525 | * 526 | * @param {HTMLElement|String} el target element or 'id' attribute of element. 527 | * @param {Object|String} vOption 528 | * @param {String} vOption.text QRCode link data 529 | * @param {Number} [vOption.width=256] 530 | * @param {Number} [vOption.height=256] 531 | * @param {String} [vOption.colorDark="#000000"] 532 | * @param {String} [vOption.colorLight="#ffffff"] 533 | * @param {QRCode.CorrectLevel} [vOption.correctLevel=QRCode.CorrectLevel.H] [L|M|Q|H] 534 | */ 535 | QRCode = function (el, vOption) { 536 | this._htOption = { 537 | width : 256, 538 | height : 256, 539 | typeNumber : 4, 540 | colorDark : "#000000", 541 | colorLight : "#ffffff", 542 | correctLevel : QRErrorCorrectLevel.H 543 | }; 544 | 545 | if (typeof vOption === 'string') { 546 | vOption = { 547 | text : vOption 548 | }; 549 | } 550 | 551 | // Overwrites options 552 | if (vOption) { 553 | for (var i in vOption) { 554 | this._htOption[i] = vOption[i]; 555 | } 556 | } 557 | 558 | if (typeof el == "string") { 559 | el = document.getElementById(el); 560 | } 561 | 562 | if (this._htOption.useSVG) { 563 | Drawing = svgDrawer; 564 | } 565 | 566 | this._android = _getAndroid(); 567 | this._el = el; 568 | this._oQRCode = null; 569 | this._oDrawing = new Drawing(this._el, this._htOption); 570 | 571 | if (this._htOption.text) { 572 | this.makeCode(this._htOption.text); 573 | } 574 | }; 575 | 576 | /** 577 | * Make the QRCode 578 | * 579 | * @param {String} sText link data 580 | */ 581 | QRCode.prototype.makeCode = function (sText) { 582 | this._oQRCode = new QRCodeModel(_getTypeNumber(sText, this._htOption.correctLevel), this._htOption.correctLevel); 583 | this._oQRCode.addData(sText); 584 | this._oQRCode.make(); 585 | this._el.title = sText; 586 | this._oDrawing.draw(this._oQRCode); 587 | this.makeImage(); 588 | }; 589 | 590 | /** 591 | * Make the Image from Canvas element 592 | * - It occurs automatically 593 | * - Android below 3 doesn't support Data-URI spec. 594 | * 595 | * @private 596 | */ 597 | QRCode.prototype.makeImage = function () { 598 | if (typeof this._oDrawing.makeImage == "function" && (!this._android || this._android >= 3)) { 599 | this._oDrawing.makeImage(); 600 | } 601 | }; 602 | 603 | /** 604 | * Clear the QRCode 605 | */ 606 | QRCode.prototype.clear = function () { 607 | this._oDrawing.clear(); 608 | }; 609 | 610 | /** 611 | * @name QRCode.CorrectLevel 612 | */ 613 | QRCode.CorrectLevel = QRErrorCorrectLevel; 614 | })(); 615 | -------------------------------------------------------------------------------- /web/style.css: -------------------------------------------------------------------------------- 1 | #tickets { 2 | border-top: dashed 1px gray; 3 | } 4 | 5 | #tickets tr { 6 | border-bottom: 1px solid gray; 7 | } 8 | 9 | #tickets th { 10 | text-align: right; 11 | font-weight: normal; 12 | } 13 | 14 | #qr { 15 | width: 128px; 16 | } 17 | 18 | #qr-msg { 19 | margin: 1em 0; 20 | } 21 | 22 | .doh-url { 23 | font-weight: bold; 24 | font-family: monospace; 25 | color: blue; 26 | margin: 0.5em 0; 27 | font-size: 1.5em; 28 | } 29 | 30 | span.warn { 31 | color: red; 32 | } 33 | --------------------------------------------------------------------------------