├── go.mod ├── .gitignore ├── LICENSE ├── go.sum ├── README.md └── main.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/schollz/broadcast-server 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/gabriel-vasile/mimetype v1.4.0 7 | github.com/h2non/filetype v1.1.3 8 | github.com/schollz/logger v1.2.0 9 | ) 10 | 11 | require golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 // indirect 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | broadcast 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zack 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 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= 2 | github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= 3 | github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= 4 | github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 5 | github.com/schollz/logger v1.2.0 h1:5WXfINRs3lEUTCZ7YXhj0uN+qukjizvITLm3Ca2m0Ho= 6 | github.com/schollz/logger v1.2.0/go.mod h1:P6F4/dGMGcx8wh+kG1zrNEd4vnNpEBY/mwEMd/vn6AM= 7 | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 h1:Ugb8sMTWuWRC3+sz5WeN/4kejDx9BvIwnPUiJBjJE+8= 8 | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 9 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 10 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 11 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 12 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 13 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # broadcast 2 | 3 | A simple Go server that broadcasts any data/stream. Read more about it [on my blog](https://schollz.com/blog/radio/). The code was written based of [schollz/duct](https://github.com/schollz/duct), which is a fork of of [patchbay-pub](https://github.com/patchbay-pub/patchbay-simple-server). 4 | 5 | 6 | ## Usage 7 | 8 | ### Sending data 9 | 10 | You can POST data. 11 | 12 | ``` 13 | curl -X POST --data-binary "@someimage.png" localhost:9222/test.png 14 | ``` 15 | 16 | The image will be available at `localhost:9222/test.png`. 17 | 18 | 19 | ### Streaming audio 20 | 21 | You can POST an audio stream to the server for any number of clients to consume it. For example, you can `curl` a local music stream and then POST it: 22 | 23 | ``` 24 | curl http:///radio.mp3 | curl -k -H "Transfer-Encoding: chunked" -X POST -T - 'localhost:9222/test.mp3?stream=true' 25 | ``` 26 | 27 | This stream is now accessible at `localhost:9222/test.mp3`. The `?stream=true` flag is important to tell the server to start reading bytes right awawy, even if there is no listener. It has the benefit of immediately sending data to *all listeners* so that you can have multiple connections on that will all receive the data. Another useful flags for streaming is `advertise=true` which will advertise the stream on the main page. 28 | 29 | ## Installation 30 | 31 | First install Go. 32 | 33 | ``` 34 | go install -v github.com/schollz/broadcast-server@latest 35 | ``` 36 | 37 | ## License 38 | 39 | MIT -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "html/template" 7 | "io" 8 | "math/rand" 9 | "net/http" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "runtime" 14 | "sort" 15 | "strings" 16 | "sync" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/gabriel-vasile/mimetype" 21 | "github.com/h2non/filetype" 22 | log "github.com/schollz/logger" 23 | ) 24 | 25 | var flagDebug bool 26 | var flagPort int 27 | var flagFolder string 28 | 29 | func init() { 30 | flag.StringVar(&flagFolder, "folder", "archived", "folder to save archived") 31 | flag.IntVar(&flagPort, "port", 9222, "port for server") 32 | flag.BoolVar(&flagDebug, "debug", false, "debug mode") 33 | } 34 | 35 | func main() { 36 | flag.Parse() 37 | os.MkdirAll(flagFolder, os.ModePerm) 38 | // use all of the processors 39 | runtime.GOMAXPROCS(runtime.NumCPU()) 40 | if flagDebug { 41 | log.SetLevel("debug") 42 | log.Debug("debug mode") 43 | } else { 44 | log.SetLevel("info") 45 | } 46 | if err := serve(); err != nil { 47 | panic(err) 48 | } 49 | } 50 | 51 | type stream struct { 52 | b []byte 53 | done bool 54 | } 55 | 56 | // Serve will start the server 57 | func serve() (err error) { 58 | channels := make(map[string]map[float64]chan stream) 59 | archived := make(map[string]*os.File) 60 | advertisements := make(map[string]bool) 61 | mutex := &sync.Mutex{} 62 | const tpl = ` 63 | 64 | 65 | 66 | 67 | 68 | {{.Title}} 69 | 100 | 101 | 102 |

Current broadcasts:

103 | {{range .Items}}{{ . }}


107 | {{else}}
No broadcasts currently.
{{end}} 108 |

Archived broadcasts:

109 | {{if .Archived}}

click ❌ to remove an archive, ✎ to rename an archive (maybe don't remove/rename ones that you didn't create).

{{end}} 110 | {{range .Archived}}{{ .Filename }} ({{.Created.Format "Jan 02, 2006 15:04:05 UTC"}}, 111 |
are you sure?
click->👍absolutely sure? 🗑️
112 |
113 | 114 |
115 | 116 | Rename to: 117 | 118 |
119 |
) 120 |



124 | {{else}}
No archives currently.
{{end}} 125 | 126 | 127 | ` 128 | tplmain, err := template.New("webpage").Parse(tpl) 129 | if err != nil { 130 | return 131 | } 132 | 133 | handler := func(w http.ResponseWriter, r *http.Request) { 134 | w.Header().Set("Access-Control-Allow-Origin", "*") 135 | w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") 136 | w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") 137 | 138 | log.Debugf("opened %s %s", r.Method, r.URL.Path) 139 | defer func() { 140 | log.Debugf("finished %s\n", r.URL.Path) 141 | }() 142 | 143 | if r.URL.Path == "/" { 144 | // serve the README 145 | adverts := []string{} 146 | mutex.Lock() 147 | for advert := range advertisements { 148 | adverts = append(adverts, strings.TrimPrefix(advert, "/")) 149 | } 150 | mutex.Unlock() 151 | 152 | active := make(map[string]struct{}) 153 | // mutex.Lock() 154 | // for ch := range channels { 155 | // active[strings.TrimPrefix(ch, "/")] = struct{}{} 156 | // } 157 | // log.Debugf("active: %+v", active) 158 | // mutex.Unlock() 159 | 160 | data := struct { 161 | Title string 162 | Items []string 163 | Rand string 164 | Archived []ArchivedFile 165 | }{ 166 | Title: "Current broadcasts", 167 | Items: adverts, 168 | Rand: fmt.Sprintf("%d", rand.Int31()), 169 | Archived: listArchived(active), 170 | } 171 | err = tplmain.Execute(w, data) 172 | return 173 | } else if r.URL.Path == "/favicon.ico" { 174 | w.WriteHeader(http.StatusOK) 175 | return 176 | } else if strings.HasPrefix(r.URL.Path, "/"+flagFolder+"/") { 177 | filename := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/"+flagFolder+"/")) 178 | // This extra join implicitly does a clean and thereby prevents directory traversal 179 | filename = path.Join("/", filename) 180 | filename = path.Join(flagFolder, filename) 181 | v, ok := r.URL.Query()["remove"] 182 | if ok && v[0] == "true" { 183 | os.Remove(filename) 184 | w.Write([]byte(fmt.Sprintf("removed %s", filename))) 185 | } else { 186 | v, ok := r.URL.Query()["rename"] 187 | if ok && v[0] == "true" { 188 | newname_param, ok := r.URL.Query()["newname"] 189 | if(!ok) { 190 | w.Write([]byte(fmt.Sprintf("ERROR"))) 191 | return 192 | } 193 | // This join with "/" prevents directory traversal with an implicit clean 194 | newname := newname_param[0] 195 | newname = path.Join("/", newname) 196 | newname = path.Join(flagFolder, newname) 197 | os.Rename(filename, newname) 198 | w.Write([]byte(fmt.Sprintf("renamed %s to %s", filename, newname))) 199 | } else { 200 | http.ServeFile(w, r, filename) 201 | } 202 | } 203 | return 204 | } 205 | 206 | v, ok := r.URL.Query()["stream"] 207 | doStream := ok && v[0] == "true" 208 | 209 | v, ok = r.URL.Query()["archive"] 210 | doArchive := ok && v[0] == "true" 211 | 212 | if doArchive && r.Method == "POST" { 213 | if _, ok := archived[r.URL.Path]; !ok { 214 | folderName := path.Join(flagFolder, time.Now().Format("200601021504")) 215 | os.MkdirAll(folderName, os.ModePerm) 216 | archived[r.URL.Path], err = os.Create(path.Join(folderName, strings.TrimPrefix(r.URL.Path, "/"))) 217 | if err != nil { 218 | log.Error(err) 219 | } 220 | } 221 | defer func() { 222 | mutex.Lock() 223 | if _, ok := archived[r.URL.Path]; ok { 224 | log.Debugf("closed archive for %s", r.URL.Path) 225 | archived[r.URL.Path].Close() 226 | delete(archived, r.URL.Path) 227 | } 228 | mutex.Unlock() 229 | }() 230 | } 231 | 232 | v, ok = r.URL.Query()["advertise"] 233 | if ok && v[0] == "true" && doStream { 234 | mutex.Lock() 235 | advertisements[r.URL.Path] = true 236 | mutex.Unlock() 237 | defer func() { 238 | mutex.Lock() 239 | delete(advertisements, r.URL.Path) 240 | mutex.Unlock() 241 | }() 242 | } 243 | 244 | mutex.Lock() 245 | if _, ok := channels[r.URL.Path]; !ok { 246 | channels[r.URL.Path] = make(map[float64]chan stream) 247 | } 248 | mutex.Unlock() 249 | 250 | if r.Method == "GET" { 251 | id := rand.Float64() 252 | mutex.Lock() 253 | channels[r.URL.Path][id] = make(chan stream, 30) 254 | channel := channels[r.URL.Path][id] 255 | log.Debugf("added listener %f", id) 256 | mutex.Unlock() 257 | 258 | w.Header().Set("Connection", "keep-alive") 259 | w.Header().Set("Pragma", "no-cache") 260 | w.Header().Set("Cache-Control", "no-cache, no-store") 261 | 262 | mimetyped := false 263 | canceled := false 264 | for { 265 | select { 266 | case s := <-channel: 267 | if s.done { 268 | canceled = true 269 | } else { 270 | if !mimetyped { 271 | mimetyped = true 272 | mimetype := mimetype.Detect(s.b).String() 273 | if mimetype == "application/octet-stream" { 274 | ext := strings.TrimPrefix(filepath.Ext(r.URL.Path), ".") 275 | log.Debug("checking extension %s", ext) 276 | mimetype = filetype.GetType(ext).MIME.Value 277 | } 278 | w.Header().Set("Content-Type", mimetype) 279 | log.Debugf("serving as Content-Type: '%s'", mimetype) 280 | } 281 | w.Write(s.b) 282 | w.(http.Flusher).Flush() 283 | } 284 | case <-r.Context().Done(): 285 | log.Debug("consumer canceled") 286 | canceled = true 287 | } 288 | if canceled { 289 | break 290 | } 291 | } 292 | 293 | mutex.Lock() 294 | delete(channels[r.URL.Path], id) 295 | log.Debugf("removed listener %f", id) 296 | mutex.Unlock() 297 | close(channel) 298 | } else if r.Method == "POST" { 299 | buffer := make([]byte, 2048) 300 | cancel := true 301 | isdone := false 302 | lifetime := 0 303 | for { 304 | if !doStream { 305 | select { 306 | case <-r.Context().Done(): 307 | isdone = true 308 | default: 309 | } 310 | if isdone { 311 | log.Debug("is done") 312 | break 313 | } 314 | mutex.Lock() 315 | numListeners := len(channels[r.URL.Path]) 316 | mutex.Unlock() 317 | if numListeners == 0 { 318 | time.Sleep(1 * time.Second) 319 | lifetime++ 320 | if lifetime > 600 { 321 | isdone = true 322 | } 323 | continue 324 | } 325 | } 326 | n, err := r.Body.Read(buffer) 327 | if err != nil { 328 | log.Debugf("err: %s", err) 329 | if err == io.ErrUnexpectedEOF { 330 | cancel = false 331 | } 332 | break 333 | } 334 | if doArchive { 335 | mutex.Lock() 336 | archived[r.URL.Path].Write(buffer[:n]) 337 | mutex.Unlock() 338 | } 339 | mutex.Lock() 340 | channels_current := channels[r.URL.Path] 341 | mutex.Unlock() 342 | for _, c := range channels_current { 343 | var b2 = make([]byte, n) 344 | copy(b2, buffer[:n]) 345 | c <- stream{b: b2} 346 | } 347 | } 348 | if cancel { 349 | mutex.Lock() 350 | channels_current := channels[r.URL.Path] 351 | mutex.Unlock() 352 | for _, c := range channels_current { 353 | c <- stream{done: true} 354 | } 355 | } 356 | } else { 357 | w.WriteHeader(http.StatusOK) 358 | } 359 | } 360 | 361 | log.Infof("running on port %d", flagPort) 362 | err = http.ListenAndServe(fmt.Sprintf(":%d", flagPort), http.HandlerFunc(handler)) 363 | if err != nil { 364 | log.Error(err) 365 | } 366 | return 367 | } 368 | 369 | type ArchivedFile struct { 370 | Filename string 371 | FullFilename string 372 | Created time.Time 373 | } 374 | 375 | func listArchived(active map[string]struct{}) (afiles []ArchivedFile) { 376 | fnames := []string{} 377 | err := filepath.Walk(flagFolder, 378 | func(path string, info os.FileInfo, err error) error { 379 | if err != nil { 380 | return err 381 | } 382 | if !info.IsDir() { 383 | fnames = append(fnames, path) 384 | } 385 | return nil 386 | }) 387 | if err != nil { 388 | return 389 | } 390 | for _, fname := range fnames { 391 | _, onlyfname := path.Split(fname) 392 | finfo, _ := os.Stat(fname) 393 | stat_t := finfo.Sys().(*syscall.Stat_t) 394 | created := timespecToTime(stat_t.Ctim) 395 | if _, ok := active[onlyfname]; !ok { 396 | afiles = append(afiles, ArchivedFile{ 397 | Filename: onlyfname, 398 | FullFilename: fname, 399 | Created: created, 400 | }) 401 | } 402 | } 403 | 404 | sort.Slice(afiles, func(i, j int) bool { 405 | return afiles[i].Created.After(afiles[j].Created) 406 | }) 407 | 408 | return 409 | } 410 | 411 | func timespecToTime(ts syscall.Timespec) time.Time { 412 | return time.Unix(int64(ts.Sec), int64(ts.Nsec)) 413 | } 414 | --------------------------------------------------------------------------------