ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
0
|
// Copyright (C) 2023, Jakob Wakeling |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
1
|
// All rights reserved. |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
2
|
|
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
3
|
package goit |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
4
|
|
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
5
|
import ( |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
6
|
"crypto/rand" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
7
|
"encoding/base64" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
8
|
"fmt" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
9
|
"log" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
10
|
"net/http" |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
11
|
"strconv" |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
12
|
"strings" |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
13
|
"sync" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
14
|
"time" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
15
|
|
d631c5e |
Jakob Wakeling |
2023-07-20 23:13:39 |
16
|
"github.com/Jamozed/Goit/src/util" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
17
|
"golang.org/x/crypto/argon2" |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
18
|
) |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
19
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
20
|
type Session struct { |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
21
|
Token, Ip string |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
22
|
Seen, Expiry time.Time |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
23
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
24
|
|
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
25
|
var Sessions = map[int64][]Session{} |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
26
|
var SessionsMutex = sync.RWMutex{} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
27
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
28
|
/* Generate a new user session. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
29
|
func NewSession(uid int64, ip string, expiry time.Time) (Session, error) { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
30
|
var b = make([]byte, 24) |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
31
|
if _, err := rand.Read(b); err != nil { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
32
|
return Session{}, err |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
33
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
34
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
35
|
var t = base64.StdEncoding.EncodeToString(b) |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
36
|
var s = Session{Token: t, Ip: util.If(Conf.IpSessions, ip, ""), Seen: time.Now(), Expiry: expiry} |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
37
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
38
|
SessionsMutex.Lock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
39
|
util.Debugln("[goit.NewSession] SessionsMutex lock") |
5166d87 |
Jakob Wakeling |
2023-10-23 16:00:35 |
40
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
41
|
if Sessions[uid] == nil { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
42
|
Sessions[uid] = []Session{} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
43
|
} |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
44
|
|
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
45
|
Sessions[uid] = append(Sessions[uid], s) |
5166d87 |
Jakob Wakeling |
2023-10-23 16:00:35 |
46
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
47
|
SessionsMutex.Unlock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
48
|
util.Debugln("[goit.EndSession] SessionsMutex unlock") |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
49
|
|
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
50
|
return s, nil |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
51
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
52
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
53
|
/* End a user session. */ |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
54
|
func EndSession(uid int64, token string) { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
55
|
SessionsMutex.Lock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
56
|
util.Debugln("[goit.EndSession] SessionsMutex lock") |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
57
|
defer SessionsMutex.Unlock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
58
|
defer util.Debugln("[goit.EndSession] SessionsMutex unlock") |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
59
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
60
|
if Sessions[uid] == nil { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
61
|
return |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
62
|
} |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
63
|
|
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
64
|
for i, t := range Sessions[uid] { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
65
|
if t.Token == token { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
66
|
Sessions[uid] = append(Sessions[uid][:i], Sessions[uid][i+1:]...) |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
67
|
break |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
68
|
} |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
69
|
} |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
70
|
|
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
71
|
if len(Sessions[uid]) == 0 { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
72
|
delete(Sessions, uid) |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
73
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
74
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
75
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
76
|
/* Cleanup expired user sessions. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
77
|
func CleanupSessions() { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
78
|
var n int = 0 |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
79
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
80
|
SessionsMutex.Lock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
81
|
util.Debugln("[goit.CleanupSessions] SessionsMutex lock") |
5166d87 |
Jakob Wakeling |
2023-10-23 16:00:35 |
82
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
83
|
for uid, v := range Sessions { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
84
|
var i = 0 |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
85
|
for _, s := range v { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
86
|
if s.Expiry.After(time.Now()) { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
87
|
v[i] = s |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
88
|
i += 1 |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
89
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
90
|
} |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
91
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
92
|
n += len(v) - i |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
93
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
94
|
if i == 0 { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
95
|
delete(Sessions, uid) |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
96
|
} else { |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
97
|
Sessions[uid] = v[:i] |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
98
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
99
|
} |
5166d87 |
Jakob Wakeling |
2023-10-23 16:00:35 |
100
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
101
|
SessionsMutex.Unlock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
102
|
util.Debugln("[goit.CleanupSessions] SessionsMutex unlock") |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
103
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
104
|
if n > 0 { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
105
|
log.Println("[Cleanup] cleaned up", n, "expired sessions") |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
106
|
} |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
107
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
108
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
109
|
/* Set a user session cookie. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
110
|
func SetSessionCookie(w http.ResponseWriter, uid int64, s Session) { |
b804701 |
Jakob Wakeling |
2023-11-27 23:52:28 |
111
|
c := &http.Cookie{ |
b804701 |
Jakob Wakeling |
2023-11-27 23:52:28 |
112
|
Name: "session", Value: fmt.Sprint(uid) + "." + s.Token, Path: "/", Expires: s.Expiry, |
b804701 |
Jakob Wakeling |
2023-11-27 23:52:28 |
113
|
Secure: util.If(Conf.UsesHttps, true, false), HttpOnly: true, SameSite: http.SameSiteLaxMode, |
b804701 |
Jakob Wakeling |
2023-11-27 23:52:28 |
114
|
} |
b804701 |
Jakob Wakeling |
2023-11-27 23:52:28 |
115
|
|
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
116
|
if err := c.Valid(); err != nil { |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
117
|
log.Println("[Cookie]", err.Error()) |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
118
|
} |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
119
|
|
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
120
|
http.SetCookie(w, c) |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
121
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
122
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
123
|
/* Get a user session cookie if one is present. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
124
|
func GetSessionCookie(r *http.Request) (int64, Session) { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
125
|
if c := util.Cookie(r, "session"); c != nil { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
126
|
ss := strings.SplitN(c.Value, ".", 2) |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
127
|
if len(ss) != 2 { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
128
|
return -1, Session{} |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
129
|
} |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
130
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
131
|
uid, err := strconv.ParseInt(ss[0], 10, 64) |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
132
|
if err != nil { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
133
|
return -1, Session{} |
a0ba6ae |
Jakob Wakeling |
2023-07-21 14:52:36 |
134
|
} |
a0ba6ae |
Jakob Wakeling |
2023-07-21 14:52:36 |
135
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
136
|
SessionsMutex.Lock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
137
|
util.Debugln("[goit.GetSessionCookie] SessionsMutex lock") |
5166d87 |
Jakob Wakeling |
2023-10-23 16:00:35 |
138
|
defer SessionsMutex.Unlock() |
570144e |
Jakob Wakeling |
2023-12-15 23:28:06 |
139
|
defer util.Debugln("[goit.GetSessionCookie] SessionsMutex unlock") |
5166d87 |
Jakob Wakeling |
2023-10-23 16:00:35 |
140
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
141
|
for i, s := range Sessions[uid] { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
142
|
if ss[1] == s.Token { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
143
|
if s != (Session{}) { |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
144
|
s.Seen = time.Now() |
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
145
|
Sessions[uid][i] = s |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
146
|
} |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
147
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
148
|
return uid, s |
6fb9830 |
Jakob Wakeling |
2023-09-28 18:04:37 |
149
|
} |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
150
|
} |
52b258d |
Jakob Wakeling |
2023-07-21 19:22:31 |
151
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
152
|
return uid, Session{} |
a0ba6ae |
Jakob Wakeling |
2023-07-21 14:52:36 |
153
|
} |
a0ba6ae |
Jakob Wakeling |
2023-07-21 14:52:36 |
154
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
155
|
return -1, Session{} |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
156
|
} |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
157
|
|
410f65b |
Jakob Wakeling |
2023-10-23 15:14:42 |
158
|
/* End the current user session cookie. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
159
|
func EndSessionCookie(w http.ResponseWriter) { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
160
|
http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", MaxAge: -1}) |
a0ba6ae |
Jakob Wakeling |
2023-07-21 14:52:36 |
161
|
} |
a0ba6ae |
Jakob Wakeling |
2023-07-21 14:52:36 |
162
|
|
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
163
|
/* Authenticate a user session, returns auth, user, error. */ |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
164
|
func Auth(w http.ResponseWriter, r *http.Request, renew bool) (bool, *User, error) { |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
165
|
uid, s := GetSessionCookie(r) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
166
|
if s == (Session{}) { |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
167
|
return false, nil, nil |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
168
|
} |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
169
|
|
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
170
|
/* Attempt to get the user associated with the session UID */ |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
171
|
user, err := GetUser(uid) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
172
|
if err != nil { |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
173
|
return false, nil, fmt.Errorf("[auth] %w", err) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
174
|
} |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
175
|
|
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
176
|
/* End invalid and expired sessions */ |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
177
|
if user == nil || s.Expiry.Before(time.Now()) { |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
178
|
EndSession(uid, s.Token) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
179
|
return false, nil, nil |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
180
|
} |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
181
|
|
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
182
|
/* Renew the session if appropriate */ |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
183
|
if renew && time.Until(s.Expiry) < 24*time.Hour { |
e530f2c |
Jakob Wakeling |
2023-12-17 22:28:16 |
184
|
ip := Ip(r) |
e530f2c |
Jakob Wakeling |
2023-12-17 22:28:16 |
185
|
|
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
186
|
s1, err := NewSession(uid, ip, time.Now().Add(2*24*time.Hour)) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
187
|
if err != nil { |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
188
|
log.Println("[auth/renew]", err.Error()) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
189
|
} else { |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
190
|
SetSessionCookie(w, uid, s1) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
191
|
EndSession(uid, s.Token) |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
192
|
} |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
193
|
} |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
194
|
|
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
195
|
return true, user, nil |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
196
|
} |
7974d70 |
Jakob Wakeling |
2023-11-03 22:19:52 |
197
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
198
|
/* Hash a password with a salt using Argon2. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
199
|
func Hash(pass string, salt []byte) []byte { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
200
|
return argon2.IDKey([]byte(pass), salt, 3, 64*1024, 4, 32) |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
201
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
202
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
203
|
/* Generate a random Base64 salt. */ |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
204
|
func Salt() ([]byte, error) { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
205
|
b := make([]byte, 16) |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
206
|
if _, err := rand.Read(b); err != nil { |
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
207
|
return nil, err |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
208
|
} |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
209
|
|
0893c1e |
Jakob Wakeling |
2023-07-21 17:11:15 |
210
|
return b, nil |
ae5fc19 |
Jakob Wakeling |
2023-07-17 21:54:54 |
211
|
} |
|
|
|
212
|
|