├── .gitignore ├── LICENSE ├── README.md ├── cookie.go ├── database.go ├── main.go ├── model.go ├── public ├── background.png ├── css │ └── style.css ├── favicon.ico └── logo.png ├── retwis.go └── templates ├── error.tmpl ├── footer.tmpl ├── header.tmpl ├── home.tmpl ├── layout.tmpl ├── navbar.tmpl ├── profile.tmpl ├── register.tmpl ├── timeline.tmpl └── welcome.tmpl /.gitignore: -------------------------------------------------------------------------------- 1 | # ### 2 | # osx 3 | # ### 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must ends with two \r. 10 | Icon 11 | 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear on external disk 17 | .Spotlight-V100 18 | .Trashes 19 | 20 | # ########### 21 | # SublimeText 22 | # ########### 23 | 24 | # workspace files are user-specific 25 | *.sublime-workspace 26 | 27 | # project files should be checked into the repository, unless a significant 28 | # proportion of contributors will probably not be using SublimeText 29 | *.sublime-project 30 | 31 | # ###### 32 | # golang 33 | # ###### 34 | 35 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 36 | *.o 37 | *.a 38 | *.so 39 | 40 | # Folders 41 | _obj 42 | _test 43 | 44 | # Architecture specific extensions/prefixes 45 | *.[568vq] 46 | [568vq].out 47 | 48 | *.cgo1.go 49 | *.cgo2.c 50 | _cgo_defun.c 51 | _cgo_gotypes.go 52 | _cgo_export.* 53 | 54 | _testmain.go 55 | 56 | *.exe 57 | *.test 58 | 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 gsempe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | retwis-go 2 | ========= 3 | ## What is it 4 | retwis-go is the port to Go of the redis tutorial [Twitter clone](http://redis.io/topics/twitter-clone) 5 | ~~You can see it in action at http://retwis.sempe.net~~ Demo is now offline. The project is too old to be interesting. 6 | 7 | ## How to contribute 8 | retwis-go is a direct port without almost no improvements done on the go. I have done it as a way to practice Golang and redis. 9 | If you have the same goals and search projects to practice, just fork it and open pull requests. 10 | There is lot of things that can be done: 11 | - protect users passwords. They are not encrypted in the database 12 | - Many errors messages should not be shown to the retwis user 13 | - User profile is very poor. For instance there is no way to know who follows who 14 | - There is no reply feature, no retweet feature, no favorite feature, etc... 15 | 16 | ## Usage 17 | Set the `GOPATH` env, like described in the [Golang documentation](http://golang.org/doc/code.html#GOPATH) 18 | 19 | Install retwis-go: 20 | ``` 21 | go get github.com/gsempe/retwis-go 22 | ``` 23 | 24 | Run retwis-go: 25 | ``` 26 | go build 27 | ./retwis-go 28 | ``` 29 | 30 | Note: A redis database must run on the same machine 31 | 32 | ## How it's done 33 | To get it done faster and safer the port uses : 34 | - [redigo](https://github.com/garyburd/redigo) Go client for Redis 35 | - [negroni](https://github.com/codegangsta/negroni) Idiomatic HTTP Middleware for Golang 36 | - [httprouter](https://github.com/julienschmidt/httprouter) A high performance HTTP request router that scales well 37 | - [render](https://github.com/unrolled/render) Go package for easily rendering JSON, XML, and HTML template responses. 38 | - [securecookie](https://github.com/gorilla/securecookie) Gorilla package that encodes and decodes authenticated and optionally encrypted cookie values. 39 | -------------------------------------------------------------------------------- /cookie.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/securecookie" 7 | ) 8 | 9 | var ( 10 | cookieHandler = securecookie.New( 11 | []byte("in0y(>'@N+#N6A5*=iL%lM}[U`|AH#8ltj@02>e9gwsU&Wu'JNuhCRFPri7Z{*H1"), 12 | []byte("zy-hA 0 { 53 | templateParams["prev"] = start - 10 54 | } 55 | templateParams["posts"] = posts 56 | if rest > 0 { 57 | templateParams["next"] = start + 10 58 | } 59 | } 60 | templateRender.HTML(w, http.StatusOK, "home", templateParams) 61 | } 62 | 63 | func Register(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 64 | username := r.PostFormValue("username") 65 | password := r.PostFormValue("password") 66 | password2 := r.PostFormValue("password2") 67 | if username == "" || password == "" || password2 == "" { 68 | GoBack(w, r, errors.New("Every field of the registration form is needed!")) 69 | return 70 | } 71 | if password != password2 { 72 | GoBack(w, r, errors.New("The two password fileds don't match!")) 73 | return 74 | } 75 | auth, err := register(username, password) 76 | if err != nil { 77 | GoBack(w, r, err) 78 | return 79 | } 80 | setSession(auth, w) 81 | templateParams := map[string]interface{}{} 82 | templateParams["username"] = username 83 | templateRender.HTML(w, http.StatusOK, "register", templateParams) 84 | } 85 | 86 | func Login(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 87 | username := r.PostFormValue("username") 88 | password := r.PostFormValue("password") 89 | if username == "" || password == "" { 90 | GoBack(w, r, errors.New("You need to enter both username and password to login.")) 91 | return 92 | } 93 | auth, err := login(username, password) 94 | if err != nil { 95 | GoBack(w, r, err) 96 | return 97 | } 98 | setSession(auth, w) 99 | http.Redirect(w, r, "/", http.StatusFound) 100 | } 101 | 102 | func Logout(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 103 | u, err := isLogin(getAuth(r)) 104 | if nil != err { 105 | http.Redirect(w, r, "/", http.StatusFound) 106 | return 107 | } 108 | logout(u) 109 | http.Redirect(w, r, "/", http.StatusFound) 110 | } 111 | 112 | func Publish(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 113 | u, err := isLogin(getAuth(r)) 114 | if nil != err { 115 | http.Redirect(w, r, "/", http.StatusFound) 116 | return 117 | } 118 | status := r.PostFormValue("status") 119 | if status == "" { 120 | http.Redirect(w, r, "/", http.StatusFound) 121 | return 122 | } 123 | err = post(u, status) 124 | http.Redirect(w, r, "/", http.StatusFound) 125 | } 126 | 127 | func Timeline(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 128 | templateParams := map[string]interface{}{} 129 | users, err := getLastUsers() 130 | if err != nil { 131 | log.Println(err) 132 | } else { 133 | templateParams["users"] = users 134 | } 135 | posts, _, err := getUserPosts("timeline", 0, 50) 136 | if err != nil { 137 | log.Println(err) 138 | } else { 139 | templateParams["posts"] = posts 140 | } 141 | templateRender.HTML(w, http.StatusOK, "timeline", templateParams) 142 | } 143 | 144 | func Profile(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 145 | templateParams := map[string]interface{}{} 146 | // get username 147 | username := r.FormValue("u") 148 | if username == "" { 149 | http.Redirect(w, r, "/", http.StatusFound) 150 | return 151 | } 152 | // Get profile 153 | p, err := profileByUsername(username) 154 | if err != nil { 155 | http.Redirect(w, r, "/", http.StatusFound) 156 | return 157 | } 158 | templateParams["profile"] = p 159 | // Get logged in user 160 | u, err := isLogin(getAuth(r)) 161 | if nil == err { 162 | templateParams["user"] = u 163 | } 164 | 165 | var start int64 166 | if "" == r.FormValue("start") { 167 | start = int64(0) 168 | } else { 169 | start, err = strconv.ParseInt(r.FormValue("start"), 10, 64) 170 | if err != nil { 171 | start = int64(0) 172 | } 173 | } 174 | posts, rest, err := getUserPosts("posts:"+p.Id, start, 10) 175 | if err == nil { 176 | if start > 0 { 177 | templateParams["prev"] = start - 10 178 | } 179 | templateParams["posts"] = posts 180 | if rest > 0 { 181 | templateParams["next"] = start + 10 182 | } 183 | } 184 | templateRender.HTML(w, http.StatusOK, "profile", templateParams) 185 | } 186 | 187 | func Follow(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 188 | // get the user id 189 | userId := r.FormValue("uid") 190 | if userId == "" { 191 | http.Redirect(w, r, "/", http.StatusFound) 192 | return 193 | } 194 | // Get the action to do 195 | doFollow := false 196 | switch r.FormValue("f") { 197 | case "1": 198 | doFollow = true 199 | case "0": 200 | doFollow = false 201 | default: 202 | http.Redirect(w, r, "/", http.StatusFound) 203 | return 204 | } 205 | u, err := isLogin(getAuth(r)) 206 | if nil != err { 207 | http.Redirect(w, r, "/", http.StatusFound) 208 | return 209 | } 210 | if userId == u.Id { 211 | http.Redirect(w, r, "/", http.StatusFound) 212 | return 213 | } 214 | if doFollow { 215 | u.Follow(&User{Id: userId}) 216 | } else { 217 | u.Unfollow(&User{Id: userId}) 218 | } 219 | p, err := profileByUserId(userId) 220 | if err != nil { 221 | http.Redirect(w, r, "/", http.StatusFound) 222 | return 223 | } 224 | http.Redirect(w, r, "/profile?u="+p.Username, http.StatusFound) 225 | } 226 | 227 | func GoBack(w http.ResponseWriter, r *http.Request, err error) { 228 | templateParams := map[string]interface{}{} 229 | templateParams["error"] = err 230 | templateRender.HTML(w, http.StatusOK, "error", templateParams) 231 | } 232 | 233 | func main() { 234 | 235 | router := httprouter.New() 236 | router.GET("/", Index) 237 | router.GET("/home", Home) 238 | router.POST("/register", Register) 239 | router.POST("/login", Login) 240 | router.GET("/logout", Logout) 241 | router.POST("/post", Publish) 242 | router.GET("/timeline", Timeline) 243 | router.GET("/profile", Profile) 244 | router.GET("/follow", Follow) 245 | 246 | n := negroni.Classic() 247 | n.UseHandler(router) 248 | n.Run(":8080") 249 | } 250 | -------------------------------------------------------------------------------- /model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/garyburd/redigo/redis" 7 | ) 8 | 9 | type User struct { 10 | Id string 11 | Username string `redis:"username"` 12 | Auth string `redis:"auth"` 13 | } 14 | 15 | type Post struct { 16 | UserId string `redis:"user_id"` 17 | Username string 18 | Body string `redis:"body"` 19 | Elapsed string `redis:"time"` 20 | } 21 | 22 | func (user *User) Is(aUser *User) bool { 23 | if user == nil || aUser == nil { 24 | return false 25 | } 26 | if user.Id == aUser.Id { 27 | return true 28 | } 29 | return false 30 | } 31 | 32 | func (u *User) IsFollowing(p *User) bool { 33 | 34 | v, err := redis.Int(conn.Do("ZSCORE", "following:"+u.Id, p.Id)) 35 | if err != nil { 36 | return false 37 | } 38 | if v > 0 { 39 | return true 40 | } else { 41 | return false 42 | } 43 | } 44 | 45 | func (u *User) Followers() int { 46 | nbFollowers, err := redis.Int(conn.Do("ZCARD", "followers:"+u.Id)) 47 | if err != nil { 48 | return 0 49 | } else { 50 | return nbFollowers 51 | } 52 | } 53 | 54 | func (u *User) Following() int { 55 | nbFollowing, err := redis.Int(conn.Do("ZCARD", "following:"+u.Id)) 56 | if err != nil { 57 | return 0 58 | } else { 59 | return nbFollowing 60 | } 61 | } 62 | 63 | func (u *User) Follow(p *User) { 64 | conn.Do("ZADD", "followers:"+p.Id, time.Now().Unix(), u.Id) 65 | conn.Do("ZADD", "following:"+u.Id, time.Now().Unix(), p.Id) 66 | } 67 | 68 | func (u *User) Unfollow(p *User) { 69 | conn.Do("ZREM", "followers:"+p.Id, u.Id) 70 | conn.Do("ZREM", "following:"+u.Id, p.Id) 71 | } 72 | -------------------------------------------------------------------------------- /public/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsempe/retwis-go/ad6f2c5246b7f25fbe633952e3c44e55ad2f80fb/public/background.png -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | BODY { 2 | font-family: Verdana, sans-serif; 3 | background: url(/background.png) repeat-x top white; 4 | background-attachment: fixed; 5 | } 6 | 7 | #page { 8 | margin: 0px; 9 | width:900px; 10 | margin-left: auto; 11 | margin-right: auto; 12 | padding:10px; 13 | border:1px gray solid; 14 | background-color:white; 15 | -moz-border-radius:5px; 16 | -webkit-border-radius:5px; 17 | border-radius:5px; 18 | } 19 | 20 | #page h2 { 21 | color:#0f2a44; 22 | font-weight:bold; 23 | } 24 | 25 | #header { 26 | width:885px; 27 | height:85px; 28 | border-bottom: 1px #aaa solid; 29 | padding:5px; 30 | margin-bottom:10px; 31 | position:relative; 32 | 33 | } 34 | 35 | #header H1 { 36 | margin:0px; 37 | padding:0px; 38 | margin-bottom:10px; 39 | } 40 | 41 | #navbar { 42 | position:absolute; 43 | top:65px; 44 | right:8px; 45 | font-size:14px; 46 | color: #aaa; 47 | } 48 | 49 | #navbar a { 50 | text-decoration: none; 51 | } 52 | 53 | #footer { 54 | margin-top:20px; 55 | border-top: 1px #aaa solid; 56 | font-size:12px; 57 | color: #666; 58 | text-align:center; 59 | } 60 | 61 | .post { 62 | margin:10px; 63 | padding:10px; 64 | border-top: 1px #ddd dashed; 65 | color:#444; 66 | } 67 | 68 | .post i { 69 | font-size:10px; 70 | color:#999; 71 | } 72 | 73 | #postform { 74 | -moz-border-radius:5px; 75 | -webkit-border-radius:5px; 76 | border-radius:5px; 77 | position:relative; 78 | padding:10px; 79 | margin:10px; 80 | background-color: #eee; 81 | } 82 | 83 | .rightlink { 84 | text-align:right; 85 | margin-right:30px; 86 | color:#aaa; 87 | } 88 | 89 | .rightlink a { 90 | color: #f55000; 91 | text-decoration: none; 92 | } 93 | 94 | a.username { 95 | text-decoration: none; 96 | font-weight:bold; 97 | color:#629e43; 98 | margin-right:10px; 99 | } 100 | 101 | h2.username { 102 | color:#66dd66; 103 | margin-left:15px; 104 | } 105 | 106 | a.button { 107 | margin-left:15px; 108 | text-decoration:none; 109 | border: 1px #aaa dotted; 110 | padding:3px; 111 | background-color:#eee; 112 | color:#444; 113 | font-size:12px; 114 | } 115 | 116 | #homeinfobox { 117 | -moz-border-radius:5px; 118 | -webkit-border-radius:5px; 119 | border-radius:5px; 120 | position: absolute; 121 | right:5px; 122 | top:5px; 123 | background-color: #ccc; 124 | width:200px; 125 | font-size:12px; 126 | color:white; 127 | padding:3px; 128 | } 129 | 130 | #welcomebox{ 131 | width:890px; 132 | height:450px; 133 | padding-top:10px; 134 | position:relative; 135 | font-size:14px; 136 | } 137 | #registerbox{ 138 | width:400px; 139 | height:400px; 140 | background-color:#e3e8e4; 141 | float:right; 142 | padding:5px; 143 | -moz-border-radius:5px; 144 | -webkit-border-radius:5px; 145 | border-radius:5px; 146 | border:3px #c8d8bf solid; 147 | margin-right:0px; 148 | margin-left:15px; 149 | } 150 | 151 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsempe/retwis-go/ad6f2c5246b7f25fbe633952e3c44e55ad2f80fb/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsempe/retwis-go/ad6f2c5246b7f25fbe633952e3c44e55ad2f80fb/public/logo.png -------------------------------------------------------------------------------- /retwis.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/deckarep/golang-set" 12 | "github.com/garyburd/redigo/redis" 13 | "github.com/gorilla/securecookie" 14 | "github.com/kylemcc/twitter-text-go/extract" 15 | ) 16 | 17 | func isLogin(auth string) (*User, error) { 18 | 19 | if "" == auth { 20 | return nil, errors.New("No authentification token") 21 | } 22 | userId, err := redis.String(conn.Do("HGET", "auths", auth)) 23 | if err != nil { 24 | return nil, err 25 | } 26 | savedAuth, err := redis.String(conn.Do("HGET", "user:"+userId, "auth")) 27 | if err != nil || savedAuth != auth { 28 | return nil, errors.New("Wrong authentification token") 29 | } 30 | return loadUserInfo(userId) 31 | } 32 | 33 | func loadUserInfo(userId string) (*User, error) { 34 | 35 | v, err := redis.Values(conn.Do("HGETALL", "user:"+userId)) 36 | if err != nil { 37 | return nil, err 38 | } 39 | u := &User{Id: userId} 40 | err = redis.ScanStruct(v, u) 41 | if err != nil { 42 | return nil, err 43 | } 44 | return u, nil 45 | } 46 | 47 | func profileByUsername(username string) (*User, error) { 48 | 49 | if username == "" { 50 | return nil, errors.New("Invalid username") 51 | } 52 | userId, err := redis.String(conn.Do("HGET", "users", username)) 53 | if err != nil { 54 | return nil, err 55 | } 56 | u := &User{Id: userId, Username: username} 57 | return u, nil 58 | } 59 | 60 | func profileByUserId(userId string) (*User, error) { 61 | 62 | if userId == "" { 63 | return nil, errors.New("Invalid user Id") 64 | } 65 | username, err := redis.String(conn.Do("HGET", "user:"+userId, "username")) 66 | if err != nil { 67 | return nil, err 68 | } 69 | u := &User{Id: userId, Username: username} 70 | return u, nil 71 | } 72 | 73 | func register(username, password string) (auth string, err error) { 74 | 75 | userId, err := redis.Int(conn.Do("INCR", "next_user_id")) 76 | if err != nil { 77 | return "", err 78 | } 79 | auth = string(securecookie.GenerateRandomKey(32)) // We reuse the securecookie random string generator 80 | auth, err = redis.String(registerScript.Do( 81 | conn, 82 | "users", // KEYS[1] 83 | fmt.Sprintf("user:%d", userId), // KEYS[2] 84 | "auths", // KEYS[3] 85 | "users_by_time", // KEYS[4] 86 | userId, // ARGV[1] 87 | username, // ARGV[2] 88 | password, // ARGV[3] 89 | auth, // ARGV[4] 90 | time.Now().Unix())) // ARGV[5] 91 | return auth, err 92 | } 93 | 94 | func login(username, password string) (auth string, err error) { 95 | 96 | userId, err := redis.Int(conn.Do("HGET", "users", username)) 97 | if err != nil { 98 | return "", errors.New("Wrong username or password") 99 | } 100 | realPassword, err := redis.String(conn.Do("HGET", fmt.Sprintf("user:%d", userId), "password")) 101 | if err != nil { 102 | return "", err 103 | } 104 | if password != realPassword { 105 | return "", errors.New("Wrong username or password") 106 | } 107 | auth, err = redis.String(conn.Do("HGET", fmt.Sprintf("user:%d", userId), "auth")) 108 | if err != nil { 109 | return "", err 110 | } 111 | return auth, nil 112 | } 113 | 114 | func logout(user *User) { 115 | 116 | if nil == user { 117 | return 118 | } 119 | 120 | newAuth := string(securecookie.GenerateRandomKey(32)) 121 | oldAuth, _ := redis.String(conn.Do("HGET", "user:"+user.Id, "auth")) 122 | 123 | _, err := conn.Do("HSET", "user:"+user.Id, "auth", newAuth) 124 | if err != nil { 125 | log.Println(err) 126 | } 127 | _, err = conn.Do("HSET", "auths", newAuth, user.Id) 128 | if err != nil { 129 | log.Println(err) 130 | } 131 | _, err = conn.Do("HDEL", "auths", oldAuth) 132 | if err != nil { 133 | log.Println(err) 134 | } 135 | } 136 | 137 | func post(user *User, status string) error { 138 | 139 | postId, err := redis.Int(conn.Do("INCR", "next_post_id")) 140 | if err != nil { 141 | return err 142 | } 143 | status = strings.Replace(status, "\n", " ", -1) 144 | _, err = conn.Do("HMSET", fmt.Sprintf("post:%d", postId), "user_id", user.Id, "time", time.Now().Unix(), "body", status) 145 | if err != nil { 146 | return err 147 | } 148 | followers, err := redis.Strings(conn.Do("ZRANGE", "followers:"+user.Id, 0, -1)) 149 | if err != nil { 150 | return err 151 | } 152 | recipients := mapset.NewSet() 153 | for _, fId := range followers { 154 | recipients.Add(fId) 155 | } 156 | entities := extract.ExtractMentionedScreenNames(status) 157 | for _, e := range entities { 158 | username, _ := e.ScreenName() 159 | profile, err := profileByUsername(username) 160 | if err == nil { 161 | recipients.Add(profile.Id) 162 | } 163 | } 164 | recipients.Add(user.Id) 165 | for fId := range recipients.Iter() { 166 | str, ok := fId.(string) 167 | if ok { 168 | conn.Do("LPUSH", "posts:"+str, postId) 169 | } 170 | } 171 | _, err = conn.Do("LPUSH", "timeline", postId) 172 | if err != nil { 173 | return err 174 | } 175 | _, err = conn.Do("LTRIM", "timeline", 0, 1000) 176 | if err != nil { 177 | return err 178 | } 179 | return nil 180 | } 181 | 182 | func strElapsed(t string) string { 183 | 184 | ts, err := strconv.ParseInt(t, 10, 64) 185 | if err != nil { 186 | return "" 187 | } 188 | te := time.Now().Unix() - ts 189 | if te < 60 { 190 | return fmt.Sprintf("%d seconds", te) 191 | } 192 | if te < 3600 { 193 | m := int(te / 60) 194 | if m > 1 { 195 | return fmt.Sprintf("%d minutes", m) 196 | } else { 197 | return fmt.Sprintf("%d minute", m) 198 | } 199 | } 200 | if te < 3600*24 { 201 | h := int(te / 3600) 202 | if h > 1 { 203 | return fmt.Sprintf("%d hours", h) 204 | } else { 205 | return fmt.Sprintf("%d hour", h) 206 | } 207 | } 208 | d := int(te / (3600 * 24)) 209 | if d > 1 { 210 | return fmt.Sprintf("%d days", d) 211 | } else { 212 | return fmt.Sprintf("%d day", d) 213 | } 214 | } 215 | 216 | func getPost(postId string) (*Post, error) { 217 | 218 | v, err := redis.Values(conn.Do("HGETALL", "post:"+postId)) 219 | if err != nil { 220 | return nil, err 221 | } 222 | p := &Post{} 223 | err = redis.ScanStruct(v, p) 224 | if err != nil { 225 | return nil, err 226 | } 227 | username, err := redis.String(conn.Do("HGET", "user:"+p.UserId, "username")) 228 | if err != nil { 229 | return nil, err 230 | } 231 | p.Username = username 232 | p.Elapsed = strElapsed(p.Elapsed) 233 | return p, nil 234 | } 235 | 236 | /* 237 | getUserPosts returns posts of the timeline if key == "timeline" 238 | or of an user if key is something with this format posts:%d 239 | 240 | @return The posts, the number of remaining posts and an error if there is a problem 241 | */ 242 | func getUserPosts(key string, start, count int64) ([]*Post, int64, error) { 243 | 244 | values, err := redis.Strings(conn.Do("LRANGE", key, start, start+count-1)) 245 | if err != nil { 246 | return nil, 0, err 247 | } 248 | posts := []*Post{} 249 | for _, pid := range values { 250 | p, err := getPost(pid) 251 | if err == nil { 252 | posts = append(posts, p) 253 | } 254 | } 255 | r, err := redis.Int64(conn.Do("LLEN", key)) 256 | if err != nil { 257 | return posts, 0, nil 258 | } else { 259 | return posts, r - start - int64(len(values)), nil 260 | } 261 | } 262 | 263 | func getLastUsers() ([]*User, error) { 264 | 265 | v, err := redis.Strings(conn.Do("ZREVRANGE", "users_by_time", 0, 9)) 266 | if err != nil { 267 | return nil, err 268 | } 269 | users := []*User{} 270 | for _, username := range v { 271 | users = append(users, &User{Username: username}) 272 | } 273 | return users, nil 274 | } 275 | -------------------------------------------------------------------------------- /templates/error.tmpl: -------------------------------------------------------------------------------- 1 | {{template "header" . }} 2 |