diff --git a/public/discord-login.svg b/public/discord-login.svg new file mode 100644 index 0000000..0e1c8fb --- /dev/null +++ b/public/discord-login.svg @@ -0,0 +1,24 @@ + + + diff --git a/public/style.css b/public/style.css index e2f0530..574382b 100644 --- a/public/style.css +++ b/public/style.css @@ -4602,7 +4602,7 @@ code, .code { .pa2, .tab, figure, header .root-item > a, header .submenu > a { padding: 0.5rem; } -.pa3, header #login-popup { +.pa3 { padding: 1rem; } .pa4 { @@ -7422,7 +7422,7 @@ article code { color: #ccc; color: var(--theme-color-dimmest); } -.b--dimmest, .optionbar, blockquote, .post-content th, .post-content td, header #login-popup { +.b--dimmest, .optionbar, blockquote, .post-content th, .post-content td { border-color: #bbb; border-color: var(--dimmest-color); } @@ -8936,25 +8936,19 @@ header #login-popup { background-color: var(--login-popup-background); color: black; color: var(--fg-font-color); - border-width: 1px; - border-style: dashed; visibility: hidden; position: absolute; z-index: 12; - margin-top: 10px; - right: 0px; - top: 20px; - width: 290px; - max-height: 0px; overflow: hidden; - opacity: 0; - transition: all 0.2s; } + right: 0; + top: 100%; + width: 100%; } header #login-popup.open { - max-height: 170px; - opacity: 1; visibility: visible; } - header #login-popup label { - padding-right: 10px; } + @media screen and (min-width: 35em) { + header #login-popup { + top: 2.2rem; + width: 17rem; } } @font-face { font-family: icons; diff --git a/src/auth/session.go b/src/auth/session.go index 997704d..61c0648 100644 --- a/src/auth/session.go +++ b/src/auth/session.go @@ -24,7 +24,7 @@ const CSRFFieldName = "csrf_token" const sessionDuration = time.Hour * 24 * 14 -func makeSessionId() string { +func MakeSessionId() string { idBytes := make([]byte, 40) _, err := io.ReadFull(rand.Reader, idBytes) if err != nil { @@ -47,7 +47,16 @@ func makeCSRFToken() string { var ErrNoSession = errors.New("no session found") func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) { - sess, err := db.QueryOne[models.Session](ctx, conn, "SELECT $columns FROM session WHERE id = $1", id) + sess, err := db.QueryOne[models.Session](ctx, conn, + ` + SELECT $columns + FROM session + WHERE + id = $1 + AND expires_at > CURRENT_TIMESTAMP + `, + id, + ) if err != nil { if errors.Is(err, db.NotFound) { return nil, ErrNoSession @@ -61,7 +70,7 @@ func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Ses func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) { session := models.Session{ - ID: makeSessionId(), + ID: MakeSessionId(), Username: username, ExpiresAt: time.Now().Add(sessionDuration), CSRFToken: makeCSRFToken(), @@ -134,7 +143,16 @@ func DeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) (int64, erro return tag.RowsAffected(), nil } -func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) jobs.Job { +func DeleteExpiredPendingLogins(ctx context.Context, conn *pgxpool.Pool) (int64, error) { + tag, err := conn.Exec(ctx, "DELETE FROM pending_login WHERE expires_at <= CURRENT_TIMESTAMP") + if err != nil { + return 0, oops.New(err, "failed to delete expired pending logins") + } + + return tag.RowsAffected(), nil +} + +func PeriodicallyDeleteExpiredStuff(ctx context.Context, conn *pgxpool.Pool) jobs.Job { job := jobs.New() go func() { defer job.Done() @@ -145,6 +163,7 @@ func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) case <-t.C: err := func() (err error) { defer utils.RecoverPanicAsError(&err) + n, err := DeleteExpiredSessions(ctx, conn) if err == nil { if n > 0 { @@ -153,10 +172,20 @@ func PeriodicallyDeleteExpiredSessions(ctx context.Context, conn *pgxpool.Pool) } else { logging.Error().Err(err).Msg("Failed to delete expired sessions") } + + n, err = DeleteExpiredPendingLogins(ctx, conn) + if err == nil { + if n > 0 { + logging.Info().Int64("num deleted pending logins", n).Msg("Deleted expired pending logins") + } + } else { + logging.Error().Err(err).Msg("Failed to delete expired pending logins") + } + return nil }() if err != nil { - logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteExpiredSessions") + logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteExpiredStuff") } case <-ctx.Done(): return diff --git a/src/discord/message_handling.go b/src/discord/message_handling.go index cfd9df1..6cbc2da 100644 --- a/src/discord/message_handling.go +++ b/src/discord/message_handling.go @@ -431,7 +431,7 @@ var discordDownloadClient = &http.Client{ type DiscordResourceBadStatusCode error -func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) { +func DownloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, "", oops.New(err, "failed to make Discord download request") @@ -491,7 +491,7 @@ func saveAttachment( height = *attachment.Height } - content, _, err := downloadDiscordResource(ctx, attachment.Url) + content, _, err := DownloadDiscordResource(ctx, attachment.Url) if err != nil { return nil, oops.New(err, "failed to download Discord attachment") } @@ -561,7 +561,7 @@ func saveEmbed( } maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) { - content, contentType, err := downloadDiscordResource(ctx, *i.Url) + content, contentType, err := DownloadDiscordResource(ctx, *i.Url) if err != nil { var statusError DiscordResourceBadStatusCode if errors.As(err, &statusError) { @@ -838,7 +838,8 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in } // TODO(asaf): I believe this will also match https://example.com?hello=1&whatever=5 -// Probably need to add word boundaries. +// +// Probably need to add word boundaries. var REDiscordTag = regexp.MustCompile(`&([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)`) func getDiscordTags(content string) []string { diff --git a/src/discord/payloads.go b/src/discord/payloads.go index d477d22..c103894 100644 --- a/src/discord/payloads.go +++ b/src/discord/payloads.go @@ -346,6 +346,7 @@ type User struct { Avatar *string `json:"avatar"` IsBot bool `json:"bot"` Locale string `json:"locale"` + Email string `json:"email"` } func UserFromMap(m interface{}, k string) *User { @@ -387,8 +388,9 @@ func GuildFromMap(m interface{}, k string) *Guild { // https://discord.com/developers/docs/resources/guild#guild-member-object type GuildMember struct { - User *User `json:"user"` - Nick *string `json:"nick"` + User *User `json:"user"` + Nick *string `json:"nick"` + Avatar *string `json:"avatar"` // more fields not yet handled here } @@ -409,8 +411,9 @@ func GuildMemberFromMap(m interface{}, k string) *GuildMember { } gm := &GuildMember{ - User: UserFromMap(m, "user"), - Nick: maybeStringP(mmap, "nick"), + User: UserFromMap(m, "user"), + Nick: maybeStringP(mmap, "nick"), + Avatar: maybeStringP(mmap, "avatar"), } return gm diff --git a/src/discord/ratelimiting.go b/src/discord/ratelimiting.go index 8d74e1d..1f9e44b 100644 --- a/src/discord/ratelimiting.go +++ b/src/discord/ratelimiting.go @@ -110,7 +110,7 @@ func createLimiter(headers rateLimitHeaders, routeName string) { buckets.Store(routeName, headers.Bucket) ilimiter, loaded := rateLimiters.LoadOrStore(headers.Bucket, &restRateLimiter{ - requests: make(chan struct{}, 100), // presumably this is big enough to handle bursts + requests: make(chan struct{}, 200), // presumably this is big enough to handle bursts refills: make(chan rateLimiterRefill), }) if !loaded { @@ -124,7 +124,9 @@ func createLimiter(headers rateLimitHeaders, routeName string) { select { case limiter.requests <- struct{}{}: default: - log.Warn().Msg("rate limiting channel was too small; you should increase the default capacity") + log.Warn(). + Int("remaining", headers.Remaining). + Msg("rate limiting channel was too small; you should increase the default capacity") break prefillloop } } @@ -158,7 +160,9 @@ func createLimiter(headers rateLimitHeaders, routeName string) { select { case limiter.requests <- struct{}{}: default: - log.Warn().Msg("rate limiting channel was too small; you should increase the default capacity") + log.Warn(). + Int("maxRequests", refill.maxRequests). + Msg("rate limiting channel was too small; you should increase the default capacity") break refillloop } } diff --git a/src/discord/rest.go b/src/discord/rest.go index 9124858..417be53 100644 --- a/src/discord/rest.go +++ b/src/discord/rest.go @@ -652,11 +652,17 @@ func EditOriginalInteractionResponse(ctx context.Context, interactionToken strin return &msg, nil } -func GetAuthorizeUrl(state string) string { +func GetAuthorizeUrl(state string, includeEmail bool) string { + scope := "identify" + if includeEmail { + scope = "identify email" + } + params := make(url.Values) params.Set("response_type", "code") params.Set("client_id", config.Config.Discord.OAuthClientID) - params.Set("scope", "identify") + params.Set("scope", scope) + params.Set("prompt", "none") // immediately redirect back to HMN if already authorized params.Set("state", state) params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback()) return fmt.Sprintf("%s?%s", buildUrl("/oauth2/authorize"), params.Encode()) diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index f175a7d..a1fae05 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -121,6 +121,13 @@ func BuildLoginPage(redirectTo string) string { return Url("/login", []Q{{Name: "redirect", Value: redirectTo}}) } +var RegexLoginWithDiscord = regexp.MustCompile("^/login-with-discord$") + +func BuildLoginWithDiscord(redirectTo string) string { + defer CatchPanic() + return Url("/login-with-discord", []Q{{Name: "redirect", Value: redirectTo}}) +} + var RegexLogoutAction = regexp.MustCompile("^/logout$") func BuildLogoutAction(redir string) string { diff --git a/src/migration/migrations/2023-05-04T024712Z_AddPendingSignups.go b/src/migration/migrations/2023-05-04T024712Z_AddPendingSignups.go new file mode 100644 index 0000000..ed59719 --- /dev/null +++ b/src/migration/migrations/2023-05-04T024712Z_AddPendingSignups.go @@ -0,0 +1,53 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddPendingSignups{}) +} + +type AddPendingSignups struct{} + +func (m AddPendingSignups) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2023, 5, 4, 2, 47, 12, 0, time.UTC)) +} + +func (m AddPendingSignups) Name() string { + return "AddPendingSignups" +} + +func (m AddPendingSignups) Description() string { + return "Adds the pending login table" +} + +func (m AddPendingSignups) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + CREATE TABLE pending_login ( + id VARCHAR(40) NOT NULL PRIMARY KEY, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + destination_url VARCHAR(999) NOT NULL + ) + `, + ) + if err != nil { + return err + } + + return nil +} + +func (m AddPendingSignups) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, `DROP TABLE pending_login`) + if err != nil { + return err + } + + return nil +} diff --git a/src/migration/seed.go b/src/migration/seed.go index 0b6aff7..4d89e6c 100644 --- a/src/migration/seed.go +++ b/src/migration/seed.go @@ -189,7 +189,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models. $6, $7, $8, $9, TRUE, $10, - '2017-01-01T00:00:00Z', '192.168.2.1', null + '2017-01-01T00:00:00Z', '192.168.2.1', NULL ) RETURNING $columns `, diff --git a/src/models/user.go b/src/models/user.go index 5caaf6d..d838324 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -71,3 +71,9 @@ func (u *User) CanSeeUnpublishedEducationContent() bool { func (u *User) CanAuthorEducation() bool { return u.IsStaff || u.EducationRole == EduRoleAuthor } + +type PendingLogin struct { + ID string `db:"id"` + ExpiresAt time.Time `db:"expires_at"` + DestinationUrl string `db:"destination_url"` +} diff --git a/src/rawdata/scss/_header.scss b/src/rawdata/scss/_header.scss index 3a403e0..1460410 100644 --- a/src/rawdata/scss/_header.scss +++ b/src/rawdata/scss/_header.scss @@ -130,33 +130,21 @@ header { @include usevar(background-color, login-popup-background); @include usevar(color, fg-font-color); - @extend .pa3; - - border-width: 1px; - border-style: dashed; - @extend .b--dimmest; - visibility: hidden; position: absolute; z-index: 12; - margin-top: 10px; - right: 0px; - top: 20px; - width: 290px; - max-height: 0px; overflow: hidden; - opacity: 0; - - transition: all 0.2s; + right: 0; + top: 100%; + width: 100%; &.open { - max-height: 170px; - opacity: 1; visibility: visible; } - - label { - padding-right:10px; + + @media #{$breakpoint-not-small} { + top: 2.2rem; + width: 17rem; } } } diff --git a/src/templates/src/auth_login.html b/src/templates/src/auth_login.html index 666611f..4a40738 100644 --- a/src/templates/src/auth_login.html +++ b/src/templates/src/auth_login.html @@ -37,6 +37,18 @@