Skip to content
This repository was archived by the owner on Mar 16, 2021. It is now read-only.

Commit 5e5d6a6

Browse files
memorymxschmitt
authored andcommitted
Added redis support (#100)
- add a redis store implementing stores.Storage - add config file support to pick a storage backend - add config file support to set redis host:port and password - add docker_releases to .gitignore - update README to mention redis support - update example config.yaml
1 parent 17df45e commit 5e5d6a6

File tree

8 files changed

+379
-5
lines changed

8 files changed

+379
-5
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@ debug.test
2323
/handlers/tmpls/tmpls.go
2424
/store/main.db
2525
/releases
26-
/data
26+
/data
27+
docker_releases/

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
- Expirable Links
1616
- URL deletion
1717
- Authorization System via OAuth 2.0 (Google, GitHub and Microsoft)
18-
- High performance database with [bolt](https://github.com/boltdb/bolt)
1918
- Easy [ShareX](https://github.com/ShareX/ShareX) integration
2019
- Dockerizable
20+
- Multiple supported storage backends
21+
- High performance local database with [bolt](https://github.com/boltdb/bolt)
22+
- Persistent non-local storage with [redis](https://redis.io/)
2123

2224
## [Webinterface](https://so.sh0rt.cat)
2325

build/config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
ListenAddr: ':8080' # Consists of 'IP:Port', e.g. ':8080' listens on any IP and on Port 8080
22
BaseURL: 'http://localhost:3000' # Origin URL, required for the authentication via OAuth
3+
Backend: boltdb # Can be 'boltdb' or 'redis'
4+
RedisHost: localhost:6379 # If using the redis backend, a host:port combination
5+
RedisPassword: replace me # if using the redis backend, a conneciton password.
36
DataDir: ./data # Contains: the database and the private key
47
EnableDebugMode: true # Activates more detailed logging
58
ShortedIDLength: 10 # Length of the random generated ID which is used for new shortened URLs

stores/redis/redis.go

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
package redis
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"time"
7+
8+
"github.com/go-redis/redis"
9+
"github.com/mxschmitt/golang-url-shortener/stores/shared"
10+
"github.com/pkg/errors"
11+
"github.com/sirupsen/logrus"
12+
)
13+
14+
var (
15+
entryPathPrefix = "entry:" // prefix for path-to-url mappings
16+
entryUserPrefix = "user:" // prefix for path-to-user mappings
17+
userToEntriesPrefix = "userEntries:" // prefix for user-to-[]entries mappings (redis SET)
18+
entryVisitsPrefix = "entryVisits:" // prefix for entry-to-[]visit mappings (redis LIST)
19+
)
20+
21+
// Store implements the stores.Storage interface
22+
type Store struct {
23+
c *redis.Client
24+
}
25+
26+
// New initializes connection to the redis instance.
27+
func New(hostaddr, password string) (*Store, error) {
28+
c := redis.NewClient(&redis.Options{
29+
Addr: hostaddr,
30+
Password: password,
31+
DB: 0,
32+
})
33+
// if we can't talk to redis, fail fast
34+
_, err := c.Ping().Result()
35+
if err != nil {
36+
return nil, errors.Wrap(err, "Could not connect to redis db0")
37+
}
38+
ret := &Store{c: c}
39+
return ret, nil
40+
}
41+
42+
// keyExists checks for the existence of a key in redis.
43+
func (r *Store) keyExists(key string) (exists bool, err error) {
44+
logrus.Debugf("Checking for existence of key: %s", key)
45+
result := r.c.Exists(key)
46+
if result.Err() != nil {
47+
msg := fmt.Sprintf("Error looking up key '%s': '%v', got val: '%d'", key, result.Err(), result.Val())
48+
logrus.Error(msg)
49+
return false, errors.Wrap(result.Err(), msg)
50+
}
51+
if result.Val() == 1 {
52+
logrus.Debugf("Key '%s' exists!", key)
53+
return true, nil
54+
}
55+
logrus.Debugf("Key '%s' does not exist!", key)
56+
return false, nil
57+
}
58+
59+
// setValue sets the value of a key in redis.
60+
func (r *Store) setValue(key string, raw []byte) error {
61+
logrus.Debugf("Setting value for key '%s: '%s''", key, raw)
62+
status := r.c.Set(key, raw, 0) // n.b. expiration 0 means never expire
63+
if status.Err() != nil {
64+
msg := fmt.Sprintf("Got an unexpected error adding key '%s': %s", key, status.Err())
65+
logrus.Error(msg)
66+
return errors.Wrap(status.Err(), msg)
67+
}
68+
return nil
69+
}
70+
71+
// createValue is a wrapper around setValue that returns an error if the key already exists.
72+
func (r *Store) createValue(key string, raw []byte) error {
73+
logrus.Debugf("Creating key '%s'", key)
74+
exists, err := r.keyExists(key)
75+
if err != nil {
76+
msg := fmt.Sprintf("Could not check existence of key '%s': %s", key, err)
77+
logrus.Error(msg)
78+
return errors.Wrap(err, msg)
79+
}
80+
if exists == true {
81+
msg := fmt.Sprintf("Could not create key '%s': already exists", key)
82+
logrus.Error(msg)
83+
return errors.New(msg)
84+
}
85+
return r.setValue(key, raw)
86+
}
87+
88+
// delValue deletes a key in redis.
89+
func (r *Store) delValue(key string) error {
90+
logrus.Debugf("Deleting key '%s'", key)
91+
92+
exists, err := r.keyExists(key)
93+
if err != nil {
94+
msg := fmt.Sprintf("Could not check existence of key '%s': %s", key, err)
95+
logrus.Error(msg)
96+
return errors.Wrap(err, msg)
97+
}
98+
if exists == false {
99+
logrus.Warnf("Tried to delete key '%s' but it's already gone", key)
100+
return err
101+
}
102+
103+
status := r.c.Del(key)
104+
if status.Err() != nil {
105+
msg := fmt.Sprintf("Got an unexpected error deleting key '%s': %s", key, status.Err())
106+
logrus.Error(msg)
107+
return errors.Wrap(status.Err(), msg)
108+
}
109+
return err
110+
}
111+
112+
// CreateEntry creates an entry (path->url mapping) and all associated stored data.
113+
func (r *Store) CreateEntry(entry shared.Entry, id, userIdentifier string) error {
114+
// add the entry (path->url mapping)
115+
logrus.Debugf("Creating entry '%s' for user '%s'", id, userIdentifier)
116+
raw, err := json.Marshal(entry)
117+
if err != nil {
118+
msg := fmt.Sprintf("Could not marshal JSON for entry %s: %v", id, err)
119+
logrus.Error(msg)
120+
return errors.Wrap(err, msg)
121+
}
122+
entryKey := entryPathPrefix + id
123+
logrus.Debugf("Adding key '%s': %s", entryKey, raw)
124+
err = r.createValue(entryKey, raw)
125+
if err != nil {
126+
msg := fmt.Sprintf("Failed to set key '%s' for user '%s': %v", entryKey, userIdentifier, err)
127+
logrus.Error(msg)
128+
return errors.Wrap(err, msg)
129+
}
130+
131+
// add the path->user mapping
132+
userKey := entryUserPrefix + id
133+
logrus.Debugf("Adding key '%s': %s", userKey, raw)
134+
err = r.createValue(userKey, []byte(userIdentifier))
135+
if err != nil {
136+
msg := fmt.Sprintf("Failed to set key '%s' for user '%s': %v", userKey, userIdentifier, err)
137+
logrus.Error(msg)
138+
return errors.Wrap(err, msg)
139+
}
140+
141+
// add the entry to the SET of entries for the useridentifier
142+
userEntriesKey := userToEntriesPrefix + userIdentifier
143+
logrus.Debugf("Adding entry '%s' to set of entries for user '%s'", id, userIdentifier)
144+
result := r.c.SAdd(userEntriesKey, id)
145+
if result.Err() != nil {
146+
msg := fmt.Sprintf("Failed to add entry '%s' for user '%s': %v", id, userIdentifier, result.Err())
147+
logrus.Error(msg)
148+
return errors.Wrap(result.Err(), msg)
149+
}
150+
logrus.Debugf("Successfully added entry '%s' to set '%s'", id, userEntriesKey)
151+
return nil
152+
}
153+
154+
// DeleteEntry deletes an entry and all associated stored data.
155+
func (r *Store) DeleteEntry(id string) error {
156+
// delete the id-to-url mapping
157+
entryKey := entryPathPrefix + id
158+
err := r.delValue(entryKey)
159+
if err != nil {
160+
msg := fmt.Sprintf("Could not delete entry id %s: %v", id, err)
161+
logrus.Error(msg)
162+
return errors.Wrap(err, msg)
163+
}
164+
// delete the visitors list for the id
165+
entryVisitsKey := entryVisitsPrefix + id
166+
err = r.delValue(entryVisitsKey)
167+
if err != nil {
168+
msg := fmt.Sprintf("Could not delete visitors list for id %s: %v", id, err)
169+
logrus.Error(msg)
170+
return errors.Wrap(err, msg)
171+
}
172+
173+
// get the user for the id
174+
userKey := entryUserPrefix + id
175+
var userIdentifier string
176+
userIdentifier, err = r.c.Get(userKey).Result()
177+
if err != nil {
178+
msg := fmt.Sprintf("Could not fetch id to user mapping for id '%s': %v", id, err)
179+
logrus.Error(msg)
180+
return errors.Wrap(err, msg)
181+
}
182+
183+
// delete the entry from set of entries for the user
184+
userEntriesKey := userToEntriesPrefix + userIdentifier
185+
err = r.c.SRem(userEntriesKey, id).Err()
186+
if err != nil {
187+
msg := fmt.Sprintf("Could not remove entry '%s' from list of entries for user '%s': %v", id, userIdentifier, err)
188+
logrus.Error(msg)
189+
return errors.Wrap(err, msg)
190+
}
191+
192+
// delete the id-to-user mapping
193+
err = r.delValue(userKey)
194+
if err != nil {
195+
msg := fmt.Sprintf("Could not delete the path-to-user mapping for entry '%s': %v", id, err)
196+
logrus.Error(msg)
197+
return errors.Wrap(err, msg)
198+
}
199+
200+
return err
201+
}
202+
203+
// GetEntryByID looks up an entry by its path and returns a pointer to a
204+
// shared.Entry instance, with the visit count and last visit time set
205+
// properly.
206+
func (r *Store) GetEntryByID(id string) (*shared.Entry, error) {
207+
entryKey := entryPathPrefix + id
208+
logrus.Debugf("Fetching key: '%s'", entryKey)
209+
result := r.c.Get(entryKey)
210+
raw, err := result.Bytes()
211+
if err != nil {
212+
msg := fmt.Sprintf("Error looking up key '%s': %s'", entryKey, err)
213+
logrus.Warn(msg)
214+
err = shared.ErrNoEntryFound
215+
return nil, err
216+
}
217+
logrus.Debugf("Got entry for key '%s': '%s'", entryKey, raw)
218+
219+
var entry *shared.Entry
220+
err = json.Unmarshal(raw, &entry)
221+
if err != nil {
222+
msg := fmt.Sprintf("Error unmarshalling JSON for entry '%s': %v (json str: '%s')", id, err, raw)
223+
logrus.Error(msg)
224+
return nil, errors.Wrap(err, msg)
225+
}
226+
227+
// now we interleave the visit count and the last visit time
228+
// from the redis sources (we do this so we don't have to rewrite
229+
// the entry every time someone visits which is madness)
230+
//
231+
// first, the visit count is just the length of the visitors list
232+
entryVisitsKey := entryVisitsPrefix + id
233+
visitCount, err := r.c.LLen(entryVisitsKey).Result()
234+
if err != nil {
235+
logrus.Warnf("Could not get length of visitor list for id '%s': '%v'", id, err)
236+
entry.Public.VisitCount = int(0) // or zero if nobody's visited, that's fine.
237+
} else {
238+
entry.Public.VisitCount = int(visitCount)
239+
}
240+
241+
// grab the timestamp out of the last visitor on the list
242+
var visitor *shared.Visitor
243+
lastVisit := time.Time(time.Unix(0, 0)) // default to start-of-epoch if we can't figure it out
244+
raw, err = r.c.LIndex(entryVisitsKey, 0).Bytes()
245+
if err != nil {
246+
logrus.Warnf("Could not fetch visitor list for entry '%s': %v", id, err)
247+
} else {
248+
err = json.Unmarshal(raw, &visitor)
249+
if err != nil {
250+
logrus.Warnf("Could not unmarshal JSON for last visitor to entry '%s': %v (got string: '%s')", id, err, raw)
251+
} else {
252+
lastVisit = visitor.Timestamp
253+
}
254+
}
255+
logrus.Debugf("Setting last visit time for entry '%s' to '%v'", id, lastVisit)
256+
entry.Public.LastVisit = &lastVisit
257+
258+
return entry, nil
259+
}
260+
261+
// GetUserEntries returns all entries that are owned by a given user, in the
262+
// form of a map of path->shared.Entry
263+
func (r *Store) GetUserEntries(userIdentifier string) (map[string]shared.Entry, error) {
264+
logrus.Debugf("Getting all entries for user %s", userIdentifier)
265+
entries := map[string]shared.Entry{}
266+
key := userToEntriesPrefix + userIdentifier
267+
result := r.c.SMembers(key)
268+
if result.Err() != nil {
269+
msg := fmt.Sprintf("Could not fetch set of entries for user '%s': %v", userIdentifier, result.Err())
270+
logrus.Errorf(msg)
271+
return nil, errors.Wrap(result.Err(), msg)
272+
}
273+
for _, v := range result.Val() {
274+
logrus.Debugf("got entry: %s", v)
275+
entry, err := r.GetEntryByID(string(v))
276+
if err != nil {
277+
msg := fmt.Sprintf("Could not get entry '%s': %s", v, err)
278+
logrus.Warn(msg)
279+
} else {
280+
entries[string(v)] = *entry
281+
}
282+
}
283+
logrus.Debugf("all out of entries")
284+
return entries, nil
285+
}
286+
287+
// RegisterVisitor adds a shared.Visitor to the list of visits for a path.
288+
func (r *Store) RegisterVisitor(id, visitId string, visitor shared.Visitor) error {
289+
data, err := json.Marshal(visitor)
290+
if err != nil {
291+
msg := fmt.Sprintf("Could not marshal JSON for entry %s, visitId %s", id, visitId)
292+
logrus.Error(msg)
293+
return errors.Wrap(err, msg)
294+
}
295+
// push the visit data onto a redis list who's key is the url id
296+
key := entryVisitsPrefix + id
297+
result := r.c.LPush(key, data)
298+
if result.Err() != nil {
299+
msg := fmt.Sprintf("Could not register visitor for ID %s", id)
300+
logrus.Error(msg)
301+
return errors.Wrap(result.Err(), msg)
302+
}
303+
return err
304+
}
305+
306+
// Return the full list of visitors for a path.
307+
func (r *Store) GetVisitors(id string) ([]shared.Visitor, error) {
308+
var visitors []shared.Visitor
309+
key := entryVisitsPrefix + id
310+
// TODO: for non-trivial numbers of keys, this could start
311+
// to get hairy; should convert to a paginated Scan operation.
312+
result := r.c.LRange(key, 0, -1)
313+
if result.Err() != nil {
314+
msg := fmt.Sprintf("Could not get visitors for id '%s'", id)
315+
logrus.Error(msg)
316+
return nil, errors.Wrap(result.Err(), msg)
317+
}
318+
for _, v := range result.Val() {
319+
var value shared.Visitor
320+
if err := json.Unmarshal([]byte(v), &value); err != nil {
321+
msg := fmt.Sprintf("Could not unmarshal json for visit '%s': %v", id, err)
322+
logrus.Error(msg)
323+
return nil, errors.Wrap(err, msg)
324+
}
325+
visitors = append(visitors, value)
326+
}
327+
return visitors, nil
328+
}
329+
330+
// IncreaseVisitCounter is a no-op and returns nil for all values.
331+
//
332+
// This function is unnecessary for the redis backend: we already
333+
// have a redis LIST of visitors, and we can derive the visit count
334+
// by calling redis.client.LLen(list) (which is a constant-time op)
335+
// during GetEntryByID(). If we want the timestamp of the most recent
336+
// visit we can pull the most recent visit off with redis.client.LIndex(0)
337+
// (also constant-time) and reading the timetamp field.
338+
func (r *Store) IncreaseVisitCounter(id string) error {
339+
return nil
340+
}
341+
342+
// Close closes the connection to redis.
343+
func (r *Store) Close() error {
344+
err := r.c.Close()
345+
if err != nil {
346+
msg := "Cloud not close the redis connection"
347+
logrus.Error(msg)
348+
return errors.Wrap(err, msg)
349+
}
350+
return err
351+
}

0 commit comments

Comments
 (0)