Browse Source

Handle missing links gracefully. (#112)

Herein, we do two things:

1- implement a custom handler for the virtual filesystem that,
   rather than returning a simple (and ugly) 404 page, redirects
   the client back to the root URL with the `customUrl` query
   parameter filled out with the value of the request path.

2- In home.js, if the `customUrl` param is filled out, automatically
   select the `custom` state setting, and pre-fill out the CustomID
   input field with the value of that param.

In short, the server will never again return a 404 error, but instead
will gracefully prompt the user to fill in the missing link.
dependabot/npm_and_yarn/web/prismjs-1.21.0
memory 7 years ago
committed by Max Schmitt
parent
commit
55abb0b7ff
  1. 61
      internal/handlers/handlers.go
  2. 4
      internal/handlers/public_test.go
  3. 41
      web/src/Home/Home.js

61
internal/handlers/handlers.go

@ -166,14 +166,65 @@ func (h *Handler) setHandlers() error {
// Handling the shorted URLs, if no one exists, it checks // Handling the shorted URLs, if no one exists, it checks
// in the filesystem and sets headers for caching // in the filesystem and sets headers for caching
h.engine.NoRoute(h.handleAccess, func(c *gin.Context) { h.engine.NoRoute(
c.Header("Vary", "Accept-Encoding") h.handleAccess, // look up shortcuts
c.Header("Cache-Control", "public, max-age=2592000") func(c *gin.Context) { // no shortcut found, prep response for FS
c.Header("ETag", util.VersionInfo.Commit) c.Header("Vary", "Accept-Encoding")
}, gin.WrapH(http.FileServer(FS(false)))) c.Header("Cache-Control", "public, max-age=2592000")
c.Header("ETag", util.VersionInfo.Commit)
},
// Pass down to the embedded FS, but let 404s escape via
// the interceptHandler.
gin.WrapH(interceptHandler(http.FileServer(FS(false)), customErrorHandler)),
// not in FS; redirect to root with customURL target filled out
func(c *gin.Context) {
// if we get to this point we should not let the client cache
c.Header("Cache-Control", "no-cache, no-store")
c.Redirect(http.StatusTemporaryRedirect, "/?customUrl="+c.Request.URL.Path[1:])
})
return nil return nil
} }
type interceptResponseWriter struct {
http.ResponseWriter
errH func(http.ResponseWriter, int)
}
func (w *interceptResponseWriter) WriteHeader(status int) {
if status >= http.StatusBadRequest {
w.errH(w.ResponseWriter, status)
w.errH = nil
} else {
w.ResponseWriter.WriteHeader(status)
}
}
type errorHandler func(http.ResponseWriter, int)
func (w *interceptResponseWriter) Write(p []byte) (n int, err error) {
if w.errH == nil {
return len(p), nil
}
return w.ResponseWriter.Write(p)
}
func interceptHandler(next http.Handler, errH errorHandler) http.Handler {
if errH == nil {
errH = customErrorHandler
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&interceptResponseWriter{w, errH}, r)
})
}
func customErrorHandler(w http.ResponseWriter, status int) {
// let 404s fall through: the next NoRoute handler will redirect
// them back to the main page with the customURL box filled out.
if status != 404 {
http.Error(w, "error", status)
}
}
// Listen starts the http server // Listen starts the http server
func (h *Handler) Listen() error { func (h *Handler) Listen() error {
return h.engine.Run(util.GetConfig().ListenAddr) return h.engine.Run(util.GetConfig().ListenAddr)

4
internal/handlers/public_test.go

@ -275,8 +275,8 @@ func TestHandleDeletion(t *testing.T) {
t.Fatalf("could not send visit request: %v", err) t.Fatalf("could not send visit request: %v", err)
} }
fmt.Println(body.URL) fmt.Println(body.URL)
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status: %d; got: %d", http.StatusNotFound, resp.StatusCode) t.Fatalf("expected status: %d; got: %d", http.StatusOK, resp.StatusCode)
} }
} }

41
web/src/Home/Home.js

@ -10,21 +10,31 @@ import CustomCard from '../Card/Card'
import './Home.css' import './Home.css'
export default class HomeComponent extends Component { export default class HomeComponent extends Component {
constructor(props) {
super(props);
this.urlParams = new URLSearchParams(window.location.search);
this.state = {
links: [],
usedSettings: this.urlParams.get('customUrl') ? ['custom'] : [],
customID: this.urlParams.get('customUrl') ? this.urlParams.get('customUrl') : '',
showCustomIDError: false,
expiration: null
}
}
handleURLChange = (e, { value }) => this.url = value handleURLChange = (e, { value }) => this.url = value
handlePasswordChange = (e, { value }) => this.password = value handlePasswordChange = (e, { value }) => this.password = value
handleCustomExpirationChange = expire => this.setState({ expiration: expire }) handleCustomExpirationChange = expire => this.setState({ expiration: expire })
handleCustomIDChange = (e, { value }) => { handleCustomIDChange = (e, { value }) => {
this.customID = value this.setState({customID: value})
util.lookupEntry(value, () => this.setState({ showCustomIDError: true }), () => this.setState({ showCustomIDError: false })) util.lookupEntry(value, () => this.setState({ showCustomIDError: true }), () => this.setState({ showCustomIDError: false }))
} }
onSettingsChange = (e, { value }) => this.setState({ usedSettings: value }) onSettingsChange = (e, { value }) => {
this.setState({ usedSettings: value })
state = {
links: [],
usedSettings: [],
showCustomIDError: false,
expiration: null
} }
componentDidMount() { componentDidMount() {
this.urlInput.focus() this.urlInput.focus()
} }
@ -32,7 +42,7 @@ export default class HomeComponent extends Component {
if (!this.state.showCustomIDError) { if (!this.state.showCustomIDError) {
util.createEntry({ util.createEntry({
URL: this.url, URL: this.url,
ID: this.customID, ID: this.state.customID,
Expiration: this.state.usedSettings.includes("expire") && this.state.expiration ? this.state.expiration.toISOString() : undefined, Expiration: this.state.usedSettings.includes("expire") && this.state.expiration ? this.state.expiration.toISOString() : undefined,
Password: this.state.usedSettings.includes("protected") && this.password ? this.password : undefined Password: this.state.usedSettings.includes("protected") && this.password ? this.password : undefined
}, r => this.setState({ }, r => this.setState({
@ -56,13 +66,18 @@ export default class HomeComponent extends Component {
return ( return (
<div> <div>
<Segment raised> <Segment raised>
<Header size='huge'>Simplify your links</Header> {this.urlParams.get("customUrl") && (
<Header size='medium'>I don't have a link named <em>"{this.urlParams.get("customUrl")}"</em> in my database, would
you like to create one?</Header>
) ||
<Header size='huge'>Simplify your links</Header>
}
<Form onSubmit={this.handleURLSubmit} autoComplete="off"> <Form onSubmit={this.handleURLSubmit} autoComplete="off">
<Form.Field> <Form.Field>
<Input required size='large' type='url' ref={input => this.urlInput = input} onChange={this.handleURLChange} placeholder='Paste a link to shorten it' action> <Input required size='large' type='url' ref={input => this.urlInput = input} onChange={this.handleURLChange} placeholder='Paste a link to shorten it' action>
<input /> <input />
<MediaQuery query="(min-width: 768px)"> <MediaQuery query="(min-width: 768px)">
<Select options={options} placeholder='Settings' onChange={this.onSettingsChange} multiple /> <Select options={options} placeholder='Settings' value={this.state.usedSettings} onChange={this.onSettingsChange} multiple />
</MediaQuery> </MediaQuery>
<Button type='submit'>Shorten<Icon name="arrow right" /></Button> <Button type='submit'>Shorten<Icon name="arrow right" /></Button>
</Input> </Input>
@ -74,7 +89,7 @@ export default class HomeComponent extends Component {
</MediaQuery> </MediaQuery>
<Form.Group style={{ marginBottom: "1rem" }}> <Form.Group style={{ marginBottom: "1rem" }}>
{usedSettings.includes("custom") && <Form.Field error={showCustomIDError} width={16}> {usedSettings.includes("custom") && <Form.Field error={showCustomIDError} width={16}>
<Input label={window.location.origin + "/"} onChange={this.handleCustomIDChange} placeholder='my-shortened-url' /> <Input label={window.location.origin + "/"} onChange={this.handleCustomIDChange} placeholder='my-shortened-url' value={this.state.customID}/>
</Form.Field>} </Form.Field>}
</Form.Group> </Form.Group>
<Form.Group widths="equal"> <Form.Group widths="equal">
@ -100,4 +115,4 @@ export default class HomeComponent extends Component {
</div > </div >
) )
} }
} }

Loading…
Cancel
Save