-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.go
More file actions
235 lines (209 loc) · 9.67 KB
/
Copy pathserver.go
File metadata and controls
235 lines (209 loc) · 9.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
package graph
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/obcode/plexams.go/graph/generated"
"github.com/obcode/plexams.go/plexams"
"github.com/rs/cors"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/vektah/gqlparser/v2/ast"
)
// healthPath is the liveness endpoint the container health check calls. It is
// exempt from authentication (graph/auth.go) and is not routed to this backend
// by the reverse proxy, so it is only reachable from inside the compose network.
const healthPath = "/healthz"
// defaultAllowedOrigins are the local dev frontends permitted for CORS and
// websocket upgrades when none are configured. plexams is a local single-user
// tool, so only localhost is allowed by default. Override via the config key
// server.allowedorigins (list of full origin URLs).
var defaultAllowedOrigins = []string{
"http://localhost:5173",
"http://localhost:8080",
"http://localhost:3000",
}
// allowedOriginsFromConfig returns the configured CORS origins, or the defaults.
func allowedOriginsFromConfig() []string {
if o := viper.GetStringSlice("server.allowedorigins"); len(o) > 0 {
return o
}
return defaultAllowedOrigins
}
func StartServer(plexams *plexams.Plexams, port string) {
plexamsResolver := NewResolver(plexams)
origins := allowedOriginsFromConfig()
originSet := make(map[string]bool, len(origins))
for _, o := range origins {
originSet[o] = true
}
c := generated.Config{Resolvers: plexamsResolver}
srv := handler.New(generated.NewExecutableSchema(c))
srv.AddTransport(transport.POST{})
// Websocket transport carries GraphQL subscriptions (e.g. the streamed
// output of long-running operations like invigilation generation).
srv.AddTransport(transport.Websocket{
KeepAlivePingInterval: 10 * time.Second,
Upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
// No Origin header → not a browser cross-origin request (same-origin,
// or a server-side/proxied client behind the reverse proxy). Browsers
// always send Origin on a cross-origin upgrade, so an empty Origin can
// never be a cross-site websocket hijack — allow it.
if origin == "" {
return true
}
if originSet[origin] {
return true
}
// Log the rejected origin: gqlgen does not surface it, so a config
// mismatch (server.allowedorigins vs the real host) is otherwise a
// silent "request origin not allowed" with no clue what to fix.
log.Warn().Str("origin", origin).Strs("allowed", origins).
Msg("websocket upgrade rejected: origin not in server.allowedorigins")
return false
},
},
})
// In production the GraphQL introspection is turned off (server.production=true);
// locally it stays on for the playground and tooling.
production := viper.GetBool("server.production")
if !production {
srv.Use(extension.Introspection{})
}
// Block write mutations while any validation subscription is running, so the
// GUI cannot mutate the plan underneath a running check.
srv.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
oc := graphql.GetOperationContext(ctx)
if oc.Operation != nil {
op := oc.Operation.Operation
// Authorization: a read-only role (VIEWER) may query and run validations
// but must not perform any data-changing operation. Enforced in the backend
// — the GUI is never the security boundary.
if user := UserFromContext(ctx); user != nil && !roleCanWrite(user.Role) && isDataChangingOperation(oc) {
return graphql.OneShot(graphql.ErrorResponse(ctx, "forbidden: your role is read-only"))
}
if op == ast.Mutation && !plexams.WritesAllowed() {
return graphql.OneShot(graphql.ErrorResponse(ctx, "writes are blocked while a validation is running"))
}
// read-only database: reject data-changing operations, but allow queries,
// validations, switching the semester and toggling read-only.
if plexams.IsReadOnly() && isDataChangingOperation(oc) {
return graphql.OneShot(graphql.ErrorResponse(ctx, "semester is read-only"))
}
}
return next(ctx)
})
// Log every mutating operation (mutations + data-changing subscriptions) to the
// per-semester mutation_log collection.
srv.AroundFields(mutationLogMiddleware(plexams))
// Mark the cached assembled exams stale when an input changes (for the GUI banner).
srv.AroundFields(assembledExamsDirtyMiddleware(plexams))
// Mark the prepared student regs stale when an input changes (for the GUI banner).
srv.AroundFields(studentRegsDirtyMiddleware(plexams))
// srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: plexamsResolver}))
router := chi.NewRouter()
router.Use(cors.New(cors.Options{
AllowedOrigins: origins,
AllowCredentials: true,
Debug: false,
}).Handler)
// Authenticate/authorize every request (GraphQL, websocket upgrade, REST routes)
// from the auth-proxy header, or inject a local dev user when auth is disabled.
// Runs after CORS so preflight OPTIONS are short-circuited before reaching it.
router.Use(authMiddleware(plexams))
// The GraphQL playground is a dev convenience; disabled in production (the GUI is
// served by the reverse proxy there, not by this backend).
if !production {
router.Handle("/", playground.Handler("GraphQL playground", "/query"))
}
router.Handle("/query", srv)
// Liveness for the container health check, which is what makes a deploy fail
// loudly instead of quietly: the server migrates the schema at startup and
// exits on a migration error, `restart: unless-stopped` turns that into a
// crash loop, and `docker compose up -d` alone would still report success.
//
// Deliberately unauthenticated (see authMiddleware) and deliberately NOT a
// readiness check: the process is only listening once the database is up, so
// there is nothing left to probe that a 200 does not already say.
router.Get(healthPath, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
// Binary uploads (browser-generated PNGs, cover-page PDF ZIPs) for email
// attachments; the send subscriptions read them back from the DB.
router.Post("/upload/email-attachment", plexams.HTTPUploadEmailAttachment)
router.Post("/upload/email-attachments-zip", plexams.HTTPUploadEmailAttachmentsZip)
router.Post("/upload/primuss-zip", plexams.HTTPUploadPrimussZip)
// Attach an uploaded file (PDF/CSV/…) to a Jira issue (multipart: key, file).
router.Post("/upload/jira-attachment", plexams.HTTPUploadJiraAttachment)
router.Get("/download/planned-rooms.json", plexams.HTTPDownloadPlannedRooms)
// Generated documents (formerly the pdf/csv/ics CLI commands): draft plans and
// exports for the faculties/examers. Read-only, so no write gating.
router.Get("/download/pdf/{kind}", plexams.HTTPDownloadPDF)
router.Get("/download/csv/{kind}", plexams.HTTPDownloadCSVDraft)
router.Get("/download/ics/{program}", plexams.HTTPDownloadICS)
// Human-readable CSV of the manually entered data (absolute date/time, robust
// against exam-period shifts): per-dataset and a combined "my inputs" ZIP.
router.Get("/download/dataset-csv", plexams.HTTPDownloadDatasetCSV)
router.Get("/download/my-inputs-csv.zip", plexams.HTTPDownloadMyInputsCSV)
router.Post("/upload/dataset-csv", plexams.HTTPUploadDatasetCSV)
server := &http.Server{Addr: fmt.Sprintf(":%s", port), Handler: router}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
// The nightly auto-sync scheduler shares the server lifetime. Its context is a
// fallback stop; the primary, graceful stop is sched.Shutdown below, which drains an
// in-flight run instead of cutting it off.
schedulerCtx, cancelScheduler := context.WithCancel(context.Background())
defer cancelScheduler()
sched := startScheduledSync(schedulerCtx, plexams)
// A second, independent scheduler for the daily admin-overview digest (nil when disabled).
digestSched := startAdminDigestMail(schedulerCtx, plexams)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal().Err(err).Msg("Startup failed")
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Info().Msg("Server will be shut down.")
if sched != nil {
// Drain a running auto-sync gracefully (bounded); on timeout it is cancelled
// cooperatively. A cut-off run is safe: the sync is idempotent and self-heals
// via the next nightly run plus the startup catch-up.
drainCtx, cancelDrain := context.WithTimeout(context.Background(), 30*time.Second)
if err := sched.Shutdown(drainCtx); err != nil {
log.Warn().Err(err).Msg("auto-sync did not drain within grace period")
}
cancelDrain()
}
if digestSched != nil {
// Drain a running digest send gracefully; on timeout it is cancelled cooperatively.
drainCtx, cancelDrain := context.WithTimeout(context.Background(), 30*time.Second)
if err := digestSched.Shutdown(drainCtx); err != nil {
log.Warn().Err(err).Msg("admin digest did not drain within grace period")
}
cancelDrain()
}
cancelScheduler()
// log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
// if err := http.ListenAndServe(":"+port, router); err != nil {
// log.Fatal().Err(err).Msg("fatal error")
// }
}