Support open invites (fixes #102)

This commit is contained in:
Henry 2021-05-14 15:11:29 +02:00 committed by Henry
parent 385b98a3a1
commit 81c05a663d
14 changed files with 103 additions and 28 deletions

View File

@ -140,7 +140,7 @@ type AliasesService interface {
// InvitesService manages creation and consumption of invite tokens for joining the room. // InvitesService manages creation and consumption of invite tokens for joining the room.
type InvitesService interface { type InvitesService interface {
// Create creates a new invite for a new member. It returns the token or an error. // Create creates a new invite for a new member. It returns the token or an error.
// createdBy is user ID of the admin or moderator who created it. // createdBy is user ID of the admin or moderator who created it. MemberID -1 is allowed if Privacy Mode is set to Open.
// aliasSuggestion is optional (empty string is fine) but can be used to disambiguate open invites. (See https://github.com/ssb-ngi-pointer/rooms2/issues/21) // aliasSuggestion is optional (empty string is fine) but can be used to disambiguate open invites. (See https://github.com/ssb-ngi-pointer/rooms2/issues/21)
Create(ctx context.Context, createdBy int64) (string, error) Create(ctx context.Context, createdBy int64) (string, error)

View File

@ -42,6 +42,27 @@ func (i Invites) Create(ctx context.Context, createdBy int64) (string, error) {
err := transact(i.db, func(tx *sql.Tx) error { err := transact(i.db, func(tx *sql.Tx) error {
if createdBy == -1 {
config, err := models.FindConfig(ctx, tx, configRowID)
if err != nil {
return err
}
if config.PrivacyMode != roomdb.ModeOpen {
return fmt.Errorf("roomdb: privacy mode not set to open but %s", config.PrivacyMode.String())
}
m, err := models.Members(qm.Where("role = ?", roomdb.RoleAdmin)).One(ctx, tx)
if err != nil {
// we could insert something like a system user but should probably hit it from the members list then
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("roomdb: no admin user available to associate invite to")
}
return err
}
newInvite.CreatedBy = m.ID
}
inserted := false inserted := false
trying: trying:
for tries := 100; tries > 0; tries-- { for tries := 100; tries > 0; tries-- {
@ -68,7 +89,7 @@ func (i Invites) Create(ctx context.Context, createdBy int64) (string, error) {
} }
if !inserted { if !inserted {
return errors.New("admindb: failed to generate an invite token in a reasonable amount of time") return errors.New("roomdb: failed to generate an invite token in a reasonable amount of time")
} }
return nil return nil
@ -139,7 +160,7 @@ func (i Invites) Consume(ctx context.Context, token string, newMember refs.FeedR
func deleteConsumedInvites(tx boil.ContextExecutor) error { func deleteConsumedInvites(tx boil.ContextExecutor) error {
_, err := models.Invites(qm.Where("active = false")).DeleteAll(context.Background(), tx) _, err := models.Invites(qm.Where("active = false")).DeleteAll(context.Background(), tx)
if err != nil { if err != nil {
return fmt.Errorf("admindb: failed to delete used invites: %w", err) return fmt.Errorf("roomdb: failed to delete used invites: %w", err)
} }
return nil return nil
} }
@ -269,7 +290,7 @@ func getHashedToken(b64tok string) (string, error) {
} }
if n := len(tokenBytes); n != inviteTokenLength { if n := len(tokenBytes); n != inviteTokenLength {
return "", fmt.Errorf("admindb: invalid invite token length (only got %d bytes)", n) return "", fmt.Errorf("roomdb: invalid invite token length (only got %d bytes)", n)
} }
// hash the binary of the passed token // hash the binary of the passed token

View File

@ -116,30 +116,21 @@ func localizeError(ih *i18n.Localizer, err error) (int, template.HTML) {
msg = ih.LocalizeSimple("ErrorNotFound") msg = ih.LocalizeSimple("ErrorNotFound")
case errors.As(err, &aa): case errors.As(err, &aa):
msg = ih.LocalizeWithData("ErrorAlreadyAdded", map[string]string{ msg = ih.LocalizeWithData("ErrorAlreadyAdded", "Feed", aa.Ref.Ref())
"Feed": aa.Ref.Ref(),
})
case errors.As(err, &pnf): case errors.As(err, &pnf):
code = http.StatusNotFound code = http.StatusNotFound
msg = ih.LocalizeWithData("ErrorPageNotFound", map[string]string{ msg = ih.LocalizeWithData("ErrorPageNotFound", "Path", pnf.Path)
"Path": pnf.Path,
})
case errors.As(err, &br): case errors.As(err, &br):
code = http.StatusBadRequest code = http.StatusBadRequest
// TODO: we could localize all the "Where:" as labels, too // TODO: we could localize all the "Where:" as labels, too
// buttt it feels like overkill right now // buttt it feels like overkill right now
msg = ih.LocalizeWithData("ErrorBadRequest", map[string]string{ msg = ih.LocalizeWithData("ErrorBadRequest", "Where", br.Where, "Details", br.Details.Error())
"Where": br.Where,
"Details": br.Details.Error(),
})
case errors.As(err, &f): case errors.As(err, &f):
code = http.StatusForbidden code = http.StatusForbidden
msg = ih.LocalizeWithData("ErrorForbidden", map[string]string{ msg = ih.LocalizeWithData("ErrorForbidden", "Details", f.Details.Error())
"Details": f.Details.Error(),
})
} }
return code, msg return code, msg

View File

@ -147,8 +147,6 @@ func Handler(
db: dbs.Invites, db: dbs.Invites,
config: dbs.Config, config: dbs.Config,
domainName: netInfo.Domain,
} }
mux.HandleFunc("/invites", r.HTML("admin/invite-list.tmpl", ih.overview)) mux.HandleFunc("/invites", r.HTML("admin/invite-list.tmpl", ih.overview))

View File

@ -23,8 +23,6 @@ type invitesHandler struct {
db roomdb.InvitesService db roomdb.InvitesService
config roomdb.RoomConfig config roomdb.RoomConfig
domainName string
} }
func (h invitesHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) { func (h invitesHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
@ -73,8 +71,6 @@ func (h invitesHandler) create(w http.ResponseWriter, req *http.Request) (interf
} }
facadeURL := h.urlTo(router.CompleteInviteFacade, "token", token) facadeURL := h.urlTo(router.CompleteInviteFacade, "token", token)
facadeURL.Host = h.domainName
facadeURL.Scheme = "https"
return map[string]interface{}{ return map[string]interface{}{
"FacadeURL": facadeURL.String(), "FacadeURL": facadeURL.String(),

View File

@ -144,6 +144,14 @@ func newSession(t *testing.T) *testSession {
testFuncs["urlToNotice"] = func(name string) string { return "" } testFuncs["urlToNotice"] = func(name string) string { return "" }
testFuncs["language_count"] = func() int { return 1 } testFuncs["language_count"] = func() int { return 1 }
testFuncs["list_languages"] = func(*url.URL, string) string { return "" } testFuncs["list_languages"] = func(*url.URL, string) string { return "" }
testFuncs["privacy_mode_is"] = func(mode string) bool {
pm, err := ts.ConfigDB.GetPrivacyMode(context.TODO())
if err != nil {
t.Fatal(err)
}
return pm.String() == mode
}
testFuncs["member_is_elevated"] = func() bool { return ts.User.Role == roomdb.RoleAdmin || ts.User.Role == roomdb.RoleModerator } testFuncs["member_is_elevated"] = func() bool { return ts.User.Role == roomdb.RoleAdmin || ts.User.Role == roomdb.RoleModerator }
testFuncs["member_is_admin"] = func() bool { return ts.User.Role == roomdb.RoleAdmin } testFuncs["member_is_admin"] = func() bool { return ts.User.Role == roomdb.RoleAdmin }
testFuncs["member_can"] = func(what string) (bool, error) { testFuncs["member_can"] = func(what string) (bool, error) {

View File

@ -110,6 +110,16 @@ func New(
render.SetErrorHandler(eh.Handle), render.SetErrorHandler(eh.Handle),
render.FuncMap(web.TemplateFuncs(m, netInfo)), render.FuncMap(web.TemplateFuncs(m, netInfo)),
render.InjectTemplateFunc("privacy_mode_is", func(r *http.Request) interface{} {
return func(want string) bool {
has, err := dbs.Config.GetPrivacyMode(r.Context())
if err != nil {
return false
}
return has.String() == want
}
}),
render.InjectTemplateFunc("current_page_is", func(r *http.Request) interface{} { render.InjectTemplateFunc("current_page_is", func(r *http.Request) interface{} {
return func(routeName string) bool { return func(routeName string) bool {
route := m.Get(routeName) route := m.Get(routeName)
@ -351,6 +361,7 @@ func New(
m.Get(router.CompleteInviteFacadeFallback).Handler(r.HTML("invite/facade-fallback.tmpl", ih.presentFacadeFallback)) m.Get(router.CompleteInviteFacadeFallback).Handler(r.HTML("invite/facade-fallback.tmpl", ih.presentFacadeFallback))
m.Get(router.CompleteInviteInsertID).Handler(r.HTML("invite/insert-id.tmpl", ih.presentInsert)) m.Get(router.CompleteInviteInsertID).Handler(r.HTML("invite/insert-id.tmpl", ih.presentInsert))
m.Get(router.CompleteInviteConsume).HandlerFunc(ih.consume) m.Get(router.CompleteInviteConsume).HandlerFunc(ih.consume)
m.Get(router.OpenModeCreateInvite).HandlerFunc(r.HTML("admin/invite-created.tmpl", ih.createOpenMode))
// static assets // static assets
m.PathPrefix("/assets/").Handler(http.StripPrefix("/assets/", http.FileServer(web.Assets))) m.PathPrefix("/assets/").Handler(http.StripPrefix("/assets/", http.FileServer(web.Assets)))

View File

@ -345,3 +345,18 @@ func (html inviteConsumeHTMLResponder) SendSuccess() {
func (html inviteConsumeHTMLResponder) SendError(err error) { func (html inviteConsumeHTMLResponder) SendError(err error) {
html.renderer.Error(html.rw, html.req, http.StatusInternalServerError, err) html.renderer.Error(html.rw, html.req, http.StatusInternalServerError, err)
} }
func (h inviteHandler) createOpenMode(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
ctx := req.Context()
token, err := h.invites.Create(ctx, -1)
if err != nil {
return nil, err
}
facadeURL := h.urlTo(router.CompleteInviteFacade, "token", token)
return map[string]interface{}{
"FacadeURL": facadeURL.String(),
}, nil
}

View File

@ -161,14 +161,14 @@ AdminInviteSuggestedAliasIs = "Der vorgeschlagene Alias lautet:"
AdminInviteSuggestedAliasIsShort = "Alias:" AdminInviteSuggestedAliasIsShort = "Alias:"
AdminInviteCreatedTitle = "Einladung erfolgreich erstellt!" AdminInviteCreatedTitle = "Einladung erfolgreich erstellt!"
AdminInviteCreatedInstruct = "Kopieren Sie nun den folgenden Link und fügen Sie ihn in einen Freund ein, den Sie in diesen Raum einladen möchten." AdminInviteCreatedInstruct = "Kopieren Sie nun den folgenden Link und geben Sie ihn an den Entfänger weiter, den Sie in diesen Raum einladen möchten."
# public invites # public invites
################ ################
InviteFacade = "Raum betreten" InviteFacade = "Raum betreten"
InviteFacadeTitle = "Raum betreten" InviteFacadeTitle = "Raum betreten"
InviteFacadeWelcome = "Sie haben die Erlaubnis, Mitglied dieses Raums zu werden, weil jemand diese Einladung mit Ihnen geteilt hat." InviteFacadeWelcome = "Willkommen bei {{.RoomTitle}}! Sie haben die Erlaubnis, Mitglied dieses Raums zu werden, weil jemand diese Einladung mit Ihnen geteilt hat."
InviteFacadeInstruct = "Um die Einladung zu erhalten, klicken Sie auf die Schaltfläche unten, um eine kompatible SSB-App zu öffnen, falls diese installiert ist." InviteFacadeInstruct = "Um die Einladung zu erhalten, klicken Sie auf die Schaltfläche unten, um eine kompatible SSB-App zu öffnen, falls diese installiert ist."
InviteFacadeJoin = "Tritt diesem Raum bei" InviteFacadeJoin = "Tritt diesem Raum bei"
InviteFacadeWaiting = "SSB App öffnen" InviteFacadeWaiting = "SSB App öffnen"
@ -183,6 +183,13 @@ InviteInsertWelcome = "Sie können Ihre Einladung anfordern, indem Sie unten Ihr
InviteConsumedTitle = "Einladung angenommen!" InviteConsumedTitle = "Einladung angenommen!"
InviteConsumedWelcome = "Sie sind jetzt Mitglied dieses Raums. Wenn Sie eine Multiserver-Adresse benötigen, um eine Verbindung zum Raum herzustellen, können Sie die folgende kopieren und einfügen:" InviteConsumedWelcome = "Sie sind jetzt Mitglied dieses Raums. Wenn Sie eine Multiserver-Adresse benötigen, um eine Verbindung zum Raum herzustellen, können Sie die folgende kopieren und einfügen:"
# ssb uri links
###############
SSBURIOpening = "SSB App öffnen "
SSBURIFailureWelcome = "Sind Sie neu bei SSB? Es scheint, dass Sie keine SSB-App haben, die diesen Link verstehen kann. Sie können eine dieser Apps installieren: "
SSBURIFailureInstallManyverse = "Manyverse Installieren"
# notices (mini-CMS) # notices (mini-CMS)
#################### ####################

View File

@ -175,7 +175,7 @@ AdminInviteCreatedInstruct = "Now, copy the link below and paste it to a friend
InviteFacade = "Join Room" InviteFacade = "Join Room"
InviteFacadeTitle = "Join Room" InviteFacadeTitle = "Join Room"
InviteFacadeWelcome = "You have permission to become a member of this room because someone has shared this invite with you." InviteFacadeWelcome = "Welcome to {{.RoomTitle}}! You have permission to become a member of this room because someone has shared this invite with you."
InviteFacadeInstruct = "To claim the invite, press the button below which will open a compatible SSB app, if it's installed." InviteFacadeInstruct = "To claim the invite, press the button below which will open a compatible SSB app, if it's installed."
InviteFacadeJoin = "Join this room" InviteFacadeJoin = "Join this room"
InviteFacadeInstructQR = "If your SSB app is on another device, you can scan the following QR code to claim your invite on that device:" InviteFacadeInstructQR = "If your SSB app is on another device, you can scan the following QR code to claim your invite on that device:"

View File

@ -245,6 +245,11 @@ func (h Helper) GetRenderFuncs() []render.Option {
loc := h.FromRequest(r) loc := h.FromRequest(r)
return loc.LocalizeSimple return loc.LocalizeSimple
}), }),
render.InjectTemplateFunc("i18nWithData", func(r *http.Request) interface{} {
loc := h.FromRequest(r)
return loc.LocalizeWithData
}),
} }
return opts return opts
} }
@ -260,7 +265,19 @@ func (l Localizer) LocalizeSimple(messageID string) template.HTML {
panic(fmt.Sprintf("i18n/error: failed to localize label %s: %s", messageID, err)) panic(fmt.Sprintf("i18n/error: failed to localize label %s: %s", messageID, err))
} }
func (l Localizer) LocalizeWithData(messageID string, tplData map[string]string) template.HTML { func (l Localizer) LocalizeWithData(messageID string, labelsAndData ...string) template.HTML {
n := len(labelsAndData)
if n%2 != 0 {
panic(fmt.Errorf("expected an even amount of labels and data. got %d", n))
}
tplData := make(map[string]string, n/2)
for i := 0; i < n; i += 2 {
key := labelsAndData[i]
data := labelsAndData[i+1]
tplData[key] = data
}
msg, err := l.loc.Localize(&i18n.LocalizeConfig{ msg, err := l.loc.Localize(&i18n.LocalizeConfig{
MessageID: messageID, MessageID: messageID,
TemplateData: tplData, TemplateData: tplData,

View File

@ -24,6 +24,8 @@ const (
MembersChangePasswordForm = "members:change-password:form" MembersChangePasswordForm = "members:change-password:form"
MembersChangePassword = "members:change-password" MembersChangePassword = "members:change-password"
OpenModeCreateInvite = "open:invites:create"
) )
// CompleteApp constructs a mux.Router containing the routes for batch Complete html frontend // CompleteApp constructs a mux.Router containing the routes for batch Complete html frontend
@ -40,6 +42,7 @@ func CompleteApp() *mux.Router {
m.Path("/members/change-password").Methods("GET").Name(MembersChangePasswordForm) m.Path("/members/change-password").Methods("GET").Name(MembersChangePasswordForm)
m.Path("/members/change-password").Methods("POST").Name(MembersChangePassword) m.Path("/members/change-password").Methods("POST").Name(MembersChangePassword)
m.Path("/create-invite").Methods("GET").Name(OpenModeCreateInvite)
m.Path("/join").Methods("GET").Name(CompleteInviteFacade) m.Path("/join").Methods("GET").Name(CompleteInviteFacade)
m.Path("/join-fallback").Methods("GET").Name(CompleteInviteFacadeFallback) m.Path("/join-fallback").Methods("GET").Name(CompleteInviteFacadeFallback)
m.Path("/join-manually").Methods("GET").Name(CompleteInviteInsertID) m.Path("/join-manually").Methods("GET").Name(CompleteInviteInsertID)

View File

@ -46,10 +46,18 @@
>{{i18n "AuthSignOut"}}</a> >{{i18n "AuthSignOut"}}</a>
</span> </span>
{{else}} {{else}}
<span class="divide-x divide-gray-300">
{{if privacy_mode_is "ModeOpen"}}
<a
href="{{urlTo "open:invites:create"}}"
class="pl-3 pr-4 py-2 sm:py-1 font-semibold text-sm text-gray-500 hover:text-green-500"
>{{i18n "AdminInvitesCreate"}}</a>
{{end}}
<a <a
href="{{urlTo "auth:login"}}" href="{{urlTo "auth:login"}}"
class="pl-3 pr-4 py-2 sm:py-1 font-semibold text-sm text-gray-500 hover:text-green-500" class="pl-3 pr-4 py-2 sm:py-1 font-semibold text-sm text-gray-500 hover:text-green-500"
>{{i18n "AuthSignIn"}}</a> >{{i18n "AuthSignIn"}}</a>
</span>
{{end}} {{end}}
</div> </div>

View File

@ -1,7 +1,7 @@
{{ define "title" }}{{ i18n "InviteFacadeTitle" }}{{ end }} {{ define "title" }}{{ i18n "InviteFacadeTitle" }}{{ end }}
{{ define "content" }} {{ define "content" }}
<div class="flex flex-col justify-center items-center self-center max-w-lg"> <div class="flex flex-col justify-center items-center self-center max-w-lg">
<p id="welcome" class="text-center mt-8 italic">Welcome to {{.RoomTitle}}! {{i18n "InviteFacadeWelcome"}}</p> <p id="welcome" class="text-center mt-8 italic">{{i18nWithData "InviteFacadeWelcome" "RoomTitle" .RoomTitle}}</p>
<p class="text-center mt-3">{{i18n "InviteFacadeInstruct"}}</p> <p class="text-center mt-3">{{i18n "InviteFacadeInstruct"}}</p>
<a <a