Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module wellness-ping

go 1.25.1

require golang.org/x/crypto v0.43.0
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
177 changes: 163 additions & 14 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"strings"
"sync"
"time"

"golang.org/x/crypto/bcrypt"
)

const VERSION = "1.0.4"
const VERSION = "1.1.0"

type User struct {
Email string `json:"email"`
Expand All @@ -29,6 +31,10 @@ type User struct {
Active bool `json:"active"`
Token string `json:"token"`
AlertSent bool `json:"alert_sent"`
PINEnabled bool `json:"pin_enabled"`
PINHash string `json:"pin_hash"`
DuressPINHash string `json:"duress_pin_hash"`
CustomAlertMsg string `json:"custom_alert_msg"`
}

type PendingVerification struct {
Expand Down Expand Up @@ -73,6 +79,7 @@ func main() {
http.HandleFunc("/settings", settingsHandler)
http.HandleFunc("/update", updateHandler)
http.HandleFunc("/pong", pongHandler)
http.HandleFunc("/check-pin", checkPinHandler)
http.HandleFunc("/inbound", inboundEmailHandler)
http.HandleFunc("/test-ping", testPingHandler)
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
Expand All @@ -88,7 +95,6 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
"Version": VERSION,
}
tmpl := template.Must(template.ParseFiles("templates/index.html"))

tmpl.Execute(w, data)
}

Expand Down Expand Up @@ -312,6 +318,54 @@ func updateHandler(w http.ResponseWriter, r *http.Request) {
utcTime := localTime.UTC()
checkInHourUTC := utcTime.Hour()

// Handle PIN settings
pinEnabled := r.FormValue("pin_enabled") == "on"
var pinHash, duressPinHash string

if pinEnabled {
pin := r.FormValue("pin")
duressPin := r.FormValue("duress_pin")

if pin == "" {
http.Error(w, "PIN cannot be empty when PIN is enabled", http.StatusBadRequest)
return
}

if len(pin) < 4 || len(pin) > 8 {
Comment thread
micr0-dev marked this conversation as resolved.
http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest)
return
}

// Using email as extra salt for security
hash, err := bcrypt.GenerateFromPassword([]byte(email+":"+pin), bcrypt.DefaultCost)
Comment thread
micr0-dev marked this conversation as resolved.
if err != nil {
http.Error(w, "Error hashing PIN", http.StatusInternalServerError)
return
}
pinHash = string(hash)

if duressPin != "" {
if len(duressPin) < 4 || len(duressPin) > 8 {
http.Error(w, "Duress PIN must be 4-8 digits", http.StatusBadRequest)
return
}
if duressPin == pin {
http.Error(w, "Duress PIN must be different from normal PIN", http.StatusBadRequest)
return
}

// Using email as extra salt for security
hash, err := bcrypt.GenerateFromPassword([]byte(email+":"+duressPin), bcrypt.DefaultCost)
Comment thread
micr0-dev marked this conversation as resolved.
if err != nil {
http.Error(w, "Error hashing duress PIN", http.StatusInternalServerError)
return
}
duressPinHash = string(hash)
}
}

customAlertMsg := strings.TrimSpace(r.FormValue("custom_alert_msg"))
Comment thread
micr0-dev marked this conversation as resolved.

token := generateToken()

user := &User{
Expand All @@ -325,6 +379,10 @@ func updateHandler(w http.ResponseWriter, r *http.Request) {
Active: true,
Token: token,
AlertSent: false,
PINEnabled: pinEnabled,
PINHash: pinHash,
DuressPINHash: duressPinHash,
CustomAlertMsg: customAlertMsg,
}

store.mu.Lock()
Expand Down Expand Up @@ -379,34 +437,94 @@ func pongHandler(w http.ResponseWriter, r *http.Request) {

token := r.URL.Query().Get("token")

store.mu.Lock()
store.mu.RLock()
Comment thread
micr0-dev marked this conversation as resolved.
var foundUser *User
var wasAlerted bool
for _, user := range store.Users {
if user.Token == token {
foundUser = user
break
}
}
store.mu.RUnlock()

if foundUser == nil {
http.Error(w, "Invalid token", http.StatusBadRequest)
return
}

// If PIN is enabled, show PIN entry page
if foundUser.PINEnabled {
data := map[string]string{
"Token": token,
}
tmpl := template.Must(template.ParseFiles("templates/pin_entry.html"))
tmpl.Execute(w, data)
return
}

// No PIN required, check in directly
processPongCheckIn(foundUser, false)
fmt.Fprintf(w, "<html><head><link rel='stylesheet' href='/static/style.css'></head><body><h1>Confirmed</h1><p>Thanks for checking in!</p></body></html>")
Comment thread
micr0-dev marked this conversation as resolved.
}

func checkPinHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

token := r.FormValue("token")
pin := r.FormValue("pin")

store.mu.RLock()
var foundUser *User
for _, user := range store.Users {
if user.Token == token {
foundUser = user
wasAlerted = user.AlertSent
user.LastPing = time.Now()
user.LastReminderNum = 0
user.AlertSent = false
break
}
}
store.mu.Unlock()
store.mu.RUnlock()

if foundUser == nil {
http.Error(w, "Invalid token", http.StatusBadRequest)
return
}

saveStore()
// Check normal PIN
if bcrypt.CompareHashAndPassword([]byte(foundUser.PINHash), []byte(foundUser.Email+":"+pin)) == nil {
Comment thread
micr0-dev marked this conversation as resolved.
processPongCheckIn(foundUser, false)
fmt.Fprintf(w, "<html><head><link rel='stylesheet' href='/static/style.css'></head><body><h1>Confirmed</h1><p>Thanks for checking in!</p></body></html>")
return
}

if wasAlerted {
sendAllClearEmail(foundUser)
// Check duress PIN
if foundUser.DuressPINHash != "" && bcrypt.CompareHashAndPassword([]byte(foundUser.DuressPINHash), []byte(foundUser.Email+":"+pin)) == nil {
processPongCheckIn(foundUser, true)
// Show normal confirmation to not alert the attacker
fmt.Fprintf(w, "<html><head><link rel='stylesheet' href='/static/style.css'></head><body><h1>Confirmed</h1><p>Thanks for checking in!</p></body></html>")
return
}

fmt.Fprintf(w, "<html><head><link rel='stylesheet' href='/static/style.css'></head><body><h1>Confirmed</h1><p>Thanks for checking in!</p></body></html>")
// Invalid PIN
http.Error(w, "Invalid PIN", http.StatusUnauthorized)
}

func processPongCheckIn(user *User, isDuress bool) {
store.mu.Lock()
wasAlerted := user.AlertSent
user.LastPing = time.Now()
user.LastReminderNum = 0
user.AlertSent = false
store.mu.Unlock()

saveStore()

if isDuress {
sendDuressAlert(user)
} else if wasAlerted {
sendAllClearEmail(user)
}
}

func inboundEmailHandler(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -572,6 +690,16 @@ func pingScheduler() {
continue
}
}
} else {
if timeSinceLastPing >= pingInterval && now.Hour() >= user.CheckInHour {
store.mu.Lock()
user.LastReminderNum = 0
user.CurrentCycleStart = time.Now()
store.mu.Unlock()
sendPing(user, 0)
needsSave = true
continue
}
}

if !user.CurrentCycleStart.IsZero() && user.LastPing.Before(user.CurrentCycleStart) {
Expand Down Expand Up @@ -638,7 +766,28 @@ func sendPing(user *User, reminderNum int) {

func sendAlert(user *User) {
subject := fmt.Sprintf("Wellness Alert - %s Not Responding", user.Email)
body := fmt.Sprintf("WARNING: %s hasn't responded to their wellness ping.\n\nPlease check in on them to ensure they're okay.", user.Email)

var body string
if user.CustomAlertMsg != "" {
body = fmt.Sprintf("WARNING: %s hasn't responded to their wellness ping.\n\n%s\n\nPlease check in on them to ensure they're okay.", user.Email, user.CustomAlertMsg)
} else {
body = fmt.Sprintf("WARNING: %s hasn't responded to their wellness ping.\n\nPlease check in on them to ensure they're okay.", user.Email)
}

for _, alertEmail := range user.AlertEmails {
sendEmail(alertEmail, subject, body)
}
}

func sendDuressAlert(user *User) {
subject := fmt.Sprintf("DURESS ALERT - %s", user.Email)

var body string
if user.CustomAlertMsg != "" {
body = fmt.Sprintf("CRITICAL: %s has checked in using their duress PIN.\n\nThis indicates they may be in danger or under coercion.\n\nDO NOT contact them directly. Follow your emergency procedures\n\n%s", user.Email, user.CustomAlertMsg)
} else {
body = fmt.Sprintf("CRITICAL: %s has checked in using their duress PIN.\n\nThis indicates they may be in danger or under coercion.\n\nDO NOT contact them directly. Follow your emergency procedures.", user.Email)
}
Comment thread
micr0-dev marked this conversation as resolved.

for _, alertEmail := range user.AlertEmails {
sendEmail(alertEmail, subject, body)
Expand Down
60 changes: 48 additions & 12 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,53 @@ <h2>For anyone who might go <em>missing</em></h2>
</form>

<details style="margin-top: 25px;">
<summary style="cursor: pointer; font-weight: bold; padding: 10px; background: #f9f9f9; ">What is this? (click
to expand)</summary>
<summary style="cursor: pointer; font-weight: bold; padding: 10px; background: #f9f9f9;">What is this? (click to
expand)</summary>
<div style="background: #f9f9f9; padding: 15px; border-left: 3px solid #333;">
<p><strong>What this is:</strong> Regular email check-ins. If you don't respond, your emergency contacts get
notified.</p>
<p><strong>Who it's for:</strong> Activists, journalists, researchers, solo folks. Anyone who needs someone
to notice if they go silent.</p>
<p><strong>How it works:</strong> Choose daily or weekly pings. Click the link in the email or reply "PONG"
to confirm you're okay. Miss the ping? We'll send reminders. Still no response? Your emergency contacts
get alerted.</p>
<p style="font-size: 14px; margin-top: 10px;">Free forever. Premium hosting in Sweden for reliability.
Minimal data retention. <a href="https://github.com/micr0-dev/wellness-ping">Open source</a>.</p>

<p><strong>Who it's for:</strong> Anyone who needs someone to notice if they go silent.</p>
<ul style="margin: 5px 0 10px 20px;">
<li>Living alone (elderly, solo, remote workers)</li>
<li>Activists, journalists, researchers in risky situations</li>
<li>Hikers, travelers, adventurers</li>
<li>Anyone with health concerns or safety risks</li>
</ul>

<p><strong>How it works:</strong> Choose daily or weekly pings at your preferred time. Click the link in the
email or reply "PONG" to confirm you're okay. Miss the ping? We'll send gentle reminders. Still no
response by the deadline? Your emergency contacts get alerted.</p>

<p style="font-size: 14px; margin-top: 10px;">Free forever. <a
href="https://github.com/micr0-dev/wellness-ping">Open source</a>.</p>
</div>
</details>

<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; padding: 10px; background: #f9f9f9;">Features (click to
expand)</summary>
<div style="background: #f9f9f9; padding: 15px; border-left: 3px solid #333;">
<p><strong>Core features:</strong></p>
<ul style="margin: 5px 0 15px 20px;">
<li><strong>Flexible scheduling:</strong> Daily or weekly check-ins at your preferred time</li>
<li><strong>Gentle reminders:</strong> Multiple reminders before contacts are alerted</li>
<li><strong>Multiple check-in methods:</strong> Click link or reply "PONG" to email</li>
<li><strong>Custom alert messages:</strong> Add instructions for emergency contacts</li>
</ul>

<p><strong>Security features (optional):</strong></p>
<ul style="margin: 5px 0 15px 20px;">
<li><strong>PIN authentication:</strong> Require a PIN code to check in (prevents email spoofing)</li>
<li><strong>Duress PIN:</strong> A second PIN that looks normal but secretly alerts contacts you're in
danger</li>
</ul>

<p style="font-size: 13px; color: #666; margin-top: 10px;">All security features are optional. The service
works great with just email - add PINs only if you need extra protection.</p>
</div>
</details>

<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold; padding: 10px; background: #f9f9f9;">Privacy & Security
(click to expand)</summary>
Expand All @@ -44,6 +77,7 @@ <h2>For anyone who might go <em>missing</em></h2>
<li>Emergency contact emails</li>
<li>Check-in frequency and time preferences</li>
<li>Last check-in timestamp</li>
<li>Hashed PINs (if enabled, never stored in plaintext)</li>
</ul>

<p><strong>What we DON'T store:</strong></p>
Expand Down Expand Up @@ -75,9 +109,11 @@ <h2>For anyone who might go <em>missing</em></h2>
</details>

<footer style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">
<p style="font-size: 12px; color: #666;">
Questions? <a href="mailto:micr0@micr0.dev">micr0@micr0.dev</a> |
<a href="https://github.com/micr0-dev/wellness-ping">View source on GitHub</a> |
<p style="font-size: 14px; color: #666;">
Open source on <a href="https://github.com/micr0-dev/wellness-ping">GitHub</a> |
Feature requests: <a href="mailto:micr0@micr0.dev">micr0@micr0.dev</a> |
Donate: <a href="https://ko-fi.com/micr0">Ko-fi</a>, <a href="https://github.com/sponsors/micr0-dev">GitHub
Sponsors</a> |
Version: {{.Version}}
</p>
</footer>
Expand Down
23 changes: 23 additions & 0 deletions templates/pin_entry.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enter PIN - Wellness Ping</title>
<link rel="stylesheet" href="/static/style.css">
</head>

<body>
<h1>Enter Your PIN</h1>
<p>Please enter your PIN to confirm check-in.</p>

<form action="/check-pin" method="POST">
<input type="hidden" name="token" value="{{.Token}}">
<label>PIN:</label><br>
<input type="password" name="pin" inputmode="numeric" pattern="[0-9]*" maxlength="8" required autofocus><br><br>
<button type="submit">Confirm</button>
</form>
</body>

</html>
Loading