Migrate to rocket9labs.com

This commit is contained in:
Trevor Slocum 2024-10-02 16:53:21 -07:00
parent a17ada4291
commit ae388d7cad
16 changed files with 580 additions and 185 deletions

View file

@ -1,26 +0,0 @@
image: golang:latest
stages:
- validate
- build
fmt:
stage: validate
script:
- gofmt -l -s -e .
- exit $(gofmt -l -s -e . | wc -l)
vet:
stage: validate
script:
- go vet -composites=false ./...
test:
stage: validate
script:
- go test -race -v ./...
build:
stage: build
script:
- go build

View file

@ -1,6 +1,5 @@
0.1.2:
0.1.2: (not yet released)
- Add status page
- Generate static community page
0.1.1:
- Add TimeZone preference

View file

@ -1,9 +1,7 @@
# MediNET
[![GoDoc](https://godoc.org/gitlab.com/tslocum/medinet?status.svg)](https://godoc.org/gitlab.com/tslocum/medinet)
[![CI status](https://gitlab.com/tslocum/medinet/badges/master/pipeline.svg)](https://gitlab.com/tslocum/medinet/commits/master)
[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space)
[![Donate via LiberaPay](https://img.shields.io/liberapay/receives/rocket9labs.com.svg?logo=liberapay)](https://liberapay.com/rocket9labs.com)
Session repository and community portal for [Meditation Assistant](https://gitlab.com/tslocum/meditationassistant)
Session repository and community portal for [Meditation Assistant](https://code.rocket9labs.com/tslocum/meditationassistant)
## Configure
@ -12,8 +10,9 @@ Example `medinet.conf`:
```yaml
timezone: "America/Los_Angeles"
om: "localhost:10800"
dbdriver: "sqlite3"
dbsource: "/home/medinet/data/medinet.db"
htdocs: "/home/medinet/public_html"
dbdriver: "mysql"
dbsource: "user:password@localhost/dbname"
```
## Run
@ -24,11 +23,11 @@ medinet -c ~/.config/medinet/medinet.conf
## Get Support
[Open an issue](https://gitlab.com/tslocum/meditationassistant/issues) describing your problem.
[Open an issue](https://code.rocket9labs.com/tslocum/meditationassistant/issues) describing your problem.
## Translate
Translation is handled [online](https://medinet.rocketnine.space/translate/).
Translation is handled [online](https://medinet.rocket9labs.com/translate/).
## Contribute

View file

@ -13,11 +13,8 @@ import (
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/mattn/go-sqlite3"
)
// Note: SQLite may not be compiled with support for UPDATE/DELETE LIMIT
const (
databaseVersion = 1
accountKeyLength = 32 // Was using MD5 hashes
@ -77,8 +74,15 @@ var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
type account struct {
ID int
Name string
Email string
Registered int
Key string
Streak int
StreakBuffer int
TopStreak int
AllowContact int
Announcement int
}
type session struct {
@ -99,6 +103,13 @@ type recentSession struct {
AccountEmail string
}
type group struct {
ID int `json:"id"`
Creator int `json:"creator"`
Name string `json:"name"`
Description string `json:"description"`
}
func generateKey() string {
b := make([]rune, accountKeyLength)
for i := range b {
@ -117,14 +128,6 @@ func connect(driver string, dataSource string) (*database, error) {
}
d.FuncGreatest = "GREATEST"
if config.DBDriver == "sqlite3" {
d.FuncGreatest = "MAX"
_, err = d.db.Exec(`PRAGMA encoding="UTF-8"`)
if err != nil {
return nil, fmt.Errorf("failed to send PRAGMA: %s", err)
}
}
err = d.CreateTables()
if err != nil {
@ -278,24 +281,56 @@ func (d *database) authenticate(token string) (*account, error) {
return nil, fmt.Errorf("failed to fetch account key: %s", err)
}
account, err := d.getAccount(key)
account, err := d.accountByKey(key)
failOnError(err)
return account, nil
}
func (d *database) getAccount(key string) (*account, error) {
func (d *database) scanAccount(rows *sql.Rows) (*account, error) {
a := new(account)
err := d.db.QueryRow("SELECT `id`, `key`, `streakbuffer` FROM accounts WHERE `key`=?", key).Scan(&a.ID, &a.Key, &a.StreakBuffer)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("getAccount error: %s", err)
err := rows.Scan(&a.ID, &a.Name, &a.Email, &a.Registered, &a.Key, &a.Streak, &a.StreakBuffer, &a.TopStreak, &a.AllowContact, &a.Announcement)
if err != nil {
return nil, fmt.Errorf("failed to scan account: %s", err)
}
return a, nil
}
func (d *database) accountByID(id int) (*account, error) {
rows, err := d.db.Query("SELECT `id`, `name`, `email`, `registered`, `key`, `streak`, `streakbuffer`, `topstreak`, `allowcontact`, `announcement` FROM accounts WHERE `id`=?", id)
if err != nil {
return nil, fmt.Errorf("accountByID error: %s", err)
}
for rows.Next() {
a, err := d.scanAccount(rows)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("accountByID error: %s", err)
}
return a, nil
}
return nil, nil
}
func (d *database) accountByKey(key string) (*account, error) {
rows, err := d.db.Query("SELECT `id`, `name`, `email`, `registered`, `key`, `streak`, `streakbuffer`, `topstreak`, `allowcontact`, `announcement` FROM accounts WHERE `key`=?", key)
if err != nil {
return nil, fmt.Errorf("accountByID error: %s", err)
}
for rows.Next() {
a, err := d.scanAccount(rows)
if err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("accountByID error: %s", err)
}
return a, nil
}
return nil, nil
}
func (d *database) getStreak(accountID int) (int64, int64, int64, error) {
streakDay := int64(0)
streakEnd := int64(0)
@ -527,10 +562,14 @@ func (d *database) sessionExistsByDate(date time.Time, accountID int, streakBuff
return sessionid > 0, nil
}
func (d *database) getAllSessions(accountID int) ([]*session, error) {
func (d *database) getAllSessions(accountID int, sortDescending bool) ([]*session, error) {
var sessions []*session
rows, err := d.db.Query("SELECT `id`, `posted`, `started`, `streakday`, `length`, `completed`, `message`, `modified` FROM sessions WHERE `account`=?", accountID)
querySort := "ASC"
if sortDescending {
querySort = "DESC"
}
rows, err := d.db.Query("SELECT `id`, `posted`, `started`, `streakday`, `length`, `completed`, `message`, `modified` FROM sessions WHERE `account`=? ORDER BY `completed` "+querySort, accountID)
if err != nil {
return nil, fmt.Errorf("failed to fetch sessions: %s", err)
}
@ -581,3 +620,64 @@ func (d *database) deleteSession(started int, accountID int) (bool, error) {
return affected > 0, nil
}
func (d *database) getAllGroups() ([]group, error) {
var groups []group
rows, err := d.db.Query("SELECT `id`, `name`, `creator`, `description` FROM groups ORDER BY `name` ASC")
if err != nil {
return nil, fmt.Errorf("failed to fetch groups: %s", err)
}
defer rows.Close()
for rows.Next() {
g := group{}
err = rows.Scan(&g.ID, &g.Name, &g.Creator, &g.Description)
if err != nil {
return nil, fmt.Errorf("failed to scan group: %s", err)
}
groups = append(groups, g)
}
return groups, nil
}
func (d *database) groupMemberCount(groupID int) (int, error) {
rows, err := d.db.Query("SELECT COUNT(*) as c FROM `groupmembers` WHERE `group` =?", groupID)
if err != nil {
return 0, fmt.Errorf("failed to fetch member count: %s", err)
}
defer rows.Close()
var memberCount int
for rows.Next() {
err = rows.Scan(&memberCount)
if err != nil {
return 0, fmt.Errorf("failed to fetch member count: %s", err)
}
}
return memberCount, nil
}
func (d *database) showAnnouncement(a *account) (string, error) {
rows, err := d.db.Query("SELECT `id`, `text` FROM announcements WHERE `active` = 1 AND `id` > ? ORDER BY `id` ASC limit 1", a.Announcement)
if err != nil {
return "", fmt.Errorf("showAnnouncement error: %s", err)
}
var id int
var text string
for rows.Next() {
err = rows.Scan(&id, &text)
if err != nil {
return "", fmt.Errorf("failed to scan announcement: %s", err)
}
a.Announcement = id
_, err = d.db.Exec("UPDATE accounts SET announcement = ? WHERE `id` = ? LIMIT 1", a.Announcement, a.ID)
if err != nil {
return "", fmt.Errorf("failed to update account: %s", err)
}
}
return text, nil
}

12
go.mod
View file

@ -1,10 +1,10 @@
module gitlab.com/tslocum/medinet
module code.rocket9labs.com/tslocum/medinet
go 1.15
go 1.22.0
require (
github.com/go-sql-driver/mysql v1.5.0
github.com/jessevdk/go-flags v1.4.0
github.com/mattn/go-sqlite3 v1.14.4
gopkg.in/yaml.v2 v2.3.0
github.com/go-sql-driver/mysql v1.8.1
gopkg.in/yaml.v2 v2.4.0
)
require filippo.io/edwards25519 v1.1.0 // indirect

14
go.sum
View file

@ -1,10 +1,8 @@
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI=
github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

22
main.go
View file

@ -1,5 +1,5 @@
// MediNET - session repository and community portal for Meditation Assistant
// https://gitlab.com/tslocum/medinet
// MediNET - Session repository and community portal for Meditation Assistant
// https://code.rocket9labs.com/tslocum/medinet
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@ -17,6 +17,7 @@
package main
import (
"flag"
"fmt"
"io/ioutil"
"log"
@ -25,7 +26,6 @@ import (
"regexp"
"time"
"github.com/jessevdk/go-flags"
"gopkg.in/yaml.v2"
)
@ -113,18 +113,20 @@ func printStatistics() {
func main() {
var opts struct {
ConfigFile string `short:"c" long:"config" description:"Configuration file"`
Debug bool `short:"d" long:"debug" description:"Print debug information"`
ConfigFile string
Debug bool
}
_, err := flags.Parse(&opts)
failOnError(err)
flag.StringVar(&opts.ConfigFile, "config", "", "Configuration file")
flag.BoolVar(&opts.Debug, "debug", false, "Print debug information")
flag.Parse()
rand.Seed(time.Now().UTC().UnixNano())
if opts.ConfigFile == "" {
log.Fatal("Please specify configuration file with: medinet -c <config file>")
}
if _, err = os.Stat(opts.ConfigFile); err != nil {
_, err := os.Stat(opts.ConfigFile)
if err != nil {
log.Fatalf("Configuration file %s does not exist: %s", opts.ConfigFile, err)
}
@ -145,7 +147,7 @@ func main() {
log.Fatal("Specify Web directory in configuration file")
}
tz := "Local"
tz := "UTC"
if config.TimeZone != "" {
tz = config.TimeZone
}
@ -163,4 +165,6 @@ func main() {
initWeb()
listenWeb()
log.Printf("Listening on %+v", config.Om)
}

399
web.go
View file

@ -1,9 +1,12 @@
package main
import (
"crypto/md5"
"embed"
"encoding/json"
"fmt"
"html"
"io/fs"
"log"
"net/http"
"strconv"
@ -11,14 +14,46 @@ import (
"time"
)
var updateCommunity = make(chan struct{})
//go:embed www
var wwwFS embed.FS
func handleMediNET(w http.ResponseWriter, r *http.Request) {
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.Header().Set("Location", "/signin")
w.WriteHeader(http.StatusTemporaryRedirect)
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()
// Authenticate (signin)
// Send key, client will then call (connect)
// Handle logging in.
token := r.FormValue("token")
if token != "" {
a, err := db.authenticate(token)
@ -37,23 +72,11 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
return
}
key := r.URL.Query().Get("x")
if key == "" {
key = r.FormValue("x")
}
a, err := db.getAccount(key)
if err != nil {
log.Printf("ERROR! %v", err)
// Require authentication.
acc := authenticateUser(w, r)
if acc == nil {
return
}
if a == nil {
w.Header().Set("x-MediNET", "signin")
logDebugf("Asking to sign in %s %q", key, html.EscapeString(r.URL.RequestURI()))
return
}
go trackActiveAccount(a.ID)
data := make(map[string]interface{})
data["status"] = "success"
@ -80,7 +103,7 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
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)
match = regexpNumbers.FindAllString(av[0:len(av)-len(match[1])], -1)
if match != nil {
appver, err = strconv.Atoi(strings.Join(match, ""))
if err != nil {
@ -102,14 +125,19 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
}
if streakbuffer >= 0 {
if a.StreakBuffer != streakbuffer {
a.StreakBuffer = streakbuffer
go db.updateStreakBuffer(a.ID, streakbuffer)
if acc.StreakBuffer != streakbuffer {
acc.StreakBuffer = streakbuffer
go db.updateStreakBuffer(acc.ID, streakbuffer)
}
}
// TODO: read from announcement table on successful connect
//data["announce"] = "First line\n\nSecond line"
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":
@ -125,7 +153,7 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
return
}
deleted, err := db.deleteSession(started, a.ID)
deleted, err := db.deleteSession(started, acc.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
@ -133,7 +161,7 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
data["result"] = "deleted"
}
case "downloadsessions":
sessions, err := db.getAllSessions(a.ID)
sessions, err := db.getAllSessions(acc.ID, false)
if err != nil {
log.Printf("ERROR! %v", err)
return
@ -163,7 +191,7 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
sessionsuploaded := 0
for _, session := range uploadsessions {
uploaded, err = db.addSession(session, updateSessionStarted, a.ID, av, appmarket)
uploaded, err = db.addSession(session, updateSessionStarted, acc.ID, av, appmarket)
if err != nil {
log.Printf("ERROR! %v", err)
return
@ -193,7 +221,7 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
}
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(a.ID, a.StreakBuffer, tz)
streak, err := db.calculateStreak(acc.ID, acc.StreakBuffer, tz)
if err != nil {
log.Printf("ERROR! %v", err)
return
@ -204,14 +232,14 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
if streak < streakday {
streak = streakday
} else if streak > streakday {
err = db.setSessionStreakDay(started, streak, a.ID)
err = db.setSessionStreakDay(started, streak, acc.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
}
}
err = db.setStreak(streak, a.ID, a.StreakBuffer, tz)
err = db.setStreak(streak, acc.ID, acc.StreakBuffer, tz)
if err != nil {
log.Printf("ERROR! %v", err)
return
@ -237,7 +265,7 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
// Send streak
if action == "connect" || action == "downloadsessions" || action == "uploadsessions" {
streakday, streakend, topstreak, err := db.getStreak(a.ID)
streakday, streakend, topstreak, err := db.getStreak(acc.ID)
if err != nil {
log.Printf("ERROR! %v", err)
return
@ -259,38 +287,295 @@ func handleMediNET(w http.ResponseWriter, r *http.Request) {
}
logDebugf("App: %d API: %d Action: %s - %q", appver, apiver, action, html.EscapeString(r.URL.RequestURI()))
logDebugf("Account ID: %d, JSON: %s", a.ID, string(j))
logDebugf("Account ID: %d, JSON: %s", acc.ID, string(j))
}
if action == "uploadsessions" || action == "deletesession" {
updateCommunity <- struct{}{}
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() {
http.HandleFunc("/om/sessions", func(w http.ResponseWriter, r *http.Request) {
// TODO
})
for page, handler := range pages {
http.HandleFunc(fmt.Sprintf("/om/%s", page), handler)
}
http.HandleFunc("/om", handleIndex)
http.HandleFunc("/om/account", func(w http.ResponseWriter, r *http.Request) {
// TODO
})
// Handle legacy requests from pre-1.6.7 clients.
http.HandleFunc("/client_android.php", handleLegacy)
http.HandleFunc("/client_android2.php", handleLegacy) // TODO remove
http.HandleFunc("/om/forum", func(w http.ResponseWriter, r *http.Request) {
// TODO
})
http.HandleFunc("/om/groups", func(w http.ResponseWriter, r *http.Request) {
// TODO
})
http.HandleFunc("/om/status", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "MediNET ONLINE<br><br>Accounts: %d (%d new)<br>Sessions: %d<br>Top streak: %d", len(stats.ActiveAccounts), stats.AccountsCreated, stats.SessionsPosted, stats.TopStreak)
})
http.HandleFunc("/om", handleMediNET)
go handleUpdateCommunity()
updateCommunity <- struct{}{}
// 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() {

View file

@ -1,45 +1,55 @@
package main
import (
"bytes"
"crypto/md5"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
"net/url"
"path"
"time"
)
func pageHeader(light bool, title string) []byte {
func isLightTheme(r *http.Request) bool {
theme := r.URL.Query().Get("th")
if theme == "light" || theme == "lightdark" {
return true
}
return false
}
func pageHeader(lightTheme bool, title string) []byte {
theme := "dark"
if light {
if lightTheme {
theme = "light"
}
return []byte(`<!DOCTYPE HTML>
<html>
<head>
<title>` + title + `</title>
<link rel="stylesheet" type="text/css" href="css/medinet.css">
<link rel="stylesheet" type="text/css" href="css/holo.css">
<link rel="stylesheet" type="text/css" href="css/holo-` + theme + `.css">
<link rel="stylesheet" type="text/css" href="/css/medinet.css">
<link rel="stylesheet" type="text/css" href="/css/holo.css">
<link rel="stylesheet" type="text/css" href="/css/holo-` + theme + `.css">
<script src="/js/medinet.js"></script>
</head>
<body class="holo-` + theme + `">
<body class="holo holo-` + theme + `">
<div class="holo-content">
<ul class="holo-list">
`)
}
func pageFooter() []byte {
return []byte(`</div>
return []byte(`
</ul>
</div>
</body>
</html>`)
</html>
`)
}
func gravatar(light bool, size int, email string) string {
d := "https://medinet.ftp.sh/images/ic_om_sq_small_dark.png"
d := "https://medinet.rocket9labs.com/images/om_dark.png"
if light {
d = "https://medinet.ftp.sh/images/ic_om_sq_small_light.png"
d = "https://medinet.rocket9labs.com/images/om_light.png"
}
return fmt.Sprintf("https://www.gravatar.com/avatar/%x?s=%d&d=%s", md5.Sum([]byte(email)), size, url.QueryEscape(d))
}
@ -59,16 +69,21 @@ func formatRecentCompleted(completed int) string {
return c.Format("3:04 Jan 2")
}
func formatStreak(streakday int) string {
if streakday == 0 {
func formatStreak(streakDay int) string {
if streakDay == 0 {
return ""
}
s := ""
if streakday > 1 {
if streakDay > 1 {
s = "s"
}
return fmt.Sprintf("<p>%d day%s of meditation</p>", streakday, s)
formatted := fmt.Sprintf("%d day%s of meditation", streakDay, s)
if streakDay < 10 {
formatted = "<small>" + formatted + "</small>"
}
return "<p>" + formatted + "</p>"
}
func formatMessage(message string) string {
@ -78,45 +93,49 @@ func formatMessage(message string) string {
return "<p><small>" + html.EscapeString(message) + "</small></p>"
}
func communityPage(light bool) []byte {
var b bytes.Buffer
b.Write(pageHeader(light, "Community"))
recentSessions, err := db.getRecentSessions()
if err != nil {
log.Fatal(err)
func escapeName(name string) string {
escapedName := html.EscapeString(name)
if len(name) >= 15 {
escapedName = "<small>" + escapedName + "</small>"
}
return escapedName
}
format := `<li onclick="goToAccount('%d');">
<table border="0" cellspacing="3px" cellpadding="0px" width="100%%">
<tr>
<td width="57px" height="57px" style="margin: 0px;padding: 0px;">
func printSession(w http.ResponseWriter, lightTheme bool, s *recentSession) {
showAccount := s.AccountID != 0
w.Write([]byte(`<table border="0" cellspacing="3px" cellpadding="0px" width="100%">
<tr>`))
if showAccount {
w.Write([]byte(fmt.Sprintf(`<td width="57px" height="57px" style="margin: 0px;padding: 0px;">
<img src="%s" width="57px" height="57px" style="margin: 0px;padding: 0px;">
</td>
<td>
<div style="padding-left: 4px;"><span style="font-size: 2em;font-weight: bold;">%s</span>
%s
<td>`, gravatar(lightTheme, 104, s.AccountEmail))))
} else {
w.Write([]byte(`<td colspan="2">`))
}
w.Write([]byte(fmt.Sprintf(`<div style="padding-left: 4px;"><span style="font-size: 2em;font-weight: bold;">%s</span>`, formatLength(s.Length))))
if showAccount {
w.Write([]byte("<br>" + escapeName(s.AccountName)))
}
var completed []byte
if showAccount {
completed = []byte(formatRecentCompleted(s.Completed))
} else {
completed = []byte(formatCompleted(s.Completed))
}
w.Write([]byte(fmt.Sprintf(`
</div></td>
<td align="right" style="vertical-align:top;font-size: 0.85em;">
%s
</td></tr>
</td>
</tr>
</table>
%s
%s
</li>`
for _, rc := range recentSessions {
b.WriteString(fmt.Sprintf(format, rc.AccountID, gravatar(light, 104, rc.AccountEmail), formatLength(rc.Length), html.EscapeString(rc.AccountName), formatRecentCompleted(rc.Completed), formatStreak(rc.StreakDay), formatMessage(rc.Message)))
}
b.Write(pageFooter())
return b.Bytes()
}
func handleUpdateCommunity() {
for range updateCommunity {
ioutil.WriteFile(path.Join(config.Web, "community.html"), communityPage(false), 0655)
ioutil.WriteFile(path.Join(config.Web, "community_light.html"), communityPage(true), 0655)
}
`, completed, formatStreak(s.StreakDay), formatMessage(s.Message))))
}

BIN
www/images/om_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
www/images/om_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

17
www/js/medinet.js Normal file
View file

@ -0,0 +1,17 @@
function goToAccount(id) {
window.location.href = '?v=page=profile&account=' + id;
}
function goToThread(id) {
window.location.href = '?v=page=forum&thread=' + id;
}
function deleteSession(id) {
window.location.href = '?v=page=sessions&delete=' + id;
}
function toggleOption(id) {
var chkbox = document.getElementById(id);
chkbox.checked = !chkbox.checked;
return true;
}