diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4d955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.data/ +/.env diff --git a/main.go b/main.go index 676d5a7..861c1e6 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,249 @@ package main import ( - "fmt" - "log" - "net/http" + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" ) -func handler(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:]) +type Status struct { + FuzIsOpen bool `json:"fuzIsOpen"` + LastSeenAsOpen bool `json:"lastSeenAsOpen"` + LastSeen time.Time `json:"lastSeen"` + LastOpened time.Time `json:"lastOpened"` + LastClosed time.Time `json:"lastClosed"` + ProcessUptime string `json:"processUptime"` +} + +type Config struct { + PORT string + MATRIXROOM string + MATRIXOPENINGMESSAGE string + MATRIXCLOSINGMESSAGE string + MATRIXACCESSTOKEN string + MATRIXUSERNAME string + ESPUSERNAME string + ESPPASSWORD string +} + +const ( + dbPath = "./.data/data.json" + defaultClosingTimeout = 5 * time.Minute +) + +var ( + status Status + config = Config{ + PORT: "8080", + } + startTime = time.Now() + imgs = map[bool]string{ + // https://www.iconfinder.com/icons/1871431/online_open_shop_shopping_sign_icon + // formerly https://www.flaticon.com/free-icon/open_1234189, maybe try https://flaticons.net/customize.php?dir=Miscellaneous&icon=Open.png without attribution + true: ``, + // https://www.iconfinder.com/icons/1871435/closed_online_shop_shopping_sign_icon + // formerly https://www.flaticon.com/free-icon/closed_1234190, maybe try https://flaticons.net/customize.php?dir=Miscellaneous&icon=Closed.png without attribution + false: ``, + } + db *os.File +) + +func init() { + port := os.Getenv("PORT") + if val, _ := strconv.Atoi(port); val > 0 { + config.PORT = port + } + config.MATRIXROOM = os.Getenv("MATRIXROOM") + config.MATRIXOPENINGMESSAGE = os.Getenv("MATRIXOPENINGMESSAGE") + config.MATRIXCLOSINGMESSAGE = os.Getenv("MATRIXCLOSINGMESSAGE") + config.MATRIXACCESSTOKEN = os.Getenv("MATRIXACCESSTOKEN") + config.MATRIXUSERNAME = os.Getenv("MATRIXUSERNAME") + config.ESPUSERNAME = os.Getenv("ESPUSERNAME") + config.ESPPASSWORD = os.Getenv("ESPPASSWORD") + + if config.MATRIXROOM == "" { + panic("MATRIXROOM is empty") + } + if config.MATRIXOPENINGMESSAGE == "" { + panic("MATRIXOPENINGMESSAGE is empty") + } + if config.MATRIXCLOSINGMESSAGE == "" { + panic("MATRIXCLOSINGMESSAGE is empty") + } + if config.MATRIXACCESSTOKEN == "" { + panic("MATRIXACCESSTOKEN is empty") + } + if config.MATRIXUSERNAME == "" { + panic("MATRIXUSERNAME is empty") + } + if config.ESPPASSWORD == "" { + panic("ESPPASSWORD is empty") + } + + err := os.MkdirAll(filepath.Dir(dbPath), 0755) + if err != nil { + panic(err) + } + db, err = os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0600) + if err != nil && !os.IsNotExist(err) { + panic(err) + } + d := json.NewDecoder(db) + d.Decode(&status) + if err != nil { + fmt.Println("error unmarshalling db:", err) + } +} + +func updateUptime() { + for range time.Tick(time.Second) { + status.ProcessUptime = time.Since(startTime).Truncate(time.Second).String() + } +} + +func checkClosure() { + time.Sleep(time.Minute) // give some time for presence button to show up + for { + if status.LastSeen.Add(defaultClosingTimeout).Before(time.Now()) && status.LastClosed.Before(status.LastSeen) { + // the Fuz is newly closed, notify on matrix and write file to survive reboot + // TODO: matrix msg + fmt.Println("the Fuz is newly closed, notify on matrix and write file to survive reboot") + err := sendMatrixMessage(config.MATRIXUSERNAME, config.MATRIXACCESSTOKEN, config.MATRIXROOM, config.MATRIXCLOSINGMESSAGE) + if err != nil { + fmt.Println("err:", err) + time.Sleep(10 * time.Second) + continue + } + + status.LastClosed = time.Now() + status.FuzIsOpen = false + db.Truncate(0) + db.Seek(0, 0) + e := json.NewEncoder(db) + e.SetIndent("", " ") + e.Encode(status) + } + time.Sleep(10 * time.Second) + } +} + +func rootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + fmt.Fprintf(w, "Beautiful homepage") +} + +func apiHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept") + e := json.NewEncoder(w) + e.SetIndent("", " ") + e.Encode(status) +} + +func imgHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + fmt.Fprintf(w, imgs[status.FuzIsOpen]) +} + +func statusHandler(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + fmt.Println("user", user, "pass", pass, "ok", ok) + if !ok || user != config.ESPUSERNAME || pass != config.ESPPASSWORD { + w.Header().Set("WWW-Authenticate", `Basic realm="Authentication required"`) + http.Error(w, "Authentication required", 401) + return + } + q := r.URL.Query() + status.FuzIsOpen = q.Get("fuzisopen") == "1" + status.LastSeenAsOpen = q.Get("fuzisopen") == "1" + status.LastSeen = time.Now() + fmt.Fprintf(w, "OK") + + db.Truncate(0) + db.Seek(0, 0) + e := json.NewEncoder(db) + e.SetIndent("", " ") + e.Encode(status) + if status.FuzIsOpen && (status.LastOpened.Equal(status.LastClosed) || status.LastOpened.Before(status.LastClosed)) { + // the Fuz is newly opened, notify on matrix and write file to survive reboot + fmt.Println("the Fuz is newly opened, notify on matrix and write file to survive reboot") + err := sendMatrixMessage(config.MATRIXUSERNAME, config.MATRIXACCESSTOKEN, config.MATRIXROOM, config.MATRIXOPENINGMESSAGE) + if err != nil { + fmt.Println("err:", err) + return + } + + status.LastOpened = time.Now() + db.Truncate(0) + db.Seek(0, 0) + e := json.NewEncoder(db) + e.SetIndent("", " ") + e.Encode(status) + } +} + +func sendMatrixMessage(username, accessToken, room, messageText string) error { + type Message struct { + Msgtype string `json:"msgtype"` + Body string `json:"body"` + } + client := &http.Client{} + message := Message{ + Msgtype: "m.text", + Body: messageText, + } + body := new(bytes.Buffer) + err := json.NewEncoder(body).Encode(message) + if err != nil { + return err + } + v := url.Values{} + v.Set("access_token", accessToken) + v.Set("limit", "1") + url := url.URL{ + Scheme: "https", + Host: username[strings.Index(username, ":")+1:], + Path: fmt.Sprintf("/_matrix/client/r0/rooms/%s/send/m.room.message/%d", room, time.Now().UnixNano()/1000000), + RawQuery: v.Encode(), + } + req, err := http.NewRequest(http.MethodPut, url.String(), body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + resBody, _ := ioutil.ReadAll(res.Body) + fmt.Println(string(resBody)) + + return nil } func main() { - http.HandleFunc("/", handler) - log.Fatal(http.ListenAndServe(":8080", nil)) + go updateUptime() + go checkClosure() + http.HandleFunc("/", rootHandler) + http.HandleFunc("/api", apiHandler) + http.HandleFunc("/img", imgHandler) + http.HandleFunc("/status", statusHandler) + log.Fatal(http.ListenAndServe(":"+config.PORT, nil)) }