diff options
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/app/app.go | 222 | ||||
| -rw-r--r-- | pkg/app/config.go | 89 | ||||
| -rw-r--r-- | pkg/app/feed.go | 77 | ||||
| -rw-r--r-- | pkg/app/listener.go | 37 | ||||
| -rw-r--r-- | pkg/app/tor.go | 45 | ||||
| -rw-r--r-- | pkg/app/watcher.go | 63 | ||||
| -rw-r--r-- | pkg/media/library.go | 122 | ||||
| -rw-r--r-- | pkg/media/media.go | 2 | ||||
| -rw-r--r-- | pkg/media/path.go | 7 | ||||
| -rw-r--r-- | pkg/media/playlist.go | 19 | ||||
| -rw-r--r-- | pkg/media/video.go | 77 | ||||
| -rw-r--r-- | pkg/onionkey/key.go | 24 | ||||
| -rw-r--r-- | pkg/onionkey/v3.go | 78 |
13 files changed, 0 insertions, 862 deletions
diff --git a/pkg/app/app.go b/pkg/app/app.go deleted file mode 100644 index 0efd22a..0000000 --- a/pkg/app/app.go +++ /dev/null @@ -1,222 +0,0 @@ -// Package app manages main application server. -package app - -import ( - "errors" - "fmt" - "html/template" - "log" - "net" - "net/http" - "path" - - "github.com/fsnotify/fsnotify" - "github.com/gorilla/mux" - "github.com/wybiral/tube/pkg/media" - "github.com/wybiral/tube/pkg/onionkey" -) - -// App represents main application. -type App struct { - Config *Config - Library *media.Library - Watcher *fsnotify.Watcher - Templates *template.Template - Feed []byte - Tor *tor - Listener net.Listener - Router *mux.Router -} - -// NewApp returns a new instance of App from Config. -func NewApp(cfg *Config) (*App, error) { - if cfg == nil { - cfg = DefaultConfig() - } - a := &App{ - Config: cfg, - } - // Setup Library - a.Library = media.NewLibrary() - // Setup Watcher - w, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - a.Watcher = w - // Setup Listener - ln, err := newListener(cfg.Server) - if err != nil { - return nil, err - } - a.Listener = ln - // Setup Templates - a.Templates = template.Must(template.ParseGlob("templates/*")) - // Setup Tor - if cfg.Tor.Enable { - t, err := newTor(cfg.Tor) - if err != nil { - return nil, err - } - a.Tor = t - } - // Setup Router - r := mux.NewRouter().StrictSlash(true) - r.HandleFunc("/", a.indexHandler).Methods("GET") - r.HandleFunc("/v/{id}.mp4", a.videoHandler).Methods("GET") - r.HandleFunc("/v/{prefix}/{id}.mp4", a.videoHandler).Methods("GET") - r.HandleFunc("/t/{id}", a.thumbHandler).Methods("GET") - r.HandleFunc("/t/{prefix}/{id}", a.thumbHandler).Methods("GET") - r.HandleFunc("/v/{id}", a.pageHandler).Methods("GET") - r.HandleFunc("/v/{prefix}/{id}", a.pageHandler).Methods("GET") - r.HandleFunc("/feed.xml", a.rssHandler).Methods("GET") - // Static file handler - fsHandler := http.StripPrefix( - "/static/", - http.FileServer(http.Dir("./static/")), - ) - r.PathPrefix("/static/").Handler(fsHandler).Methods("GET") - a.Router = r - return a, nil -} - -// Run imports the library and starts server. -func (a *App) Run() error { - if a.Tor != nil { - var err error - cs := a.Config.Server - key := a.Tor.OnionKey - if key == nil { - key, err = onionkey.GenerateKey() - if err != nil { - return err - } - a.Tor.OnionKey = key - } - onion, err := key.Onion() - if err != nil { - return err - } - onion.Ports[80] = fmt.Sprintf("%s:%d", cs.Host, cs.Port) - err = a.Tor.Controller.AddOnion(onion) - if err != nil { - return errors.New("unable to start Tor onion service") - } - log.Printf("Onion service: http://%s.onion", onion.ServiceID) - } - for _, pc := range a.Config.Library { - p := &media.Path{ - Path: pc.Path, - Prefix: pc.Prefix, - } - err := a.Library.AddPath(p) - if err != nil { - return err - } - err = a.Library.Import(p) - if err != nil { - return err - } - a.Watcher.Add(p.Path) - } - buildFeed(a) - go startWatcher(a) - return http.Serve(a.Listener, a.Router) -} - -// HTTP handler for / -func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("/") - pl := a.Library.Playlist() - if len(pl) > 0 { - http.Redirect(w, r, "/v/"+pl[0].ID, 302) - } else { - a.Templates.ExecuteTemplate(w, "index.html", &struct { - Playing *media.Video - Playlist media.Playlist - }{ - Playing: &media.Video{ID: ""}, - Playlist: a.Library.Playlist(), - }) - } -} - -// HTTP handler for /v/id -func (a *App) pageHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - prefix, ok := vars["prefix"] - if ok { - id = path.Join(prefix, id) - } - log.Printf("/v/%s", id) - playing, ok := a.Library.Videos[id] - if !ok { - a.Templates.ExecuteTemplate(w, "index.html", &struct { - Playing *media.Video - Playlist media.Playlist - }{ - Playing: &media.Video{ID: ""}, - Playlist: a.Library.Playlist(), - }) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - a.Templates.ExecuteTemplate(w, "index.html", &struct { - Playing *media.Video - Playlist media.Playlist - }{ - Playing: playing, - Playlist: a.Library.Playlist(), - }) -} - -// HTTP handler for /v/id.mp4 -func (a *App) videoHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - prefix, ok := vars["prefix"] - if ok { - id = path.Join(prefix, id) - } - log.Printf("/v/%s", id) - m, ok := a.Library.Videos[id] - if !ok { - return - } - title := m.Title - disposition := "attachment; filename=\"" + title + ".mp4\"" - w.Header().Set("Content-Disposition", disposition) - w.Header().Set("Content-Type", "video/mp4") - http.ServeFile(w, r, m.Path) -} - -// HTTP handler for /t/id -func (a *App) thumbHandler(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id := vars["id"] - prefix, ok := vars["prefix"] - if ok { - id = path.Join(prefix, id) - } - log.Printf("/t/%s", id) - m, ok := a.Library.Videos[id] - if !ok { - return - } - w.Header().Set("Cache-Control", "public, max-age=7776000") - if m.ThumbType == "" { - w.Header().Set("Content-Type", "image/jpeg") - http.ServeFile(w, r, "static/defaulticon.jpg") - } else { - w.Header().Set("Content-Type", m.ThumbType) - w.Write(m.Thumb) - } -} - -// HTTP handler for /feed.xml -func (a *App) rssHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "public, max-age=7776000") - w.Header().Set("Content-Type", "text/xml") - w.Write(a.Feed) -} diff --git a/pkg/app/config.go b/pkg/app/config.go deleted file mode 100644 index ec27435..0000000 --- a/pkg/app/config.go +++ /dev/null @@ -1,89 +0,0 @@ -package app - -import ( - "encoding/json" - "os" -) - -// Config settings for main App. -type Config struct { - Library []*PathConfig `json:"library"` - Server *ServerConfig `json:"server"` - Feed *FeedConfig `json:"feed"` - Tor *TorConfig `json:"tor,omitempty"` -} - -// PathConfig settings for media library path. -type PathConfig struct { - Path string `json:"path"` - Prefix string `json:"prefix"` -} - -// ServerConfig settings for App Server. -type ServerConfig struct { - Host string `json:"host"` - Port int `json:"port"` -} - -// FeedConfig settings for App Feed. -type FeedConfig struct { - ExternalURL string `json:"external_url"` - Title string `json:"title"` - Link string `json:"link"` - Description string `json:"description"` - Author struct { - Name string `json:"name"` - Email string `json:"email"` - } `json:"author"` - Copyright string `json:"copyright"` -} - -// TorConfig stores tor configuration. -type TorConfig struct { - Enable bool `json:"enable"` - Controller *TorControllerConfig `json:"controller"` -} - -// TorControllerConfig stores tor controller configuration. -type TorControllerConfig struct { - Host string `json:"host"` - Port int `json:"port"` - Password string `json:"password,omitempty"` -} - -// DefaultConfig returns Config initialized with default values. -func DefaultConfig() *Config { - return &Config{ - Library: []*PathConfig{ - &PathConfig{ - Path: "videos", - Prefix: "", - }, - }, - Server: &ServerConfig{ - Host: "127.0.0.1", - Port: 0, - }, - Feed: &FeedConfig{ - ExternalURL: "http://localhost", - }, - Tor: &TorConfig{ - Enable: false, - Controller: &TorControllerConfig{ - Host: "127.0.0.1", - Port: 9051, - }, - }, - } -} - -// ReadFile reads a JSON file into Config. -func (c *Config) ReadFile(path string) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - d := json.NewDecoder(f) - return d.Decode(c) -} diff --git a/pkg/app/feed.go b/pkg/app/feed.go deleted file mode 100644 index e4c1ce2..0000000 --- a/pkg/app/feed.go +++ /dev/null @@ -1,77 +0,0 @@ -package app - -import ( - "fmt" - "net/url" - "os" - "path" - "strconv" - "time" - - "github.com/wybiral/feeds" -) - -// buildFeed creates RSS feed attribute for App based on Library contents. -func buildFeed(a *App) { - cfg := a.Config.Feed - now := time.Now() - f := &feeds.Feed{ - Title: cfg.Title, - Link: &feeds.Link{Href: cfg.Link}, - Description: cfg.Description, - Author: &feeds.Author{ - Name: cfg.Author.Name, - Email: cfg.Author.Email, - }, - Created: now, - Copyright: cfg.Copyright, - } - var externalURL string - if len(cfg.ExternalURL) > 0 { - externalURL = cfg.ExternalURL - } else if a.Tor != nil { - onion, err := a.Tor.OnionKey.Onion() - if err != nil { - return - } - externalURL = fmt.Sprintf("http://%s.onion", onion.ServiceID) - } else { - hostname, err := os.Hostname() - if err != nil { - host := a.Config.Server.Host - port := a.Config.Server.Port - externalURL = fmt.Sprintf("http://%s:%d", host, port) - } else { - externalURL = fmt.Sprintf("http://%s", hostname) - } - } - for _, v := range a.Library.Playlist() { - u, err := url.Parse(externalURL) - if err != nil { - return - } - u.Path = path.Join(u.Path, "v", v.ID) - id := u.String() - f.Items = append(f.Items, &feeds.Item{ - Id: id, - Title: v.Title, - Link: &feeds.Link{Href: id}, - Description: v.Description, - Enclosure: &feeds.Enclosure{ - Url: id + ".mp4", - Length: strconv.FormatInt(v.Size, 10), - Type: "video/mp4", - }, - Author: &feeds.Author{ - Name: cfg.Author.Name, - Email: cfg.Author.Email, - }, - Created: v.Timestamp, - }) - } - feed, err := f.ToRss() - if err != nil { - return - } - a.Feed = []byte(feed) -} diff --git a/pkg/app/listener.go b/pkg/app/listener.go deleted file mode 100644 index a4236d8..0000000 --- a/pkg/app/listener.go +++ /dev/null @@ -1,37 +0,0 @@ -// Instead of using the default new.Listener this file will construct a custom -// one. The main purpose for this is to have more control over the settings -// (like keep-alive) and to retrieve the assigned port when using port 0. - -package app - -import ( - "fmt" - "net" - "time" -) - -func newListener(cfg *ServerConfig) (net.Listener, error) { - addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) - ln, err := net.Listen("tcp", addr) - if err != nil { - return nil, err - } - // set actual port on config object (in case original port was 0) - cfg.Port = ln.Addr().(*net.TCPAddr).Port - return tcpListener{ln.(*net.TCPListener)}, nil -} - -// custom TCP listener with keep-alive timeout -type tcpListener struct { - *net.TCPListener -} - -func (ln tcpListener) Accept() (net.Conn, error) { - tc, err := ln.AcceptTCP() - if err != nil { - return nil, err - } - tc.SetKeepAlive(true) - tc.SetKeepAlivePeriod(3 * time.Minute) - return tc, nil -} diff --git a/pkg/app/tor.go b/pkg/app/tor.go deleted file mode 100644 index d06751f..0000000 --- a/pkg/app/tor.go +++ /dev/null @@ -1,45 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "os" - - "github.com/wybiral/torgo" - "github.com/wybiral/tube/pkg/onionkey" -) - -type tor struct { - OnionKey onionkey.Key - Controller *torgo.Controller -} - -func newTor(ct *TorConfig) (*tor, error) { - addr := fmt.Sprintf("%s:%d", ct.Controller.Host, ct.Controller.Port) - ctrl, err := torgo.NewController(addr) - if err != nil { - return nil, errors.New("unable to connect to Tor controller") - } - if len(ct.Controller.Password) > 0 { - err = ctrl.AuthenticatePassword(ct.Controller.Password) - } else { - err = ctrl.AuthenticateCookie() - if err != nil { - err = ctrl.AuthenticateNone() - } - } - if err != nil { - return nil, errors.New("unable to authenticate to Tor controller") - } - key, err := onionkey.ReadFile("onion.key") - if os.IsNotExist(err) { - key = nil - } else if err != nil { - return nil, err - } - t := &tor{ - Controller: ctrl, - OnionKey: key, - } - return t, nil -} diff --git a/pkg/app/watcher.go b/pkg/app/watcher.go deleted file mode 100644 index 74db715..0000000 --- a/pkg/app/watcher.go +++ /dev/null @@ -1,63 +0,0 @@ -package app - -import ( - "time" - - fs "github.com/fsnotify/fsnotify" -) - -// This is the amount of time to wait after changes before reacting to them. -// Debounce is done because moving files into the watched directories causes -// many rapid "Write" events to fire which would cause excessive Remove/Add -// method calls on the Library. To avoid this we accumulate the changes and -// only perform them once the events have stopped for this amount of time. -const debounceTimeout = time.Second * 5 - -// create, write, and chmod all require an add event -const addFlags = fs.Create | fs.Write | fs.Chmod - -// remove, rename, write, and chmod all require a remove event -const removeFlags = fs.Remove | fs.Rename | fs.Write | fs.Chmod - -// watch library paths and update Library with changes. -func startWatcher(a *App) { - timer := time.NewTimer(debounceTimeout) - addEvents := make(map[string]struct{}) - removeEvents := make(map[string]struct{}) - for { - select { - case e := <-a.Watcher.Events: - if e.Op&removeFlags != 0 { - removeEvents[e.Name] = struct{}{} - } - if e.Op&addFlags != 0 { - addEvents[e.Name] = struct{}{} - } - // reset timer - timer.Reset(debounceTimeout) - case <-timer.C: - eventCount := len(removeEvents) + len(addEvents) - // handle remove events first - if len(removeEvents) > 0 { - for p := range removeEvents { - a.Library.Remove(p) - } - // clear map - removeEvents = make(map[string]struct{}) - } - // then handle add events - if len(addEvents) > 0 { - for p := range addEvents { - a.Library.Add(p) - } - // clear map - addEvents = make(map[string]struct{}) - } - if eventCount > 0 { - buildFeed(a) - } - // reset timer - timer.Reset(debounceTimeout) - } - } -} diff --git a/pkg/media/library.go b/pkg/media/library.go deleted file mode 100644 index 87fb223..0000000 --- a/pkg/media/library.go +++ /dev/null @@ -1,122 +0,0 @@ -package media - -import ( - "errors" - "io/ioutil" - "log" - "path" - "path/filepath" - "sort" - "strings" - "sync" -) - -// Library manages importing and retrieving video data. -type Library struct { - mu sync.RWMutex - Paths map[string]*Path - Videos map[string]*Video -} - -// NewLibrary returns new instance of Library. -func NewLibrary() *Library { - lib := &Library{ - Paths: make(map[string]*Path), - Videos: make(map[string]*Video), - } - return lib -} - -// AddPath adds a media path to the library. -func (lib *Library) AddPath(p *Path) error { - lib.mu.Lock() - defer lib.mu.Unlock() - // make sure new path doesn't collide with existing ones - for _, p2 := range lib.Paths { - if p.Path == p2.Path { - return errors.New("media: duplicate library path") - } - if p.Prefix == p2.Prefix { - return errors.New("media: duplicate library prefix") - } - } - lib.Paths[p.Path] = p - return nil -} - -// Import adds all valid videos from a given path. -func (lib *Library) Import(p *Path) error { - files, err := ioutil.ReadDir(p.Path) - if err != nil { - return err - } - for _, info := range files { - err = lib.Add(path.Join(p.Path, info.Name())) - if err != nil { - // Ignore files that can't be parsed - continue - } - } - return nil -} - -// Add adds a single video from a given file path. -func (lib *Library) Add(fp string) error { - lib.mu.Lock() - defer lib.mu.Unlock() - fp = filepath.ToSlash(fp) - d := path.Dir(fp) - p, ok := lib.Paths[d] - if !ok { - return errors.New("media: path not found") - } - n := path.Base(fp) - v, err := ParseVideo(p, n) - if err != nil { - return err - } - lib.Videos[v.ID] = v - log.Println("Added:", v.Path) - return nil -} - -// Remove removes a single video from a given file path. -func (lib *Library) Remove(fp string) { - lib.mu.Lock() - defer lib.mu.Unlock() - fp = filepath.ToSlash(fp) - d := path.Dir(fp) - p, ok := lib.Paths[d] - if !ok { - return - } - n := path.Base(fp) - // ID is name without extension - idx := strings.LastIndex(n, ".") - if idx == -1 { - idx = len(n) - } - id := n[:idx] - if len(p.Prefix) > 0 { - id = path.Join(p.Prefix, id) - } - v, ok := lib.Videos[id] - if ok { - delete(lib.Videos, id) - log.Println("Removed:", v.Path) - } -} - -// Playlist returns a sorted Playlist of all videos. -func (lib *Library) Playlist() Playlist { - lib.mu.RLock() - defer lib.mu.RUnlock() - pl := make(Playlist, len(lib.Videos)) - i := 0 - for _, v := range lib.Videos { - pl[i] = v - i++ - } - sort.Sort(pl) - return pl -} diff --git a/pkg/media/media.go b/pkg/media/media.go deleted file mode 100644 index 6fa6725..0000000 --- a/pkg/media/media.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package media manages video library functionality. -package media diff --git a/pkg/media/path.go b/pkg/media/path.go deleted file mode 100644 index 1248ed5..0000000 --- a/pkg/media/path.go +++ /dev/null @@ -1,7 +0,0 @@ -package media - -// Path represents a media library path. -type Path struct { - Path string - Prefix string -} diff --git a/pkg/media/playlist.go b/pkg/media/playlist.go deleted file mode 100644 index cf8edcf..0000000 --- a/pkg/media/playlist.go +++ /dev/null @@ -1,19 +0,0 @@ -package media - -// Playlist holds an array of videos capable of sorting by Timestamp. -type Playlist []*Video - -// Len returns length of array (for sorting). -func (p Playlist) Len() int { - return len(p) -} - -// Swap swaps two values in array by index (for sorting). -func (p Playlist) Swap(i, j int) { - p[i], p[j] = p[j], p[i] -} - -// Less returns true if p[i] Timestamp is after p[j] (for sorting). -func (p Playlist) Less(i, j int) bool { - return p[i].Timestamp.After(p[j].Timestamp) -} diff --git a/pkg/media/video.go b/pkg/media/video.go deleted file mode 100644 index 9340e86..0000000 --- a/pkg/media/video.go +++ /dev/null @@ -1,77 +0,0 @@ -package media - -import ( - "os" - "path" - "strings" - "time" - - "github.com/dhowden/tag" -) - -// Video represents metadata for a single video. -type Video struct { - ID string - Title string - Album string - Description string - Thumb []byte - ThumbType string - Modified string - Size int64 - Path string - Timestamp time.Time -} - -// ParseVideo parses a video file's metadata and returns a Video. -func ParseVideo(p *Path, name string) (*Video, error) { - pth := path.Join(p.Path, name) - f, err := os.Open(pth) - if err != nil { - return nil, err - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, err - } - size := info.Size() - timestamp := info.ModTime() - modified := timestamp.Format("2006-01-02 03:04 PM") - // ID is name without extension - idx := strings.LastIndex(name, ".") - if idx == -1 { - idx = len(name) - } - id := name[:idx] - if len(p.Prefix) > 0 { - // if there's a prefix prepend it to the ID - id = path.Join(p.Prefix, name[:idx]) - } - m, err := tag.ReadFrom(f) - if err != nil { - return nil, err - } - title := m.Title() - // Default title is filename - if title == "" { - title = name - } - v := &Video{ - ID: id, - Title: title, - Album: m.Album(), - Description: m.Comment(), - Modified: modified, - Size: size, - Path: pth, - Timestamp: timestamp, - } - // Add thumbnail (if exists) - pic := m.Picture() - if pic != nil { - v.Thumb = pic.Data - v.ThumbType = pic.MIMEType - } - return v, nil -} diff --git a/pkg/onionkey/key.go b/pkg/onionkey/key.go deleted file mode 100644 index b91d43a..0000000 --- a/pkg/onionkey/key.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package onionkey manages onion service key generation, serialization, and -// service ID calculation. Currently only supports version 3 onions. -package onionkey - -import ( - "github.com/wybiral/torgo" -) - -// Key is generic interface type for Tor onion keys. -type Key interface { - WriteFile(path string) error - Onion() (*torgo.Onion, error) - ServiceID() string -} - -// GenerateKey generates a Tor onion key. -func GenerateKey() (Key, error) { - return generateV3() -} - -// ReadFile reads a Tor onion key from file path. -func ReadFile(path string) (Key, error) { - return readV3(path) -} diff --git a/pkg/onionkey/v3.go b/pkg/onionkey/v3.go deleted file mode 100644 index cbd6c73..0000000 --- a/pkg/onionkey/v3.go +++ /dev/null @@ -1,78 +0,0 @@ -// Implements Tor v3 onion key based on ed25519. - -package onionkey - -import ( - "crypto/rand" - "encoding/base32" - "encoding/base64" - "errors" - "io/ioutil" - "os" - "strings" - - "github.com/wybiral/torgo" - "golang.org/x/crypto/ed25519" - "golang.org/x/crypto/sha3" -) - -type v3Key ed25519.PrivateKey - -func generateV3() (v3Key, error) { - _, key, err := ed25519.GenerateKey(rand.Reader) - return v3Key(key), err -} - -func readV3(path string) (v3Key, error) { - raw, err := ioutil.ReadFile(path) - if err != nil { - return nil, err - } - pk := strings.TrimSpace(string(raw)) - parts := strings.SplitN(pk, ":", 2) - if parts[0] != "v3" { - return nil, errors.New("Invalid key type") - } - seed, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } - key := ed25519.NewKeyFromSeed(seed) - return v3Key(key), nil -} - -func (k v3Key) Onion() (*torgo.Onion, error) { - return torgo.OnionFromEd25519(ed25519.PrivateKey(k)) -} - -func (k v3Key) WriteFile(path string) error { - seed := ed25519.PrivateKey(k).Seed() - b64 := base64.StdEncoding.EncodeToString(seed) - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString("v3:" + b64) - if err != nil { - return err - } - return nil -} - -func (k v3Key) ServiceID() string { - // Get ed25519 public key - pub := ed25519.PrivateKey(k).Public().(ed25519.PublicKey) - // Calculate check digits - checkstr := []byte(".onion checksum") - checkstr = append(checkstr, pub...) - checkstr = append(checkstr, 0x03) - checksum := sha3.Sum256(checkstr) - checkdigits := checksum[:2] - // Calculate service ID - combined := pub[:] - combined = append(combined, checkdigits...) - combined = append(combined, 0x03) - serviceID := base32.StdEncoding.EncodeToString(combined) - return strings.ToLower(serviceID) -} |
