Compare commits

...

3 Commits

Author SHA1 Message Date
Evan Chen 4ebb686b1c build: add npm 2021-10-19 14:26:41 +08:00
Evan Chen 4bcce8ddfd feat: #8, #9, #10 2021-10-19 12:34:01 +08:00
Evan Chen f654cd347c update 2021-10-19 02:38:28 +08:00
24 changed files with 56081 additions and 72 deletions

View File

@ -1 +1,2 @@
dist dist
node_modules

3
.gitignore vendored
View File

@ -1 +1,4 @@
dist dist
node_modules
public/css/main.css*
public/js/main.js*

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
.parcel-cache/data.mdb Normal file

Binary file not shown.

BIN
.parcel-cache/lock.mdb Normal file

Binary file not shown.

42
api.go
View File

@ -11,7 +11,7 @@ import (
func ListFiles(w http.ResponseWriter, r *http.Request) { func ListFiles(w http.ResponseWriter, r *http.Request) {
data, err := json.Marshal(files) data, err := json.Marshal(files)
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
w.Write(data) w.Write(data)
@ -21,12 +21,12 @@ func GetFile(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name") name := r.URL.Query().Get("name")
file, ok := files[name] file, ok := files[name]
if name == "" || !ok { if name == "" || !ok {
HttpWriter(w, 404, "file not found") MakeResponse(w, 404, []byte("file not found"))
return return
} }
data, err := file.Read() data, err := file.Read()
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
response, err := json.Marshal(map[string]string{ response, err := json.Marshal(map[string]string{
@ -36,7 +36,7 @@ func GetFile(w http.ResponseWriter, r *http.Request) {
"data": string(data), "data": string(data),
}) })
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -47,21 +47,21 @@ func PostFile(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body) data, err := ioutil.ReadAll(r.Body)
r.Body.Close() r.Body.Close()
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
f := configui.File{} f := configui.File{}
if err := json.Unmarshal(data, &f); err != nil { if err := json.Unmarshal(data, &f); err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
file, ok := files[f.Alias] file, ok := files[f.Alias]
if !ok { if !ok {
HttpWriter(w, 404, "file not found") MakeResponse(w, 404, []byte("file not found"))
return return
} }
if err := file.Write([]byte(f.Data)); err != nil { if err := file.Write([]byte(f.Data)); err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
w.Write([]byte("ok")) w.Write([]byte("ok"))
@ -71,12 +71,12 @@ func Apply(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name") name := r.URL.Query().Get("name")
file, ok := files[name] file, ok := files[name]
if name == "" || !ok { if name == "" || !ok {
HttpWriter(w, 404, "file not found") MakeResponse(w, 404, []byte("file not found"))
return return
} }
result, err := file.Do() result, err := file.Do()
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
w.Write([]byte(result)) w.Write([]byte(result))
@ -95,27 +95,31 @@ func LoadConfig(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body) data, err := ioutil.ReadAll(r.Body)
r.Body.Close() r.Body.Close()
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
ftmp, err := configui.ReadConfig(string(data)) ftmp, err := configui.ReadConfig(string(data))
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
files = configui.GetFileMap(ftmp) files = configui.GetFileMap(ftmp)
w.Write([]byte("ok")) w.Write([]byte("ok"))
} }
func GetConfig(w http.ResponseWriter, r *http.Request) { func getConfigHandler(w http.ResponseWriter, r *http.Request) {
config := []configui.File{} data, err := GetConfig()
for _, f := range files {
config = append(config, *f)
}
data, err := json.Marshal(config)
if err != nil { if err != nil {
HttpWriter(w, 500, err.Error()) AbortError(w, err)
return return
} }
w.Write(data) w.Write(data)
} }
func GetConfig() ([]byte, error) {
config := []configui.File{}
for _, f := range files {
config = append(config, *f)
}
return json.Marshal(config)
}

25
main.go
View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"bytes"
"embed" "embed"
"flag" "flag"
"fmt" "fmt"
@ -13,6 +12,7 @@ import (
"time" "time"
"kumoly.io/tools/configui/configui" "kumoly.io/tools/configui/configui"
"kumoly.io/tools/configui/public"
) )
//go:embed templates //go:embed templates
@ -27,6 +27,7 @@ var (
flagBind string flagBind string
flagLogFile string flagLogFile string
flagAllow string
flagVer bool flagVer bool
) )
@ -42,6 +43,7 @@ func init() {
flag.StringVar(&flagAlias, "n", "", "alias of file") flag.StringVar(&flagAlias, "n", "", "alias of file")
flag.StringVar(&flagAction, "c", "", "cmd to apply") flag.StringVar(&flagAction, "c", "", "cmd to apply")
flag.StringVar(&flagLogFile, "log", "", "log to file") flag.StringVar(&flagLogFile, "log", "", "log to file")
flag.StringVar(&flagAllow, "allow", "", "IPs to allow, blank to allow all")
flag.StringVar(&flagBind, "bind", "localhost:8000", "address to bind") flag.StringVar(&flagBind, "bind", "localhost:8000", "address to bind")
flag.BoolVar(&flagVer, "v", false, "show version") flag.BoolVar(&flagVer, "v", false, "show version")
flag.Usage = func() { flag.Usage = func() {
@ -96,7 +98,7 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/conf", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/conf", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" { if r.Method == "GET" {
GetConfig(w, r) getConfigHandler(w, r)
} else if r.Method == "POST" { } else if r.Method == "POST" {
LoadConfig(w, r) LoadConfig(w, r)
} else { } else {
@ -133,28 +135,15 @@ func main() {
w.WriteHeader(404) w.WriteHeader(404)
} }
}) })
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
buf := &bytes.Buffer{}
err := tmpl.ExecuteTemplate(buf, "home", nil)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(err.Error()))
} else {
w.Write(buf.Bytes())
}
})
mux.Handle("/public/", http.StripPrefix("/public/", http.FileServer(http.FS(public.FS))))
tmpl = template.Must(template.New("").ParseFS(tmplFS, "templates/*.tmpl", "templates/**/*.tmpl")) tmpl = template.Must(template.New("").ParseFS(tmplFS, "templates/*.tmpl", "templates/**/*.tmpl"))
setRoutes(mux)
server := &http.Server{ server := &http.Server{
Addr: flagBind, Addr: flagBind,
WriteTimeout: time.Second * 3, WriteTimeout: time.Second * 3,
ReadTimeout: time.Second * 30, ReadTimeout: time.Second * 30,
Handler: LogMiddleware(mux), Handler: Middleware(mux),
} }
// start server // start server

108
netutil.go Normal file
View File

@ -0,0 +1,108 @@
package main
import (
"bytes"
"log"
"net"
"net/http"
"strconv"
"strings"
)
type ResponseWriter struct {
http.ResponseWriter
StatueCode int
}
func (w *ResponseWriter) WriteHeader(statusCode int) {
if w.StatueCode != 0 {
return
}
w.StatueCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *ResponseWriter) Write(body []byte) (int, error) {
if w.StatueCode == 0 {
w.WriteHeader(200)
}
return w.ResponseWriter.Write(body)
}
func MakeResponse(w http.ResponseWriter, status int, body []byte) (int, error) {
w.WriteHeader(status)
return w.Write(body)
}
func AbortError(w http.ResponseWriter, err interface{}) (int, error) {
switch v := err.(type) {
case int:
w.WriteHeader(v)
return w.Write([]byte(strconv.Itoa(v)))
case string:
w.WriteHeader(500)
return w.Write([]byte(v))
case error:
w.WriteHeader(500)
return w.Write([]byte(v.Error()))
default:
w.WriteHeader(500)
return w.Write([]byte(strconv.Itoa(500)))
}
}
func Catch(rw *ResponseWriter, r *http.Request) {
ex := recover()
if ex != nil {
AbortError(rw, r)
log.Printf("%s %s %d %s %s\n", GetIP(r), r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
}
}
func Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
rw := &ResponseWriter{w, 0}
defer Catch(rw, r)
abort := false
if flagAllow != "" {
if !matchIPGlob(GetIP(r), flagAllow) {
MakeResponse(rw, 403, []byte("permission denyed"))
abort = true
}
}
if !abort {
next.ServeHTTP(rw, r)
}
log.Printf("%s %s %d %s %s\n", GetIP(r), r.Method, rw.StatueCode, r.URL, r.Header.Get("User-Agent"))
},
)
}
func GetIP(r *http.Request) string {
ip := r.Header.Get("X-Real-Ip")
if ip == "" {
ips := r.Header.Get("X-Forwarded-For")
ipArr := strings.Split(ips, ",")
ip = strings.Trim(ipArr[len(ipArr)-1], " ")
}
if ip == "" {
var err error
ip, _, err = net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
}
return ip
}
func Parse(w http.ResponseWriter, name string, data interface{}) error {
buf := &bytes.Buffer{}
err := tmpl.ExecuteTemplate(buf, "home", data)
if err != nil {
AbortError(w, err)
return err
}
_, err = w.Write(buf.Bytes())
return err
}

6112
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "configui",
"version": "1.0.0",
"description": "a web app to edit and action on update",
"scripts": {
"build-sass": "sass --style compressed src/main.scss public/css/main.css",
"build-js": "parcel build src/main.js --dist-dir public/js",
"start":"npm run build-sass && npm run build-js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git@kumoly.io:tools/configui.git"
},
"author": "",
"license": "ISC",
"dependencies": {
"bulma": "^0.9.3",
"prismjs": "^1.25.0",
"sass": "^1.43.2"
},
"devDependencies": {
"@parcel/transformer-sass": "^2.0.0",
"parcel": "^2.0.0"
}
}

0
public/css/.gitkeep Normal file
View File

6
public/embed.go Normal file
View File

@ -0,0 +1,6 @@
package public
import "embed"
//go:embed js css
var FS embed.FS

0
public/js/.gitkeep Normal file
View File

42
route.go Normal file
View File

@ -0,0 +1,42 @@
package main
import (
"net/http"
)
func setRoutes(mux *http.ServeMux) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
Files := []string{}
for file := range files {
Files = append(Files, files[file].Alias)
}
contentB, err := GetConfig()
content := ""
if err != nil {
content = err.Error()
} else {
content = string(contentB)
}
data := struct {
Active string
Files []string
Content string
Lang string
}{
Files: Files,
Active: "ConfigUI",
Content: content,
Lang: "json",
}
Parse(w, "home", data)
})
}

5
src/main.js Normal file
View File

@ -0,0 +1,5 @@
import Prism from "prismjs";
import "prismjs/plugins/custom-class/prism-custom-class";
Prism.plugins.customClass.map({ number: "prism-number", tag: "prism-tag" });

2
src/main.scss Normal file
View File

@ -0,0 +1,2 @@
@charset "utf-8";
@import "../node_modules/bulma/bulma.sass";

233
src/tmp.css Normal file
View File

@ -0,0 +1,233 @@
/* Code-Input Compability */
/* By WebCoder49 */
/* First Published on CSS-Tricks.com */
textarea {
border: none;
overflow: auto;
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
resize: none; /*remove the resize handle on the bottom right*/
}
.content .tag, .content .number {
display: inline;
padding: inherit;
font-size: inherit;
line-height: inherit;
text-align: inherit;
vertical-align: inherit;
border-radius: inherit;
font-weight: inherit;
white-space: inherit;
background: inherit;
margin: inherit;
}
/* Please see the article */
#editing, #highlighting {
/* Both elements need the same text and space styling so they are directly on top of each other */
margin: 10px;
padding: 10px;
border: 0;
width: calc(100% - 32px);
height: 150px;
}
#editing, #highlighting, #highlighting * {
/* Also add text styles to highlighing tokens */
font-size: 15pt;
font-family: monospace;
line-height: 20pt;
tab-size: 2;
}
#editing, #highlighting {
/* In the same place */
position: absolute;
top: 0;
left: 0;
}
/* Move the textarea in front of the result */
#editing {
z-index: 1;
}
#highlighting {
z-index: 0;
}
/* Make textarea almost completely transparent */
#editing {
color: transparent;
background: transparent;
caret-color: white; /* Or choose your favourite color */
}
/* Can be scrolled */
#editing, #highlighting {
overflow: auto;
white-space: nowrap; /* Allows textarea to scroll horizontally */
}
/* No resize on textarea */
#editing {
resize: none;
}
/* Paragraphs; First Image */
* {
font-family: "Fira Code", monospace;
}
p code {
border-radius: 2px;
background-color: #eee;
color: #111;
}
/* Syntax Highlighting from prism.js starts below, partly modified: */
/* PrismJS 1.23.0
https://prismjs.com/download.html#themes=prism-funky&languages=markup */
/**
* prism.js Funky theme
* Based on Polyfilling the gaps talk slides http://lea.verou.me/polyfilling-the-gaps/
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: .4em .8em;
margin: .5em 0;
overflow: auto;
/* background: url('data:image/svg+xml;charset=utf-8,<svg%20version%3D"1.1"%20xmlns%3D"http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg"%20width%3D"100"%20height%3D"100"%20fill%3D"rgba(0%2C0%2C0%2C.2)">%0D%0A<polygon%20points%3D"0%2C50%2050%2C0%200%2C0"%20%2F>%0D%0A<polygon%20points%3D"0%2C100%2050%2C100%20100%2C50%20100%2C0"%20%2F>%0D%0A<%2Fsvg>');
background-size: 1em 1em; - WebCoder49*/
background: black; /* - WebCoder49 */
}
code[class*="language-"] {
background: black;
color: white;
box-shadow: -.3em 0 0 .3em black, .3em 0 0 .3em black;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .2em;
border-radius: .3em;
box-shadow: none;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #aaa;
}
.token.punctuation {
color: #999;
}
.token.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol {
color: #0cf;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin {
color: yellow;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.token.variable,
.token.inserted {
color: yellowgreen;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: deeppink;
}
.token.regex,
.token.important {
color: orange;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.deleted {
color: red;
}
/* Plugin styles: Diff Highlight */
pre.diff-highlight.diff-highlight > code .token.deleted:not(.prefix),
pre > code.diff-highlight.diff-highlight .token.deleted:not(.prefix) {
background-color: rgba(255, 0, 0, .3);
display: inline;
}
pre.diff-highlight.diff-highlight > code .token.inserted:not(.prefix),
pre > code.diff-highlight.diff-highlight .token.inserted:not(.prefix) {
background-color: rgba(0, 255, 128, .3);
display: inline;
}
/* End of prism.js syntax highlighting*/

39
src/tmp.js Normal file
View File

@ -0,0 +1,39 @@
// codeInput
// by WebCoder49
// Based on a CSS-Tricks Post
// Needs Prism.js
function update(text) {
let result_element = document.querySelector("#highlighting-content");
// Handle final newlines (see article)
if(text[text.length-1] == "\n") {
text += " ";
}
// Update code
result_element.innerHTML = text.replace(new RegExp("&", "g"), "&amp;").replace(new RegExp("<", "g"), "&lt;"); /* Global RegExp */
// Syntax Highlight
Prism.highlightElement(result_element);
}
function sync_scroll(element) {
/* Scroll result to scroll coords of event - sync with textarea */
let result_element = document.querySelector("#highlighting");
// Get and set x and y
result_element.scrollTop = element.scrollTop;
result_element.scrollLeft = element.scrollLeft;
}
function check_tab(element, event) {
let code = element.value;
if(event.key == "Tab") {
/* Tab key pressed */
event.preventDefault(); // stop normal
let before_tab = code.slice(0, element.selectionStart); // text before tab
let after_tab = code.slice(element.selectionEnd, element.value.length); // text after tab
let cursor_pos = element.selectionEnd + 1; // where cursor moves after tab - moving forward by 1 char to after tab
element.value = before_tab + "\t" + after_tab; // add tab char
// move cursor
element.selectionStart = cursor_pos;
element.selectionEnd = cursor_pos;
update(element.value); // Update text to include indent
}
}

View File

@ -1,6 +1,9 @@
{{define "base/footer"}} {{define "base/footer"}}
<script src="/public/js/main.js"></script> <script src="/public/js/main.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> <!-- <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script> -->
<script src="public/js/prism1.23.0.min.js"></script>
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.23.0/prism.min.js"></script> -->
</body> </body>
</html> </html>
{{end}} {{end}}

View File

@ -4,8 +4,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Serviced</title> <title>ConfigUI</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"> <link rel="stylesheet" href="/public/css/bulma0.9.3.min.css">
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"> -->
<link rel="stylesheet" href="/public/css/main.css">
</head> </head>
<body> <body>
{{end}} {{end}}

View File

@ -1,14 +1,54 @@
{{define "home"}} {{define "home"}}
{{template "base/header" .}} {{template "base/header" .}}
<section class="section">
<div class="container"> <div class="columns is-mobile is-centered">
<h1 class="title"> <div class="column is-half">
Hello World <section class="section">
</h1> <container class="container is-max-desktop">
<p class="subtitle"> <h1 class="title">ConfigUI</h1>
My first website with <strong>Bulma</strong>! </container>
</p> </section>
</div> </div>
</section> </div>
<div class="tile is-ancestor mx-2">
<div class="tile is-3 is-vertical is-parent">
<div class="tile is-child box">
<section class="is-large">
<aside class="menu">
<p class="menu-label">
Files
</p>
<ul class="menu-list">
{{ range .Files }}
<li><a>{{ . }}</a></li>
{{ end }}
</ul>
<p class="menu-label">
ConfigUI
</p>
<ul class="menu-list">
<li><a{{if eq .Active "ConfigUI"}} class="is-active"{{end}}>config.json</a></li>
</ul>
</aside>
</section>
</div>
<div class="tile is-child box">
<p class="title">Two</p>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box">
<p class="title">Three</p>
<textarea class="content" placeholder="Enter HTML Source Code" id="editing" spellcheck="false" oninput="update(this.value); sync_scroll(this);" onscroll="sync_scroll(this);" onkeydown="check_tab(this, event);"></textarea>
<pre id="highlighting" aria-hidden="true">
<code class="language-html" id="highlighting-content">{{.Content}}</code>
</pre>
<!-- <code-input class="content" id="edit" lang="{{.Lang}}" name="config">{{.Content}}</code-input> -->
</div>
</div>
</div>
{{template "base/footer" .}} {{template "base/footer" .}}
{{end}} {{end}}

62
util.go
View File

@ -4,26 +4,51 @@ import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"io" "io"
"log"
"net/http"
"os" "os"
"strings" "strings"
) )
func LogMiddleware(next http.Handler) http.Handler { func matchIPGlob(ip, pattern string) bool {
return http.HandlerFunc( parts := strings.Split(pattern, ".")
func(w http.ResponseWriter, r *http.Request) { seg := strings.Split(ip, ".")
defer func() { for i, part := range parts {
r := recover()
if r != nil { // normalize pattern to 3 digits
log.Println("panic", r) switch len(part) {
w.WriteHeader(500) case 1:
} if part == "*" {
}() part = "***"
next.ServeHTTP(w, r) } else {
log.Printf("%s %s %s %s\n", r.RemoteAddr, r.Method, r.URL, r.Header.Get("User-Agent")) part = "00" + part
}, }
) case 2:
if strings.HasPrefix(part, "*") {
part = "*" + part
} else if strings.HasSuffix(part, "*") {
part = part + "*"
} else {
part = "0" + part
}
}
// normalize ip to 3 digits
switch len(seg[i]) {
case 1:
seg[i] = "00" + seg[i]
case 2:
seg[i] = "0" + seg[i]
}
for j := range part {
if string(part[j]) == "*" {
continue
}
if part[j] != seg[i][j] {
return false
}
}
}
return true
} }
func bundle(buf io.Writer, paths []string, flat bool) error { func bundle(buf io.Writer, paths []string, flat bool) error {
@ -83,8 +108,3 @@ func bundle(buf io.Writer, paths []string, flat bool) error {
return nil return nil
} }
func HttpWriter(w http.ResponseWriter, status int, errStr string) {
w.WriteHeader(status)
w.Write([]byte(errStr))
}