diff --git a/.env b/.env new file mode 100644 index 0000000..70c984c --- /dev/null +++ b/.env @@ -0,0 +1 @@ +LISTEN=:80 diff --git a/.gitignore b/.gitignore index 144585f..66fd13c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,78 +1,15 @@ -# ---> Node -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib -# Runtime data -pids -*.pid -*.seed -*.pid.lock +# Test binary, built with `go test -c` +*.test -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# next.js build output -.next - -# nuxt.js build output -.nuxt - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless - -# FuseBox cache -.fusebox/ +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/README.md b/README.md index cc80a45..a52e299 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,116 @@ # backend-webrtc -Beep backend handling WebRTC Selective Forwarding Units \ No newline at end of file +Beep backend handling WebRTC Selective Forwarding Units (SFUs). + +**The security of this service is handled by backend-auth called by traefik.** + +## Environment variables + +Supply environment variables by either exporting them or editing `.env`. + +| ENV | Description | Default | +| --- | ----------- | ------- | +| LISTEN | Host and port to listen on | :80 | + +## API + +All endpoints require a populated `X-User-Claim` header from `backend-auth`. + +| Contents | +| -------- | +| New Connection | +| Join Conversation | + +--- + +### New Connection + +``` +GET /connect +``` + +Creates a new WebRTC peer connection with the server. Connection gets upgraded to a websocket through which signalling occurs before it is closed once the peer connection is established. Please supply the token via GET querystring since Websockets do not support Auth headers. + +#### Example (Javascript) + +```js +const wsuri = `wss:\/\/localhost:80/connect`; + +let localSDP = ''; +let remoteSDP = ''; + +const socket = new WebSocket(wsuri); +socket.onmessage = (e) => { + remoteSDP = e.data; + try { + pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: remoteSDP }); + } catch(e) { + console.error(e); + } +}; + +const pc = new RTCPeerConnection({ + iceServers: [ + { + urls: 'stun:stun.l.google.com:19302', + }, + ], +}); +pc.onicecandidate = (e) => { + if (e.candidate === null) { + localSDP = pc.localDescription.sdp; + socket.send(localSDP); + } +}; + +navigator.mediaDevices + .getUserMedia({ audio: true }) + .then((stream) => { + for (const track of stream.getAudioTracks()) { + pc.addTrack(track); + } + pc.createOffer() + .then((d) => { + p.setLocalDescription(d); + }) + .catch((e) => console.error(e)); + }) + .catch((e) => console.error(e)); + +pc.ontrack = (event) => { + // Do stuff with event.streams +}; +``` + +#### Errors + +| Code | Description | +| ---- | ----------- | +| 400 | Error parsing `X-User-Claims` header | +| 500 | Error establishing WebRTC connection | + +--- + +### Join Conversation + +``` +POST /join/:conversationid +``` + +Signify a user's intention to join a conversation. + +#### Params + +| Name | Type | Description | +| ---- | ---- | ----------- | +| conversationid | String | ID of conversation to be joined | + +#### Success (200 OK) + +Empty body + +#### Errors + +| Code | Description | +| ---- | ----------- | +| 400 | Error parsing `X-User-Claims` header | diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..48caf38 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module webrtc + +go 1.12 + +require ( + github.com/gorilla/websocket v1.4.0 + github.com/joho/godotenv v1.3.0 + github.com/julienschmidt/httprouter v1.2.0 + github.com/pion/webrtc/v2 v2.0.23 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..39ec04d --- /dev/null +++ b/go.sum @@ -0,0 +1,79 @@ +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gortc/turn v0.7.1/go.mod h1:3FZ+LvCZKCKu6YYgwuYPqEi3FqCtdjfSFnFqVQNwfjk= +github.com/gortc/turn v0.7.3 h1:CE72C79erbcsfa6L/QDhKztcl2kDq1UK20ImrJWDt/w= +github.com/gortc/turn v0.7.3/go.mod h1:gvguwaGAFyv5/9KrcW9MkCgHALYD+e99mSM7pSCYYho= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw= +github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pion/datachannel v1.4.3 h1:tqS6YiqqAiFCxGGhvn1K7fHEzemK9Aov025dE/isGFo= +github.com/pion/datachannel v1.4.3/go.mod h1:SpMJbuu8v+qbA94m6lWQwSdCf8JKQvgmdSHDNtcbe+w= +github.com/pion/dtls v1.3.5 h1:mBioifvh6JSE9pD4FtJh5WoizygoqkOJNJyS5Ns+y1U= +github.com/pion/dtls v1.3.5/go.mod h1:CjlPLfQdsTg3G4AEXjJp8FY5bRweBlxHrgoFrN+fQsk= +github.com/pion/ice v0.4.0 h1:BdTXHTjzdsJHGi9yMFnj9ffgr+Kg2oHVv1qk4B0mQ8A= +github.com/pion/ice v0.4.0/go.mod h1:/gw3aFmD/pBG8UM3TcEHs6HuaOEMSd/v1As3TodE7Ss= +github.com/pion/logging v0.2.1 h1:LwASkBKZ+2ysGJ+jLv1E/9H1ge0k1nTfi1X+5zirkDk= +github.com/pion/logging v0.2.1/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.2 h1:T22Gg4dSuYVYsZ21oRFh9z7twzAm27+5PEKiABbjCvM= +github.com/pion/mdns v0.0.2/go.mod h1:VrN3wefVgtfL8QgpEblPUC46ag1reLIfpqekCnKunLE= +github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k= +github.com/pion/rtcp v1.2.0 h1:rT2FptW5YHIern+4XlbGYnnsT26XGxurnkNLnzhtDXg= +github.com/pion/rtcp v1.2.0/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM= +github.com/pion/rtp v1.1.2 h1:ERNugzYHW9F2ldpwoARbeFGKRoq1REe5Jxdjvm/rOx8= +github.com/pion/rtp v1.1.2/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE= +github.com/pion/sctp v1.6.3 h1:SC4vKOjcddK8tXiTNj05a+0/GyPpCmuNfeBA/rzNFqs= +github.com/pion/sctp v1.6.3/go.mod h1:cCqpLdYvgEUdl715+qbWtgT439CuQrAgy8BZTp0aEfA= +github.com/pion/sdp/v2 v2.2.0 h1:JiixCEU8g6LbSsh1Bg5SOk0TPnJrn2HBOA1yJ+mRYhI= +github.com/pion/sdp/v2 v2.2.0/go.mod h1:idSlWxhfWQDtTy9J05cgxpHBu/POwXN2VDRGYxT/EjU= +github.com/pion/srtp v1.2.4 h1:wwGKC5ewuBukkZ+i+pZ8aO33+t6z2y/XRiYtyP0Xpv0= +github.com/pion/srtp v1.2.4/go.mod h1:52qiP0g3FVMG/5NL6Ko8Vr2qirevKH+ukYbNS/4EX40= +github.com/pion/stun v0.3.0/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= +github.com/pion/stun v0.3.1 h1:d09JJzOmOS8ZzIp8NppCMgrxGZpJ4Ix8qirfNYyI3BA= +github.com/pion/stun v0.3.1/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M= +github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= +github.com/pion/transport v0.7.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE= +github.com/pion/transport v0.8.0 h1:YHZnWBBrBuMqkuvMFUHeAETXS+LgfwW1IsVd2K2cyW8= +github.com/pion/transport v0.8.0/go.mod h1:nAmRRnn+ArVtsoNuwktvAD+jrjSD7pA+H3iRmZwdUno= +github.com/pion/turn v1.1.4/go.mod h1:2O2GFDGO6+hJ5gsyExDhoNHtVcacPB1NOyc81gkq0WA= +github.com/pion/turnc v0.0.6 h1:FHsmwYvdJ8mhT1/ZtWWer9L0unEb7AyRgrymfWy6mEY= +github.com/pion/turnc v0.0.6/go.mod h1:4MSFv5i0v3MRkDLdo5eF9cD/xJtj1pxSphHNnxKL2W8= +github.com/pion/webrtc/v2 v2.0.23 h1:v/tDKsP4zB6Sj+Wx861fLsaNmbwWbxacciHUhetH288= +github.com/pion/webrtc/v2 v2.0.23/go.mod h1:AgremGibyNcHWIEkDbXt4ujKzKBO3tMuoYXybVRa8zo= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5 h1:bselrhR0Or1vomJZC8ZIjWtbDmn9OYFLX5Ik9alpJpE= +golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190403144856-b630fd6fe46b/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b h1:lkjdUzSyJ5P1+eal9fxXX9Xg2BTfswsonKUse48C0uE= +golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e h1:nFYrTHrdrAOpShe27kaFHjsqYSEQ0KWqdWLu3xuZJts= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 0000000..59befe0 --- /dev/null +++ b/main.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "os" + + "github.com/joho/godotenv" + "github.com/julienschmidt/httprouter" + "github.com/pion/webrtc/v2" + "github.com/gorilla/websocket" +) + +// Peer config +var peerConnectionConfig webrtc.Configuration + +var listen string +var upgrader websocket.Upgrader +var mediaEngine webrtc.MediaEngine +var webrtcApi *webrtc.API + +var userTracks map[string] map[string] *webrtc.Track // userid + clientid +var conversationUsers map[string] []string +var userConversation map[string] string + +func main() { + // Load .env + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + listen = os.Getenv("LISTEN") + + upgrader = websocket.Upgrader{} + + mediaEngine = webrtc.MediaEngine{} + mediaEngine.RegisterCodec(webrtc.NewRTPOpusCodec(webrtc.DefaultPayloadTypeOpus, 48000)) + webrtcApi = webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine)) + + userTracks = make(map[string] map[string] *webrtc.Track) + conversationUsers = make(map[string] []string) + userConversation = make(map[string] string) + + // Routes + router := httprouter.New() + router.GET("/connect", GetAuth(NewConnection)) + router.POST("/join/:conversationid", GetAuth(JoinConversation)) + + // Start server + log.Printf("starting server on %s", listen) + log.Fatal(http.ListenAndServe(listen, router)) +} + +type RawClient struct { + UserId string `json:"userid"` + ClientId string `json:"clientid"` +} +func GetAuth(next httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + ua := r.Header.Get("X-User-Claim") + if ua == "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + var client RawClient + err := json.Unmarshal([]byte(ua), &client) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + context := context.WithValue(r.Context(), "user", client) + next(w, r.WithContext(context), p) + } +} + +func NewConnection(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + // Get user id + user := r.Context().Value("user").(RawClient) + + // Websocket client + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + defer c.Close() + + // Read SDP from websocket + mt, msg, err := c.ReadMessage() + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // Establish connection (it's a publisher) + clientReceiver, err := webrtcApi.NewPeerConnection(peerConnectionConfig) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + _, err = clientReceiver.AddTransceiver(webrtc.RTPCodecTypeAudio) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // Handle OnTrack + clientReceiver.OnTrack(func(remoteTrack *webrtc.Track, receiver *webrtc.RTPReceiver) { + rtpBuf := make([]byte, 1400) + for { + i, err := remoteTrack.Read(rtpBuf) + if err != nil { + log.Printf("%s", err) + break + } + + if conversationId, ok := userConversation[user.UserId]; ok { + if users, ok2 := conversationUsers[conversationId]; ok2 { + for _, u := range users { + if clients, ok3 := userTracks[u]; ok3 { + for client, track := range clients { + if !(u == user.UserId && client == user.ClientId) { + _, err = track.Write(rtpBuf[:i]) + if !(err == io.ErrClosedPipe || err == nil) { + log.Printf("%s", err) + break + } + } + } + } + } + } + } + } + }) + + // Add sending track + var track *webrtc.Track = &webrtc.Track{} + _, err = clientReceiver.AddTrack(track) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + userTracks[user.UserId][user.ClientId] = track + + // Do signalling things + err = clientReceiver.SetRemoteDescription( + webrtc.SessionDescription { + SDP: string(msg), + Type: webrtc.SDPTypeOffer, + }) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + answer, err := clientReceiver.CreateAnswer(nil) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = clientReceiver.SetLocalDescription(answer) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + err = c.WriteMessage(mt, []byte(answer.SDP)) + if err != nil { + log.Printf("%s", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } +} + +func JoinConversation(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + // Get user id + user := r.Context().Value("user").(RawClient) + + conversationId := p.ByName("conversationid") + + // Remove user from existing conversation + if oldConversation, ok := userConversation[user.UserId]; ok { + if users, ok2 := conversationUsers[oldConversation]; ok2 { + var lastIndex int + for i, u := range users { + if u == user.UserId { + lastIndex = i + break; + } + } + users[lastIndex] = users[len(users) - 1] + conversationUsers[oldConversation] = users[:len(users)-1] + } + } + + // Populate new values + userConversation[user.UserId] = conversationId + if _, ok := conversationUsers[conversationId]; !ok { + conversationUsers[conversationId] = make([]string, 0) + } + conversationUsers[conversationId] = append(conversationUsers[conversationId], user.UserId) + + w.WriteHeader(200) +}