Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

server: add a RoutePrefix option #26

Merged
merged 1 commit into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/tailsql/tailsql.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func runLocalService(ctx context.Context, opts tailsql.Options, port int) error
hsrv.Shutdown(context.Background()) // ctx is already terminated
tsql.Close()
}()
log.Printf("Starting local tailsql at http://%s", hsrv.Addr)
log.Printf("Starting local tailsql at http://%s", hsrv.Addr+opts.RoutePrefix)
if err := hsrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
ctrl.Fatalf(err.Error())
}
Expand Down
13 changes: 13 additions & 0 deletions server/tailsql/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ type Options struct {
// Additional links that should be propagated to the UI.
UILinks []UILink `json:"links,omitempty"`

// If set, prepend this prefix to each HTTP route. By default, routes are
// anchored at "/".
RoutePrefix string `json:"routePrefix,omitempty"`

// The maximum timeout for a database query (0 means no timeout).
QueryTimeout Duration `json:"queryTimeout,omitempty"`

Expand Down Expand Up @@ -211,6 +215,15 @@ func (o Options) localState() (*localState, error) {
return newLocalState(db)
}

func (o Options) routePrefix() string {
if o.RoutePrefix != "" {
// Routes are anchored at "/" by default, so remove a trailing "/" if
// there is one. E.g., "/foo/" beomes "/foo", and "/" becomes "".
return strings.TrimSuffix(o.RoutePrefix, "/")
}
return ""
}

func (o Options) logf() logger.Logf {
if o.Logf == nil {
return log.Printf
Expand Down
4 changes: 2 additions & 2 deletions server/tailsql/static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Params, Area, Cycle, Loop } from './sprite.js';
const dlButton = document.getElementById("dl-button");
const qform = document.getElementById('qform');
const output = document.getElementById('output');
const origin = document.location.origin;
const base = document.location.origin + document.location.pathname;
const sources = document.getElementById('sources');
const body = document.getElementById('tsql');

Expand Down Expand Up @@ -100,8 +100,8 @@ import { Params, Area, Cycle, Loop } from './sprite.js';
dlButton.addEventListener("click", (evt) => {
var fd = new FormData(qform);
var sp = new URLSearchParams(fd);
var href = base + "csv?" + sp.toString();

var href = origin + "/csv?" + sp.toString();
performDownload('query.csv', href);
});

Expand Down
4 changes: 2 additions & 2 deletions server/tailsql/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ div.action {
.action .ctrl:before {
display: none;
}
.action .ctrl:-webkit-details-marker {
.action .ctrl::-webkit-details-marker {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unrelated syntax issue I just happened to notice while testing.

display: none;
}

Expand All @@ -195,6 +195,6 @@ div.action {
position: relative;
height: 32px;
width: 32px;
background: url('/static/nut.png') 0px 0px;
background: url('nut.png') 0px 0px;
visibility: hidden;
}
18 changes: 12 additions & 6 deletions server/tailsql/tailsql.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ type Server struct {
state *localState // local state database (for query logs)
self string // if non-empty, the local state source label
links []UILink
prefix string
rules []UIRewriteRule
authorize func(string, *apitype.WhoIsResponse) error
qcheck func(Query) (Query, error)
Expand Down Expand Up @@ -160,6 +161,7 @@ func NewServer(opts Options) (*Server, error) {
state: state,
self: opts.LocalSource,
links: opts.UILinks,
prefix: opts.routePrefix(),
rules: opts.UIRewriteRules,
authorize: opts.authorize(),
qcheck: opts.checkQuery(),
Expand Down Expand Up @@ -219,8 +221,11 @@ func (s *Server) Close() error {
// NewMux constructs an HTTP router for the service.
func (s *Server) NewMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/", s.serveUI)
mux.Handle("/static/", http.FileServer(http.FS(staticFS)))
mux.Handle(s.prefix+"/", http.StripPrefix(s.prefix, http.HandlerFunc(s.serveUI)))

// N.B. We have to strip the prefix back off for the static files, since the
// embedded FS thinks it is rooted at "/".
mux.Handle(s.prefix+"/static/", http.StripPrefix(s.prefix, http.FileServer(http.FS(staticFS))))
return mux
}

Expand Down Expand Up @@ -297,10 +302,11 @@ func (s *Server) serveUIInternal(w http.ResponseWriter, r *http.Request, caller

w.Header().Set("Content-Type", "text/html")
data := &uiData{
Query: q.Query,
Source: q.Source,
Sources: s.getHandles(),
Links: s.links,
Query: q.Query,
Source: q.Source,
Sources: s.getHandles(),
Links: s.links,
RoutePrefix: s.prefix,
}
out, err := s.queryContext(r.Context(), caller, q)
if errors.Is(err, errTooManyRows) {
Expand Down
43 changes: 43 additions & 0 deletions server/tailsql/tailsql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,46 @@ type sqlDB struct{ *sql.DB }
func (s sqlDB) Query(ctx context.Context, query string, params ...any) (tailsql.RowSet, error) {
return s.DB.QueryContext(ctx, query, params...)
}

func TestRoutePrefix(t *testing.T) {
s, err := tailsql.NewServer(tailsql.Options{
RoutePrefix: "/sub/dir",
})
if err != nil {
t.Fatalf("NewServer: unexpected error: %v", err)
}
defer s.Close()

hs := httptest.NewServer(s.NewMux())
defer hs.Close()
cli := hs.Client()

t.Run("NotFound", func(t *testing.T) {
rsp, err := cli.Get(hs.URL + "/static/logo.svg")
if err != nil {
t.Fatalf("Get: unexpected error: %v", err)
}
_, err = io.ReadAll(rsp.Body)
rsp.Body.Close()
if err != nil {
t.Errorf("Read body: %v", err)
}
if code := rsp.StatusCode; code != http.StatusNotFound {
t.Errorf("Get: got response %v, want %v", code, http.StatusNotFound)
}
})

t.Run("OK", func(t *testing.T) {
txt := string(mustGet(t, cli, hs.URL+"/sub/dir/static/logo.svg"))
if !strings.HasPrefix(txt, "<svg ") {
t.Errorf("Get logo.svg: output missing SVG prefix:\n%s", txt)
}
})

t.Run("UI", func(t *testing.T) {
txt := string(mustGet(t, cli, hs.URL+"/sub/dir"))
if !strings.Contains(txt, "Tailscale SQL Playground") {
t.Errorf("Get UI: missing expected title:\n%s", txt)
}
})
}
8 changes: 4 additions & 4 deletions server/tailsql/ui.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
<html><head>
<title>Tailscale SQL Playground</title>
<meta charset="utf-8" />
<link rel="stylesheet" type="text/css" href="/static/style.css" />
<link rel="icon" href="/static/favicon.ico" />
<link rel="stylesheet" type="text/css" href="{{.RoutePrefix}}/static/style.css" />
<link rel="icon" href="{{.RoutePrefix}}/static/favicon.ico" />
</head><body id="tsql">

<div class="logo">
<img src="/static/logo.svg" width=64 height=64 />
<img src="{{.RoutePrefix}}/static/logo.svg" width=64 height=64 />
<span>TailSQL</span>
</div>

Expand Down Expand Up @@ -56,6 +56,6 @@
</table></div>
{{end -}}

<script type="module" src="/static/script.js"></script>
<script type="module" src="{{.RoutePrefix}}/static/script.js"></script>
</body>
</html>
13 changes: 7 additions & 6 deletions server/tailsql/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ type LocalClient interface {

// uiData is the concrete type of the data value passed to the UI template.
type uiData struct {
Query string // the original query
Sources []*dbHandle // the available databases
Source string // the selected source
Output *dbResult // query results (may be nil)
Error *string // error results (may be nil)
Links []UILink // static UI links
Query string // the original query
Sources []*dbHandle // the available databases
Source string // the selected source
Output *dbResult // query results (may be nil)
Error *string // error results (may be nil)
Links []UILink // static UI links
RoutePrefix string // for links to the API and static files
}

// Version reports the version string of the currently running binary.
Expand Down