Add initial implementation of ePage application with Docker support
- Create main application logic with HTTP handlers for form submissions - Implement Pushover API integration for sending messages - Add unit tests for handlers and Pushover payload structure - Include Dockerfile and docker-compose configuration for easy deployment - Add example environment file and update README with setup instructions - Create HTML templates for user interface
This commit is contained in:
110
src/handlers.go
Normal file
110
src/handlers.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
templateCache = make(map[string]*template.Template)
|
||||
cacheMutex sync.RWMutex
|
||||
)
|
||||
|
||||
func loadTemplate(baseDir, templateName string) (*template.Template, error) {
|
||||
cacheMutex.RLock()
|
||||
if tmpl, exists := templateCache[templateName]; exists {
|
||||
cacheMutex.RUnlock()
|
||||
return tmpl, nil
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
|
||||
templatePath := filepath.Join(baseDir, "templates", templateName)
|
||||
tmpl, err := template.ParseFiles(templatePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cacheMutex.Lock()
|
||||
templateCache[templateName] = tmpl
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func handleIndex(baseDir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
indexTemplate, err := loadTemplate(baseDir, "index.html")
|
||||
if err != nil {
|
||||
log.Printf("Error loading template: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]string{
|
||||
"status": "",
|
||||
}
|
||||
|
||||
err = indexTemplate.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Printf("Error executing template: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSend(baseDir, apiToken, userKey string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
indexTemplate, err := loadTemplate(baseDir, "index.html")
|
||||
if err != nil {
|
||||
log.Printf("Error loading template: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
log.Printf("Error parsing form: %v", err)
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
email := r.FormValue("email")
|
||||
message := r.FormValue("message")
|
||||
|
||||
// Validate required fields
|
||||
if name == "" || email == "" || message == "" {
|
||||
data := map[string]string{
|
||||
"status": "fail",
|
||||
}
|
||||
indexTemplate.Execute(w, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Send page via Pushover
|
||||
success, err := sendPage(apiToken, userKey, name, email, message)
|
||||
|
||||
status := "fail"
|
||||
if success {
|
||||
status = "success"
|
||||
}
|
||||
|
||||
data := map[string]string{
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error sending page: %v", err)
|
||||
}
|
||||
|
||||
err = indexTemplate.Execute(w, data)
|
||||
if err != nil {
|
||||
log.Printf("Error executing template: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
311
src/handlers_test.go
Normal file
311
src/handlers_test.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandleIndexGET(t *testing.T) {
|
||||
// Create a temporary directory with a test template
|
||||
tmpDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
os.Mkdir(templatesDir, 0755)
|
||||
|
||||
// Create a simple test template
|
||||
templateContent := `
|
||||
<html>
|
||||
<body>
|
||||
{{if eq .status "success"}}
|
||||
<div>Success!</div>
|
||||
{{else if eq .status "fail"}}
|
||||
<div>Failed!</div>
|
||||
{{else}}
|
||||
<div>Form</div>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
templatePath := filepath.Join(templatesDir, "index.html")
|
||||
err := os.WriteFile(templatePath, []byte(templateContent), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test template: %v", err)
|
||||
}
|
||||
|
||||
// Clear the template cache before test
|
||||
clearTemplateCache()
|
||||
|
||||
// Test GET request to /
|
||||
req, err := http.NewRequest("GET", "/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler := handleIndex(tmpDir)
|
||||
handler(w, req)
|
||||
|
||||
// Check response
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
if !strings.Contains(string(body), "Form") {
|
||||
t.Error("Expected response to contain form content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSendPOST(t *testing.T) {
|
||||
// Create a temporary directory with a test template
|
||||
tmpDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
os.Mkdir(templatesDir, 0755)
|
||||
|
||||
// Create a simple test template
|
||||
templateContent := `
|
||||
<html>
|
||||
<body>
|
||||
{{if eq .status "success"}}
|
||||
<div>Success</div>
|
||||
{{else}}
|
||||
<div>Failed</div>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
templatePath := filepath.Join(templatesDir, "index.html")
|
||||
os.WriteFile(templatePath, []byte(templateContent), 0644)
|
||||
|
||||
// Clear the template cache before test
|
||||
clearTemplateCache()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
name_ string
|
||||
email string
|
||||
message string
|
||||
wantCode int
|
||||
wantInBody string
|
||||
}{
|
||||
{
|
||||
name: "Valid form submission",
|
||||
name_: "John Doe",
|
||||
email: "john@example.com",
|
||||
message: "Test message",
|
||||
wantCode: http.StatusOK,
|
||||
wantInBody: "Failed", // Will fail because no actual Pushover creds
|
||||
},
|
||||
{
|
||||
name: "Missing name",
|
||||
name_: "",
|
||||
email: "john@example.com",
|
||||
message: "Test message",
|
||||
wantCode: http.StatusOK,
|
||||
wantInBody: "Failed",
|
||||
},
|
||||
{
|
||||
name: "Missing email",
|
||||
name_: "John Doe",
|
||||
email: "",
|
||||
message: "Test message",
|
||||
wantCode: http.StatusOK,
|
||||
wantInBody: "Failed",
|
||||
},
|
||||
{
|
||||
name: "Missing message",
|
||||
name_: "John Doe",
|
||||
email: "john@example.com",
|
||||
message: "",
|
||||
wantCode: http.StatusOK,
|
||||
wantInBody: "Failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
formData := "name=" + tt.name_ + "&email=" + tt.email + "&message=" + tt.message
|
||||
|
||||
req, err := http.NewRequest("POST", "/", strings.NewReader(formData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler := handleSend(tmpDir, "test_token", "test_user")
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != tt.wantCode {
|
||||
t.Errorf("Expected status %d, got %d", tt.wantCode, w.Code)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(w.Body)
|
||||
if !strings.Contains(string(body), tt.wantInBody) {
|
||||
t.Errorf("Expected response to contain '%s', got %s", tt.wantInBody, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSendInvalidForm(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
os.Mkdir(templatesDir, 0755)
|
||||
|
||||
templateContent := `<html><body>Test</body></html>`
|
||||
templatePath := filepath.Join(templatesDir, "index.html")
|
||||
os.WriteFile(templatePath, []byte(templateContent), 0644)
|
||||
|
||||
clearTemplateCache()
|
||||
|
||||
// Test with malformed form data that causes parsing issues
|
||||
req, err := http.NewRequest("POST", "/", strings.NewReader("invalid\x00data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler := handleSend(tmpDir, "token", "user")
|
||||
handler(w, req)
|
||||
|
||||
// Should return a bad request error
|
||||
if w.Code != http.StatusBadRequest && w.Code != http.StatusOK {
|
||||
// Some implementations might parse it differently
|
||||
t.Logf("Got status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateCache(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
os.Mkdir(templatesDir, 0755)
|
||||
|
||||
templateContent := `<html><body>Cached</body></html>`
|
||||
templatePath := filepath.Join(templatesDir, "index.html")
|
||||
os.WriteFile(templatePath, []byte(templateContent), 0644)
|
||||
|
||||
clearTemplateCache()
|
||||
|
||||
// Load template twice
|
||||
tmpl1, err := loadTemplate(tmpDir, "index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load template first time: %v", err)
|
||||
}
|
||||
|
||||
tmpl2, err := loadTemplate(tmpDir, "index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load template second time: %v", err)
|
||||
}
|
||||
|
||||
// Both should be the same template object (cached)
|
||||
if tmpl1 != tmpl2 {
|
||||
t.Error("Expected cached template to return same object")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTemplateNotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
clearTemplateCache()
|
||||
|
||||
tmpl, err := loadTemplate(tmpDir, "nonexistent.html")
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent template")
|
||||
}
|
||||
if tmpl != nil {
|
||||
t.Error("Expected nil template for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTemplateInvalidTemplate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
os.Mkdir(templatesDir, 0755)
|
||||
|
||||
// Create invalid template with unclosed action
|
||||
invalidContent := `<html><body>{{.unclosed</body></html>`
|
||||
templatePath := filepath.Join(templatesDir, "invalid.html")
|
||||
os.WriteFile(templatePath, []byte(invalidContent), 0644)
|
||||
|
||||
clearTemplateCache()
|
||||
|
||||
tmpl, err := loadTemplate(tmpDir, "invalid.html")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid template syntax")
|
||||
}
|
||||
if tmpl != nil {
|
||||
t.Error("Expected nil template for invalid syntax")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to clear the template cache between tests
|
||||
func clearTemplateCache() {
|
||||
cacheMutex.Lock()
|
||||
defer cacheMutex.Unlock()
|
||||
// Clear all entries
|
||||
for key := range templateCache {
|
||||
delete(templateCache, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper to create a test server with handlers
|
||||
func createTestServer(t *testing.T) *httptest.Server {
|
||||
tmpDir := t.TempDir()
|
||||
templatesDir := filepath.Join(tmpDir, "templates")
|
||||
os.Mkdir(templatesDir, 0755)
|
||||
|
||||
templateContent := `
|
||||
<html>
|
||||
<body>
|
||||
{{if eq .status "success"}}Success{{else}}Form{{end}}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
templatePath := filepath.Join(templatesDir, "index.html")
|
||||
os.WriteFile(templatePath, []byte(templateContent), 0644)
|
||||
|
||||
clearTemplateCache()
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /", handleIndex(tmpDir))
|
||||
router.HandleFunc("POST /", handleSend(tmpDir, "token", "user"))
|
||||
|
||||
return httptest.NewServer(router)
|
||||
}
|
||||
|
||||
func TestServerIntegration(t *testing.T) {
|
||||
server := createTestServer(t)
|
||||
defer server.Close()
|
||||
|
||||
// Test GET
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to GET: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Test POST
|
||||
formData := "name=John&email=john@example.com&message=Hello"
|
||||
resp, err = http.PostForm(server.URL, map[string][]string{
|
||||
"name": {"John"},
|
||||
"email": {"john@example.com"},
|
||||
"message": {"Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to POST: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
_ = formData // silence unused var
|
||||
}
|
||||
55
src/main.go
Normal file
55
src/main.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load .env file if it exists (not required, only for local development)
|
||||
_ = godotenv.Load()
|
||||
|
||||
// Load environment variables
|
||||
pushoverAPIToken := os.Getenv("PUSHOVER_API_TOKEN")
|
||||
pushoverUserKey := os.Getenv("PUSHOVER_USER_KEY")
|
||||
csrfKey := os.Getenv("CSRF_KEY")
|
||||
|
||||
if csrfKey == "" {
|
||||
// Generate a random key if not provided (not recommended for production)
|
||||
csrfKey = "dev-key-change-in-production"
|
||||
}
|
||||
|
||||
// Set up paths relative to binary location
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get executable path: %v", err)
|
||||
}
|
||||
baseDir := filepath.Dir(exePath)
|
||||
|
||||
// Create router
|
||||
router := http.NewServeMux()
|
||||
|
||||
// Register handlers
|
||||
router.HandleFunc("GET /", handleIndex(baseDir))
|
||||
router.HandleFunc("POST /", handleSend(baseDir, pushoverAPIToken, pushoverUserKey))
|
||||
|
||||
// Serve static files
|
||||
staticDir := filepath.Join(baseDir, "static")
|
||||
fs := http.FileServer(http.Dir(staticDir))
|
||||
router.Handle("GET /static/", http.StripPrefix("/static/", fs))
|
||||
|
||||
// Start server
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "5000"
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%s", port)
|
||||
log.Printf("Starting server on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, router))
|
||||
}
|
||||
80
src/send_page.go
Normal file
80
src/send_page.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const pushoverAPIURL = "https://api.pushover.net/1/messages.json"
|
||||
|
||||
// PushoverPayload represents the JSON payload sent to Pushover API
|
||||
type PushoverPayload struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Sound string `json:"sound"`
|
||||
}
|
||||
|
||||
// PushoverResponse represents the JSON response from Pushover API
|
||||
type PushoverResponse struct {
|
||||
Status int `json:"status"`
|
||||
Request string `json:"request"`
|
||||
}
|
||||
|
||||
func sendPage(apiToken, userKey, name, email, message string) (bool, error) {
|
||||
fullMsg := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage: %s", name, email, message)
|
||||
|
||||
payload := PushoverPayload{
|
||||
Token: apiToken,
|
||||
User: userKey,
|
||||
Title: fmt.Sprintf("ePage from %s", name),
|
||||
Message: fullMsg,
|
||||
Priority: 1,
|
||||
Sound: "cosmic",
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", pushoverAPIURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var response PushoverResponse
|
||||
err = json.Unmarshal(body, &response)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK && response.Status == 1 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("pushover API returned status %d", resp.StatusCode)
|
||||
}
|
||||
85
src/send_page_test.go
Normal file
85
src/send_page_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPushoverPayloadStructure(t *testing.T) {
|
||||
payload := PushoverPayload{
|
||||
Token: "token123",
|
||||
User: "user456",
|
||||
Title: "Test Title",
|
||||
Message: "Test Message",
|
||||
Priority: 1,
|
||||
Sound: "cosmic",
|
||||
}
|
||||
|
||||
// Test JSON marshaling
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
// Verify JSON contains expected fields
|
||||
jsonStr := string(data)
|
||||
expectedFields := []string{"token123", "user456", "Test Title", "Test Message", "cosmic"}
|
||||
for _, field := range expectedFields {
|
||||
if !strings.Contains(jsonStr, field) {
|
||||
t.Errorf("Expected JSON to contain '%s'", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushoverMessageFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiToken string
|
||||
userKey string
|
||||
name_ string
|
||||
email string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Valid inputs",
|
||||
apiToken: "token",
|
||||
userKey: "user",
|
||||
name_: "Alice",
|
||||
email: "alice@example.com",
|
||||
message: "Hello",
|
||||
},
|
||||
{
|
||||
name: "Special characters",
|
||||
apiToken: "token",
|
||||
userKey: "user",
|
||||
name_: "John O'Brien",
|
||||
email: "john+tag@example.com",
|
||||
message: "Message with\nmultiple\nlines",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := PushoverPayload{
|
||||
Token: tt.apiToken,
|
||||
User: tt.userKey,
|
||||
Title: "ePage from " + tt.name_,
|
||||
Message: "Name: " + tt.name_ + "\nEmail: " + tt.email + "\n\nMessage: " + tt.message,
|
||||
Priority: 1,
|
||||
Sound: "cosmic",
|
||||
}
|
||||
|
||||
// Verify structure
|
||||
if payload.Token != tt.apiToken {
|
||||
t.Errorf("Token mismatch")
|
||||
}
|
||||
if payload.Priority != 1 {
|
||||
t.Errorf("Priority should be 1")
|
||||
}
|
||||
if payload.Sound != "cosmic" {
|
||||
t.Errorf("Sound should be 'cosmic'")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user