diff options
| author | sostler <sbostler@gmail.com> | 2010-04-29 03:11:35 -0400 |
|---|---|---|
| committer | sostler <sbostler@gmail.com> | 2010-04-29 03:11:35 -0400 |
| commit | 210d0294b59759c7cccd3d1f7408627cecc7f86a (patch) | |
| tree | 1fa9a192f66d4c78de0e268b56acec5a6ad6f37a | |
| parent | 34869a3f8fb0ac6ed6c17db4a90e28c705829f0d (diff) | |
Password reset feature
| -rw-r--r-- | src/admin.clj | 22 | ||||
| -rw-r--r-- | src/email.clj | 8 | ||||
| -rw-r--r-- | src/image_utils.clj | 3 | ||||
| -rw-r--r-- | src/site.clj | 176 | ||||
| -rw-r--r-- | src/user.clj | 42 | ||||
| -rwxr-xr-x | src/utils.clj | 59 | ||||
| -rw-r--r-- | template/debug.st | 29 | ||||
| -rw-r--r-- | template/mail/reset.st | 6 | ||||
| -rw-r--r-- | template/reset.st | 61 |
9 files changed, 280 insertions, 126 deletions
diff --git a/src/admin.clj b/src/admin.clj index f0b6453..76e690a 100644 --- a/src/admin.clj +++ b/src/admin.clj @@ -4,6 +4,7 @@ (:use compojure email scheduled-agent + user utils)) ;; Muting @@ -105,9 +106,6 @@ WHERE mute_id = ? AND cancelled = false ") -(defn- assert-update [res ok err] - (if (zero? (first res)) err ok)) - (defn cancel-mute! [mute-id admin-id] (let [mute (fetch-mute mute-id) active (nor (:expired mute) (:cancelled mute)) @@ -152,6 +150,9 @@ AND cancelled = false ;; Debug Page +(defn error-header [& text] + (html [:h2 {"color" "red"} text])) + (defn exception-to-string [e] (let [sw (java.io.StringWriter.) pw (java.io.PrintWriter. sw)] @@ -171,6 +172,7 @@ AND cancelled = false st (fetch-template "debug" session)] (.setAttribute st "flash" (:msg flash)) (.setAttribute st "mailtemps" (lookup-templates "template/mail" "welcome")) + (.setAttribute st "resettemps" (lookup-templates "template/mail" "reset")) (.toString st)))) (defn debug-reg-email [session params] @@ -179,14 +181,22 @@ AND cancelled = false (params :template)) (str "Sent registration mail to " (params :to))) -(defn debug-reset-email [session params] - ) +(defn debug-reset-email [session {nick :nick to :to template :template}] + (if-let [info (fetch-nick nick)] + (let [nick (info :nick) + hash (info :hash) + ts (System/currentTimeMillis) + token (reset-token nick hash ts) + link (reset-link nick token ts)] + (do (send-reset-email nick to link template) + (str "Send reset email to " to))) + (error-header "Unknown user " nick))) (def *debug-action-map* {"regemail" debug-reg-email "resetemail" debug-reset-email}) (defn format-unknown-action [action] - (html [:h2 {"color" "red"} ["Unknown action " action]])) + (error-header "Unknown action " action)) (defn format-debug-exception [action e] (html diff --git a/src/email.clj b/src/email.clj index 1124f48..e2a5f1f 100644 --- a/src/email.clj +++ b/src/email.clj @@ -82,9 +82,11 @@ (let [[s b] (parse-mail-template temp {"nick" nick})] (dump-mail [email] s b)))) -(defn send-reset-email [nick email key] - (let [[s b] (parse-mail-template "reset" {"nick" nick "key" key})] - (dump-mail [email] s b))) +(defn send-reset-email + ([nick email link temp] + (let [[s b] (parse-mail-template temp {"nick" nick "link" link})] + (dump-mail [email] s b))) + ([nick email link] (send-reset-email nick email link "reset"))) (defn send-mute-email [user-nick admin-nick reason duration] (let [subject (format "%s was muted by %s for %s" diff --git a/src/image_utils.clj b/src/image_utils.clj deleted file mode 100644 index 638cd05..0000000 --- a/src/image_utils.clj +++ /dev/null @@ -1,3 +0,0 @@ -(ns image-utils - (:import javax.imageio.ImageIO)) - diff --git a/src/site.clj b/src/site.clj index f48f560..7848dcb 100644 --- a/src/site.clj +++ b/src/site.clj @@ -20,7 +20,8 @@ session-sweeper feed tags - scheduled-agent)) + scheduled-agent + user)) (def *run-flusher* true) (def *flusher-sleep* (seconds 4)) @@ -240,12 +241,13 @@ (defn session-assoc-from-db [user-info] - (session-assoc :user_id (user-info :user_id) - :nick (user-info :nick) - :email (user-info :email) - :is_admin (user-info :is_admin) - :avatar (user-info :avatar) - :password_login true)) + (session-assoc + :user_id (user-info :user_id) + :nick (user-info :nick) + :email (user-info :email) + :is_admin (user-info :is_admin) + :avatar (user-info :avatar) + :password_login true)) ;; login-token functions @@ -348,9 +350,9 @@ (> (count (do-select [query (lower-case nick)])) 0)))) (defn register [session params] - (let [nick (params :nick) - email (params :email) - hash (params :hash) + (let [nick (params :nick) + email (params :email) + hash (params :hash) invalid-nick-reason (is-invalid-nick? nick)] (cond invalid-nick-reason (resp-error invalid-nick-reason) (nick-reserved? nick) (resp-error "NICK_TAKEN") @@ -507,43 +509,42 @@ ORDER BY msg_count DESC") "maker" (topic :maker)})) (defn validate-topic-list [session] - (if (is-vip? session) - (let [st (fetch-template "topic_list" session)] - (.setAttribute st "rooms" - (map topic-map-from-room (vals @rooms))) - (.toString st)) - [404 "UNKNOWN_ROOM"])) + (if-vip + (let [st (fetch-template "topic_list" session)] + (.setAttribute st "rooms" + (map topic-map-from-room (vals @rooms))) + (.toString st)))) (defn set-topic! [room topic deadline maker] (dosync (ref-set (room :topic) - {:topic topic + {:topic topic :deadline deadline - :maker maker}))) + :maker maker}))) (defn end-topic! [room] (dosync (ref-set (room :topic) nil))) (defn validate-set-topic [session params] - (let [room (lookup-room (params :room)) - topic (params :topic) - deadline (params :deadline) - maker (params :maker)] - (cond (not (is-vip? session)) (resp-error "NOT_VIP") - (not (valid-topic? topic)) (resp-error "INVALID_TOPIC") - (not (valid-deadline? deadline)) (resp-error "INVALID_DEADLINE") - (not room) (resp-error "INVALID_ROOM") - (not maker) (resp-error "NOT_MAKER") - :else (do - (set-topic! room topic deadline maker) - (resp-success "OK"))))) + (if-vip + (let [room (lookup-room (params :room)) + topic (params :topic) + deadline (params :deadline) + maker (params :maker)] + (cond (not (valid-topic? topic)) (resp-error "INVALID_TOPIC") + (not (valid-deadline? deadline)) (resp-error "INVALID_DEADLINE") + (not room) (resp-error "INVALID_ROOM") + (not maker) (resp-error "NOT_MAKER") + :else (do + (set-topic! room topic deadline maker) + (resp-success "OK")))))) (defn validate-end-topic [session params] - (let [room (lookup-room (params :room))] - (cond (not (is-vip? :is_admin)) (resp-error "NOT_VIP") - (not room) (resp-error "INVALID_ROOM") - :else (do - (end-topic! room) - (resp-success "OK"))))) + (if-vip + (if-let [room (lookup-room (params :room))] + (do + (end-topic! room) + (resp-success "OK")) + (resp-error "INVALID_ROOM")))) ;; Chat @@ -678,13 +679,12 @@ ORDER BY msg_count DESC") (.setAttribute st "json_user_nick" (if nick (json-str nick) "null")) (.setAttribute st "roomname" (room :name)) (.setAttribute st "timestamp" now) + (.setAttribute st "version" *chat-version-number*) (.toString st))) ;; Chat Log -; TODO: Optimize dump counts -; timb: ^^ done... i changed it to fetch one more than is shown per page to determine if next page is needed (defn log [session room offset params] (let [st (fetch-template "log" session) logger (make-time-logger) @@ -816,22 +816,45 @@ ORDER BY msg_count DESC") ;; Account resets -(defn reset-request-page [session] - (.toString (fetch-template "reset_request.st" session))) +(defn reset-request! [session {nick :nick}] + (if-let [info (fetch-nick nick)] + (let [email (info :email) + hash (info :hash) + ts (System/currentTimeMillis) + token (reset-token nick hash ts) + link (reset-link nick token ts)] + (do (send-reset-email nick email link) + (resp-success "OK"))) + (resp-error "NO_NICK"))) -(defn reset-account-request! [session params] - ) +(defn reset-page [session params] + (let [st (fetch-template "reset" session) + nick (params :nick) + ts (maybe-parse-long (params :ts) 0) + token (params :token) + valid (valid-reset-link? nick token ts)] + (.setAttribute st "valid_request" valid) + (.setAttribute st "nick" nick) + (when valid + (.setAttribute st "link" (reset-link nick token ts))) + (.toString st))) -(defn reset-account! [session key] - ) +(defn reset-account! [session params] + (let [nick (params :nick) + ts (maybe-parse-long (params :ts) 0) + token (params :token) + hash (params :hash)] + (if (and (valid-reset-link? nick token ts) hash) + (let [info (fetch-nick nick)] + (update-nick-hash nick hash) + [(session-assoc-from-db info) + (redirect-to "/")]) + [200 "BAD_REQUEST"]))) ;; Upload - -(def *max-image-height* 2000) -(def *max-image-width* 2000) -(def *max-avatar-height* 2000) -(def *max-avatar-width* 2000) +(def *max-image-dimensions* [2000 2000]) +(def *max-avatar-dimensions* [2000 2000]) (def *vip-max-file-size* (mbytes 5)) ; don't be nuts guys (def *max-file-size* (kbytes 750)) (def *ignore-size-limit-for-vip* true) @@ -847,15 +870,15 @@ ORDER BY msg_count DESC") (if (> (.length f) limit) (str "FILE_TOO_BIG " limit)))) -(defn is-image-invalid? [f] +(defn invalid-image-dimensions? [f [max-width max-height]] (try (let [i (ImageIO/read f) height (.getHeight i) width (.getWidth i)] - (if (or (> width *max-image-width*) - (> height *max-image-height*)) - (str "INVALID_RESOLUTION " *max-image-width* " " *max-image-height*))) - (catch Exception _ "FILE_NOT_IMAGE"))) + (if (or (> width max-width) + (> height max-height)) + (str "INVALID_RESOLUTION " max-width " " max-height))) + (catch Exception _ "INVALID_IMAGE"))) (defn format-filename [s nick] (let [spaceless (.replace s \space \-) @@ -868,7 +891,7 @@ ORDER BY msg_count DESC") (defn validate-upload-file [f vip] (or (is-file-too-big? f vip) - (is-image-invalid? f))) + (invalid-image-dimensions? f *max-image-dimensions*))) ; Upload notes: @@ -920,19 +943,15 @@ ORDER BY msg_count DESC") (defn upload-avatar [session params] (let [image (params :image)] - (cond (not image) [200 "INVALID_REQUEST"] + (cond (not image) [200 "INVALID_REQUEST"] (not (session :nick)) [200 "NOT_LOGGED_IN"] :else (do-upload-avatar session image)))) ;; Compojure Routes -(defn no-cache [resp] - [{:headers {"Cache-Control" "no-cache, no-store, max-age=0, must-revalidate"}} - resp]) - (defn serve-static [dir path] - ; TODO: cache policy for other static files (js, css, etc.) + ; TODO: cache other static files (js, css, etc.) (let [cache-header (if (re-find pic-regex path) {:headers {"Cache-Control" "max-age=604800,public"}} {})] @@ -940,12 +959,12 @@ ORDER BY msg_count DESC") (serve-file dir path)])) (defroutes static - (GET "/static/*" (serve-static "static" (params :*))) - (GET "/images/*" (serve-static *image-directory* (params :*))) + (GET "/static/*" (serve-static "static" (params :*))) + (GET "/images/*" (serve-static *image-directory* (params :*))) (GET "/avatars/*" (serve-static *avatar-directory* (params :*)))) (defroutes pichat - (GET "/" (no-cache (landing session))) + (GET "/" (landing session)) (GET "/favicon.ico" (serve-static "static" "favicon.ico")) (GET "/u/:nick" (profile session (params :nick) "0")) (GET "/u/:nick/" (profile session (params :nick) "0")) @@ -954,16 +973,17 @@ ORDER BY msg_count DESC") (GET "/u/:nick/favorites" (favorites session params)) (GET "/u/:nick/favorites/:offset" (favorites session params)) (GET "/json/:nick/favorites" (json-favorites session params)) - (GET "/u/:nick/:offset" (profile session - (params :nick) - (params :offset))) ; have to put this route after favs + + ; have to put this route after favs + (GET "/u/:nick/:offset" (profile session (params :nick) (params :offset))) + (GET "/p/:nick/:postid" (single-message session (params :nick) (params :postid))) (GET "/login" (login session params cookies)) (GET "/logout" (logout session)) (GET "/register" (serve-static "static" "register.html")) - (GET "/:room/chat" (no-cache (validated-chat session (-> request :route-params :room) "chat"))) - (GET "/chat" (no-cache (validated-chat session "dumpfm" "chat"))) - (GET "/chat/:t" (no-cache (validated-chat session "dumpfm" (-> request :route-params :t)))) + (GET "/:room/chat" (validated-chat session (params :room) "chat")) + (GET "/chat" (validated-chat session "dumpfm" "chat")) + (GET "/chat/:t" (validated-chat session "dumpfm" (params :t))) (GET "/browser" (browser session)) (GET "/refresh" (validated-refresh session params)) (GET "/tag/:tag" (tagged-dumps session params (request-url request))) @@ -971,12 +991,8 @@ ORDER BY msg_count DESC") (POST "/cmd/tag/add" (validated-add-tag session params)) (POST "/cmd/tag/rm" (validated-remove-tag session params)) (GET "/log" (validated-log session "dumpfm" "0" params)) - (GET "/:room/log" (validated-log session - (-> request :route-params :room) - "0" params)) - (GET "/:room/log/:offset" (validated-log session - (-> request :route-params :room) - (-> request :route-params :offset) + (GET "/:room/log" (validated-log session (params :room) "0" params)) + (GET "/:room/log/:offset" (validated-log session (params :room) (params :offset) params)) ;; TODO: add form tokens for all destructive actions (POST "/msg" (validated-msg session params)) @@ -987,10 +1003,10 @@ ORDER BY msg_count DESC") (POST "/end-topic" (validate-end-topic session params)) (GET "/directory" (directory session 0)) (GET "/directory/:offset" - (directory session (maybe-parse-int (-> request :route-params :offset) 0))) - (GET "/reset" (reset-request-page session)) - (POST "/reset-request" (reset-account-request! session params)) - (POST "/reset/:key" (reset-account! session (-> request :route-params :key))) + (directory session (maybe-parse-int (params :offset) 0))) + (POST "/reset-request" (reset-request! session params)) + (GET "/reset" (reset-page session params)) + (POST "/reset" (reset-account! session params)) ;; Admin stuff (should be own route?) (GET "/debug" (debug-page session flash)) diff --git a/src/user.clj b/src/user.clj new file mode 100644 index 0000000..9d3060f --- /dev/null +++ b/src/user.clj @@ -0,0 +1,42 @@ +(ns user + (:use compojure + utils)) + +(def *nick-regex* #"^[A-Za-z0-9\-_∆˚†]*$") + +(defn is-invalid-nick? [n] + (cond + (< (count n) 3) "NICK_TOO_SHORT" + (not (re-matches *nick-regex* n)) "NICK_INVALID_CHARS")) + +(defn fetch-nick [nick] + (let [q1 "SELECT * FROM users WHERE nick = ? LIMIT 1" + ; ORDER BY ensures consistent retrieval of ambiguious names + q2 "SELECT * FROM users WHERE lower(nick) = ? ORDER BY nick LIMIT 1"] + (or (first-or-nil (do-select [q1 nick])) + (first-or-nil (do-select [q2 (lower-case nick)]))))) + +(defn authorize-nick-hash [nick hash] + (let [db-user (fetch-nick nick)] + (and db-user (= (db-user :hash) hash) db-user))) + +(defn update-nick-hash [nick hash] + (if (not (assert-update + (do-update :users ["nick=?" nick] + {:hash hash}))) + ; TODO: logging + (println (format "Error updating hash for %s" nick)))) + + +(defn reset-token [nick hash ts] + (sha1-hash nick hash ts)) + +(defn reset-link [nick token ts] + (url-params "http://dump.fm/reset" {"nick" nick + "ts" ts + "token" token})) + +(defn valid-reset-link? [nick token ts] + (if-let [info (fetch-nick nick)] + (and (= token (reset-token (info :nick) (info :hash) ts)) + (>= ts (ms-ago (days 2))))))
\ No newline at end of file diff --git a/src/utils.clj b/src/utils.clj index f42746c..628e4b8 100755 --- a/src/utils.clj +++ b/src/utils.clj @@ -56,7 +56,12 @@ :else s)) (defn seconds [t] (* t 1000)) -(defn minutes [t] (* t 60 1000)) +(defn minutes [t] (* 60 (seconds t))) +(defn hours [t] (* 60 (minutes t))) +(defn days [t] (* 24 (hours t))) + +(defn ms-ago [ms] + (- (System/currentTimeMillis) ms)) (defn kbytes [b] (* b 1024)) (defn mbytes [b] (* b 1024 1024)) @@ -72,12 +77,12 @@ (defn sha1-hash [& more] (DigestUtils/shaHex (apply str more))) -(defn gmt-string - ([] (gmt-string (new Date))) - ([dt] - (let [df (new SimpleDateFormat "EEE, dd MMM yyyy kk:mm:ss z")] - (.setTimeZone df (TimeZone/getTimeZone "GMT")) - (.format df dt)))) +(defmacro with-timing [e] + `(let [s# (System/nanoTime) + r# ~e + f# (System/nanoTime)] + [(int (/ (- f# s#) 1000000.0)) r#])) + ;; Formatters @@ -123,6 +128,13 @@ [k (f v)] [k v])))) +(defn gmt-string + ([] (gmt-string (new Date))) + ([dt] + (let [df (new SimpleDateFormat "EEE, dd MMM yyyy kk:mm:ss z")] + (.setTimeZone df (TimeZone/getTimeZone "GMT")) + (.format df dt)))) + ;; JSON responses (defmethod print-json Date @@ -168,6 +180,10 @@ (with-connection *db* (insert-values table cols values))) +(defn assert-update + ([res ok err] (if (not (= (first res) 1)) err ok)) + ([res] (assert-update res true false))) + ;; Parsing (defn maybe-parse-int @@ -197,43 +213,24 @@ (def template-group (new StringTemplateGroup "dumpfm" "template")) (.setRefreshInterval template-group 3) -;; TODO: handle exception +; TODO: handle exception, clean-up template setting (defn fetch-template [template session] (let [st (.getInstanceOf template-group template)] (if (session :nick) (do (.setAttribute st "user_email" (session :email)) (.setAttribute st "user_nick" (session :nick)) - (if (non-empty-string? (session :avatar)) (.setAttribute st "user_avatar" (session :avatar))) - (.setAttribute st "isadmin" (session :is_admin)))) ;; TODO: consolidate session/user code + (if (non-empty-string? (session :avatar)) + (.setAttribute st "user_avatar" (session :avatar))) + (.setAttribute st "isadmin" (session :is_admin)))) st)) (defn serve-template [template session] (.toString (fetch-template template session))) - -;; User authentication -; TODO: create user module - (defn first-or-nil [l] (if (empty? l) nil (first l))) -(def nick-regex #"^[A-Za-z0-9\-_∆˚†]*$") - -(defn is-invalid-nick? [n] - (cond - (< (count n) 3) "NICK_TOO_SHORT" - (not (re-matches nick-regex n)) "NICK_INVALID_CHARS")) - -(defn fetch-nick [nick] - (let [q1 "SELECT * FROM users WHERE nick = ? LIMIT 1" - ; ORDER BY ensures consistent retrieval of ambiguious names - q2 "SELECT * FROM users WHERE lower(nick) = ? ORDER BY nick LIMIT 1"] - (or (first-or-nil (do-select [q1 nick])) - (first-or-nil (do-select [q2 (lower-case nick)]))))) - -(defn authorize-nick-hash [nick hash] - (let [db-user (fetch-nick nick)] - (and db-user (= (db-user :hash) hash) db-user))) +;; VIP (defn is-vip? [session] (session :is_admin)) diff --git a/template/debug.st b/template/debug.st index c9bbcdb..bba203f 100644 --- a/template/debug.st +++ b/template/debug.st @@ -1,5 +1,4 @@ <html> - <head> <title>dump.fm DEBUG</title> $head()$ <style> @@ -17,7 +16,6 @@ width: 100px; } </style> - </head> <body> $banner()$ <div id="main"> @@ -47,7 +45,32 @@ <input type="submit" value="Send Email"> <input type="hidden" name="action" value="regemail"> <br> - <form/> + </form> + + <br><br><br><br> + + <form action="/debug" method="POST"> + <h1>Password Reset Email</h1> + <br> + <label>To:</label> + <input name="to" value="$user_email$"> + <br> + <label>Nick:</label> + <input name="nick" value="$user_nick$"> + <br> + <label>Template:</label> + <div> + $resettemps: { t | + <input type="radio" name="template" value="$t.template$" + $if(t.selected)$checked="checked"$endif$> $t.template$ + }$ + </div> + <br> + <input type="submit" value="Send Email"> + <input type="hidden" name="action" value="resetemail"> + <br> + </form> + </div> </body> </html> diff --git a/template/mail/reset.st b/template/mail/reset.st new file mode 100644 index 0000000..8112f90 --- /dev/null +++ b/template/mail/reset.st @@ -0,0 +1,6 @@ +SUBJECT: dump.fm Password Reset + +We're sorry you don't have your password, $nick$. Every moment +you're not dumping is a dagger in our hearts. + +Click <$link$> to get back into the game! diff --git a/template/reset.st b/template/reset.st new file mode 100644 index 0000000..f166d88 --- /dev/null +++ b/template/reset.st @@ -0,0 +1,61 @@ +<html> + <head> + <title>dump.fm Password Reset</title> + <script type="text/javascript" src="/static/js/sha1.js"></script> + $head()$ + <style> + #main { + padding: 100px 2em 0px 2em; + } + label { + float: left; + width: 150px; + } + </style> + <script> + jQuery(function() { + jQuery("#submit").click(function() { + var p1 = jQuery('#p1').val(); + var p2 = jQuery('#p1').val(); + if (p1.length < 5) { + jQuery('#feedback').text("Password must be at least five letters long!").show(); + jQuery('#p1, #p2').val(""); + return false; + } else if (p1 != p2) { + jQuery('#feedback').text("Both passwords must match!").show(); + jQuery('#p1, #p2').val(""); + return false; + } + var nick = '$nick$'; + var hash = hex_sha1(nick + '\$' + p1 + '\$dumpfm'); + jQuery('#hash').val(hash); + return true; + }); + }); + </script> + </head> + <body> + $banner()$ + <div id="main"> + $if(valid_request)$ + <div id="feedback" style="display: none; color: red; margin-bottom: 2em;"></div> + <h1>Reset Your Password</h1> + <br> + <label>New Password:</label> + <input type="password" id="p1"> + <br> + <label>Repeat Password:</label> + <input type="password" id="p2"> + <br> + <form action="$link$" method="POST"> + <input type="hidden" value="hash" name="hash" id="hash"> + <input type="submit" value="Change password" id="submit"> + </form> + $else$ + <h1>Bad Request</h1> + <span>Your password reset link is invalid or has expired. Please try + resetting again.</span> + $endif$ + </div> + </body> +</html> |
