medinet/web.go

593 lines
15 KiB
Go

package main
import (
"crypto/md5"
"embed"
"encoding/json"
"fmt"
"html"
"io/fs"
"log"
"net/http"
"strconv"
"strings"
"time"
)
//go:embed www
var wwwFS embed.FS
var signInPage = []byte(`
<body onload="askToSignIn()">
<script type="text/javascript">
function askToSignIn() {
MA.askToSignIn();
}
</script>
</body>
`)
var pages = map[string]http.HandlerFunc{
"community": handleCommunity,
"sessions": handleSessions,
"account": handleAccount,
"forum": handleForum,
"groups": handleGroups,
"profile": handleProfile,
"status": handleStatus,
}
func authenticateUser(w http.ResponseWriter, r *http.Request) *account {
key := r.URL.Query().Get("x")
if key == "" {
key = r.FormValue("x")
}
acc, err := db.accountByKey(key)
if err != nil {
log.Printf("ERROR! %v", err)
return nil
} else if acc == nil {
w.Header().Set("x-MediNET", "signin")
w.WriteHeader(http.StatusOK)
w.Write(signInPage)
logDebugf("Asking to sign in %s %q", key, html.EscapeString(r.URL.RequestURI()))
return nil
}
go trackActiveAccount(acc.ID)
return acc
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = r.ParseForm()
// Handle logging in.
token := r.FormValue("token")
if token != "" {
a, err := db.authenticate(token)
if err != nil {
log.Printf("ERROR! failed to authenticate token: %s", err)
return
} else if a == nil {
log.Printf("ERROR! failed to retrieve authenticated account")
return
}
go trackActiveAccount(a.ID)
w.Header().Set("x-MediNET", "connected")
w.Header().Set("x-MediNET-Key", a.Key)
return
}
// Require authentication.
acc := authenticateUser(w, r)
if acc == nil {
return
}
data := make(map[string]interface{})
data["status"] = "success"
action := r.FormValue("action")
tz, err := time.LoadLocation(r.URL.Query().Get("tz"))
if err != nil {
tz, err = time.LoadLocation("UTC")
if err != nil {
log.Printf("ERROR! %v", err)
return
}
}
apiver, err := strconv.Atoi(r.URL.Query().Get("v"))
if err != nil {
apiver = 0
}
appver := 0
streakbuffer := -1
av := r.URL.Query().Get("av")
match := regexpMarket.FindStringSubmatch(av)
if len(match) == 2 {
match = regexpNumbers.FindAllString(av[0:len(av)-len(match[1])], -1)
if match != nil {
appver, err = strconv.Atoi(strings.Join(match, ""))
if err != nil {
appver = 0
}
}
} else {
av = "x-" + av
}
appmarket := r.URL.Query().Get("am")
sb := r.URL.Query().Get("buf")
if sb != "" {
streakbuffer, err = strconv.Atoi(sb)
if err != nil {
streakbuffer = -1
}
}
if streakbuffer >= 0 {
if acc.StreakBuffer != streakbuffer {
acc.StreakBuffer = streakbuffer
go db.updateStreakBuffer(acc.ID, streakbuffer)
}
}
announcement, err := db.showAnnouncement(acc)
if err != nil {
log.Printf("ERROR! failed to show announcement: %s", err)
return
} else if announcement != "" {
data["announce"] = announcement
}
switch action {
case "deletesession":
data["result"] = "notdeleted"
st := r.FormValue("session")
if st == "" {
break
}
started, err := strconv.Atoi(st)
if err != nil || started == 0 {
log.Printf("failed to read session started when deleting session: %s", err)
return
}
deleted, err := db.deleteSession(started, acc.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
} else if deleted {
data["result"] = "deleted"
}
case "downloadsessions":
sessions, err := db.getAllSessions(acc.ID, false)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
data["downloadsessions"] = sessions
case "uploadsessions":
data["result"] = "corrupt"
u := r.FormValue("uploadsessions")
if u == "" {
break
}
postsession := r.FormValue("postsession")
updateSessionStarted, _ := strconv.Atoi(r.FormValue("editstarted"))
var uploadsessions []session
err = json.Unmarshal([]byte(u), &uploadsessions)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
data["result"] = "uploaded"
uploaded := false
sessionsuploaded := 0
for _, session := range uploadsessions {
uploaded, err = db.addSession(session, updateSessionStarted, acc.ID, av, appmarket)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
if uploaded {
sessionsuploaded++
}
}
data["sessionsuploaded"] = sessionsuploaded
if sessionsuploaded > 0 {
started := 0
streakday := 0
for _, session := range uploadsessions {
if session.Started > started {
started = session.Started
if action != "editposting" {
streakday = session.StreakDay
}
}
}
t := time.Now().In(tz)
if beforeWindowStart(t, streakbuffer) {
t = t.AddDate(0, 0, -1)
}
t = atWindowStart(t, streakbuffer)
if int64(started) >= t.Unix() { // A session was recorded after the start of today's streak window
streak, err := db.calculateStreak(acc.ID, acc.StreakBuffer, tz)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
logDebugf("NEW SESSION %v - CALCULATED: %d, SUBMITTED: %d", t, streak, streakday)
if streak < streakday {
streak = streakday
} else if streak > streakday {
err = db.setSessionStreakDay(started, streak, acc.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
}
err = db.setStreak(streak, acc.ID, acc.StreakBuffer, tz)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
}
stats.SessionsPosted++
if streakday > stats.TopStreak {
stats.TopStreak = streakday
}
}
if postsession != "" {
if sessionsuploaded > 0 {
data["result"] = "posted"
} else {
data["result"] = "alreadyposted"
}
}
}
w.Header().Set("x-MediNET", "connected")
// Send streak
if action == "connect" || action == "downloadsessions" || action == "uploadsessions" {
streakday, streakend, topstreak, err := db.getStreak(acc.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
w.Header().Set("x-MediNET-Streak", fmt.Sprintf("%d,%d", streakday, streakend))
w.Header().Set("x-MediNET-MaxStreak", fmt.Sprintf("%d", topstreak))
}
err = json.NewEncoder(w).Encode(data)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
j, err := json.Marshal(data)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
logDebugf("App: %d API: %d Action: %s - %q", appver, apiver, action, html.EscapeString(r.URL.RequestURI()))
logDebugf("Account ID: %d, JSON: %s", acc.ID, string(j))
}
func handleCommunity(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
lightTheme := isLightTheme(r)
w.Write(pageHeader(lightTheme, "Community"))
recentSessions, err := db.getRecentSessions()
if err != nil {
log.Fatal(err)
}
for _, recent := range recentSessions {
w.Write([]byte(fmt.Sprintf(`<li onclick="goToAccount('%d');">`, recent.AccountID)))
printSession(w, lightTheme, recent)
w.Write([]byte(`</li>`))
}
w.Write(pageFooter())
}
func handleAccount(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
acc := authenticateUser(w, r)
if acc == nil {
return
}
lightTheme := isLightTheme(r)
w.Write(pageHeader(lightTheme, "Account"))
// ...
w.Write(pageFooter())
}
func handleSessions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
acc := authenticateUser(w, r)
if acc == nil {
return
}
lightTheme := isLightTheme(r)
w.Write(pageHeader(lightTheme, "Sessions"))
sessions, err := db.getAllSessions(acc.ID, true)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
if len(sessions) > 0 {
for _, s := range sessions {
w.Write([]byte(fmt.Sprintf(`<li onclick="deleteSession('%d');">`, s.ID)))
printSession(w, lightTheme, &recentSession{session: *s})
w.Write([]byte(`</li>`))
}
} else {
w.Write([]byte(`<li><h1>No sessions</h1></li>`))
}
w.Write(pageFooter())
}
func handleGroups(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
acc := authenticateUser(w, r)
if acc == nil || acc.ID == 0 {
return
}
lightTheme := isLightTheme(r)
w.Write(pageHeader(lightTheme, "Groups"))
groups, err := db.getAllGroups()
if err != nil {
log.Printf("ERROR! %v", err)
return
}
format := `<li>
<table border="0" width="100%%">
<tr>
<td><span style="font-size: 1.5em;">%s</span></td>
<td align="right">%d member%s</td></tr>
</table>
<small>%s</small>
</li>`
for _, grp := range groups {
memberCount, err := db.groupMemberCount(grp.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
pluralMembers := "s"
if memberCount == 1 {
pluralMembers = ""
}
w.Write([]byte(fmt.Sprintf(format, html.EscapeString(grp.Name), memberCount, pluralMembers, html.EscapeString(grp.Description))))
}
w.Write([]byte("<br><br><br><h1>Not yet completed</h1>"))
w.Write(pageFooter())
}
func handleForum(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
acc := authenticateUser(w, r)
if acc == nil {
return
}
lightTheme := isLightTheme(r)
w.Write(pageHeader(lightTheme, "Forum"))
// ...
w.Write(pageFooter())
}
func handleProfile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
acc := authenticateUser(w, r)
if acc == nil {
return
}
var profile *account
profileID, err := strconv.Atoi(r.URL.Query().Get("account"))
if err == nil {
profile, err = db.accountByID(profileID)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
}
profileName := "Unknown Account"
if profile != nil {
profileName = profile.Name
if profileName == "" {
profileName = "Anonymous"
}
}
lightTheme := isLightTheme(r)
w.Write(pageHeader(lightTheme, html.EscapeString(profileName)))
if profile == nil {
w.Write([]byte("<li><h1>Account not found</h1></li>"))
w.Write(pageFooter())
return
}
w.Write([]byte(`
<table border="0" cellspacing="5px" width="100%">
<tr>
<td width="145" height="145"><a href="https://gravatar.com/` + fmt.Sprintf("%x", md5.Sum([]byte(profile.Email))) + `" target="_blank"><img src="` + gravatar(lightTheme, 200, profile.Email) + `" width="139" height="139" alt="Avatar" border="0"></a></td>
<td align="left" style="vertical-align: top;"><span style="font-size:1.7em;">` + html.EscapeString(profileName) + `</span><br>
<small>Joined ` + formatCompleted(profile.Registered) + `</small>`))
if profile.AllowContact == 1 && strings.TrimSpace(profile.Email) != "" {
w.Write([]byte(`<br><br><a href="mailto:` + html.EscapeString(strings.TrimSpace(profile.Email)) + `">Send email</a>`))
}
w.Write([]byte(`
</td>
</tr>
</table>
<div width="100%" style="position: absolute;bottom: 0;left: 0;right: 0;text-align: center;" align="center">`))
/*
echo '<table border="0" cellspacing="5px" width="100%"><tr><td width="145" height="145" ><a href="https://gravatar.com/' . md5($profile_account["email"]) . '" target="_blank"><img src="' . gravatarFromEmail($profile_account["email"], '200', $lighttheme) . '" width="139" height="139" alt="Avatar" border="0"></a></td>';
echo '<td align="left" style="vertical-align: top;"><span style="font-size:1.7em;">' . ($profile_account["name"] == "" ? "Anonymous" : cleanString($profile_account["name"])) . '</span><br>' . "\n";
echo '<small>Joined ' . date("j M Y", $profile_account["registered"]) . "</small>";
if ($profile_account["allowcontact"] == 1 && trim($profile_account["email"]) != "") {
echo "<br><br><a href=\"mailto:" . cleanString(trim($profile_account["email"])) . "\">Send email";
//echo (trim($profile_account["name"]) != "") ? cleanString(trim($profile_account["name"])) : 'this member';
//echo 'this member';
echo "</a>";
}
echo '</td></tr></table>' . "\n";
echo '<div width="100%" style="position: absolute;bottom: 0;left: 0;right: 0;text-align: center;" align="center">';
*/
if profile.ID == 1 {
// TODO easter egg
}
/*
if ($profile_account["id"] == 1) {
$num_accounts = mysqli_result(mysqli_query($GLOBALS["___mysqli_ston"], "SELECT COUNT(*) as 'total' FROM `accounts`"), 0, 0);
$num_sessions = mysqli_result(mysqli_query($GLOBALS["___mysqli_ston"], "SELECT COUNT(*) FROM `sessions`"), 0, 0);
$sum_sessions_seconds = mysqli_result(mysqli_query($GLOBALS["___mysqli_ston"], "SELECT SUM(`length`) as totalsecs FROM `sessions`"), 0, 0);
echo '<span style="font-weight: bold;">' . number_format($num_accounts) . '</span> users have posted<br>
<span style="font-weight: bold;">' . number_format($num_sessions) . '</span> sessions totalling<br>
<span style="font-weight: bold;">' . $sum_sessions . '</span> of meditation.
<br><br><br>';
}
*/
if profile.Streak > 1 {
w.Write([]byte(fmt.Sprintf(`<span style="font-size:1.25em;">%d days of meditation`, profile.Streak)))
if profile.TopStreak > profile.Streak {
w.Write([]byte(fmt.Sprintf(`<br><small>(Longest: %d days)</small>`, profile.TopStreak)))
}
w.Write([]byte(`</span>`))
} else if profile.TopStreak > 1 {
w.Write([]byte(fmt.Sprintf(`<span style="font-size:1.25em;">Longest meditation streak: %d days</span>`, profile.TopStreak)))
}
w.Write([]byte(`</div>`))
/*
if ($profile_account["streak"] > 1) {
echo '<span style="font-size:1.25em;">';
echo $profile_account["streak"] . ' days of meditation';
if ($profile_account["topstreak"] > $profile_account["streak"]) {
echo '<br><small>(Longest: ' . $profile_account["topstreak"] . ' days)</small>';
}
echo '</span>';
} else if ($profile_account["topstreak"] > 1) {
echo '<span style="font-size:1.25em;">';
echo 'Longest meditation streak: ' . $profile_account["topstreak"] . ' days';
echo '</span>';
}
echo '</div>';
*/
w.Write(pageFooter())
}
func handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
const statusText = `
MediNET ONLINE
Accounts: %d (%d new)
Sessions: %d
Top streak: %d
`
fmt.Fprintf(w, strings.TrimSpace(statusText), len(stats.ActiveAccounts), stats.AccountsCreated, stats.SessionsPosted, stats.TopStreak)
}
func handleLegacy(w http.ResponseWriter, r *http.Request) {
requestPage := r.URL.Query().Get("page")
for page, handler := range pages {
if page == requestPage {
handler(w, r)
return
}
}
// Unknown page. Use default handler.
handleIndex(w, r)
}
func initWeb() {
for page, handler := range pages {
http.HandleFunc(fmt.Sprintf("/om/%s", page), handler)
}
http.HandleFunc("/om", handleIndex)
// Handle legacy requests from pre-1.6.7 clients.
http.HandleFunc("/client_android.php", handleLegacy)
http.HandleFunc("/client_android2.php", handleLegacy) // TODO remove
// Serve static files.
staticDirs := map[string]string{
"/css/": "www/css",
"/js/": "www/js",
"/images/": "www/images",
}
for webPath, embedPath := range staticDirs {
fileSystem, err := fs.Sub(wwwFS, embedPath)
if err != nil {
log.Fatal(err)
}
http.Handle(webPath, http.StripPrefix(webPath, http.FileServer(http.FS(fileSystem))))
}
}
func listenWeb() {
log.Fatal(http.ListenAndServe(config.Om, nil))
}