diff --git a/cmd/stats/README.md b/cmd/stats/README.md new file mode 100644 index 0000000..5213320 --- /dev/null +++ b/cmd/stats/README.md @@ -0,0 +1,11 @@ +# townstats + +This program dumps information about tilde.town in the [Tilde Data Protocol](http://protocol.club/~datagrok/beta-wiki/tdp.html). + +# author + +vilmibm, based on python work by [datagrok](https://datagrok.org) + +# license + +gplv3+ \ No newline at end of file diff --git a/cmd/stats/main.go b/cmd/stats/main.go new file mode 100644 index 0000000..d522b75 --- /dev/null +++ b/cmd/stats/main.go @@ -0,0 +1,359 @@ +// townstats returns information about tilde.town in the tilde data protcol format +// It was originally a Python script written by Michael F. Lamb +// 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" +) + +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() + if err != nil { + log.Fatal(err) + } + data, err := json.Marshal(systemData) + if err != nil { + log.Fatalf("Failed to marshal JSON: %s", err) + } + fmt.Printf("%s\n", data) +}