Merge pull request #1 from chemistry-software/develop/goserver_and_dockerfile

Move static files to static, add Go server and Dockerfile
This commit is contained in:
Bd authored and GitHub committed 2025-08-21 22:28:30 +02:00
commit 3288b04187
7 files changed
+352 -22

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
## Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum* ./
RUN go mod download || true
COPY . .
# Build static binary
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .
## Final runtime image
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /srv
COPY --from=builder /app/server ./server
COPY --from=builder /app/static ./static
EXPOSE 8080
ENV PORT=8080
CMD ["/srv/server"]
+46 -2
View File
@@ -1,3 +1,47 @@
# website
# chemistry.software website
chemistry.software website
## For editing the website (No Go, Docker or Coolify knowledge needed)
All the web pages, styles, and JavaScript live in the `static/` folder:
- `static/index.html` – the website page to edit
- `static/styles.css` – the visual styling (colors, layout, fonts)
- `static/script.js` – small behaviors like dark mode and the contact form
You can just edit this and view index.html as you usually would. Contact form just does not submit unless you have the server running.
When the site is deployed, the server will automatically serve everything from `/static` and handle the contact form submission.
## For deployment
- Backend is a small Go server (`main.go`) that serves:
- `GET /` → serves `static/index.html`
- `GET /static/*` → serves files from `static/`
- `POST /api/contact` → sends messages to Telegram
- Env variables:
- `PORT` (default `8080`)
- `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` (optional locally)
- Dockerfile supports Coolify deployment (multi-stage build)
## Running the server locally
If you for some reason do need to run the server (Go required but not Docker), you can simply do:
```
go run .
```
or to run with the environment vars for the Telegram bot to work:
```
TELEGRAM_BOT_TOKEN=7890948543:AAHJoSZZU4l-jrWmqLcVCh0R74R9U_bn5ZE \
TELEGRAM_CHAT_ID=126131628 \
go run .
```
Or if you want to run the entire Docker image (Docker required but not Go):
```docker build -t chemistrysoftware-site .
docker run -p 8080:8080 \
-e PORT=8080 \
-e TELEGRAM_BOT_TOKEN=7890948543:AAHJoSZZU4l-jrWmqLcVCh0R74R9U_bn5ZE \
-e TELEGRAM_CHAT_ID=126131628 \
chemistrysoftware-site```
+5
View File
@@ -0,0 +1,5 @@
module chemistrysoftware/website
go 1.22
+233
View File
@@ -0,0 +1,233 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
)
type Config struct {
Port string
TelegramBotToken string
TelegramChatID string
}
type contactRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Subject string `json:"subject"`
Message string `json:"message"`
}
var (
mu sync.Mutex
lastSubmissionTime = make(map[string]time.Time)
submissionCooldown = 2 * time.Minute
)
func getEnv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}
func escapeMarkdown(text string) string {
if text == "" {
return "N/A"
}
replacer := strings.NewReplacer(
"*", "\\*",
"_", "\\_",
"`", "\\`",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"~", "\\~",
">", "\\>",
"#", "\\#",
"+", "\\+",
"-", "\\-",
"=", "\\=",
"|", "\\|",
"{", "\\{",
"}", "\\}",
".", "\\.",
"!", "\\!",
)
return replacer.Replace(text)
}
func sendToTelegram(cfg Config, payload contactRequest) error {
if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" {
return nil
}
message := fmt.Sprintf(
"📨 New contact request\n\n"+
"👤 *Name:* %s\n"+
"📧 *Email:* %s\n"+
"📝 *Subject:* %s\n\n"+
"💬 *Message:*\n%s",
escapeMarkdown(payload.Name),
escapeMarkdown(payload.Email),
escapeMarkdown(payload.Subject),
escapeMarkdown(payload.Message),
)
telegramURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramBotToken)
body := map[string]any{
"chat_id": cfg.TelegramChatID,
"text": message,
"parse_mode": "Markdown",
}
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal telegram body: %w", err)
}
resp, err := http.Post(telegramURL, "application/json", bytes.NewBuffer(data))
if err != nil {
return fmt.Errorf("telegram post failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("telegram api error: status=%d body=%s", resp.StatusCode, string(b))
}
return nil
}
func contactHandler(cfg Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
ip := getClientIP(r)
mu.Lock()
if last, ok := lastSubmissionTime[ip]; ok {
if time.Since(last) < submissionCooldown {
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Please wait a few minutes before sending another message.",
})
return
}
}
mu.Unlock()
var req contactRequest
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "application/json") {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
} else {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
req = contactRequest{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Subject: strings.TrimSpace(r.FormValue("subject")),
Message: strings.TrimSpace(r.FormValue("message")),
}
}
if req.Name == "" || req.Email == "" || req.Subject == "" || req.Message == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Missing required fields.",
})
return
}
if err := sendToTelegram(cfg, req); err != nil {
log.Printf("telegram send failed: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": false,
"message": "Failed to send message. Please try again later.",
})
return
}
mu.Lock()
lastSubmissionTime[ip] = time.Now()
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": "Thank you for your message!",
})
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"healthy","service":"chemistrysoftware"}`))
}
func main() {
cfg := Config{
Port: ":" + getEnv("PORT", "8080"),
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""),
}
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/contact", contactHandler(cfg))
mux.HandleFunc("/health", healthHandler)
// Static assets
staticFS := http.FileServer(http.Dir("static"))
mux.Handle("/static/", http.StripPrefix("/static/", staticFS))
// Root page
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, "static/index.html")
return
}
http.NotFound(w, r)
})
log.Printf("Server starting on %s", cfg.Port)
log.Fatal(http.ListenAndServe(cfg.Port, mux))
}
+3 -3
View File
@@ -11,10 +11,10 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="/static/styles.css">
<!-- Favicon -->
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>
<body>
@@ -618,6 +618,6 @@
</footer>
<!-- JavaScript -->
<script src="script.js"></script>
<script src="/static/script.js"></script>
</body>
</html>
+36 -17
View File
@@ -59,25 +59,44 @@ if (themeToggle) {
// Contact form submission
const contactForm = document.getElementById('contact-form');
if (contactForm) {
contactForm.addEventListener('submit', (e) => {
contactForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Get form data
const formData = {
name: document.getElementById('name').value,
email: document.getElementById('email').value,
subject: document.getElementById('subject').value,
message: document.getElementById('message').value
const submitButton = contactForm.querySelector('button[type="submit"]');
const originalText = submitButton ? submitButton.textContent : '';
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
}
const payload = {
name: document.getElementById('name').value.trim(),
email: document.getElementById('email').value.trim(),
subject: document.getElementById('subject').value.trim(),
message: document.getElementById('message').value.trim()
};
// Here you would typically send the data to a server
console.log('Form submitted:', formData);
// Show success message
alert('Thank you for your message! I\'ll get back to you soon.');
// Reset form
contactForm.reset();
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => ({ ok: false, message: 'Unexpected response' }));
if (!res.ok || data.ok === false) {
alert(data.message || 'Failed to send message. Please try again later.');
} else {
alert('Thank you for your message! I\'ll get back to you soon.');
contactForm.reset();
}
} catch (err) {
alert('Network error. Please try again later.');
} finally {
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
});
}
View File
File renamed without changes.