forked from mirrors/pronouns.cc
feat: add captcha when signing up (closes #53)
This commit is contained in:
parent
bb3d56f548
commit
6f7eb5eeee
23 changed files with 316 additions and 61 deletions
58
backend/routes/auth/captcha.go
Normal file
58
backend/routes/auth/captcha.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/u1f320/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hcaptchaURL = "https://hcaptcha.com/siteverify"
|
||||||
|
|
||||||
|
type hcaptchaResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyCaptcha verifies a captcha response.
|
||||||
|
func (s *Server) verifyCaptcha(ctx context.Context, response string) (ok bool, err error) {
|
||||||
|
vals := url.Values{
|
||||||
|
"response": []string{response},
|
||||||
|
"secret": []string{s.hcaptchaSecret},
|
||||||
|
"sitekey": []string{s.hcaptchaSitekey},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", hcaptchaURL, strings.NewReader(vals.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "creating request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "sending request")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
return false, errors.Sentinel("error status code")
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "reading body")
|
||||||
|
}
|
||||||
|
fmt.Println(string(b))
|
||||||
|
var hr hcaptchaResponse
|
||||||
|
err = json.Unmarshal(b, &hr)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "unmarshaling json")
|
||||||
|
}
|
||||||
|
|
||||||
|
return hr.Success, nil
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ type discordCallbackResponse struct {
|
||||||
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
Discord string `json:"discord,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
Ticket string `json:"ticket,omitempty"`
|
||||||
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
@ -152,6 +153,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
Discord: du.String(),
|
Discord: du.String(),
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -254,6 +256,7 @@ type signupRequest struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
|
CaptchaResponse string `json:"captcha_response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupResponse struct {
|
type signupResponse struct {
|
||||||
|
@ -298,6 +301,19 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
|
|
@ -30,6 +30,7 @@ type fediCallbackResponse struct {
|
||||||
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
|
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
Ticket string `json:"ticket,omitempty"`
|
||||||
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
@ -173,6 +174,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
Fediverse: mu.Username,
|
Fediverse: mu.Username,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -282,6 +284,7 @@ type fediSignupRequest struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
|
CaptchaResponse string `json:"captcha_response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -326,6 +329,19 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
|
|
@ -153,6 +153,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
Fediverse: mu.User.Username,
|
Fediverse: mu.User.Username,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -256,6 +257,19 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
|
|
@ -36,6 +36,7 @@ type googleCallbackResponse struct {
|
||||||
Google string `json:"google,omitempty"` // username, for UI purposes
|
Google string `json:"google,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
Ticket string `json:"ticket,omitempty"`
|
||||||
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
@ -171,6 +172,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
Google: googleUsername,
|
Google: googleUsername,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -302,6 +304,19 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
|
|
@ -18,6 +18,9 @@ type Server struct {
|
||||||
|
|
||||||
RequireInvite bool
|
RequireInvite bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
|
||||||
|
hcaptchaSitekey string
|
||||||
|
hcaptchaSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type userResponse struct {
|
type userResponse struct {
|
||||||
|
@ -73,6 +76,8 @@ func Mount(srv *server.Server, r chi.Router) {
|
||||||
Server: srv,
|
Server: srv,
|
||||||
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||||
BaseURL: os.Getenv("BASE_URL"),
|
BaseURL: os.Getenv("BASE_URL"),
|
||||||
|
hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"),
|
||||||
|
hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"),
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
|
|
@ -58,6 +58,7 @@ type tumblrCallbackResponse struct {
|
||||||
Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes
|
Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes
|
||||||
Ticket string `json:"ticket,omitempty"`
|
Ticket string `json:"ticket,omitempty"`
|
||||||
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
RequireInvite bool `json:"require_invite"` // require an invite for signing up
|
||||||
|
RequireCaptcha bool `json:"require_captcha"`
|
||||||
|
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
@ -204,6 +205,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
Tumblr: tumblrName,
|
Tumblr: tumblrName,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RequireInvite: s.RequireInvite,
|
RequireInvite: s.RequireInvite,
|
||||||
|
RequireCaptcha: s.hcaptchaSecret != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -335,6 +337,19 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
|
||||||
return server.APIError{Code: server.ErrInvalidTicket}
|
return server.APIError{Code: server.ErrInvalidTicket}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check captcha
|
||||||
|
if s.hcaptchaSecret != "" {
|
||||||
|
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("verifying captcha: %v", err)
|
||||||
|
return server.APIError{Code: server.ErrInternalServerError}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return server.APIError{Code: server.ErrInvalidCaptcha}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||||
|
|
|
@ -97,6 +97,7 @@ const (
|
||||||
ErrAlreadyLinked = 1014 // user already has linked account of the same type
|
ErrAlreadyLinked = 1014 // user already has linked account of the same type
|
||||||
ErrNotLinked = 1015 // user already doesn't have a linked account
|
ErrNotLinked = 1015 // user already doesn't have a linked account
|
||||||
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
|
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
|
||||||
|
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
|
||||||
|
|
||||||
// User-related error codes
|
// User-related error codes
|
||||||
ErrUserNotFound = 2001
|
ErrUserNotFound = 2001
|
||||||
|
@ -141,6 +142,7 @@ var errCodeMessages = map[int]string{
|
||||||
ErrAlreadyLinked: "Your account is already linked to an account of this type",
|
ErrAlreadyLinked: "Your account is already linked to an account of this type",
|
||||||
ErrNotLinked: "Your account is already not linked to an account of this type",
|
ErrNotLinked: "Your account is already not linked to an account of this type",
|
||||||
ErrLastProvider: "This is your account's only authentication provider",
|
ErrLastProvider: "This is your account's only authentication provider",
|
||||||
|
ErrInvalidCaptcha: "Invalid or missing captcha response",
|
||||||
|
|
||||||
ErrUserNotFound: "User not found",
|
ErrUserNotFound: "User not found",
|
||||||
ErrMemberListPrivate: "This user's member list is private.",
|
ErrMemberListPrivate: "This user's member list is private.",
|
||||||
|
@ -181,6 +183,7 @@ var errCodeStatuses = map[int]int{
|
||||||
ErrAlreadyLinked: http.StatusBadRequest,
|
ErrAlreadyLinked: http.StatusBadRequest,
|
||||||
ErrNotLinked: http.StatusBadRequest,
|
ErrNotLinked: http.StatusBadRequest,
|
||||||
ErrLastProvider: http.StatusBadRequest,
|
ErrLastProvider: http.StatusBadRequest,
|
||||||
|
ErrInvalidCaptcha: http.StatusBadRequest,
|
||||||
|
|
||||||
ErrUserNotFound: http.StatusNotFound,
|
ErrUserNotFound: http.StatusNotFound,
|
||||||
ErrMemberListPrivate: http.StatusForbidden,
|
ErrMemberListPrivate: http.StatusForbidden,
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
"prettier-plugin-svelte": "^2.10.0",
|
"prettier-plugin-svelte": "^2.10.0",
|
||||||
"svelte": "^3.58.0",
|
"svelte": "^3.58.0",
|
||||||
"svelte-check": "^3.1.4",
|
"svelte-check": "^3.1.4",
|
||||||
|
"svelte-hcaptcha": "^0.1.1",
|
||||||
"sveltestrap": "^5.10.0",
|
"sveltestrap": "^5.10.0",
|
||||||
"tslib": "^2.5.0",
|
"tslib": "^2.5.0",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
|
|
6
frontend/pnpm-lock.yaml
generated
6
frontend/pnpm-lock.yaml
generated
|
@ -27,6 +27,7 @@ specifiers:
|
||||||
sanitize-html: ^2.10.0
|
sanitize-html: ^2.10.0
|
||||||
svelte: ^3.58.0
|
svelte: ^3.58.0
|
||||||
svelte-check: ^3.1.4
|
svelte-check: ^3.1.4
|
||||||
|
svelte-hcaptcha: ^0.1.1
|
||||||
sveltestrap: ^5.10.0
|
sveltestrap: ^5.10.0
|
||||||
tslib: ^2.5.0
|
tslib: ^2.5.0
|
||||||
typescript: ^4.9.5
|
typescript: ^4.9.5
|
||||||
|
@ -62,6 +63,7 @@ devDependencies:
|
||||||
prettier-plugin-svelte: 2.10.0_ur5pqdgn24bclu6l6i7qojopk4
|
prettier-plugin-svelte: 2.10.0_ur5pqdgn24bclu6l6i7qojopk4
|
||||||
svelte: 3.58.0
|
svelte: 3.58.0
|
||||||
svelte-check: 3.1.4_svelte@3.58.0
|
svelte-check: 3.1.4_svelte@3.58.0
|
||||||
|
svelte-hcaptcha: 0.1.1
|
||||||
sveltestrap: 5.10.0_svelte@3.58.0
|
sveltestrap: 5.10.0_svelte@3.58.0
|
||||||
tslib: 2.5.0
|
tslib: 2.5.0
|
||||||
typescript: 4.9.5
|
typescript: 4.9.5
|
||||||
|
@ -2018,6 +2020,10 @@ packages:
|
||||||
- sugarss
|
- sugarss
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/svelte-hcaptcha/0.1.1:
|
||||||
|
resolution: {integrity: sha512-iFF3HwfrCRciJnDs4Y9/rpP/BM2U/5zt+vh+9d4tALPAHVkcANiJIKqYuS835pIaTm6gt+xOzjfFI3cgiRI29A==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/svelte-hmr/0.15.1_svelte@3.58.0:
|
/svelte-hmr/0.15.1_svelte@3.58.0:
|
||||||
resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==}
|
resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==}
|
||||||
engines: {node: ^12.20 || ^14.13.1 || >= 16}
|
engines: {node: ^12.20 || ^14.13.1 || >= 16}
|
||||||
|
|
19
frontend/src/app.d.ts
vendored
19
frontend/src/app.d.ts
vendored
|
@ -9,4 +9,23 @@ declare global {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "svelte-hcaptcha" {
|
||||||
|
import type { SvelteComponent } from "svelte";
|
||||||
|
|
||||||
|
export interface HCaptchaProps {
|
||||||
|
sitekey?: string;
|
||||||
|
apihost?: string;
|
||||||
|
hl?: string;
|
||||||
|
reCaptchaCompat?: boolean;
|
||||||
|
theme?: CaptchaTheme;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare class HCaptcha extends SvelteComponent {
|
||||||
|
$$prop_def: HCaptchaProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HCaptcha;
|
||||||
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
@ -150,6 +150,7 @@ export enum ErrorCode {
|
||||||
AlreadyLinked = 1014,
|
AlreadyLinked = 1014,
|
||||||
NotLinked = 1015,
|
NotLinked = 1015,
|
||||||
LastProvider = 1016,
|
LastProvider = 1016,
|
||||||
|
InvalidCaptcha = 1017,
|
||||||
|
|
||||||
UserNotFound = 2001,
|
UserNotFound = 2001,
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
import { fastFetch } from "$lib/api/fetch";
|
import { fastFetch } from "$lib/api/fetch";
|
||||||
import { usernameRegex } from "$lib/api/regex";
|
import { usernameRegex } from "$lib/api/regex";
|
||||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||||
|
import { PUBLIC_HCAPTCHA_SITEKEY } from "$env/static/public";
|
||||||
|
import HCaptcha from "svelte-hcaptcha";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import { addToast } from "$lib/toast";
|
import { addToast } from "$lib/toast";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
@ -23,6 +25,7 @@
|
||||||
export let remoteName: string | undefined;
|
export let remoteName: string | undefined;
|
||||||
export let error: APIError | undefined;
|
export let error: APIError | undefined;
|
||||||
export let requireInvite: boolean | undefined;
|
export let requireInvite: boolean | undefined;
|
||||||
|
export let requireCaptcha: boolean | undefined;
|
||||||
export let isDeleted: boolean | undefined;
|
export let isDeleted: boolean | undefined;
|
||||||
export let ticket: string | undefined;
|
export let ticket: string | undefined;
|
||||||
export let token: string | undefined;
|
export let token: string | undefined;
|
||||||
|
@ -54,7 +57,31 @@
|
||||||
let toggleForceDeleteModal = () => (forceDeleteModalOpen = !forceDeleteModalOpen);
|
let toggleForceDeleteModal = () => (forceDeleteModalOpen = !forceDeleteModalOpen);
|
||||||
|
|
||||||
export let linkAccount: () => Promise<void>;
|
export let linkAccount: () => Promise<void>;
|
||||||
export let signupForm: (username: string, inviteCode: string) => Promise<void>;
|
export let signupForm: (
|
||||||
|
username: string,
|
||||||
|
inviteCode: string,
|
||||||
|
captchaToken: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
let captchaToken = "";
|
||||||
|
let captcha: any;
|
||||||
|
|
||||||
|
const captchaSuccess = (token: any) => {
|
||||||
|
captchaToken = token.detail.token;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canSubmit = false;
|
||||||
|
$: canSubmit = usernameValid && (!!captchaToken || !requireCaptcha);
|
||||||
|
|
||||||
|
const captchaError = () => {
|
||||||
|
addToast({
|
||||||
|
header: "Captcha failed",
|
||||||
|
body: "There was an error verifying the captcha, please try again.",
|
||||||
|
});
|
||||||
|
captcha.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetCaptcha = (): void => captcha.reset();
|
||||||
|
|
||||||
const forceDeleteAccount = async () => {
|
const forceDeleteAccount = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -116,7 +143,7 @@
|
||||||
<Button color="secondary" href="/settings/auth">Cancel</Button>
|
<Button color="secondary" href="/settings/auth">Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if ticket}
|
{:else if ticket}
|
||||||
<form on:submit|preventDefault={() => signupForm(username, inviteCode)}>
|
<form on:submit|preventDefault={() => signupForm(username, inviteCode, captchaToken)}>
|
||||||
<div>
|
<div>
|
||||||
<FormGroup floating label="{authType} username">
|
<FormGroup floating label="{authType} username">
|
||||||
<Input readonly value={remoteName} />
|
<Input readonly value={remoteName} />
|
||||||
|
@ -144,12 +171,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if requireCaptcha}
|
||||||
|
<div class="mt-2 mx-2 mb-1">
|
||||||
|
<HCaptcha
|
||||||
|
bind:this={captcha}
|
||||||
|
sitekey={PUBLIC_HCAPTCHA_SITEKEY}
|
||||||
|
on:success={captchaSuccess}
|
||||||
|
on:error={captchaError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="text-muted my-1">
|
<div class="text-muted my-1">
|
||||||
By signing up, you agree to the <a href="/page/terms">terms of service</a> and the
|
By signing up, you agree to the <a href="/page/terms">terms of service</a> and the
|
||||||
<a href="/page/privacy">privacy policy</a>.
|
<a href="/page/privacy">privacy policy</a>.
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<Button type="submit" color="primary" disabled={!usernameValid}>Sign up</Button>
|
<Button type="submit" color="primary" disabled={!canSubmit}>Sign up</Button>
|
||||||
{#if !usernameValid && username.length > 0}
|
{#if !usernameValid && username.length > 0}
|
||||||
<span class="text-danger-emphasis mb-2">That username is not valid.</span>
|
<span class="text-danger-emphasis mb-2">That username is not valid.</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -30,6 +30,7 @@ interface CallbackResponse {
|
||||||
discord?: string;
|
discord?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { APIError, MeUser } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const signupForm = async (username: string, invite: string) => {
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch<SignupResponse>("/auth/discord/signup", {
|
const resp = await apiFetch<SignupResponse>("/auth/discord/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -18,6 +20,7 @@
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
username: username,
|
username: username,
|
||||||
invite_code: invite,
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,6 +30,10 @@
|
||||||
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
goto(`/@${resp.user.name}`);
|
goto(`/@${resp.user.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
data.error = e as APIError;
|
data.error = e as APIError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -48,10 +55,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CallbackPage
|
<CallbackPage
|
||||||
|
bind:this={callbackPage}
|
||||||
authType="Discord"
|
authType="Discord"
|
||||||
remoteName={data.discord}
|
remoteName={data.discord}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
requireInvite={data.require_invite}
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
isDeleted={data.is_deleted}
|
isDeleted={data.is_deleted}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket}
|
||||||
token={data.token}
|
token={data.token}
|
||||||
|
|
|
@ -30,6 +30,7 @@ interface CallbackResponse {
|
||||||
google?: string;
|
google?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { APIError, MeUser } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const signupForm = async (username: string, invite: string) => {
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch<SignupResponse>("/auth/google/signup", {
|
const resp = await apiFetch<SignupResponse>("/auth/google/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -18,6 +20,7 @@
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
username: username,
|
username: username,
|
||||||
invite_code: invite,
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,6 +30,10 @@
|
||||||
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
goto(`/@${resp.user.name}`);
|
goto(`/@${resp.user.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
data.error = e as APIError;
|
data.error = e as APIError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -48,10 +55,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CallbackPage
|
<CallbackPage
|
||||||
|
bind:this={callbackPage}
|
||||||
authType="Google"
|
authType="Google"
|
||||||
remoteName={data.google}
|
remoteName={data.google}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
requireInvite={data.require_invite}
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
isDeleted={data.is_deleted}
|
isDeleted={data.is_deleted}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket}
|
||||||
token={data.token}
|
token={data.token}
|
||||||
|
|
|
@ -30,6 +30,7 @@ interface CallbackResponse {
|
||||||
fediverse?: string;
|
fediverse?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { APIError, MeUser } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const signupForm = async (username: string, invite: string) => {
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch<SignupResponse>("/auth/mastodon/signup", {
|
const resp = await apiFetch<SignupResponse>("/auth/mastodon/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -19,6 +21,7 @@
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
username: username,
|
username: username,
|
||||||
invite_code: invite,
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -28,6 +31,10 @@
|
||||||
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
goto(`/@${resp.user.name}`);
|
goto(`/@${resp.user.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
data.error = e as APIError;
|
data.error = e as APIError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -50,10 +57,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CallbackPage
|
<CallbackPage
|
||||||
|
bind:this={callbackPage}
|
||||||
authType="Fediverse"
|
authType="Fediverse"
|
||||||
remoteName="{data.fediverse}@{data.instance}"
|
remoteName="{data.fediverse}@{data.instance}"
|
||||||
error={data.error}
|
error={data.error}
|
||||||
requireInvite={data.require_invite}
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
isDeleted={data.is_deleted}
|
isDeleted={data.is_deleted}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket}
|
||||||
token={data.token}
|
token={data.token}
|
||||||
|
|
|
@ -29,6 +29,7 @@ interface CallbackResponse {
|
||||||
fediverse?: string;
|
fediverse?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { APIError, MeUser } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const signupForm = async (username: string, invite: string) => {
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch<SignupResponse>("/auth/misskey/signup", {
|
const resp = await apiFetch<SignupResponse>("/auth/misskey/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -19,6 +21,7 @@
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
username: username,
|
username: username,
|
||||||
invite_code: invite,
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -28,6 +31,10 @@
|
||||||
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
goto(`/@${resp.user.name}`);
|
goto(`/@${resp.user.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
data.error = e as APIError;
|
data.error = e as APIError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -54,6 +61,7 @@
|
||||||
remoteName="{data.fediverse}@{data.instance}"
|
remoteName="{data.fediverse}@{data.instance}"
|
||||||
error={data.error}
|
error={data.error}
|
||||||
requireInvite={data.require_invite}
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
isDeleted={data.is_deleted}
|
isDeleted={data.is_deleted}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket}
|
||||||
token={data.token}
|
token={data.token}
|
||||||
|
|
|
@ -30,6 +30,7 @@ interface CallbackResponse {
|
||||||
tumblr?: string;
|
tumblr?: string;
|
||||||
ticket?: string;
|
ticket?: string;
|
||||||
require_invite: boolean;
|
require_invite: boolean;
|
||||||
|
require_captcha: boolean;
|
||||||
|
|
||||||
is_deleted: boolean;
|
is_deleted: boolean;
|
||||||
deleted_at?: string;
|
deleted_at?: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { APIError, MeUser } from "$lib/api/entities";
|
import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities";
|
||||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||||
import { userStore } from "$lib/store";
|
import { userStore } from "$lib/store";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
@ -10,7 +10,9 @@
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
const signupForm = async (username: string, invite: string) => {
|
let callbackPage: any;
|
||||||
|
|
||||||
|
const signupForm = async (username: string, invite: string, captchaToken: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetch<SignupResponse>("/auth/tumblr/signup", {
|
const resp = await apiFetch<SignupResponse>("/auth/tumblr/signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -18,6 +20,7 @@
|
||||||
ticket: data.ticket,
|
ticket: data.ticket,
|
||||||
username: username,
|
username: username,
|
||||||
invite_code: invite,
|
invite_code: invite,
|
||||||
|
captcha_response: captchaToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -27,6 +30,10 @@
|
||||||
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
addToast({ header: "Welcome!", body: "Signed up successfully!" });
|
||||||
goto(`/@${resp.user.name}`);
|
goto(`/@${resp.user.name}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if ((e as APIError).code === ErrorCode.InvalidCaptcha) {
|
||||||
|
callbackPage.resetCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
data.error = e as APIError;
|
data.error = e as APIError;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -48,10 +55,12 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CallbackPage
|
<CallbackPage
|
||||||
|
bind:this={callbackPage}
|
||||||
authType="Tumblr"
|
authType="Tumblr"
|
||||||
remoteName={data.tumblr}
|
remoteName={data.tumblr}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
requireInvite={data.require_invite}
|
requireInvite={data.require_invite}
|
||||||
|
requireCaptcha={data.require_captcha}
|
||||||
isDeleted={data.is_deleted}
|
isDeleted={data.is_deleted}
|
||||||
ticket={data.ticket}
|
ticket={data.ticket}
|
||||||
token={data.token}
|
token={data.token}
|
||||||
|
|
Loading…
Reference in a new issue