mirror of
https://github.com/gosom/google-maps-scraper.git
synced 2026-09-19 07:27:12 +08:00
097904f2b9
* feat: implement pagination in Web UI (Fixes #86) - Added Count method to JobRepository and Offset to SelectParams - Updated sqlite implementation to support offset and count calculations - Added ListJobs function to Service, keeping All intact for API backward compatibility - Updated getJobs to parse ?page query param and return JobRowsData - Updated HTML templates to support HTMX outerHTML replacements with out-of-band updates * fix: resolve pagination bugs and add tests for #86 * fix: remove HTMX load loop, stray files, add trigger regression test (#86) - index.html: initial tbody uses hx-trigger='load' only (fires once) - job_rows.html: returned tbody uses hx-trigger='every 10s' only (no load) This eliminates the outerHTML reload loop where the inserted element immediately fires 'load' again before the 10s timer. - Remove pr_body.md and results.csv (local artifacts, not part of #86) - Add TestGetJobsHTMXTriggerIsPollingOnly to assert the fix is permanent - Service.All remains unchanged for existing callers * fix: address pagination review feedback Co-authored-by: Giorgos Komninos <admin@gkomninos.com> --------- Co-authored-by: Giorgos Komninos <admin@gkomninos.com>
720 lines
15 KiB
Go
720 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"uuid"
|
|
)
|
|
|
|
//go:embed static
|
|
var static embed.FS
|
|
|
|
const (
|
|
jobsPageLimit = 20
|
|
methodNotAllowedMessage = "Method not allowed"
|
|
)
|
|
|
|
type Server struct {
|
|
tmpl map[string]*template.Template
|
|
srv *http.Server
|
|
svc *Service
|
|
}
|
|
|
|
func New(svc *Service, addr string) (*Server, error) {
|
|
ans := Server{
|
|
svc: svc,
|
|
tmpl: make(map[string]*template.Template),
|
|
srv: &http.Server{
|
|
Addr: addr,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 60 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
MaxHeaderBytes: 1 << 20,
|
|
},
|
|
}
|
|
|
|
staticFS, err := fs.Sub(static, "static")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
mux := http.NewServeMux()
|
|
|
|
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
|
|
mux.HandleFunc("/scrape", ans.scrape)
|
|
mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
|
|
r = requestWithID(r)
|
|
|
|
ans.download(w, r)
|
|
})
|
|
mux.HandleFunc("/delete", func(w http.ResponseWriter, r *http.Request) {
|
|
r = requestWithID(r)
|
|
|
|
ans.delete(w, r)
|
|
})
|
|
mux.HandleFunc("/jobs", ans.getJobs)
|
|
mux.HandleFunc("/view", func(w http.ResponseWriter, r *http.Request) {
|
|
r = requestWithID(r)
|
|
|
|
ans.viewJob(w, r)
|
|
})
|
|
mux.HandleFunc("/", ans.index)
|
|
|
|
// api routes
|
|
mux.HandleFunc("/api/docs", ans.redocHandler)
|
|
mux.HandleFunc("/api/v1/jobs", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
ans.apiScrape(w, r)
|
|
case http.MethodGet:
|
|
ans.apiGetJobs(w, r)
|
|
default:
|
|
ans := apiError{
|
|
Code: http.StatusMethodNotAllowed,
|
|
Message: methodNotAllowedMessage,
|
|
}
|
|
|
|
renderJSON(w, http.StatusMethodNotAllowed, ans)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/jobs/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
r = requestWithID(r)
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
ans.apiGetJob(w, r)
|
|
case http.MethodDelete:
|
|
ans.apiDeleteJob(w, r)
|
|
default:
|
|
ans := apiError{
|
|
Code: http.StatusMethodNotAllowed,
|
|
Message: methodNotAllowedMessage,
|
|
}
|
|
|
|
renderJSON(w, http.StatusMethodNotAllowed, ans)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/api/v1/jobs/{id}/download", func(w http.ResponseWriter, r *http.Request) {
|
|
r = requestWithID(r)
|
|
|
|
if r.Method != http.MethodGet {
|
|
ans := apiError{
|
|
Code: http.StatusMethodNotAllowed,
|
|
Message: methodNotAllowedMessage,
|
|
}
|
|
|
|
renderJSON(w, http.StatusMethodNotAllowed, ans)
|
|
|
|
return
|
|
}
|
|
|
|
ans.download(w, r)
|
|
})
|
|
|
|
handler := securityHeaders(mux)
|
|
ans.srv.Handler = handler
|
|
|
|
tmplsKeys := []string{
|
|
"static/templates/index.html",
|
|
"static/templates/job_rows.html",
|
|
"static/templates/job_row.html",
|
|
"static/templates/job_view.html",
|
|
"static/templates/redoc.html",
|
|
}
|
|
|
|
for _, key := range tmplsKeys {
|
|
tmp, err := template.ParseFS(static, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ans.tmpl[key] = tmp
|
|
}
|
|
|
|
return &ans, nil
|
|
}
|
|
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
go func(shutdownSource context.Context) {
|
|
<-shutdownSource.Done()
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(shutdownSource), 10*time.Second)
|
|
defer cancel()
|
|
|
|
err := s.srv.Shutdown(shutdownCtx)
|
|
if err != nil {
|
|
log.Println(err)
|
|
|
|
return
|
|
}
|
|
|
|
log.Println("server stopped")
|
|
}(ctx)
|
|
|
|
fmt.Fprintf(os.Stderr, "visit http://localhost%s\n", s.srv.Addr)
|
|
|
|
err := s.srv.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type formData struct {
|
|
Name string
|
|
MaxTime string
|
|
Keywords []string
|
|
Language string
|
|
Zoom int
|
|
FastMode bool
|
|
Radius int
|
|
Lat string
|
|
Lon string
|
|
Depth int
|
|
Email bool
|
|
Proxies []string
|
|
}
|
|
|
|
type ctxKey string
|
|
|
|
const idCtxKey ctxKey = "id"
|
|
|
|
func requestWithID(r *http.Request) *http.Request {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
id = r.URL.Query().Get("id")
|
|
}
|
|
|
|
parsed, err := uuid.Parse(id)
|
|
if err == nil {
|
|
r = r.WithContext(context.WithValue(r.Context(), idCtxKey, parsed))
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
func getIDFromRequest(r *http.Request) (uuid.UUID, bool) {
|
|
id, ok := r.Context().Value(idCtxKey).(uuid.UUID)
|
|
|
|
return id, ok
|
|
}
|
|
|
|
//nolint:gocritic // this is used in template
|
|
func (f formData) ProxiesString() string {
|
|
return strings.Join(f.Proxies, "\n")
|
|
}
|
|
|
|
//nolint:gocritic // this is used in template
|
|
func (f formData) KeywordsString() string {
|
|
return strings.Join(f.Keywords, "\n")
|
|
}
|
|
|
|
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
tmpl, ok := s.tmpl["static/templates/index.html"]
|
|
if !ok {
|
|
http.Error(w, "missing tpl", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
data := formData{
|
|
Name: "",
|
|
MaxTime: "10m",
|
|
Keywords: []string{},
|
|
Language: "en",
|
|
Zoom: 15,
|
|
FastMode: false,
|
|
Radius: 10000,
|
|
Lat: "0",
|
|
Lon: "0",
|
|
Depth: 10,
|
|
Email: false,
|
|
}
|
|
|
|
_ = tmpl.Execute(w, data)
|
|
}
|
|
|
|
func (s *Server) scrape(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
newJob := Job{
|
|
ID: uuid.NewV4().String(),
|
|
Name: r.Form.Get("name"),
|
|
Date: time.Now().UTC(),
|
|
Status: StatusPending,
|
|
Data: JobData{},
|
|
}
|
|
|
|
maxTimeStr := r.Form.Get("maxtime")
|
|
|
|
maxTime, err := time.ParseDuration(maxTimeStr)
|
|
if err != nil {
|
|
http.Error(w, "invalid max time", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
if maxTime < time.Minute*3 {
|
|
http.Error(w, "max time must be more than 3m", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
newJob.Data.MaxTime = maxTime
|
|
|
|
keywordsStr, ok := r.Form["keywords"]
|
|
if !ok {
|
|
http.Error(w, "missing keywords", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
keywords := strings.Split(keywordsStr[0], "\n")
|
|
for _, k := range keywords {
|
|
k = strings.TrimSpace(k)
|
|
if k == "" {
|
|
continue
|
|
}
|
|
|
|
newJob.Data.Keywords = append(newJob.Data.Keywords, k)
|
|
}
|
|
|
|
newJob.Data.Lang = r.Form.Get("lang")
|
|
|
|
newJob.Data.Zoom, err = strconv.Atoi(r.Form.Get("zoom"))
|
|
if err != nil {
|
|
http.Error(w, "invalid zoom", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Form.Get("fastmode") == "on" {
|
|
newJob.Data.FastMode = true
|
|
}
|
|
|
|
newJob.Data.Radius, err = strconv.Atoi(r.Form.Get("radius"))
|
|
if err != nil {
|
|
http.Error(w, "invalid radius", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
newJob.Data.Lat = r.Form.Get("latitude")
|
|
newJob.Data.Lon = r.Form.Get("longitude")
|
|
|
|
newJob.Data.Depth, err = strconv.Atoi(r.Form.Get("depth"))
|
|
if err != nil {
|
|
http.Error(w, "invalid depth", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
newJob.Data.Email = r.Form.Get("email") == "on"
|
|
|
|
proxies := strings.Split(r.Form.Get("proxies"), "\n")
|
|
if len(proxies) > 0 {
|
|
for _, p := range proxies {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
|
|
newJob.Data.Proxies = append(newJob.Data.Proxies, p)
|
|
}
|
|
}
|
|
|
|
err = newJob.Validate()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
err = s.svc.Create(r.Context(), &newJob)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if err := s.renderJobsPage(r.Context(), w, 1); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderJobsPage(ctx context.Context, w io.Writer, page int) error {
|
|
tmpl, ok := s.tmpl["static/templates/job_rows.html"]
|
|
if !ok {
|
|
return errors.New("missing tpl")
|
|
}
|
|
|
|
jobPage, err := s.svc.ListJobs(ctx, page, jobsPageLimit)
|
|
if err != nil {
|
|
return fmt.Errorf("list jobs: %w", err)
|
|
}
|
|
|
|
var output bytes.Buffer
|
|
if err := tmpl.Execute(&output, jobPage); err != nil {
|
|
return fmt.Errorf("render jobs: %w", err)
|
|
}
|
|
|
|
if _, err := output.WriteTo(w); err != nil {
|
|
return fmt.Errorf("write jobs: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func pageFromRequest(r *http.Request) int {
|
|
page, err := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if err != nil || page < 1 {
|
|
return 1
|
|
}
|
|
|
|
return page
|
|
}
|
|
|
|
func (s *Server) getJobs(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
if err := s.renderJobsPage(r.Context(), w, pageFromRequest(r)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) download(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
id, ok := getIDFromRequest(r)
|
|
if !ok {
|
|
http.Error(w, "Invalid ID", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
filePath, err := s.svc.GetCSV(ctx, id.String())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
file, err := os.Open(filePath) //nolint:gosec // GetCSV returns a validated path rooted in the configured data directory.
|
|
if err != nil {
|
|
http.Error(w, "Failed to open file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
fileName := filepath.Base(filePath)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
|
|
_, err = io.Copy(w, file)
|
|
if err != nil {
|
|
http.Error(w, "Failed to send file", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *Server) delete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
deleteID, ok := getIDFromRequest(r)
|
|
if !ok {
|
|
http.Error(w, "Invalid ID", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
err := s.svc.Delete(r.Context(), deleteID.String())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if err := s.renderJobsPage(r.Context(), w, pageFromRequest(r)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type apiError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type apiScrapeRequest struct {
|
|
Name string
|
|
JobData
|
|
}
|
|
|
|
type apiScrapeResponse struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
func (s *Server) redocHandler(w http.ResponseWriter, _ *http.Request) {
|
|
tmpl, ok := s.tmpl["static/templates/redoc.html"]
|
|
if !ok {
|
|
http.Error(w, "missing tpl", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
_ = tmpl.Execute(w, nil)
|
|
}
|
|
|
|
func (s *Server) apiScrape(w http.ResponseWriter, r *http.Request) {
|
|
var req apiScrapeRequest
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
ans := apiError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: err.Error(),
|
|
}
|
|
|
|
renderJSON(w, http.StatusUnprocessableEntity, ans)
|
|
|
|
return
|
|
}
|
|
|
|
newJob := Job{
|
|
ID: uuid.NewV4().String(),
|
|
Name: req.Name,
|
|
Date: time.Now().UTC(),
|
|
Status: StatusPending,
|
|
Data: req.JobData,
|
|
}
|
|
|
|
// convert to seconds
|
|
newJob.Data.MaxTime *= time.Second
|
|
|
|
err = newJob.Validate()
|
|
if err != nil {
|
|
ans := apiError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: err.Error(),
|
|
}
|
|
|
|
renderJSON(w, http.StatusUnprocessableEntity, ans)
|
|
|
|
return
|
|
}
|
|
|
|
err = s.svc.Create(r.Context(), &newJob)
|
|
if err != nil {
|
|
ans := apiError{
|
|
Code: http.StatusInternalServerError,
|
|
Message: err.Error(),
|
|
}
|
|
|
|
renderJSON(w, http.StatusInternalServerError, ans)
|
|
|
|
return
|
|
}
|
|
|
|
ans := apiScrapeResponse{
|
|
ID: newJob.ID,
|
|
}
|
|
|
|
renderJSON(w, http.StatusCreated, ans)
|
|
}
|
|
|
|
func (s *Server) apiGetJobs(w http.ResponseWriter, r *http.Request) {
|
|
jobs, err := s.svc.All(r.Context())
|
|
if err != nil {
|
|
apiError := apiError{
|
|
Code: http.StatusInternalServerError,
|
|
Message: err.Error(),
|
|
}
|
|
|
|
renderJSON(w, http.StatusInternalServerError, apiError)
|
|
|
|
return
|
|
}
|
|
|
|
renderJSON(w, http.StatusOK, jobs)
|
|
}
|
|
|
|
func (s *Server) apiGetJob(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := getIDFromRequest(r)
|
|
if !ok {
|
|
apiError := apiError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: "Invalid ID",
|
|
}
|
|
|
|
renderJSON(w, http.StatusUnprocessableEntity, apiError)
|
|
|
|
return
|
|
}
|
|
|
|
job, err := s.svc.Get(r.Context(), id.String())
|
|
if err != nil {
|
|
apiError := apiError{
|
|
Code: http.StatusNotFound,
|
|
Message: http.StatusText(http.StatusNotFound),
|
|
}
|
|
|
|
renderJSON(w, http.StatusNotFound, apiError)
|
|
|
|
return
|
|
}
|
|
|
|
renderJSON(w, http.StatusOK, job)
|
|
}
|
|
|
|
// viewJob renders the map modal fragment for a job, embedding the job's places
|
|
// directly so the client needs no separate data request.
|
|
func (s *Server) viewJob(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, methodNotAllowedMessage, http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
id, ok := getIDFromRequest(r)
|
|
if !ok {
|
|
http.Error(w, "Invalid ID", http.StatusUnprocessableEntity)
|
|
|
|
return
|
|
}
|
|
|
|
places, err := s.svc.GetPlaces(r.Context(), id.String())
|
|
if err != nil {
|
|
if !errors.Is(err, ErrPlacesNotFound) {
|
|
log.Printf("view job %s: %v", id, err) //nolint:gosec // id is a parsed UUID and cannot contain log control characters.
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
// No CSV yet: render the modal with an empty state rather than an error.
|
|
places = []Place{}
|
|
}
|
|
|
|
tmpl, ok := s.tmpl["static/templates/job_view.html"]
|
|
if !ok {
|
|
http.Error(w, "missing tpl", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, places); err != nil {
|
|
log.Printf("view job %s: render: %v", id, err) //nolint:gosec // id is a parsed UUID and cannot contain log control characters.
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
func (s *Server) apiDeleteJob(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := getIDFromRequest(r)
|
|
if !ok {
|
|
apiError := apiError{
|
|
Code: http.StatusUnprocessableEntity,
|
|
Message: "Invalid ID",
|
|
}
|
|
|
|
renderJSON(w, http.StatusUnprocessableEntity, apiError)
|
|
|
|
return
|
|
}
|
|
|
|
err := s.svc.Delete(r.Context(), id.String())
|
|
if err != nil {
|
|
apiError := apiError{
|
|
Code: http.StatusInternalServerError,
|
|
Message: err.Error(),
|
|
}
|
|
|
|
renderJSON(w, http.StatusInternalServerError, apiError)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func renderJSON(w http.ResponseWriter, code int, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
|
|
_ = json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func formatDate(t time.Time) string {
|
|
return t.Format("Jan 02, 2006 15:04:05")
|
|
}
|
|
|
|
func securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
w.Header().Set("Content-Security-Policy",
|
|
"default-src 'self'; "+
|
|
"script-src 'self' cdn.redoc.ly cdnjs.cloudflare.com 'unsafe-inline' 'unsafe-eval'; "+
|
|
"worker-src 'self' blob:; "+
|
|
"style-src 'self' 'unsafe-inline' fonts.googleapis.com cdnjs.cloudflare.com; "+
|
|
"img-src 'self' data: cdn.redoc.ly cdnjs.cloudflare.com *.tile.openstreetmap.org; "+
|
|
"font-src 'self' fonts.gstatic.com; "+
|
|
"connect-src 'self'")
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|