feat(server): скелет Go-сервера с healthz, readyz и api/v1/meta
- internal/config: конфигурация из env с валидацией и дефолтами (§5.1, §9.1) - internal/httpx: middleware (request id, slog-логи, recover, security-заголовки), единый формат ошибок §8.5 и лимит тела запроса - internal/database: SQLite WAL + busy_timeout + foreign_keys, один writer и пул чтения, goose-миграции embedded, PRAGMA optimize по расписанию - internal/meta: полезная нагрузка /api/v1/meta (версия, фичи, лимиты) - internal/server: маршруты, SPA-раздача собранного клиента, OpenAPI 3.1 - cmd/glchat: точка входа с graceful shutdown и JSON-логами - тесты: конфиг, миграции и идемпотентность, HTTP-эндпоинты, статика, 404/405
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"glchat/internal/httpx"
|
||||
)
|
||||
|
||||
// methodOfPattern splits a Go 1.22 routing pattern such as "GET /api/v1/meta"
|
||||
// into its method and path parts. Patterns without a method apply to all.
|
||||
func methodOfPattern(pattern string) (method, path string) {
|
||||
if i := strings.IndexByte(pattern, ' '); i > 0 {
|
||||
return pattern[:i], pattern[i+1:]
|
||||
}
|
||||
return "", pattern
|
||||
}
|
||||
|
||||
func (s *Server) routeExists(method, path string) bool {
|
||||
for _, pattern := range s.patterns {
|
||||
patternMethod, patternPath := methodOfPattern(pattern)
|
||||
if patternMethod == "" || patternMethod == method {
|
||||
continue
|
||||
}
|
||||
if patternPath == path {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(patternPath, "/") && strings.HasPrefix(path, patternPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reservedPrefixes are handled by the API, the gateway or the file CDN; an
|
||||
// unknown path under them is a real 404 and must not receive the SPA shell.
|
||||
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"}
|
||||
|
||||
func isReservedPath(path string) bool {
|
||||
for _, prefix := range reservedPrefixes {
|
||||
if path == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.routeExists(r.Method, r.URL.Path) {
|
||||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||||
return
|
||||
}
|
||||
if isReservedPath(r.URL.Path) {
|
||||
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
s.static.serve(w, r)
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints
|
||||
// implemented so far. Phase 1 replaces it with a schema generated from typed
|
||||
// handler definitions (AGENT.md 8.1).
|
||||
var openAPIDocument = []byte(`{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "glchat API",
|
||||
"version": "0.1.0",
|
||||
"description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.",
|
||||
"license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" }
|
||||
},
|
||||
"servers": [{ "url": "/api/v1" }],
|
||||
"paths": {
|
||||
"/meta": {
|
||||
"get": {
|
||||
"operationId": "getMeta",
|
||||
"summary": "Instance metadata, API version and feature flags",
|
||||
"tags": ["Meta"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Instance metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Meta" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Meta": {
|
||||
"type": "object",
|
||||
"required": ["name", "version", "api_version", "base_url", "features"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"commit": { "type": "string" },
|
||||
"build_date": { "type": "string" },
|
||||
"api_version": { "type": "string", "enum": ["v1"] },
|
||||
"base_url": { "type": "string" },
|
||||
"files_url": { "type": "string" },
|
||||
"gateway_url": { "type": "string" },
|
||||
"rtc_path": { "type": "string" },
|
||||
"max_upload_size": { "type": "integer", "format": "int64" },
|
||||
"features": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registration_enabled": { "type": "boolean" },
|
||||
"anti_bot_enabled": { "type": "boolean" },
|
||||
"voice_enabled": { "type": "boolean" },
|
||||
"web_push_enabled": { "type": "boolean" },
|
||||
"oauth_enabled": { "type": "boolean" },
|
||||
"passkeys_enabled": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "object",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"details": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *database.DB
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
patterns []string
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
logger: logger,
|
||||
patterns: append([]string(nil), routePatterns...),
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
handler := httpx.Chain(mux,
|
||||
httpx.SecurityHeaders(cfg.FilesDomain),
|
||||
httpx.RequestID,
|
||||
httpx.Logger(logger),
|
||||
httpx.Recoverer(logger),
|
||||
httpx.JSONBodyLimit(1<<20),
|
||||
)
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var routePatterns = []string{
|
||||
"GET /healthz",
|
||||
"GET /readyz",
|
||||
"GET /api/v1/meta",
|
||||
"GET /api/v1/openapi.json",
|
||||
"/",
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET /api/v1/meta", s.handleMeta)
|
||||
mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI)
|
||||
mux.HandleFunc("/", s.handleFallback)
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler { return s.http.Handler }
|
||||
|
||||
func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() }
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) }
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"version": s.cfg.Version,
|
||||
"uptime": time.Since(startedAt).Round(time.Second).String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
checks := map[string]string{"database": "ok"}
|
||||
ready := true
|
||||
if err := s.db.Ready(ctx); err != nil {
|
||||
checks["database"] = "failed"
|
||||
ready = false
|
||||
s.logger.WarnContext(ctx, "readiness check failed", slog.Any("error", err))
|
||||
}
|
||||
status := http.StatusOK
|
||||
state := "ready"
|
||||
if !ready {
|
||||
status = http.StatusServiceUnavailable
|
||||
state = "unavailable"
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]any{
|
||||
"status": state,
|
||||
"version": s.cfg.Version,
|
||||
"checks": checks,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg))
|
||||
}
|
||||
|
||||
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(openAPIDocument); err != nil {
|
||||
s.logger.Error("write openapi document", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
var startedAt = time.Now()
|
||||
@@ -0,0 +1,241 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *database.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
cfg := config.Config{
|
||||
Domain: "gl.mhspx.su",
|
||||
WebRoot: filepath.Join("testdata", "web"),
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Version: "v0.1.0-test",
|
||||
Commit: "deadbee",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, db, logger), db
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("status field = %v, want ok", body["status"])
|
||||
}
|
||||
if body["version"] != "v0.1.0-test" {
|
||||
t.Errorf("version field = %v", body["version"])
|
||||
}
|
||||
if rec.Header().Get("X-Request-Id") == "" {
|
||||
t.Error("X-Request-Id header is missing")
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("security headers are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzReportsDatabase(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Checks map[string]string `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Status != "ready" {
|
||||
t.Errorf("status = %q, want ready", body.Status)
|
||||
}
|
||||
if body.Checks["database"] != "ok" {
|
||||
t.Errorf("database check = %q, want ok", body.Checks["database"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzFailsWhenDatabaseClosed(t *testing.T) {
|
||||
srv, db := newTestServer(t)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaEndpoint(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.APIVersion != "v1" {
|
||||
t.Errorf("api_version = %q, want v1", body.APIVersion)
|
||||
}
|
||||
if body.BaseURL != "https://gl.mhspx.su" {
|
||||
t.Errorf("base_url = %q", body.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentIsServed(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("openapi document is not valid json: %v", err)
|
||||
}
|
||||
if doc["openapi"] != "3.1.0" {
|
||||
t.Errorf("openapi version = %v, want 3.1.0", doc["openapi"])
|
||||
}
|
||||
if _, ok := doc["paths"].(map[string]any)["/meta"]; !ok {
|
||||
t.Error("openapi document does not describe /meta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAPIRouteUsesErrorEnvelope(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/does-not-exist", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error envelope: %v", err)
|
||||
}
|
||||
if body.Error.Code == "" || body.Error.Message == "" {
|
||||
t.Errorf("error envelope is incomplete: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesWebClientWithSPAFallback(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantSubstr string
|
||||
wantHeaders map[string]string
|
||||
}{
|
||||
{path: "/", wantStatus: http.StatusOK, wantSubstr: "glchat"},
|
||||
{path: "/app/guild/1/channel/2", wantStatus: http.StatusOK, wantSubstr: "glchat"},
|
||||
{path: "/assets/app.js", wantStatus: http.StatusOK, wantSubstr: "console.log"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.wantStatus)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), tc.wantSubstr) {
|
||||
t.Errorf("GET %s body = %q, want it to contain %q", tc.path, rec.Body.String(), tc.wantSubstr)
|
||||
}
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
|
||||
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
|
||||
t.Errorf("assets Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeWebClientReportsMissingBundle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := New(cfg, db, logger)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503 when the bundle is absent", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodNotAllowedOnMeta(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/meta", nil))
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// staticHandler serves the built web client with SPA fallback: unknown paths
|
||||
// return index.html so client-side routes survive a page reload.
|
||||
type staticHandler struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func newStaticHandler(root string) *staticHandler {
|
||||
return &staticHandler{root: root}
|
||||
}
|
||||
|
||||
func (h *staticHandler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
cleaned := path.Clean("/" + r.URL.Path)
|
||||
rel := strings.TrimPrefix(cleaned, "/")
|
||||
if rel == "" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := fs.Stat(os.DirFS(h.root), rel)
|
||||
if err != nil || info.IsDir() {
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if contentType := mime.TypeByExtension(filepath.Ext(rel)); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if strings.HasPrefix(rel, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(h.root, filepath.FromSlash(rel)))
|
||||
}
|
||||
|
||||
func (h *staticHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
index := filepath.Join(h.root, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
writeClientNotBuilt(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, index)
|
||||
}
|
||||
|
||||
func writeClientNotBuilt(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("glchat: web client bundle is not present in this image\n"))
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
console.log("app");
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><html><body><div id="root">glchat</div></body></html>
|
||||
Reference in New Issue
Block a user