Skip to content

Commit 14e581a

Browse files
authored
Implement server invite codes (#37)
This introduces the ability for a YeetFile instance admin to send out invites to a list of emails with unique signup codes. These signup codes are tied to the email they're sent to, and are removed after a user completes registration using that email and signup code. The invite codes are not stored in plaintext anywhere. They are hashed with bcrypt before storing in the database, and are only included in plaintext in the outbound email. Enabling invite codes requires the following conditions: - YEETFILE_ALLOW_INVITES set to 1 - YEETFILE_SERVER_PASSWORD set to a non-empty string - YEETFILE_EMAIL_* variables set
1 parent f80b903 commit 14e581a

File tree

18 files changed

+375
-12
lines changed

18 files changed

+375
-12
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ All environment variables can be defined in a file named `.env` at the root leve
325325
| YEETFILE_LIMITER_ATTEMPTS | The number of attempts to allow before rate limiting | 6 | Any number of requests |
326326
| YEETFILE_LOCKDOWN | Disables anonymous (not logged in) interactions | 0 | `1` to enable lockdown, `0` to allow anonymous usage |
327327
| YEETFILE_PROFILING | Enables server profiling on http://localhost:6060 | 0 | `1` to enable, `0` to disable (default) |
328+
| YEETFILE_ALLOW_INVITES | Allows the YeetFile instance admin to send unique invite codes to email addresses -- must also set `YEETFILE_SERVER_PASSWORD` and setup outgoing email (see [Misc Environment Variables](#misc-environment-variables)) | 0 | `1` to enable, `0` to disable (default) |
328329

329330
#### Backblaze Environment Variables
330331

backend/config/config.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ var (
4848
TLSCert = utils.GetEnvVar("YEETFILE_TLS_CERT", "")
4949
TLSKey = utils.GetEnvVar("YEETFILE_TLS_KEY", "")
5050

51-
IsDebugMode = utils.GetEnvVarBool("YEETFILE_DEBUG", false)
52-
IsLockedDown = utils.GetEnvVarBool("YEETFILE_LOCKDOWN", false)
53-
InstanceAdmin = utils.GetEnvVar("YEETFILE_INSTANCE_ADMIN", "")
51+
IsDebugMode = utils.GetEnvVarBool("YEETFILE_DEBUG", false)
52+
IsLockedDown = utils.GetEnvVarBool("YEETFILE_LOCKDOWN", false)
53+
InstanceAdmin = utils.GetEnvVar("YEETFILE_INSTANCE_ADMIN", "")
54+
InvitesAllowed = utils.GetEnvVarBool("YEETFILE_ALLOW_INVITES", false)
5455
)
5556

5657
// =============================================================================
@@ -157,6 +158,14 @@ func init() {
157158
if err != nil {
158159
panic(err)
159160
}
161+
} else if InvitesAllowed {
162+
log.Fatalf("ERROR: You must set YEETFILE_SERVER_PASSWORD if " +
163+
"YEETFILE_ALLOW_INVITES is enabled.")
164+
}
165+
166+
if InvitesAllowed && !email.Configured {
167+
log.Fatal("ERROR: Email must be configured if " +
168+
"YEETFILE_ALLOW_INVITES is enabled.")
160169
}
161170

162171
if slices.Equal(secret, defaultSecret) {

backend/db/invites.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package db
2+
3+
import (
4+
"fmt"
5+
"github.com/lib/pq"
6+
"strings"
7+
)
8+
9+
type Invite struct {
10+
Email string
11+
Code string
12+
CodeHash []byte
13+
}
14+
15+
func AddInviteCodeHashes(inviteCodes []Invite) error {
16+
valueStrings := make([]string, 0, len(inviteCodes))
17+
valueArgs := make([]interface{}, 0, len(inviteCodes)*2)
18+
19+
for i, inviteCode := range inviteCodes {
20+
valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2))
21+
valueArgs = append(valueArgs, inviteCode.Email)
22+
valueArgs = append(valueArgs, inviteCode.CodeHash)
23+
}
24+
25+
stmt := fmt.Sprintf(
26+
"INSERT INTO invites (email, code_hash) VALUES %s",
27+
strings.Join(valueStrings, ","),
28+
)
29+
30+
_, err := db.Exec(stmt, valueArgs...)
31+
return err
32+
}
33+
34+
func GetInviteCodeHash(email string) ([]byte, error) {
35+
var codeHash []byte
36+
s := `SELECT code_hash FROM invites WHERE email=$1`
37+
err := db.QueryRow(s, email).Scan(&codeHash)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
return codeHash, nil
43+
}
44+
45+
func GetInvitesList() ([]string, error) {
46+
var emails []string
47+
s := `SELECT email FROM invites`
48+
rows, err := db.Query(s)
49+
defer rows.Close()
50+
if err != nil {
51+
return nil, err
52+
}
53+
54+
for rows.Next() {
55+
var email string
56+
err = rows.Scan(&email)
57+
if err != nil {
58+
return nil, err
59+
}
60+
emails = append(emails, email)
61+
}
62+
63+
return emails, nil
64+
}
65+
66+
func RemoveInvites(email []string) error {
67+
s := `DELETE FROM invites WHERE email=any($1)`
68+
_, err := db.Exec(s, pq.Array(email))
69+
if err != nil {
70+
return err
71+
}
72+
73+
return nil
74+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
create table if not exists invites
2+
(
3+
email text not null
4+
constraint invites_pk
5+
primary key,
6+
code_hash bytea not null
7+
);

backend/mail/invite.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package mail
2+
3+
import (
4+
"bytes"
5+
"text/template"
6+
"yeetfile/shared/endpoints"
7+
)
8+
9+
type InviteEmail struct {
10+
Code string
11+
Email string
12+
Domain string
13+
Endpoint string
14+
}
15+
16+
var inviteSubject = "YeetFile Invite"
17+
var inviteBodyTemplate = template.Must(template.New("").Parse(
18+
"Hello,\n\nYou have been invited to join a YeetFile instance at this " +
19+
"domain: {{.Domain}}\n\n" +
20+
"YeetFile is an open source platform that allows encrypted file " +
21+
"sharing and storage.\n\n" +
22+
"To create an account, you can use the following link:\n\n" +
23+
"{{.Domain}}{{.Endpoint}}?email={{.Email}}&code={{.Code}}"))
24+
25+
func SendInviteEmail(code string, to string) error {
26+
var buf bytes.Buffer
27+
28+
inviteEmail := InviteEmail{
29+
Code: code,
30+
Email: to,
31+
Domain: smtpConfig.CallbackDomain,
32+
Endpoint: string(endpoints.HTMLSignup),
33+
}
34+
35+
err := inviteBodyTemplate.Execute(&buf, inviteEmail)
36+
if err != nil {
37+
return err
38+
}
39+
40+
body := buf.String()
41+
go sendEmail(to, inviteSubject, body)
42+
return nil
43+
}

backend/server/admin/handlers.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,28 @@ func FileActionHandler(w http.ResponseWriter, req *http.Request, _ string) {
101101

102102
_ = json.NewEncoder(w).Encode(fileInfo)
103103
}
104+
}
105+
106+
func InviteActionsHandler(w http.ResponseWriter, req *http.Request, _ string) {
107+
var inviteAction shared.AdminInviteAction
108+
err := utils.LimitedJSONReader(w, req.Body).Decode(&inviteAction)
109+
if err != nil || len(inviteAction.Emails) == 0 {
110+
http.Error(w, "Invalid request", http.StatusBadRequest)
111+
return
112+
}
104113

114+
switch req.Method {
115+
case http.MethodPost:
116+
err = createInvites(inviteAction.Emails)
117+
if err != nil {
118+
http.Error(w, "Error generating invites", http.StatusInternalServerError)
119+
return
120+
}
121+
case http.MethodDelete:
122+
err = deleteInvites(inviteAction.Emails)
123+
if err != nil {
124+
http.Error(w, "Error deleting invites", http.StatusInternalServerError)
125+
return
126+
}
127+
}
105128
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package admin
2+
3+
import (
4+
"golang.org/x/crypto/bcrypt"
5+
"log"
6+
"yeetfile/backend/db"
7+
"yeetfile/backend/mail"
8+
"yeetfile/shared"
9+
)
10+
11+
func createInvites(emails []string) error {
12+
var invites []db.Invite
13+
for _, email := range emails {
14+
code := shared.GenRandomString(12)
15+
codeHash, err := bcrypt.GenerateFromPassword([]byte(code), 8)
16+
if err != nil {
17+
log.Printf("Error generating invite code: %v\n", err)
18+
return err
19+
}
20+
21+
invite := db.Invite{
22+
Email: email,
23+
Code: code,
24+
CodeHash: codeHash,
25+
}
26+
27+
invites = append(invites, invite)
28+
}
29+
30+
err := db.AddInviteCodeHashes(invites)
31+
if err != nil {
32+
return err
33+
}
34+
35+
for _, invite := range invites {
36+
err = mail.SendInviteEmail(invite.Code, invite.Email)
37+
if err != nil {
38+
return err
39+
}
40+
}
41+
42+
return nil
43+
}
44+
45+
func deleteInvites(emails []string) error {
46+
err := db.RemoveInvites(emails)
47+
if err != nil {
48+
log.Printf("Error deleting invites: %v\n", err)
49+
return err
50+
}
51+
52+
return nil
53+
}

backend/server/auth/handlers.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,25 @@ func SignupHandler(w http.ResponseWriter, req *http.Request) {
6969
err := bcrypt.CompareHashAndPassword(
7070
config.YeetFileConfig.PasswordHash,
7171
[]byte(signupData.ServerPassword))
72+
errMsg := "Missing or invalid server password"
73+
74+
if err != nil && config.InvitesAllowed && len(signupData.Identifier) > 0 {
75+
// Check if the provided password is an invite code
76+
codeHash, dbErr := db.GetInviteCodeHash(signupData.Identifier)
77+
if dbErr == nil && len(codeHash) > 0 {
78+
err = bcrypt.CompareHashAndPassword(
79+
codeHash,
80+
[]byte(signupData.ServerPassword))
81+
errMsg = "Invalid invite code"
82+
}
83+
}
84+
7285
if err != nil {
7386
w.WriteHeader(http.StatusForbidden)
74-
_, _ = w.Write([]byte("Missing or invalid server password"))
87+
_, _ = w.Write([]byte(errMsg))
7588
return
89+
} else if err != nil && config.InvitesAllowed {
90+
7691
}
7792
} else {
7893
signupData.ServerPassword = "-"
@@ -247,6 +262,14 @@ func VerifyEmailHandler(w http.ResponseWriter, req *http.Request) {
247262
}
248263
}
249264

265+
// Remove invite entry
266+
if config.InvitesAllowed {
267+
err = db.RemoveInvites([]string{verifyEmail.Email})
268+
if err != nil {
269+
log.Printf("Error removing invite: %v\n", err)
270+
}
271+
}
272+
250273
// Remove verification entry
251274
_ = db.DeleteVerification(verifyEmail.Email)
252275
_ = session.SetSession(id, w, req)

backend/server/html/handlers.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,10 @@ func DownloadPageHandler(w http.ResponseWriter, req *http.Request) {
154154
}
155155

156156
// SignupPageHandler returns the HTML page for signing up for an account
157-
func SignupPageHandler(w http.ResponseWriter, _ *http.Request) {
157+
func SignupPageHandler(w http.ResponseWriter, req *http.Request) {
158+
inviteEmail := req.URL.Query().Get("email")
159+
inviteCode := req.URL.Query().Get("code")
160+
158161
_ = templates.ServeTemplate(
159162
w,
160163
templates.SignupHTML,
@@ -169,6 +172,8 @@ func SignupPageHandler(w http.ResponseWriter, _ *http.Request) {
169172
},
170173
ServerPasswordRequired: config.YeetFileConfig.PasswordHash != nil,
171174
EmailConfigured: config.YeetFileConfig.Email.Configured,
175+
InviteEmail: inviteEmail,
176+
InviteCode: inviteCode,
172177
},
173178
)
174179
}
@@ -486,6 +491,18 @@ func CheckoutCompleteHandler(w http.ResponseWriter, req *http.Request) {
486491
}
487492

488493
func AdminPageHandler(w http.ResponseWriter, _ *http.Request, id string) {
494+
var (
495+
err error
496+
pendingInvites []string
497+
)
498+
499+
if config.InvitesAllowed {
500+
pendingInvites, err = db.GetInvitesList()
501+
if err != nil {
502+
log.Printf("Error fetching pending invites: %v\n", err)
503+
}
504+
}
505+
489506
_ = templates.ServeTemplate(
490507
w,
491508
templates.AdminHTML,
@@ -498,6 +515,8 @@ func AdminPageHandler(w http.ResponseWriter, _ *http.Request, id string) {
498515
Config: config.HTMLConfig,
499516
Endpoints: endpoints.HTMLPageEndpoints,
500517
},
518+
InvitesAllowed: config.InvitesAllowed,
519+
PendingInvites: pendingInvites,
501520
},
502521
)
503522
}

backend/server/html/templates/admin.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
<h1>Admin</h1>
66
<hr>
77

8+
{{ if .InvitesAllowed }}
9+
<h3>Invites</h3>
10+
{{ if .PendingInvites }}
11+
<div>
12+
<label for="pending-invites">Pending Invites:</label><br>
13+
<select id="pending-invites" multiple size="10">
14+
{{ range $val := .PendingInvites }}
15+
<option value="{{$val}}">{{$val}}</option>
16+
{{ end }}
17+
</select>
18+
<br>
19+
<button id="revoke-invites" class="red-button">Revoke Selected Invites</button>
20+
</div>
21+
<hr class="half-hr">
22+
{{ end }}
23+
24+
<div>
25+
<label for="invite-emails">Insert a comma-separated list of emails to invite:</label><br>
26+
<textarea id="invite-emails"></textarea>
27+
<br>
28+
<button id="send-invites" class="accent-btn">Send Invites</button>
29+
</div>
30+
<hr>
31+
{{ end }}
32+
833
<h3>User Search</h3>
934
<label for="user-id">User ID or Email:</label>
1035
<input type="text" id="user-id" placeholder="[email protected]"><br>

0 commit comments

Comments
 (0)