mirror of
https://github.com/chemistry-software/website.git
synced 2026-11-07 08:57:29 +00:00
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:
7 files changed
+352
-22
No files matched your search
+29
@@ -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"]
|
||||
|
||||
|
||||
@@ -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```
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
File renamed without changes.
Reference in new issue
Block a user