diff --git a/cmd/stats/main.go b/cmd/stats/main.go
index d522b75..cc61fcd 100644
--- a/cmd/stats/main.go
+++ b/cmd/stats/main.go
@@ -3,351 +3,20 @@
// License: GPLv3+
// TDP is defined at http://protocol.club/~datagrok/beta-wiki/tdp.html
-// It is a JSON structure of the form:
-
// Usage: stats > /var/www/html/tilde.json
package main
import (
- "bufio"
- "bytes"
"encoding/json"
"fmt"
- "io/ioutil"
"log"
- "os"
- "os/exec"
- "path"
- "path/filepath"
- "regexp"
- "sort"
- "strings"
- "time"
+
+ "git.tilde.town/tildetown/town/stats"
)
-const defaultIndexPath = "/etc/skel/public_html/index.html"
-const description = `an intentional digital community for creating and sharing
-works of art, peer education, and technological anachronism. we are
-non-commercial, donation supported, and committed to rejecting false
-technological progress in favor of empathy and sustainable computing.`
-
-type newsEntry struct {
- Title string `json:"title"` // Title of entry
- Pubdate string `json:"pubdate"` // Human readable date
- Content string `json:"content"` // HTML of entry
-}
-
-type user struct {
- Username string `json:"username"` // Username of user
- PageTitle string `json:"title"` // Title of user's HTML page, if they have one
- Mtime int64 `json:"mtime"` // Timestamp representing the last time a user's index.html was modified
- // Town additions
- DefaultPage bool `json:"default"` // Whether or not user has updated their default index.html
-}
-
-type tildeData struct {
- Name string `json:"name"` // Name of the server
- URL string `json:"url"` // URL of the server's homepage
- SignupURL string `json:"signup_url"` // URL for server's signup page
- WantUsers bool `json:"want_users"` // Whether or not new users are being accepted
- AdminEmail string `json:"admin_email"` // Email for server admin
- Description string `json:"description"` // Description of server
- UserCount int `json:"user_count"` // Total number of users on server sorted by last activity time
- Users []*user `json:"users"`
- // Town Additions
- LiveUserCount int `json:"live_user_count"` // Users who have changed their index.html
- ActiveUserCount int `json:"active_user_count"` // Users with an active session
- ActiveUsers []string `json:"active_users"` // Usernames of logged in users
- GeneratedAt string `json:"generated_at"` // When this was generated in '%Y-%m-%d %H:%M:%S' format
- GeneratedAtSec int64 `json:"generated_at_sec"` // When this was generated in seconds since epoch
- Uptime string `json:"uptime"` // output of `uptime -p`
- News []newsEntry `json:"news"` // Collection of town news entries
-}
-
-func getEnvDefault(key, def string) string {
- result := os.Getenv(key)
- if result == "" {
- result = def
- }
- return result
-}
-
-func homesDir() string { return getEnvDefault("HOMES_DIR", "/home") }
-
-func getNews() (entries []newsEntry, err error) {
- inMeta := true
- inContent := false
- current := newsEntry{}
- blankLineRe := regexp.MustCompile(`^ *\n$`)
-
- newsPath := getEnvDefault("NEWS_PATH", "/town/news.posts")
-
- newsFile, err := os.Open(newsPath)
- if err != nil {
- return entries, fmt.Errorf("unable to read news file: %s", err)
- }
- defer newsFile.Close()
-
- scanner := bufio.NewScanner(newsFile)
-
- for scanner.Scan() {
- newsLine := scanner.Text()
- if strings.HasPrefix(newsLine, "#") || newsLine == "" || blankLineRe.FindStringIndex(newsLine) != nil {
- continue
- } else if strings.HasPrefix(newsLine, "--") {
- entries = append(entries, current)
- current = newsEntry{}
- inMeta = true
- inContent = false
- } else if inMeta {
- kv := strings.SplitN(newsLine, ":", 2)
- if kv[0] == "pubdate" {
- current.Pubdate = strings.TrimSpace(kv[1])
- } else if kv[0] == "title" {
- current.Title = strings.TrimSpace(kv[1])
- } else {
- log.Printf("ignoring unknown metadata in news entry: %v\n", newsLine)
- }
- if current.Pubdate != "" && current.Title != "" {
- inMeta = false
- inContent = true
- }
- } else if inContent {
- current.Content += fmt.Sprintf("\n%v", strings.TrimSpace(newsLine))
- } else {
- panic("news post parsing should never reach this point")
- }
- }
- return entries, nil
-}
-
-func indexPathFor(username string) (string, error) {
- potentialPaths := []string{"index.html", "index.htm"}
- indexPath := ""
- errs := []error{}
- for _, p := range potentialPaths {
- fullPath := path.Join(homesDir(), username, "public_html", p)
- _, staterr := os.Stat(fullPath)
- if staterr != nil {
- errs = append(errs, staterr)
- } else {
- indexPath = fullPath
- break
- }
- }
-
- if indexPath == "" {
- return "", fmt.Errorf("failed to locate index file for %v; tried %v; encountered errors: %v", username, potentialPaths, errs)
- }
-
- return indexPath, nil
-}
-
-func pageTitleFor(username string) string {
- pageTitleRe := regexp.MustCompile(`
]*>(.*)`)
- indexPath, err := indexPathFor(username)
- if err != nil {
- return ""
- }
- content, err := ioutil.ReadFile(indexPath)
- if err != nil {
- return ""
- }
- titleMatch := pageTitleRe.FindStringSubmatch(string(content))
- if len(titleMatch) < 2 {
- return ""
- }
- return titleMatch[1]
-}
-
-func systemUsers() map[string]bool {
- systemUsers := map[string]bool{
- "ubuntu": true,
- "ttadmin": true,
- "root": true,
- }
- envSystemUsers := os.Getenv("SYSTEM_USERS")
- if envSystemUsers != "" {
- for _, username := range strings.Split(envSystemUsers, ",") {
- systemUsers[username] = true
- }
- }
-
- return systemUsers
-}
-
-func mtimeFor(username string) int64 {
- path := path.Join(homesDir(), username, "public_html")
- var maxMtime int64 = 0
- _ = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if maxMtime < info.ModTime().Unix() {
- maxMtime = info.ModTime().Unix()
- }
- return nil
- })
-
- return maxMtime
-}
-
-func detectDefaultPageFor(username string, defaultHTML []byte) bool {
- indexPath, err := indexPathFor(username)
- if err != nil {
- return false
- }
- indexFile, err := os.Open(indexPath)
- if err != nil {
- return false
- }
- defer indexFile.Close()
-
- indexHTML, err := ioutil.ReadAll(indexFile)
- if err != nil {
- return false
- }
- return bytes.Equal(indexHTML, defaultHTML)
-}
-
-func getDefaultHTML() ([]byte, error) {
- indexPath := os.Getenv("DEFAULT_INDEX_PATH")
- if indexPath == "" {
- indexPath = defaultIndexPath
- }
-
- defaultIndexFile, err := os.Open(indexPath)
- if err != nil {
- return []byte{}, fmt.Errorf("could not open default index: %s", err)
- }
- defer defaultIndexFile.Close()
-
- defaultIndexHTML, err := ioutil.ReadAll(defaultIndexFile)
- if err != nil {
- return []byte{}, fmt.Errorf("could not read default index: %s", err)
- }
-
- return defaultIndexHTML, nil
-}
-
-type byMtime []*user
-
-func (x byMtime) Len() int { return len(x) }
-func (x byMtime) Less(i, j int) bool { return x[i].Mtime > x[j].Mtime } // because we want DESC
-func (x byMtime) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
-
-func getUsers() (users []*user, err error) {
- // For the purposes of this program, we discover users via:
- // - presence in /home/
- // - absence in systemUsers list (sourced from source code and potentially augmented by an environment variable)
- // We formally used passwd parsing. This is definitely more "correct" and I'm
- // not opposed to going back to that; going back to parsing /home is mainly to
- // get this new version going.
- defaultIndexHTML, err := getDefaultHTML()
- if err != nil {
- return users, err
- }
-
- out, err := exec.Command("ls", homesDir()).Output()
- if err != nil {
- return users, fmt.Errorf("could not run ls: %s", err)
- }
-
- scanner := bufio.NewScanner(bytes.NewReader(out))
-
- systemUsers := systemUsers()
-
- for scanner.Scan() {
- username := scanner.Text()
- if systemUsers[username] {
- continue
- }
- user := user{
- Username: username,
- PageTitle: pageTitleFor(username),
- Mtime: mtimeFor(username),
- DefaultPage: detectDefaultPageFor(username, defaultIndexHTML),
- }
- users = append(users, &user)
- }
-
- return users, nil
-}
-
-func liveUserCount(users []*user) int {
- count := 0
- for _, u := range users {
- if !u.DefaultPage {
- count++
- }
- }
- return count
-}
-
-func activeUsers() ([]string, error) {
- out, err := exec.Command("sh", "-c", "who | cut -d' ' -f1 | sort -u").Output()
- if err != nil {
- return nil, fmt.Errorf("failed to get active user count: %w", err)
- }
- scanner := bufio.NewScanner(bytes.NewReader(out))
- usernames := []string{}
- for scanner.Scan() {
- usernames = append(usernames, strings.TrimSpace(scanner.Text()))
- }
- return usernames, nil
-}
-
-func getUptime() (string, error) {
- out, err := exec.Command("uptime").Output()
- if err != nil {
- return "", fmt.Errorf("could not run uptime: %s", err)
- }
- return strings.TrimSpace(string(out)), nil
-}
-
-func tdp() (tildeData, error) {
- users, err := getUsers()
- if err != nil {
- return tildeData{}, fmt.Errorf("could not get user list: %s", err)
- }
- activeUsernames, err := activeUsers()
- if err != nil {
- return tildeData{}, fmt.Errorf("could not count active users: %s", err)
- }
- news, err := getNews()
- if err != nil {
- return tildeData{}, fmt.Errorf("could not get news: %s", err)
- }
-
- uptime, err := getUptime()
- if err != nil {
- return tildeData{}, fmt.Errorf("could not determine uptime: %s", err)
- }
-
- sort.Sort(byMtime(users))
-
- return tildeData{
- Name: "tilde.town",
- URL: "https://tilde.town",
- SignupURL: "https://cgi.tilde.town/users/signup",
- WantUsers: true,
- AdminEmail: "root@tilde.town",
- Description: description,
- UserCount: len(users),
- Users: users,
- LiveUserCount: liveUserCount(users),
- ActiveUserCount: len(activeUsernames),
- ActiveUsers: activeUsernames,
- Uptime: uptime,
- News: news,
- GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
- GeneratedAtSec: time.Now().Unix(),
- }, nil
-}
-
func main() {
- systemData, err := tdp()
+ systemData, err := stats.Stats()
if err != nil {
log.Fatal(err)
}
diff --git a/stats/stats.go b/stats/stats.go
new file mode 100644
index 0000000..24290d5
--- /dev/null
+++ b/stats/stats.go
@@ -0,0 +1,338 @@
+package stats
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+const defaultIndexPath = "/etc/skel/public_html/index.html"
+const description = `an intentional digital community for creating and sharing
+works of art, peer education, and technological anachronism. we are
+non-commercial, donation supported, and committed to rejecting false
+technological progress in favor of empathy and sustainable computing.`
+
+type NewsEntry struct {
+ Title string `json:"title"` // Title of entry
+ Pubdate string `json:"pubdate"` // Human readable date
+ Content string `json:"content"` // HTML of entry
+}
+
+type User struct {
+ Username string `json:"username"` // Username of user
+ PageTitle string `json:"title"` // Title of user's HTML page, if they have one
+ Mtime int64 `json:"mtime"` // Timestamp representing the last time a user's index.html was modified
+ // Town additions
+ DefaultPage bool `json:"default"` // Whether or not user has updated their default index.html
+}
+
+type TildeData struct {
+ Name string `json:"name"` // Name of the server
+ URL string `json:"url"` // URL of the server's homepage
+ SignupURL string `json:"signup_url"` // URL for server's signup page
+ WantUsers bool `json:"want_users"` // Whether or not new users are being accepted
+ AdminEmail string `json:"admin_email"` // Email for server admin
+ Description string `json:"description"` // Description of server
+ UserCount int `json:"user_count"` // Total number of users on server sorted by last activity time
+ Users []*User `json:"users"`
+
+ // Town Additions
+ LiveUserCount int `json:"live_user_count"` // Users who have changed their index.html
+ ActiveUserCount int `json:"active_user_count"` // Users with an active session
+ ActiveUsers []string `json:"active_users"` // Usernames of logged in users
+ GeneratedAt string `json:"generated_at"` // When this was generated in '%Y-%m-%d %H:%M:%S' format
+ GeneratedAtSec int64 `json:"generated_at_sec"` // When this was generated in seconds since epoch
+ Uptime string `json:"uptime"` // output of `uptime -p`
+ News []NewsEntry `json:"news"` // Collection of town news entries
+}
+
+func getEnvDefault(key, def string) string {
+ result := os.Getenv(key)
+ if result == "" {
+ result = def
+ }
+ return result
+}
+
+func homesDir() string { return getEnvDefault("HOMES_DIR", "/home") }
+
+func getNews() (entries []NewsEntry, err error) {
+ inMeta := true
+ inContent := false
+ current := NewsEntry{}
+ blankLineRe := regexp.MustCompile(`^ *\n$`)
+
+ newsPath := getEnvDefault("NEWS_PATH", "/town/news.posts")
+
+ newsFile, err := os.Open(newsPath)
+ if err != nil {
+ return entries, fmt.Errorf("unable to read news file: %s", err)
+ }
+ defer newsFile.Close()
+
+ scanner := bufio.NewScanner(newsFile)
+
+ for scanner.Scan() {
+ newsLine := scanner.Text()
+ if strings.HasPrefix(newsLine, "#") || newsLine == "" || blankLineRe.FindStringIndex(newsLine) != nil {
+ continue
+ } else if strings.HasPrefix(newsLine, "--") {
+ entries = append(entries, current)
+ current = NewsEntry{}
+ inMeta = true
+ inContent = false
+ } else if inMeta {
+ kv := strings.SplitN(newsLine, ":", 2)
+ if kv[0] == "pubdate" {
+ current.Pubdate = strings.TrimSpace(kv[1])
+ } else if kv[0] == "title" {
+ current.Title = strings.TrimSpace(kv[1])
+ } else {
+ log.Printf("ignoring unknown metadata in news entry: %v\n", newsLine)
+ }
+ if current.Pubdate != "" && current.Title != "" {
+ inMeta = false
+ inContent = true
+ }
+ } else if inContent {
+ current.Content += fmt.Sprintf("\n%v", strings.TrimSpace(newsLine))
+ } else {
+ panic("news post parsing should never reach this point")
+ }
+ }
+ return entries, nil
+}
+
+func indexPathFor(username string) (string, error) {
+ potentialPaths := []string{"index.html", "index.htm"}
+ indexPath := ""
+ errs := []error{}
+ for _, p := range potentialPaths {
+ fullPath := path.Join(homesDir(), username, "public_html", p)
+ _, staterr := os.Stat(fullPath)
+ if staterr != nil {
+ errs = append(errs, staterr)
+ } else {
+ indexPath = fullPath
+ break
+ }
+ }
+
+ if indexPath == "" {
+ return "", fmt.Errorf("failed to locate index file for %v; tried %v; encountered errors: %v", username, potentialPaths, errs)
+ }
+
+ return indexPath, nil
+}
+
+func pageTitleFor(username string) string {
+ pageTitleRe := regexp.MustCompile(`]*>(.*)`)
+ indexPath, err := indexPathFor(username)
+ if err != nil {
+ return ""
+ }
+ content, err := ioutil.ReadFile(indexPath)
+ if err != nil {
+ return ""
+ }
+ titleMatch := pageTitleRe.FindStringSubmatch(string(content))
+ if len(titleMatch) < 2 {
+ return ""
+ }
+ return titleMatch[1]
+}
+
+func systemUsers() map[string]bool {
+ systemUsers := map[string]bool{
+ "ubuntu": true,
+ "ttadmin": true,
+ "root": true,
+ }
+ envSystemUsers := os.Getenv("SYSTEM_USERS")
+ if envSystemUsers != "" {
+ for _, username := range strings.Split(envSystemUsers, ",") {
+ systemUsers[username] = true
+ }
+ }
+
+ return systemUsers
+}
+
+func mtimeFor(username string) int64 {
+ path := path.Join(homesDir(), username, "public_html")
+ var maxMtime int64 = 0
+ _ = filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if maxMtime < info.ModTime().Unix() {
+ maxMtime = info.ModTime().Unix()
+ }
+ return nil
+ })
+
+ return maxMtime
+}
+
+func detectDefaultPageFor(username string, defaultHTML []byte) bool {
+ indexPath, err := indexPathFor(username)
+ if err != nil {
+ return false
+ }
+ indexFile, err := os.Open(indexPath)
+ if err != nil {
+ return false
+ }
+ defer indexFile.Close()
+
+ indexHTML, err := ioutil.ReadAll(indexFile)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(indexHTML, defaultHTML)
+}
+
+func getDefaultHTML() ([]byte, error) {
+ indexPath := os.Getenv("DEFAULT_INDEX_PATH")
+ if indexPath == "" {
+ indexPath = defaultIndexPath
+ }
+
+ defaultIndexFile, err := os.Open(indexPath)
+ if err != nil {
+ return []byte{}, fmt.Errorf("could not open default index: %s", err)
+ }
+ defer defaultIndexFile.Close()
+
+ defaultIndexHTML, err := ioutil.ReadAll(defaultIndexFile)
+ if err != nil {
+ return []byte{}, fmt.Errorf("could not read default index: %s", err)
+ }
+
+ return defaultIndexHTML, nil
+}
+
+type byMtime []*User
+
+func (x byMtime) Len() int { return len(x) }
+func (x byMtime) Less(i, j int) bool { return x[i].Mtime > x[j].Mtime } // because we want DESC
+func (x byMtime) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
+
+func getUsers() (users []*User, err error) {
+ // For the purposes of this program, we discover users via:
+ // - presence in /home/
+ // - absence in systemUsers list (sourced from source code and potentially augmented by an environment variable)
+ // We formally used passwd parsing. This is definitely more "correct" and I'm
+ // not opposed to going back to that; going back to parsing /home is mainly to
+ // get this new version going.
+ defaultIndexHTML, err := getDefaultHTML()
+ if err != nil {
+ return users, err
+ }
+
+ out, err := exec.Command("ls", homesDir()).Output()
+ if err != nil {
+ return users, fmt.Errorf("could not run ls: %s", err)
+ }
+
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+
+ systemUsers := systemUsers()
+
+ for scanner.Scan() {
+ username := scanner.Text()
+ if systemUsers[username] {
+ continue
+ }
+ user := User{
+ Username: username,
+ PageTitle: pageTitleFor(username),
+ Mtime: mtimeFor(username),
+ DefaultPage: detectDefaultPageFor(username, defaultIndexHTML),
+ }
+ users = append(users, &user)
+ }
+
+ return users, nil
+}
+
+func liveUserCount(users []*User) int {
+ count := 0
+ for _, u := range users {
+ if !u.DefaultPage {
+ count++
+ }
+ }
+ return count
+}
+
+func activeUsers() ([]string, error) {
+ out, err := exec.Command("sh", "-c", "who | cut -d' ' -f1 | sort -u").Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get active user count: %w", err)
+ }
+ scanner := bufio.NewScanner(bytes.NewReader(out))
+ usernames := []string{}
+ for scanner.Scan() {
+ usernames = append(usernames, strings.TrimSpace(scanner.Text()))
+ }
+ return usernames, nil
+}
+
+func getUptime() (string, error) {
+ out, err := exec.Command("uptime").Output()
+ if err != nil {
+ return "", fmt.Errorf("could not run uptime: %s", err)
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func Stats() (TildeData, error) {
+ users, err := getUsers()
+ if err != nil {
+ return TildeData{}, fmt.Errorf("could not get user list: %s", err)
+ }
+ activeUsernames, err := activeUsers()
+ if err != nil {
+ return TildeData{}, fmt.Errorf("could not count active users: %s", err)
+ }
+ news, err := getNews()
+ if err != nil {
+ return TildeData{}, fmt.Errorf("could not get news: %s", err)
+ }
+
+ uptime, err := getUptime()
+ if err != nil {
+ return TildeData{}, fmt.Errorf("could not determine uptime: %s", err)
+ }
+
+ sort.Sort(byMtime(users))
+
+ return TildeData{
+ Name: "tilde.town",
+ URL: "https://tilde.town",
+ SignupURL: "https://cgi.tilde.town/users/signup",
+ WantUsers: true,
+ AdminEmail: "root@tilde.town",
+ Description: description,
+ UserCount: len(users),
+ Users: users,
+ LiveUserCount: liveUserCount(users),
+ ActiveUserCount: len(activeUsernames),
+ ActiveUsers: activeUsernames,
+ Uptime: uptime,
+ News: news,
+ GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
+ GeneratedAtSec: time.Now().Unix(),
+ }, nil
+}