Refactoring file storage to enable different providers

Expanding this as we go, currently have POC Backblaze B2 support and
basic 'disk' provider as well. Still WIP, but functional for the most
part. Also moving to simplified YAML configuration.

Overall, simplifying things to be extensible down the line. Still work
to be done, but coming along nicely.
This commit is contained in:
Gabriel Simmer 2020-02-24 18:07:47 +00:00
parent 9a2e6814f9
commit bf1f06b79c
13 changed files with 315 additions and 308 deletions

4
.gitignore vendored
View file

@ -14,8 +14,8 @@
# IntelliJ # IntelliJ
.idea/ .idea/
# Config file # Config files
config.json providers.yml
# Binary # Binary
nas nas

View file

@ -1,4 +0,0 @@
{
"ColdStorage": "",
"HotStorage": ""
}

View file

@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>React App</title><link href="/static/css/main.feacb500.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(l){function e(e){for(var r,t,n=e[0],o=e[1],u=e[2],f=0,i=[];f<n.length;f++)t=n[f],p[t]&&i.push(p[t][0]),p[t]=0;for(r in o)Object.prototype.hasOwnProperty.call(o,r)&&(l[r]=o[r]);for(s&&s(e);i.length;)i.shift()();return c.push.apply(c,u||[]),a()}function a(){for(var e,r=0;r<c.length;r++){for(var t=c[r],n=!0,o=1;o<t.length;o++){var u=t[o];0!==p[u]&&(n=!1)}n&&(c.splice(r--,1),e=f(f.s=t[0]))}return e}var t={},p={1:0},c=[];function f(e){if(t[e])return t[e].exports;var r=t[e]={i:e,l:!1,exports:{}};return l[e].call(r.exports,r,r.exports,f),r.l=!0,r.exports}f.m=l,f.c=t,f.d=function(e,r,t){f.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},f.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},f.t=function(r,e){if(1&e&&(r=f(r)),8&e)return r;if(4&e&&"object"==typeof r&&r&&r.__esModule)return r;var t=Object.create(null);if(f.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:r}),2&e&&"string"!=typeof r)for(var n in r)f.d(t,n,function(e){return r[e]}.bind(null,n));return t},f.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return f.d(r,"a",r),r},f.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},f.p="/";var r=window.webpackJsonp=window.webpackJsonp||[],n=r.push.bind(r);r.push=e,r=r.slice();for(var o=0;o<r.length;o++)e(r[o]);var s=n;a()}([])</script><script src="/static/js/2.ab401df5.chunk.js"></script><script src="/static/js/main.33319914.chunk.js"></script></body></html>

120
files/backblaze.go Normal file
View file

@ -0,0 +1,120 @@
package files
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
)
type BackblazeProvider struct {
FileProvider
Bucket string
}
type BackblazeAuthPayload struct {
AccountId string `json:"accountId"`
AuthToken string `json:"authorizationToken"`
ApiUrl string `json:"apiUrl"`
}
type BackblazeFile struct {
Action string `json:"action"`
Size int `json:"contentLength"`
Type string `json:"contentType"`
FileName string `json:"fileName"`
Timestamp int `json:"uploadTimestamp"`
}
type BackblazeFilePayload struct {
Files []BackblazeFile `json:"files"`
}
// Call Backblaze API endpoint to authorize and gather facts.
func (bp *BackblazeProvider) Authorize(appKeyId string, appKey string) error {
client := &http.Client{}
req, err := http.NewRequest("GET",
"https://api.backblazeb2.com/b2api/v2/b2_authorize_account",
nil)
if err != nil {
return err
}
req.SetBasicAuth(appKeyId, appKey)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
var data BackblazeAuthPayload
err = json.Unmarshal(body, &data)
if err != nil {
return err
}
bp.Authentication = data.AuthToken
bp.Location = data.ApiUrl
bp.Name = "Backblaze|" + data.AccountId
return nil
}
func (bp *BackblazeProvider) GetDirectory(path string) Directory {
client := &http.Client{}
requestBody := fmt.Sprintf(`{"bucketId": "%s"}`, bp.Bucket)
req, err := http.NewRequest("POST",
bp.Location + "/b2api/v2/b2_list_file_names",
bytes.NewBuffer([]byte(requestBody)))
if err != nil {
return Directory{}
}
req.Header.Add("Authorization", bp.Authentication)
resp, err := client.Do(req)
if err != nil {
return Directory{}
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return Directory{}
}
var data BackblazeFilePayload
err = json.Unmarshal(body, &data)
if err != nil {
fmt.Println(err.Error())
return Directory{}
}
finalDir := Directory{
Path: bp.Bucket,
}
for _, v := range data.Files {
file := FileInfo{
IsDirectory: v.Action == "folder",
Name: v.FileName,
}
if v.Action != "folder" {
split := strings.Split(v.FileName, ".")
file.Extension = split[len(split) - 1]
}
finalDir.Files = append(finalDir.Files, file)
}
return finalDir
}
func (bp *BackblazeProvider) ViewFile(path string) string {
return ""
}
func (bp *BackblazeProvider) SaveFile(contents []byte, path string) bool {
return true
}

49
files/disk.go Normal file
View file

@ -0,0 +1,49 @@
package files
import (
"io/ioutil"
"os"
"strings"
)
type DiskProvider struct{
FileProvider
}
func (dp *DiskProvider) GetDirectory(path string) Directory {
rp := strings.Join([]string{dp.Location,path}, "/")
fileDir, err := ioutil.ReadDir(rp)
if err != nil {
_ = os.MkdirAll(path, 0644)
}
var fileList []FileInfo
for _, file := range fileDir {
info := FileInfo{
IsDirectory: file.IsDir(),
Name: file.Name(),
}
if !info.IsDirectory {
split := strings.Split(file.Name(), ".")
info.Extension = split[len(split) - 1]
}
fileList = append(fileList, info)
}
return Directory{
Path: rp,
Files: fileList,
}
}
func (dp *DiskProvider) ViewFile(path string) string {
return strings.Join([]string{dp.Location,path}, "/")
}
func (dp *DiskProvider) SaveFile(contents []byte, path string) bool {
err := ioutil.WriteFile(path, contents, 0600)
if err != nil {
return false
}
return true
}

61
files/fileprovider.go Normal file
View file

@ -0,0 +1,61 @@
package files
import "fmt"
type FileProvider struct {
Name string `yaml:"name"`
Authentication string `yaml:"authentication"`
Location string `yaml:"path"`
Config map[string]string `yaml:"config"`
}
type Directory struct {
Path string
Files []FileInfo
}
type FileInfo struct {
IsDirectory bool
Name string
Extension string
}
var Providers map[string]FileProvider
type FileProviderInterface interface {
GetDirectory(path string) Directory
ViewFile(path string) string
SaveFile(contents []byte, path string) bool
}
func TranslateProvider(codename string, i *FileProviderInterface) {
provider := Providers[codename]
if codename == "disk" {
*i = &DiskProvider{provider,}
return
}
if codename == "backblaze" {
bbProv := &BackblazeProvider{provider, provider.Config["bucket"]}
err := bbProv.Authorize(provider.Config["appKeyId"], provider.Config["appId"])
if err != nil {
fmt.Println(err.Error())
}
*i = bbProv
return
}
*i = FileProvider{}
}
/** DO NOT USE THESE DEFAULTS **/
func (f FileProvider) GetDirectory(path string) Directory {
return Directory{}
}
func (f FileProvider) ViewFile(path string) string {
return ""
}
func (f FileProvider) SaveFile(contents []byte, path string) bool {
return false
}

View file

@ -1,194 +0,0 @@
package files
import (
"encoding/json"
"fmt"
"github.com/gmemstr/nas/auth"
"github.com/gmemstr/nas/common"
"github.com/gorilla/mux"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
)
type Config struct {
ColdStorage string
HotStorage string
}
type Directory struct {
Path string
Files []FileInfo
Previous string
Prefix string
SinglePrefix string
}
type FileInfo struct {
IsDirectory bool
Name string
}
func GetUserDirectory(r *http.Request, tier string) (string, string, string) {
usr, err := auth.DecryptCookie(r)
if err != nil {
return "", "", ""
}
username := usr.Username
d, err := ioutil.ReadFile("assets/config/config.json")
if err != nil {
panic(err)
}
var config Config
err = json.Unmarshal(d, &config)
if err != nil {
panic(err)
}
// Default to hot storage
storage := config.HotStorage + username
prefix := "files"
singleprefix := "file"
if tier == "cold" {
storage = config.ColdStorage + username
prefix = "archive"
singleprefix = "archived"
}
// Ensure directory exists.
_, err = ioutil.ReadDir(storage)
if err != nil && storage == "" {
fmt.Println(storage)
_ = os.MkdirAll(storage, 0644)
}
return storage, prefix, singleprefix
}
// Lists out directory using template.
func Listing() common.Handler {
return func(rc *common.RouterContext, w http.ResponseWriter, r *http.Request) *common.HTTPError {
vars := mux.Vars(r)
id := vars["file"]
tier := vars["tier"]
storage, prefix, singleprefix := GetUserDirectory(r, tier)
if storage == "" && prefix == "" && singleprefix == "" {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return &common.HTTPError{
Message: "Unauthorized, or unable to find cookie",
StatusCode: http.StatusTemporaryRedirect,
}
}
path := storage
if id != "" {
path = storage + id
}
fileDir, err := ioutil.ReadDir(path)
if err != nil && path == "" {
fmt.Println(path)
_ = os.MkdirAll(path, 0644)
}
var fileList []FileInfo
for _, file := range fileDir {
info := FileInfo{
IsDirectory: file.IsDir(),
Name: file.Name(),
}
fileList = append(fileList, info)
}
path = strings.Replace(path, storage, "", -1)
// Figure out what our previous location was.
previous := strings.Split(path, "/")
previous = previous[:len(previous)-1]
previousPath := strings.Join(previous, "/")
directory := Directory{
Path: path,
Files: fileList,
Previous: previousPath,
Prefix: prefix,
SinglePrefix: singleprefix,
}
resultJson, err := json.Marshal(directory);
w.Write(resultJson);
return nil;
}
}
// Lists out directory using template.
func ViewFile() common.Handler {
return func(rc *common.RouterContext, w http.ResponseWriter, r *http.Request) *common.HTTPError {
vars := mux.Vars(r)
id := vars["file"]
tier := vars["tier"]
d, err := ioutil.ReadFile("assets/config/config.json")
if err != nil {
panic(err)
}
var config Config
err = json.Unmarshal(d, &config)
if err != nil {
panic(err)
}
// Default to hot storage
storage, _, _ := GetUserDirectory(r, tier)
path := storage + "/" + id
common.ReadAndServeFile(path, w)
return nil
}
}
func UploadFile() common.Handler {
return func(rc *common.RouterContext, w http.ResponseWriter, r *http.Request) *common.HTTPError {
d, err := ioutil.ReadFile("assets/config/config.json")
vars := mux.Vars(r)
tier := vars["tier"]
var config Config
err = json.Unmarshal(d, &config)
if err != nil {
panic(err)
}
err = r.ParseMultipartForm(32 << 20)
path := strings.Join(r.Form["path"], "")
// Default to hot storage
storage, _, _ := GetUserDirectory(r, tier)
file, handler, err := r.FormFile("file")
if err != nil {
fmt.Println(err)
return nil
}
defer file.Close()
f, err := os.OpenFile(storage+"/"+path+"/"+handler.Filename, os.O_WRONLY|os.O_CREATE, 0666)
if err != nil {
panic(err)
}
defer f.Close()
io.Copy(f, file)
return nil
}
}

11
go.mod
View file

@ -1,10 +1,13 @@
module gmem.ca/nas module github.com/gmemstr/nas
go 1.13 go 1.13
require ( require (
github.com/gmemstr/nas v0.0.0-20190728044305-652472635722 github.com/go-yaml/yaml v2.1.0+incompatible
github.com/gorilla/mux v1.7.4 // indirect github.com/gorilla/mux v1.7.4
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/kr/pretty v0.2.0 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
) )

14
go.sum
View file

@ -1,7 +1,12 @@
github.com/gmemstr/nas v0.0.0-20190728044305-652472635722 h1:LmCeaQfQHTfKtx1HbR9cHMndomz14InE0d5lBWp/opI= github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
github.com/gmemstr/nas v0.0.0-20190728044305-652472635722/go.mod h1:hv1O7aXobFKTuw2JKHkzEnFMdQqv6wcLEgoTD7mxIY4= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -11,3 +16,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

41
router/filerouter.go Normal file
View file

@ -0,0 +1,41 @@
package router
import (
"encoding/json"
"github.com/gmemstr/nas/common"
"github.com/gmemstr/nas/files"
"github.com/gorilla/mux"
"net/http"
)
func HandleProvider() common.Handler {
return func(rc *common.RouterContext, w http.ResponseWriter, r *http.Request) *common.HTTPError {
vars := mux.Vars(r)
if r.Method == "GET" {
providerCodename := vars["provider"]
var provider files.FileProviderInterface
files.TranslateProvider(providerCodename, &provider)
fileList := provider.GetDirectory("")
if vars["file"] != "" {
fileList = provider.GetDirectory(vars["file"])
}
data, err := json.Marshal(fileList)
if err != nil {
w.Write([]byte("An error occurred"))
return nil
}
w.Write(data)
}
return nil
}
}
func ListProviders() common.Handler {
return func(rc *common.RouterContext, w http.ResponseWriter, r *http.Request) *common.HTTPError {
return nil
}
}

View file

@ -9,8 +9,6 @@ import (
"net/http" "net/http"
"github.com/gmemstr/nas/common" "github.com/gmemstr/nas/common"
"github.com/gmemstr/nas/files"
"github.com/gmemstr/nas/system"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -49,34 +47,19 @@ func Init() *mux.Router {
loginHandler(), loginHandler(),
)).Methods("POST", "GET") )).Methods("POST", "GET")
r.Handle("/api/diskusage", Handle( r.Handle("/api/providers", Handle(
auth.RequireAuthorization(1), auth.RequireAuthorization(1),
system.DiskUsages(), ListProviders(),
)).Methods("GET")
r.Handle(`/api/files/{provider}`, Handle(
//auth.RequireAuthorization(1),
HandleProvider(),
)).Methods("GET") )).Methods("GET")
r.Handle(`/api/{tier:(?:hot|cold)}/file/{file:[a-zA-Z0-9=\-\/\s.,&_+]+}`, Handle( r.Handle(`/api/files/{provider}/{file:[a-zA-Z0-9=\-\/\s.,&_+]+}`, Handle(
auth.RequireAuthorization(1), //auth.RequireAuthorization(1),
files.ViewFile(), HandleProvider(),
)).Methods("GET")
r.Handle("/api/upload/hot", Handle(
auth.RequireAuthorization(1),
files.UploadFile(),
)).Methods("POST")
r.Handle("/api/upload/{tier:(?:hot|cold)}", Handle(
auth.RequireAuthorization(1),
files.UploadFile(),
)).Methods("POST")
r.Handle("/api/{tier:(?:hot|cold)}/", Handle(
auth.RequireAuthorization(1),
files.Listing(),
)).Methods("GET")
r.Handle(`/api/{tier:(?:hot|cold)}/{file:[a-zA-Z0-9=\-\/\s.,&_+]+}`, Handle(
auth.RequireAuthorization(1),
files.Listing(),
)).Methods("GET") )).Methods("GET")
return r return r

View file

@ -1,74 +0,0 @@
package system
import (
"encoding/json"
"github.com/gmemstr/nas/common"
"github.com/gmemstr/nas/files"
"io/ioutil"
"net/http"
"os"
"syscall"
)
type Config struct {
ColdStorage string
HotStorage string
}
type UsageStat struct {
Available int64
Free int64
Total int64
}
type UsageStats struct {
ColdStorage UsageStat
HotStorage UsageStat
}
func DiskUsages() common.Handler {
return func(rc *common.RouterContext, w http.ResponseWriter, r *http.Request) *common.HTTPError {
var statHot syscall.Statfs_t
var statCold syscall.Statfs_t
d, err := ioutil.ReadFile("assets/config/config.json")
if err != nil {
panic(err)
}
var config Config
err = json.Unmarshal(d, &config)
if err != nil {
panic(err)
}
storage, _, _ := files.GetUserDirectory(r,"hot")
err = syscall.Statfs(storage, &statHot)
if err != nil {
_ = os.MkdirAll(storage, 0644)
}
hotStats := UsageStat{
Free: statHot.Bsize * int64(statHot.Bfree),
Total: statHot.Bsize * int64(statHot.Blocks),
}
storage, _, _ = files.GetUserDirectory(r,"cold")
err = syscall.Statfs(storage, &statCold)
if err != nil {
_ = os.MkdirAll(storage, 0644)
}
coldStats := UsageStat{
Free: statCold.Bsize * int64(statCold.Bfree),
Total: statCold.Bsize * int64(statCold.Blocks),
}
usages := UsageStats{
HotStorage: hotStats,
ColdStorage: coldStats,
}
// Available blocks * size per block = available space in bytes
resultJson, err := json.Marshal(usages)
w.Write(resultJson)
return nil
}
}

View file

@ -11,7 +11,10 @@ import (
"database/sql" "database/sql"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/gmemstr/nas/files"
"github.com/go-yaml/yaml"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -26,6 +29,16 @@ func main() {
createLockFile() createLockFile()
} }
file, err := ioutil.ReadFile("providers.yml")
if err != nil {
panic(err)
}
err = yaml.Unmarshal(file, &files.Providers)
if err != nil {
panic(err)
}
fmt.Println(files.Providers)
r := router.Init() r := router.Init()
fmt.Println("Your NAS instance is live on port :3000") fmt.Println("Your NAS instance is live on port :3000")
log.Fatal(http.ListenAndServe(":3000", r)) log.Fatal(http.ListenAndServe(":3000", r))