Implemented /oauth/signup. T712
This commit is contained in:
parent
a4e373065c
commit
6429d495a2
|
@ -306,12 +306,16 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
LoginUsername string
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
}{
|
||||
pageForReq(app, r),
|
||||
r.FormValue("to"),
|
||||
template.HTML(""),
|
||||
[]template.HTML{},
|
||||
getTempInfo(app, "login-user", r, w),
|
||||
app.Config().SlackOauth.ClientID != "",
|
||||
app.Config().WriteAsOauth.ClientID != "",
|
||||
}
|
||||
|
||||
if earlyError != "" {
|
||||
|
|
|
@ -42,6 +42,8 @@ type (
|
|||
PagesParentDir string `ini:"pages_parent_dir"`
|
||||
KeysParentDir string `ini:"keys_parent_dir"`
|
||||
|
||||
HashSeed string `ini:"hash_seed"`
|
||||
|
||||
Dev bool `ini:"-"`
|
||||
}
|
||||
|
||||
|
|
56
oauth.go
56
oauth.go
|
@ -7,8 +7,6 @@ import (
|
|||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"io"
|
||||
|
@ -137,6 +135,7 @@ func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauth
|
|||
}
|
||||
r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET")
|
||||
r.HandleFunc("/oauth/callback", parentHandler.OAuth(handler.viewOauthCallback)).Methods("GET")
|
||||
r.HandleFunc("/oauth/signup", parentHandler.OAuth(handler.viewOauthSignup)).Methods("POST")
|
||||
}
|
||||
|
||||
func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
|
@ -171,52 +170,31 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
if localUserID == -1 {
|
||||
// We don't have, nor do we want, the password from the origin, so we
|
||||
//create a random string. If the user needs to set a password, they
|
||||
//can do so through the settings page or through the password reset
|
||||
//flow.
|
||||
randPass := store.Generate62RandomString(14)
|
||||
hashedPass, err := auth.HashPass([]byte(randPass))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, "unable to create password hash"}
|
||||
}
|
||||
newUser := &User{
|
||||
Username: tokenInfo.Username,
|
||||
HashedPass: hashedPass,
|
||||
HasPass: true,
|
||||
Email: prepareUserEmail(tokenInfo.Email, h.EmailKey),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
displayName := tokenInfo.DisplayName
|
||||
if len(displayName) == 0 {
|
||||
displayName = tokenInfo.Username
|
||||
}
|
||||
|
||||
err = h.DB.CreateUser(h.Config, newUser, displayName)
|
||||
if localUserID != -1 {
|
||||
user, err := h.DB.GetUserByID(localUserID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
err = h.DB.RecordRemoteUserID(ctx, newUser.ID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
if err := loginOrFail(h.Store, w, r, newUser); err != nil {
|
||||
if err = loginOrFail(h.Store, w, r, user); err != nil {
|
||||
log.Error("Unable to loginOrFail %d: %s", localUserID, err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := h.DB.GetUserByID(localUserID)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
tp := &oauthSignupPageParams{
|
||||
AccessToken: tokenResponse.AccessToken,
|
||||
TokenUsername: tokenInfo.Username,
|
||||
TokenAlias: tokenInfo.DisplayName,
|
||||
TokenEmail: tokenInfo.Email,
|
||||
TokenRemoteUser: tokenInfo.UserID,
|
||||
Provider: provider,
|
||||
ClientID: clientID,
|
||||
}
|
||||
if err = loginOrFail(h.Store, w, r, user); err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
return nil
|
||||
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||
|
||||
return h.showOauthSignupPage(app, w, r, tp, nil)
|
||||
}
|
||||
|
||||
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
|
||||
|
|
|
@ -0,0 +1,209 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type viewOauthSignupVars struct {
|
||||
page.StaticPage
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
|
||||
AccessToken string
|
||||
TokenUsername string
|
||||
TokenAlias string
|
||||
TokenEmail string
|
||||
TokenRemoteUser string
|
||||
Provider string
|
||||
ClientID string
|
||||
TokenHash string
|
||||
|
||||
Username string
|
||||
Alias string
|
||||
Email string
|
||||
}
|
||||
|
||||
const (
|
||||
oauthParamAccessToken = "access_token"
|
||||
oauthParamTokenUsername = "token_username"
|
||||
oauthParamTokenAlias = "token_alias"
|
||||
oauthParamTokenEmail = "token_email"
|
||||
oauthParamTokenRemoteUserID = "token_remote_user"
|
||||
oauthParamClientID = "client_id"
|
||||
oauthParamProvider = "provider"
|
||||
oauthParamHash = "signature"
|
||||
oauthParamUsername = "username"
|
||||
oauthParamAlias = "alias"
|
||||
oauthParamEmail = "email"
|
||||
oauthParamPassword = "password"
|
||||
)
|
||||
|
||||
type oauthSignupPageParams struct {
|
||||
AccessToken string
|
||||
TokenUsername string
|
||||
TokenAlias string
|
||||
TokenEmail string
|
||||
TokenRemoteUser string
|
||||
ClientID string
|
||||
Provider string
|
||||
TokenHash string
|
||||
}
|
||||
|
||||
func (p oauthSignupPageParams) HashTokenParams(key string) string {
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(key))
|
||||
hasher.Write([]byte(p.AccessToken))
|
||||
hasher.Write([]byte(p.TokenUsername))
|
||||
hasher.Write([]byte(p.TokenAlias))
|
||||
hasher.Write([]byte(p.TokenEmail))
|
||||
hasher.Write([]byte(p.TokenRemoteUser))
|
||||
hasher.Write([]byte(p.ClientID))
|
||||
hasher.Write([]byte(p.Provider))
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
tp := &oauthSignupPageParams{
|
||||
AccessToken: r.FormValue(oauthParamAccessToken),
|
||||
TokenUsername: r.FormValue(oauthParamTokenUsername),
|
||||
TokenAlias: r.FormValue(oauthParamTokenAlias),
|
||||
TokenEmail: r.FormValue(oauthParamTokenEmail),
|
||||
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
|
||||
ClientID: r.FormValue(oauthParamClientID),
|
||||
Provider: r.FormValue(oauthParamProvider),
|
||||
}
|
||||
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
|
||||
}
|
||||
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||
if err := h.validateOauthSignup(r); err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
|
||||
hashedPass, err := auth.HashPass([]byte(r.FormValue(oauthParamPassword)))
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, fmt.Errorf("unable to hash password"))
|
||||
}
|
||||
newUser := &User{
|
||||
Username: r.FormValue(oauthParamUsername),
|
||||
HashedPass: hashedPass,
|
||||
HasPass: true,
|
||||
Email: prepareUserEmail(r.FormValue(oauthParamEmail), h.EmailKey),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
displayName := r.FormValue(oauthParamAlias)
|
||||
if len(displayName) == 0 {
|
||||
displayName = r.FormValue(oauthParamUsername)
|
||||
}
|
||||
|
||||
err = h.DB.CreateUser(h.Config, newUser, displayName)
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
|
||||
if err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
|
||||
if err := loginOrFail(h.Store, w, r, newUser); err != nil {
|
||||
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h oauthHandler) validateOauthSignup(r *http.Request) error {
|
||||
username := r.FormValue(oauthParamUsername)
|
||||
if len(username) < 5 {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too short."}
|
||||
}
|
||||
if len(username) > 20 {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too long."}
|
||||
}
|
||||
alias := r.FormValue(oauthParamAlias)
|
||||
if len(alias) < 5 {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Alias is too short."}
|
||||
}
|
||||
if len(alias) > 20 {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Alias is too long."}
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
if len(password) < 5 {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Password is too short."}
|
||||
}
|
||||
email := r.FormValue(oauthParamEmail)
|
||||
if len(email) > 0 {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 || (len(parts[0]) < 1 || len(parts[1]) < 1) {
|
||||
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Invalid email address"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *http.Request, tp *oauthSignupPageParams, errMsg error) error {
|
||||
username := tp.TokenUsername
|
||||
alias := tp.TokenAlias
|
||||
email := tp.TokenEmail
|
||||
|
||||
session, err := app.sessionStore.Get(r, cookieName)
|
||||
if err != nil {
|
||||
// Ignore this
|
||||
log.Error("Unable to get session; ignoring: %v", err)
|
||||
}
|
||||
|
||||
if tmpValue := r.FormValue(oauthParamUsername); len(tmpValue) > 0 {
|
||||
username = tmpValue
|
||||
}
|
||||
if tmpValue := r.FormValue(oauthParamAlias); len(tmpValue) > 0 {
|
||||
alias = tmpValue
|
||||
}
|
||||
if tmpValue := r.FormValue(oauthParamEmail); len(tmpValue) > 0 {
|
||||
email = tmpValue
|
||||
}
|
||||
|
||||
p := &viewOauthSignupVars{
|
||||
StaticPage: pageForReq(app, r),
|
||||
To: r.FormValue("to"),
|
||||
Flashes: []template.HTML{},
|
||||
|
||||
AccessToken: tp.AccessToken,
|
||||
TokenUsername: tp.TokenUsername,
|
||||
TokenAlias: tp.TokenAlias,
|
||||
TokenEmail: tp.TokenEmail,
|
||||
TokenRemoteUser: tp.TokenRemoteUser,
|
||||
Provider: tp.Provider,
|
||||
ClientID: tp.ClientID,
|
||||
TokenHash: tp.TokenHash,
|
||||
|
||||
Username: username,
|
||||
Alias: alias,
|
||||
Email: email,
|
||||
}
|
||||
|
||||
// Display any error messages
|
||||
flashes, _ := getSessionFlashes(app, w, r, session)
|
||||
for _, flash := range flashes {
|
||||
p.Flashes = append(p.Flashes, template.HTML(flash))
|
||||
}
|
||||
if errMsg != nil {
|
||||
p.Flashes = append(p.Flashes, template.HTML(errMsg.Error()))
|
||||
}
|
||||
err = pages["signup-oauth.tmpl"].ExecuteTemplate(w, "base", p)
|
||||
if err != nil {
|
||||
log.Error("Unable to render signup-oauth: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -3,6 +3,8 @@ package writefreely
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/slug"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -151,7 +153,7 @@ func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessTok
|
|||
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
|
||||
return &InspectResponse{
|
||||
UserID: resp.User.ID,
|
||||
Username: slug.Make(resp.User.Name),
|
||||
Username: fmt.Sprintf("%s-%s", slug.Make(resp.User.Name), store.Generate62RandomString(5)),
|
||||
DisplayName: resp.User.Name,
|
||||
Email: resp.User.Email,
|
||||
}
|
||||
|
|
|
@ -1,163 +1,38 @@
|
|||
{{define "head"}}
|
||||
<title>Sign up — {{.SiteName}}</title>
|
||||
|
||||
<style type="text/css">
|
||||
h2 {
|
||||
font-weight: normal;
|
||||
}
|
||||
#pricing.content-container div.form-container #payment-form {
|
||||
display: block !important;
|
||||
}
|
||||
#pricing #signup-form table {
|
||||
max-width: inherit !important;
|
||||
width: 100%;
|
||||
}
|
||||
#pricing #payment-form table {
|
||||
margin-top: 0 !important;
|
||||
max-width: inherit !important;
|
||||
width: 100%;
|
||||
}
|
||||
tr.subscription {
|
||||
border-spacing: 0;
|
||||
}
|
||||
#pricing.content-container tr.subscription button {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
width: 100%;
|
||||
}
|
||||
#pricing tr.subscription td {
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
#pricing table.billing > tbody > tr > td:first-child {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
.billing-section {
|
||||
display: none;
|
||||
}
|
||||
.billing-section.bill-me {
|
||||
display: table-row;
|
||||
}
|
||||
#btn-create {
|
||||
color: white !important;
|
||||
}
|
||||
#total-price {
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
#alias-site.demo {
|
||||
color: #999;
|
||||
}
|
||||
#alias-site {
|
||||
text-align: left;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
form dd {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||
<meta name="description" content="Log in to {{.SiteName}}.">
|
||||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||
<style>input{margin-bottom:0.5em;}</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
<div id="pricing" class="content-container wide-form">
|
||||
<div class="tight content-container">
|
||||
<h1>Log in to {{.SiteName}}</h1>
|
||||
|
||||
<div class="row">
|
||||
<div style="margin: 0 auto; max-width: 25em;">
|
||||
<h1>Sign up</h1>
|
||||
{{if .Flashes}}<ul class="errors">
|
||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
</ul>{{end}}
|
||||
|
||||
{{ if .Error }}
|
||||
<p style="font-style: italic">{{.Error}}</p>
|
||||
{{ else }}
|
||||
{{if .Flashes}}<ul class="errors">
|
||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
</ul>{{end}}
|
||||
<form action="/oauth/signup" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
||||
<input type="hidden" name="access_token" value="{{ .AccessToken }}" />
|
||||
<input type="hidden" name="token_username" value="{{ .TokenUsername }}" />
|
||||
<input type="hidden" name="token_alias" value="{{ .TokenAlias }}" />
|
||||
<input type="hidden" name="token_email" value="{{ .TokenEmail }}" />
|
||||
<input type="hidden" name="token_remote_user" value="{{ .TokenRemoteUser }}" />
|
||||
<input type="hidden" name="provider" value="{{ .Provider }}" />
|
||||
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
|
||||
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
|
||||
|
||||
<div id="billing">
|
||||
<form action="/auth/signup-oauth" method="POST" id="signup-form" onsubmit="return signup()">
|
||||
<input type="hidden" name="provider" value="{{.Provider}}" />
|
||||
<input type="hidden" name="access_token" value="{{.AccessToken}}" />
|
||||
<dl class="billing">
|
||||
<label>
|
||||
<dt>Username</dt>
|
||||
<dd>
|
||||
<input type="text" id="alias" name="alias" style="width: 100%; box-sizing: border-box;" tabindex="1" autofocus />
|
||||
{{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
|
||||
</dd>
|
||||
</label>
|
||||
<label>
|
||||
<dt>Email (optional)</dt>
|
||||
<dd><input type="email" name="email" id="email" style="letter-spacing: 1px; width: 100%; box-sizing: border-box;" placeholder="me@example.com" tabindex="3" /></dd>
|
||||
</label>
|
||||
<dt>
|
||||
<button id="btn-create" type="submit" style="margin-top: 0">Create blog</button>
|
||||
</dt>
|
||||
</dl>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" name="username" placeholder="Username" value="{{.Username}}" /><br />
|
||||
<input type="text" name="alias" placeholder="Alias"{{ if .Alias }} value="{{.Alias}}"{{ end }} /><br />
|
||||
<input type="text" name="email" placeholder="Email"{{ if .Email }} value="{{.Email}}"{{ end }} /><br />
|
||||
<input type="password" name="password" placeholder="Password" /><br />
|
||||
<input type="submit" id="btn-login" value="Login" />
|
||||
</form>
|
||||
|
||||
<script type="text/javascript" src="/js/h.js"></script>
|
||||
<script type="text/javascript">
|
||||
function signup() {
|
||||
// Validate input
|
||||
if (!aliasOK) {
|
||||
var $a = $alias;
|
||||
$a.el.className = 'error';
|
||||
$a.el.focus();
|
||||
$a.el.scrollIntoView();
|
||||
return false;
|
||||
}
|
||||
|
||||
var $btn = document.getElementById('btn-create');
|
||||
function disableSubmit() {
|
||||
var $btn = document.getElementById("btn-login");
|
||||
$btn.value = "Logging in...";
|
||||
$btn.disabled = true;
|
||||
$btn.value = 'Creating...';
|
||||
return true;
|
||||
}
|
||||
|
||||
var $alias = H.getEl('alias');
|
||||
var $aliasSite = document.getElementById('alias-site');
|
||||
var aliasOK = true;
|
||||
var typingTimer;
|
||||
var doneTypingInterval = 750;
|
||||
var doneTyping = function() {
|
||||
// Check on username
|
||||
var alias = $alias.el.value;
|
||||
if (alias != "") {
|
||||
var params = {
|
||||
username: alias
|
||||
};
|
||||
var http = new XMLHttpRequest();
|
||||
http.open("POST", '/api/alias', true);
|
||||
|
||||
// Send the proper header information along with the request
|
||||
http.setRequestHeader("Content-type", "application/json");
|
||||
|
||||
http.onreadystatechange = function() {
|
||||
if (http.readyState == 4) {
|
||||
data = JSON.parse(http.responseText);
|
||||
if (http.status == 200) {
|
||||
aliasOK = true;
|
||||
$alias.removeClass('error');
|
||||
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, '');
|
||||
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
|
||||
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
|
||||
} else {
|
||||
aliasOK = false;
|
||||
$alias.setClass('error');
|
||||
$aliasSite.className = 'error';
|
||||
$aliasSite.textContent = data.error_msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
http.send(JSON.stringify(params));
|
||||
} else {
|
||||
$aliasSite.className += ' demo';
|
||||
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}';
|
||||
}
|
||||
};
|
||||
$alias.on('keyup input', function() {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
|
|
Loading…
Reference in New Issue