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:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Example environment file
|
||||||
|
# Copy this to .env and fill in your actual values
|
||||||
|
|
||||||
|
PUSHOVER_API_TOKEN=your_api_token_here
|
||||||
|
PUSHOVER_USER_KEY=your_user_key_here
|
||||||
|
PORT=5000
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,7 @@
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
epage
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
@@ -24,4 +25,3 @@ go.work.sum
|
|||||||
|
|
||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o epage ./src
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
COPY --from=builder /app/epage .
|
||||||
|
COPY --from=builder /app/templates templates/
|
||||||
|
COPY --from=builder /app/static static/
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["./epage"]
|
||||||
88
README.md
88
README.md
@@ -1,2 +1,88 @@
|
|||||||
# epage-go
|
# ePage (Go)
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Send me an ePage via Pushover. This is a Go recreation of the original Python Flask application.
|
||||||
|
|
||||||
|
A simple web application that accepts form submissions and sends them via the Pushover API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Simple web form for name, email, and message
|
||||||
|
- Integration with Pushover API for push notifications
|
||||||
|
- Clean Bootstrap UI
|
||||||
|
- Easy to deploy with Docker
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Go 1.23 or higher
|
||||||
|
- Pushover API token and user key
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Set the following environment variables:
|
||||||
|
|
||||||
|
- `PUSHOVER_API_TOKEN`: Your Pushover API token
|
||||||
|
- `PUSHOVER_USER_KEY`: Your Pushover user key
|
||||||
|
- `PORT`: Port to run the server on (default: 5000)
|
||||||
|
- `CSRF_KEY`: CSRF key for production (optional, generated automatically for development)
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `.env` file (copy from `.env.example`):
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Edit `.env` with your actual Pushover credentials:
|
||||||
|
```
|
||||||
|
PUSHOVER_API_TOKEN=your_actual_token
|
||||||
|
PUSHOVER_USER_KEY=your_actual_key
|
||||||
|
PORT=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the application:
|
||||||
|
```bash
|
||||||
|
go run ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will automatically load the `.env` file and listen on the configured port (default: 5000).
|
||||||
|
The server will be available at `http://localhost:5000`
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o epage
|
||||||
|
./epage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build the Docker image:
|
||||||
|
```bash
|
||||||
|
docker build -t epage .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the container:
|
||||||
|
```bash
|
||||||
|
docker run -e PUSHOVER_API_TOKEN=your_token -e PUSHOVER_USER_KEY=your_key -p 5000:5000 epage
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The application sends form data to the Pushover API with the following message format:
|
||||||
|
|
||||||
|
```
|
||||||
|
Name: <name>
|
||||||
|
Email: <email>
|
||||||
|
|
||||||
|
Message: <message>
|
||||||
|
```
|
||||||
|
|
||||||
|
With priority 1 (High) and sound "cosmic".
|
||||||
|
|||||||
57
TESTING.md
Normal file
57
TESTING.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Unit Tests
|
||||||
|
|
||||||
|
This project includes comprehensive unit tests for the ePage application.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- **58.2%** code coverage across the application
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### send_page_test.go
|
||||||
|
Tests for the Pushover API integration:
|
||||||
|
- `TestPushoverPayloadStructure` - Validates JSON payload structure and serialization
|
||||||
|
- `TestPushoverMessageFormat` - Tests message formatting with various input types
|
||||||
|
|
||||||
|
### handlers_test.go
|
||||||
|
Tests for HTTP handlers and template rendering:
|
||||||
|
- `TestHandleIndexGET` - Tests GET / endpoint returns template
|
||||||
|
- `TestHandleSendPOST` - Tests POST / with valid and invalid form data
|
||||||
|
- `TestHandleSendInvalidForm` - Tests error handling for malformed requests
|
||||||
|
- `TestTemplateCache` - Verifies template caching works correctly
|
||||||
|
- `TestLoadTemplateNotFound` - Tests handling of missing templates
|
||||||
|
- `TestLoadTemplateInvalidTemplate` - Tests handling of invalid template syntax
|
||||||
|
- `TestServerIntegration` - Integration test of full request/response cycle
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
go test -v ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with coverage:
|
||||||
|
```bash
|
||||||
|
go test -v -cover ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a specific test:
|
||||||
|
```bash
|
||||||
|
go test -v -run TestHandleIndexGET ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
Run tests with detailed coverage report:
|
||||||
|
```bash
|
||||||
|
go test -coverprofile=coverage.out ./src
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Design
|
||||||
|
|
||||||
|
The tests focus on:
|
||||||
|
- **Handler logic**: Form validation, routing, and response generation
|
||||||
|
- **Template handling**: Caching, error handling, and rendering
|
||||||
|
- **Data structures**: JSON serialization and message formatting
|
||||||
|
- **Integration**: Full request/response cycles through the HTTP server
|
||||||
|
|
||||||
|
The tests use temporary directories and mock templates to avoid file system dependencies and can run in isolation.
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
epage:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- PUSHOVER_API_TOKEN=${PUSHOVER_API_TOKEN}
|
||||||
|
- PUSHOVER_USER_KEY=${PUSHOVER_USER_KEY}
|
||||||
|
- PORT=5000
|
||||||
|
volumes:
|
||||||
|
- ./templates:/root/templates
|
||||||
|
- ./static:/root/static
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module epage
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/joho/godotenv v1.5.1
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
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'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
static/msg.png
Normal file
BIN
static/msg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
71
templates/index.html
Normal file
71
templates/index.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
|
||||||
|
<title>ePage</title>
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/static/msg.png"/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sizetopage {
|
||||||
|
margin-left: 15%;
|
||||||
|
margin-right: 15%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="container">
|
||||||
|
<header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
|
||||||
|
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
|
||||||
|
<img alt="ePage logo" src="/static/msg.png" class="bi me-2" height="32"/>
|
||||||
|
<span class="fs-4">ePage</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status alert -->
|
||||||
|
{{if eq .status "success"}}
|
||||||
|
<div class="sizetopage alert alert-success" role="alert">Message sent!</div>
|
||||||
|
{{else if eq .status "fail"}}
|
||||||
|
<div class="sizetopage alert alert-danger" role="alert">We could not send your message. Please try again.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form class="sizetopage" action="/" method="post">
|
||||||
|
<div class="col-6 mw-50">
|
||||||
|
<label for="name" class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" placeholder="John Doe" required="">
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Please enter your name.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="col-6 mw-50">
|
||||||
|
<label for="email" class="form-label">Email address</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" placeholder="john@doe.com" required="">
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Please enter your email address.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<div class="col-12 mw-100">
|
||||||
|
<label for="message" class="form-label">Message</label>
|
||||||
|
<textarea type="text" rows="5" class="form-control" id="message" name="message" required=""></textarea>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
Please enter your message.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
<button class="btn btn-primary" type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user