https://presence.fuz.re source code mirror
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

main.go 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "log"
  6. "net/http"
  7. "os"
  8. "path/filepath"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/matrix-org/gomatrix"
  13. )
  14. type Status struct {
  15. FuzIsOpen bool `json:"fuzIsOpen"`
  16. LastSeenAsOpen bool `json:"lastSeenAsOpen"`
  17. LastSeen time.Time `json:"lastSeen"`
  18. LastOpened time.Time `json:"lastOpened"`
  19. LastClosed time.Time `json:"lastClosed"`
  20. ProcessUptime string `json:"processUptime"`
  21. }
  22. type Config struct {
  23. PORT string
  24. MATRIXROOM string
  25. MATRIXOPENINGMESSAGE string
  26. MATRIXCLOSINGMESSAGE string
  27. MATRIXACCESSTOKEN string
  28. MATRIXUSERNAME string
  29. ESPUSERNAME string
  30. ESPPASSWORD string
  31. }
  32. const (
  33. dbPath = "./.data/data.json"
  34. defaultClosingTimeout = 5 * time.Minute
  35. )
  36. var (
  37. status Status
  38. config = Config{
  39. PORT: "8080",
  40. }
  41. startTime = time.Now()
  42. imgs = map[bool]string{
  43. // https://www.iconfinder.com/icons/1871431/online_open_shop_shopping_sign_icon
  44. // formerly https://www.flaticon.com/free-icon/open_1234189, maybe try https://flaticons.net/customize.php?dir=Miscellaneous&icon=Open.png without attribution
  45. true: `<?xml version="1.0" ?><svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#5bc9e1;}.cls-2{fill:#fd0;}.cls-3{fill:#314967;}</style></defs><title/><g data-name="15 Open Sign" id="_15_Open_Sign"><rect class="cls-1" height="36" rx="6" ry="6" width="96" x="16" y="64"/><circle class="cls-2" cx="64" cy="28" r="6"/><path class="cls-3" d="M92.73,98H22a4,4,0,0,1-4-4V70a4,4,0,0,1,4-4H42.54a2,2,0,0,0,0-4H32.83L59.93,34.89a8,8,0,0,0,8.13,0L81.71,48.54a2,2,0,0,0,2.83-2.83L70.88,32.06A8,8,0,0,0,64,20a2,2,0,0,0,0,4,4,4,0,1,1-3.49,2,2,2,0,0,0-3.49-2,8,8,0,0,0,.09,8L27.17,62H22a8,8,0,0,0-8,8V94a8,8,0,0,0,8,8H92.73A2,2,0,0,0,92.73,98Z"/><path class="cls-3" d="M106,62h-5.17L88.09,49.26a2,2,0,0,0-2.83,2.83L95.17,62H76a2,2,0,0,0,0,4h30a4,4,0,0,1,4,4V94a4,4,0,0,1-4,4H98.51a2,2,0,0,0,0,4H106a8,8,0,0,0,8-8V70A8,8,0,0,0,106,62Z"/><path class="cls-3" d="M70,62H49.22a2,2,0,0,0,0,4H70A2,2,0,0,0,70,62Z"/><path class="cls-3" d="M54.86,73.62a2.2,2.2,0,0,0-2.19,2.19v12.8a2.2,2.2,0,0,0,4.41,0V84.91h1.67a5.64,5.64,0,1,0,0-11.29ZM60,79.27c0,1.49-1.64,1.23-2.93,1.23V78C58.42,78,60,77.78,60,79.27Z"/><path class="cls-3" d="M77,78c2.86,0,2.93-4.41,0-4.41H69.92a2.17,2.17,0,0,0-2.19,2.19v12.8a2.2,2.2,0,0,0,1.25,2v.21h8c2.91,0,2.87-4.41,0-4.41H72.13v-2h4.06a2.2,2.2,0,0,0,0-4.41H72.13V78Z"/><path class="cls-3" d="M96.63,76c0-2.83-4.37-2.9-4.37,0v5.85l-5.19-7.19A2.18,2.18,0,0,0,83.13,76V88.62a2.18,2.18,0,0,0,4.37,0V82.71l5.1,7.08a2.19,2.19,0,0,0,4-1.17Z"/><path class="cls-3" d="M39.84,73.19a8.82,8.82,0,0,0,0,17.62A8.69,8.69,0,0,0,48.22,82C48.22,77.31,44.49,73.19,39.84,73.19Zm0,13.15c-5.3,0-5.26-8.68,0-8.68a4.36,4.36,0,0,1,0,8.68Z"/></g></svg>`,
  46. // https://www.iconfinder.com/icons/1871435/closed_online_shop_shopping_sign_icon
  47. // formerly https://www.flaticon.com/free-icon/closed_1234190, maybe try https://flaticons.net/customize.php?dir=Miscellaneous&icon=Closed.png without attribution
  48. false: `<?xml version="1.0" ?><svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#f8991d;}.cls-2{fill:#fd0;}.cls-3{fill:#314967;}</style></defs><title/><g data-name="18 Closed Sign" id="_18_Closed_Sign"><rect class="cls-1" height="36" rx="6" ry="6" width="96" x="16" y="64"/><circle class="cls-2" cx="64" cy="28" r="6"/><path class="cls-3" d="M71.85,77.83a4.37,4.37,0,0,1,2.34,1,1.93,1.93,0,1,0,2.08-3.25A7.94,7.94,0,0,0,71.85,74c-3.13,0-5.55,2.06-5.55,4.58,0,2.74,2.64,4.21,5.35,4.58.55.11,2,.45,2.26,1,0,.57-1.38,1-2,1a5.25,5.25,0,0,1-2.79-1.16,1.93,1.93,0,1,0-2.42,3,8.73,8.73,0,0,0,5.23,2c3.12,0,5.88-2,5.88-4.82s-2.88-4.4-5.66-4.78c-1.6-.31-2-.77-2-.77C70.15,78.39,70.8,77.83,71.85,77.83Z"/><path class="cls-3" d="M40,76.34V87a2,2,0,0,0,2,2h5.83a2,2,0,0,0,0-4H44V76.34A2,2,0,0,0,40,76.34Z"/><path class="cls-3" d="M50,81.48A7.16,7.16,0,1,0,57.23,74,7.32,7.32,0,0,0,50,81.48Zm10.26,0c0,2.89-3.21,4.64-5.27,2.43-1.94-2-.71-5.86,2.2-5.86A3.29,3.29,0,0,1,60.3,81.48Z"/><path class="cls-3" d="M34.44,78.82a2,2,0,0,0,2.49-3.21A7.7,7.7,0,0,0,32.15,74a7.6,7.6,0,0,0-7.6,7.48h0c0,6.3,7.44,9.7,12.39,5.86a2,2,0,0,0-2.51-3.2,3.52,3.52,0,1,1,0-5.31Z"/><path class="cls-3" d="M87.69,78.35a2,2,0,0,0,0-4H81.8a2,2,0,0,0-2,2V87a2,2,0,0,0,1,1.75V89h6.83a2,2,0,0,0,0-4H83.81V83.65H87a2,2,0,0,0,0-4h-3.2V78.35Z"/><path class="cls-3" d="M103.09,81.64a7.29,7.29,0,0,0-7.28-7.28H93.69a2,2,0,0,0-2,2V87a2,2,0,0,0,2,2h2.13A7.31,7.31,0,0,0,103.09,81.64ZM95.81,85h-.12V78.35h.12C100.14,78.35,100.18,84.93,95.81,85Z"/><path class="cls-3" d="M92.73,98H22a4,4,0,0,1-4-4V70a4,4,0,0,1,4-4H42.55a2,2,0,0,0,0-4H32.83L59.93,34.89a8,8,0,0,0,8.13,0L81.71,48.54a2,2,0,0,0,2.83-2.83L70.88,32.06A8,8,0,0,0,64,20a2,2,0,0,0,0,4,4,4,0,0,1,2.79,6.86C63.58,34,58.24,30.08,60.51,26a2,2,0,0,0-3.49-2,8,8,0,0,0,.09,8L27.17,62H22a8,8,0,0,0-8,8V94a8,8,0,0,0,8,8H92.73A2,2,0,0,0,92.73,98Z"/><path class="cls-3" d="M106,62h-5.17L88.09,49.26a2,2,0,0,0-2.83,2.83L95.17,62H76a2,2,0,0,0,0,4h30a4,4,0,0,1,4,4V94a4,4,0,0,1-4,4H98.51a2,2,0,0,0,0,4H106a8,8,0,0,0,8-8V70A8,8,0,0,0,106,62Z"/><path class="cls-3" d="M70,62H49.22a2,2,0,0,0,0,4H70A2,2,0,0,0,70,62Z"/></g></svg>`,
  49. }
  50. db *os.File
  51. matrix *gomatrix.Client
  52. )
  53. func init() {
  54. port := os.Getenv("PORT")
  55. if val, _ := strconv.Atoi(port); val > 0 {
  56. config.PORT = port
  57. }
  58. config.MATRIXUSERNAME = os.Getenv("MATRIXUSERNAME")
  59. config.MATRIXACCESSTOKEN = os.Getenv("MATRIXACCESSTOKEN")
  60. config.MATRIXROOM = os.Getenv("MATRIXROOM")
  61. config.MATRIXOPENINGMESSAGE = os.Getenv("MATRIXOPENINGMESSAGE")
  62. config.MATRIXCLOSINGMESSAGE = os.Getenv("MATRIXCLOSINGMESSAGE")
  63. config.ESPUSERNAME = os.Getenv("ESPUSERNAME")
  64. config.ESPPASSWORD = os.Getenv("ESPPASSWORD")
  65. if config.MATRIXUSERNAME == "" {
  66. panic("MATRIXUSERNAME is empty")
  67. }
  68. if config.MATRIXACCESSTOKEN == "" {
  69. panic("MATRIXACCESSTOKEN is empty")
  70. }
  71. var err error
  72. matrix, err = gomatrix.NewClient(fmt.Sprintf("https://%s", config.MATRIXUSERNAME[strings.Index(config.MATRIXUSERNAME, ":")+1:]), config.MATRIXUSERNAME, config.MATRIXACCESSTOKEN)
  73. if err != nil {
  74. panic(fmt.Sprintf("error creating matrix client: %s", err))
  75. }
  76. if _, err := matrix.GetOwnStatus(); err != nil { // a way to quickly check if access token is valid
  77. panic(fmt.Sprintf("error getting matrix status: %s", err))
  78. }
  79. if config.MATRIXROOM == "" {
  80. panic("MATRIXROOM is empty")
  81. }
  82. if config.MATRIXOPENINGMESSAGE == "" {
  83. panic("MATRIXOPENINGMESSAGE is empty")
  84. }
  85. if config.MATRIXCLOSINGMESSAGE == "" {
  86. panic("MATRIXCLOSINGMESSAGE is empty")
  87. }
  88. if config.ESPUSERNAME == "" {
  89. panic("ESPUSERNAME is empty")
  90. }
  91. if config.ESPPASSWORD == "" {
  92. panic("ESPPASSWORD is empty")
  93. }
  94. err = os.MkdirAll(filepath.Dir(dbPath), 0755)
  95. if err != nil {
  96. panic(err)
  97. }
  98. db, err = os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0600)
  99. if err != nil && !os.IsNotExist(err) {
  100. panic(err)
  101. }
  102. err = json.NewDecoder(db).Decode(&status)
  103. if err != nil {
  104. fmt.Println("error unmarshalling db:", err)
  105. }
  106. }
  107. func updateUptime() {
  108. for range time.Tick(time.Second) {
  109. status.ProcessUptime = time.Since(startTime).Truncate(time.Second).String()
  110. }
  111. }
  112. func checkClosure() {
  113. time.Sleep(time.Minute) // give some time for presence button to show up
  114. for {
  115. if status.LastSeen.Add(defaultClosingTimeout).Before(time.Now()) && status.LastClosed.Before(status.LastSeen) {
  116. // the Fuz is newly closed, notify on matrix and write file to survive reboot
  117. // TODO: matrix msg
  118. fmt.Println("the Fuz is newly closed, notify on matrix and write file to survive reboot")
  119. _, err := matrix.SendText(config.MATRIXROOM, config.MATRIXCLOSINGMESSAGE)
  120. if err != nil {
  121. fmt.Println("err:", err)
  122. time.Sleep(10 * time.Second)
  123. continue
  124. }
  125. status.LastClosed = time.Now()
  126. status.FuzIsOpen = false
  127. db.Truncate(0)
  128. db.Seek(0, 0)
  129. e := json.NewEncoder(db)
  130. e.SetIndent("", " ")
  131. e.Encode(status)
  132. }
  133. time.Sleep(10 * time.Second)
  134. }
  135. }
  136. func rootHandler(w http.ResponseWriter, r *http.Request) {
  137. if r.URL.Path != "/" {
  138. http.NotFound(w, r)
  139. return
  140. }
  141. fmt.Fprintf(w, `Fuz presence button public API
  142. This API provides the current opening status of the hackerspace. This server also posts messages on Matrix to notify when the space opens and closes.
  143. Usage:
  144. / Shows help
  145. /api Serves some JSON with lax CORS headers to get the current opening status programatically. The properties are the following:
  146. * fuzIsOpen: (boolean) reflects if the space is currently open
  147. * lastSeenAsOpen: (boolean) reflects if the last ping by the ESP was after being pushed (space officially opened)
  148. * lastSeen: (date) last ESP ping timestamp
  149. * lastOpened: (date) last space opening timestamp
  150. * lastClosed: (date) last space closing timestamp
  151. * processUptime: (duration) API process uptime
  152. /img Serves an svg image showing if the space is open or closed.
  153. /status Private endpoint used by the ESP (physical button) to regularly ping/update the opening status.
  154. Source code: https://github.com/Lomanic/presence-button-web
  155. Source code mirror: https://git.interhacker.space/Lomanic/presence-button-web
  156. Documentation: https://wiki.fuz.re/doku.php?id=projets:fuz:presence_button`)
  157. }
  158. func apiHandler(w http.ResponseWriter, r *http.Request) {
  159. w.Header().Set("Content-Type", "application/json")
  160. w.Header().Set("Access-Control-Allow-Origin", "*")
  161. w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
  162. e := json.NewEncoder(w)
  163. e.SetIndent("", " ")
  164. e.Encode(status)
  165. }
  166. func imgHandler(w http.ResponseWriter, r *http.Request) {
  167. w.Header().Set("Content-Type", "image/svg+xml")
  168. w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate")
  169. w.Header().Set("Pragma", "no-cache")
  170. w.Header().Set("Expires", "0")
  171. fmt.Fprintf(w, imgs[status.FuzIsOpen])
  172. }
  173. func statusHandler(w http.ResponseWriter, r *http.Request) {
  174. user, pass, ok := r.BasicAuth()
  175. fmt.Printf("status notification by button... ")
  176. if !ok || user != config.ESPUSERNAME || pass != config.ESPPASSWORD {
  177. fmt.Printf("bad authentication: user:%v pass:%v\n", user, pass)
  178. w.Header().Set("WWW-Authenticate", `Basic realm="Authentication required"`)
  179. http.Error(w, "Authentication required", 401)
  180. return
  181. }
  182. q := r.URL.Query()
  183. fuzIsOpen := q.Get("fuzisopen") == "1"
  184. fmt.Printf("button pushed: %v\n", fuzIsOpen)
  185. status.FuzIsOpen = fuzIsOpen
  186. status.LastSeenAsOpen = fuzIsOpen
  187. status.LastSeen = time.Now()
  188. fmt.Fprintf(w, "OK")
  189. db.Truncate(0)
  190. db.Seek(0, 0)
  191. e := json.NewEncoder(db)
  192. e.SetIndent("", " ")
  193. e.Encode(status)
  194. if status.FuzIsOpen && (status.LastOpened.Equal(status.LastClosed) || status.LastOpened.Before(status.LastClosed)) {
  195. // the Fuz is newly opened, notify on matrix and write file to survive reboot
  196. fmt.Println("the Fuz is newly opened, notify on matrix and write file to survive reboot")
  197. _, err := matrix.SendText(config.MATRIXROOM, config.MATRIXOPENINGMESSAGE)
  198. if err != nil {
  199. fmt.Println("err:", err)
  200. return
  201. }
  202. status.LastOpened = time.Now()
  203. db.Truncate(0)
  204. db.Seek(0, 0)
  205. e := json.NewEncoder(db)
  206. e.SetIndent("", " ")
  207. e.Encode(status)
  208. }
  209. }
  210. func syncMatrix() {
  211. syncer := matrix.Syncer.(*gomatrix.DefaultSyncer)
  212. syncer.OnEventType("m.room.message", func(ev *gomatrix.Event) {
  213. if ev.Sender != matrix.UserID {
  214. matrix.MarkRead(ev.RoomID, ev.ID)
  215. }
  216. })
  217. go func() { // set online status every 15 seconds
  218. for {
  219. if err := matrix.SetStatus("online", "up and running"); err != nil {
  220. fmt.Println("error setting matrix status:", err)
  221. }
  222. time.Sleep(15 * time.Second)
  223. }
  224. }()
  225. for {
  226. if err := matrix.Sync(); err != nil {
  227. fmt.Println("error syncing with matrix:", err)
  228. }
  229. }
  230. }
  231. func main() {
  232. go updateUptime()
  233. go checkClosure()
  234. go syncMatrix()
  235. http.HandleFunc("/", rootHandler)
  236. http.HandleFunc("/api", apiHandler)
  237. http.HandleFunc("/img", imgHandler)
  238. http.HandleFunc("/status", statusHandler)
  239. log.Fatal(http.ListenAndServe(":"+config.PORT, nil))
  240. }