Compare commits
No commits in common. "362ca0fa1900c94ac516d817996510ab3dd05038" and "1bdd9249fcce354c69739b126d4bcd78be83f433" have entirely different histories.
362ca0fa19
...
1bdd9249fc
|
@ -1,11 +0,0 @@
|
|||
# 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+
|
|
@ -1,359 +0,0 @@
|
|||
// townstats returns information about tilde.town in the tilde data protcol format
|
||||
// It was originally a Python script written by Michael F. Lamb <https://datagrok.org>
|
||||
// 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(`<title[^>]*>(.*)</title>`)
|
||||
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)
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/charmbracelet/glamour"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
if len(os.Args) > 2 {
|
||||
fmt.Println("expected zero or one arguments")
|
||||
}
|
||||
if len(os.Args) > 1 {
|
||||
arg := os.Args[1]
|
||||
switch arg {
|
||||
case "-r", "--random":
|
||||
err = visitRandomUser()
|
||||
default:
|
||||
err = visitUsername(os.Args[1])
|
||||
}
|
||||
} else {
|
||||
err = visitPrompt()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func visitRandomUser() error {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
usernames, err := getUsernames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return visitUser(usernames[rand.Intn(len(usernames))])
|
||||
}
|
||||
|
||||
func visitUsername(username string) error {
|
||||
usernames, err := getUsernames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, name := range usernames {
|
||||
if name == username {
|
||||
return visitUser(username)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no such user: %s", username)
|
||||
}
|
||||
|
||||
func visitPrompt() error {
|
||||
usernames, err := getUsernames()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username := ""
|
||||
prompt := &survey.Select{
|
||||
Message: "Choose a townie to visit!",
|
||||
Options: usernames,
|
||||
}
|
||||
survey.AskOne(prompt, &username)
|
||||
return visitUser(username)
|
||||
}
|
||||
|
||||
func visitUser(username string) error {
|
||||
files, err := ioutil.ReadDir(filepath.Join("/home", username))
|
||||
if err != nil {
|
||||
return fmt.Errorf("user is not accepting visitors (could not read user's home directory: %w)", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !isReadme(file.Name()) {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join("/home", username, file.Name())
|
||||
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if isMarkdown(file.Name()) {
|
||||
r, _ := glamour.NewTermRenderer(glamour.WithAutoStyle())
|
||||
out, err := r.RenderBytes(data)
|
||||
if err == nil {
|
||||
fmt.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println(string(data))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO handle .plan and .project files
|
||||
|
||||
fmt.Println("TODO non-readme fallback")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isReadme(filename string) bool {
|
||||
fn := strings.ToLower(filename)
|
||||
return strings.HasPrefix(fn, "readme") || strings.HasPrefix(fn, "welcome")
|
||||
}
|
||||
|
||||
func isMarkdown(filename string) bool {
|
||||
fn := strings.ToLower(filename)
|
||||
return strings.HasSuffix(fn, ".md") || strings.HasSuffix(fn, ".markdown")
|
||||
}
|
||||
|
||||
func getUsernames() ([]string, error) {
|
||||
cmd := exec.Command("/bin/bash", "-c", "town stats | jq -r '.users|map(.username)'")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usernames := []string{}
|
||||
err = json.Unmarshal(output, &usernames)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return usernames, nil
|
||||
}
|
Loading…
Reference in New Issue