Merge pull request #30 from fiksn/multiple_domains

This commit is contained in:
fiatjaf
2022-01-20 12:01:33 -03:00
committed by GitHub
10 changed files with 257 additions and 80 deletions

View File

@@ -19,6 +19,17 @@ SITE_NAME=Bitmia
3. Start the app with `./satdress` 3. Start the app with `./satdress`
4. Serve the app to the world on your domain using whatever technique you're used to 4. Serve the app to the world on your domain using whatever technique you're used to
## Multiple domains
Note that DOMAIN can be a single domain or a comma-separated list. When using multiple domains
you need to make sure "Host" HTTP header is forwarded to satdress process if you have some reverse-proxy).
If you come from an old installation everything should get migrated in a seamless way, but there is also a
FORCE_MIGRATE environment variable to force a migration (else this is done just the first time).
There is also a GLOBAL_USERS to make sure the user@ part is unique across all domains. But be warned that when enabling
this option, existing users won't work anymore (which is by design).
## Get help ## Get help
Maybe ask for help on https://t.me/lnurl if you're in trouble. Maybe ask for help on https://t.me/lnurl if you're in trouble.

42
api.go
View File

@@ -18,6 +18,7 @@ type Response struct {
type SuccessClaim struct { type SuccessClaim struct {
Name string `json:"name"` Name string `json:"name"`
Domain string `json:"domain"`
PIN string `json:"pin"` PIN string `json:"pin"`
Invoice string `json:"invoice"` Invoice string `json:"invoice"`
} }
@@ -25,7 +26,7 @@ type SuccessClaim struct {
// not authenticated, if correct pin is provided call returns the SuccessClaim // not authenticated, if correct pin is provided call returns the SuccessClaim
func ClaimAddress(w http.ResponseWriter, r *http.Request) { func ClaimAddress(w http.ResponseWriter, r *http.Request) {
params := parseParams(r) params := parseParams(r)
pin, inv, err := SaveName(params.Name, params, params.Pin) pin, inv, err := SaveName(params.Name, params.Domain, params, params.Pin)
if err != nil { if err != nil {
sendError(w, 400, "could not register name: %s", err.Error()) sendError(w, 400, "could not register name: %s", err.Error())
return return
@@ -33,8 +34,8 @@ func ClaimAddress(w http.ResponseWriter, r *http.Request) {
response := Response{ response := Response{
Ok: true, Ok: true,
Message: fmt.Sprintf("claimed %v@%v", params.Name, s.Domain), Message: fmt.Sprintf("claimed %v@%v", params.Name, params.Domain),
Data: SuccessClaim{params.Name, pin, inv}, Data: SuccessClaim{params.Name, params.Domain, pin, inv},
} }
// TODO: middleware for responses that adds this header // TODO: middleware for responses that adds this header
@@ -45,18 +46,19 @@ func ClaimAddress(w http.ResponseWriter, r *http.Request) {
func GetUser(w http.ResponseWriter, r *http.Request) { func GetUser(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"] name := mux.Vars(r)["name"]
params, err := GetName(name) domain := mux.Vars(r)["domain"]
params, err := GetName(name, domain)
if err != nil { if err != nil {
sendError(w, 400, err.Error()) sendError(w, 400, err.Error())
return return
} }
// add pin to response because sometimes not saved in database; after first call to /api/v1/claim // add pin to response because sometimes not saved in database; after first call to /api/v1/claim
params.Pin = ComputePIN(name) params.Pin = ComputePIN(name, domain)
response := Response{ response := Response{
Ok: true, Ok: true,
Message: fmt.Sprintf("%v@%v found", params.Name, s.Domain), Message: fmt.Sprintf("%v@%v found", params.Name, domain),
Data: params, Data: params,
} }
@@ -68,6 +70,7 @@ func GetUser(w http.ResponseWriter, r *http.Request) {
func UpdateUser(w http.ResponseWriter, r *http.Request) { func UpdateUser(w http.ResponseWriter, r *http.Request) {
params := parseParams(r) params := parseParams(r)
name := mux.Vars(r)["name"] name := mux.Vars(r)["name"]
domain := mux.Vars(r)["domain"]
// if pin not in json request body get it from header // if pin not in json request body get it from header
if params.Pin == "" { if params.Pin == "" {
@@ -75,12 +78,12 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
params.Pin = r.Header.Get("X-Pin") params.Pin = r.Header.Get("X-Pin")
} }
if _, _, err := SaveName(name, params, params.Pin); err != nil { if _, _, err := SaveName(name, domain, params, params.Pin); err != nil {
sendError(w, 500, err.Error()) sendError(w, 500, err.Error())
return return
} }
updatedParams, err := GetName(name) updatedParams, err := GetName(name, domain)
if err != nil { if err != nil {
sendError(w, 500, err.Error()) sendError(w, 500, err.Error())
return return
@@ -89,7 +92,7 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
// return the updated values or just http.StatusCreated? // return the updated values or just http.StatusCreated?
response := Response{ response := Response{
Ok: true, Ok: true,
Message: fmt.Sprintf("updated %v@%v parameters", params.Name, s.Domain), Message: fmt.Sprintf("updated %v@%v parameters", params.Name, domain),
Data: updatedParams, Data: updatedParams,
} }
@@ -100,14 +103,15 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
func DeleteUser(w http.ResponseWriter, r *http.Request) { func DeleteUser(w http.ResponseWriter, r *http.Request) {
name := mux.Vars(r)["name"] name := mux.Vars(r)["name"]
if err := DeleteName(name); err != nil { domain := mux.Vars(r)["domain"]
if err := DeleteName(name, domain); err != nil {
sendError(w, 500, err.Error()) sendError(w, 500, err.Error())
return return
} }
response := Response{ response := Response{
Ok: true, Ok: true,
Message: fmt.Sprintf("deleted %v@%v", name, s.Domain), Message: fmt.Sprintf("deleted %v@%v", name, domain),
Data: nil, Data: nil,
} }
@@ -119,6 +123,20 @@ func DeleteUser(w http.ResponseWriter, r *http.Request) {
// authentication middleware // authentication middleware
func authenticate(next http.Handler) http.Handler { func authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// check domain
domain := mux.Vars(r)["domain"]
available := getDomains(s.Domain)
found := false
for _, one := range available {
if one == domain {
found = true
}
}
if !found {
sendError(w, 400, "could not use domain: %s", domain)
return
}
// exempt /claim from authentication check; // exempt /claim from authentication check;
if strings.HasPrefix(r.URL.Path, "/api/v1/claim") { if strings.HasPrefix(r.URL.Path, "/api/v1/claim") {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -136,7 +154,7 @@ func authenticate(next http.Handler) http.Handler {
providedPin = parseParams(r).Pin providedPin = parseParams(r).Pin
} }
if providedPin != ComputePIN(name) { if providedPin != ComputePIN(name, domain) {
err = fmt.Errorf("wrong pin") err = fmt.Errorf("wrong pin")
} }

81
db.go
View File

@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/cockroachdb/pebble" "github.com/cockroachdb/pebble"
@@ -14,6 +15,7 @@ import (
type Params struct { type Params struct {
Name string `json:"name"` Name string `json:"name"`
Domain string `json:"domain,omitempty"`
Kind string `json:"kind"` Kind string `json:"kind"`
Host string `json:"host"` Host string `json:"host"`
Key string `json:"key"` Key string `json:"key"`
@@ -26,13 +28,16 @@ type Params struct {
func SaveName( func SaveName(
name string, name string,
domain string,
params *Params, params *Params,
providedPin string, providedPin string,
) (pin string, inv string, err error) { ) (pin string, inv string, err error) {
name = strings.ToLower(name) name = strings.ToLower(name)
key := []byte(name) domain = strings.ToLower(domain)
pin = ComputePIN(name) key := []byte(getID(name, domain))
pin = ComputePIN(name, domain)
if _, closer, err := db.Get(key); err == nil { if _, closer, err := db.Get(key); err == nil {
defer closer.Close() defer closer.Close()
@@ -45,6 +50,7 @@ func SaveName(
} }
params.Name = name params.Name = name
params.Domain = domain
// check if the given data works // check if the given data works
if inv, err = makeInvoice(params, 1000, &pin); err != nil { if inv, err = makeInvoice(params, 1000, &pin); err != nil {
@@ -60,10 +66,8 @@ func SaveName(
return pin, inv, nil return pin, inv, nil
} }
func GetName(name string) (*Params, error) { func GetName(name, domain string) (*Params, error) {
name = strings.ToLower(name) val, closer, err := db.Get([]byte(getID(name, domain)))
val, closer, err := db.Get([]byte(name))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -75,12 +79,12 @@ func GetName(name string) (*Params, error) {
} }
params.Name = name params.Name = name
params.Domain = domain
return &params, nil return &params, nil
} }
func DeleteName(name string) error { func DeleteName(name, domain string) error {
name = strings.ToLower(name) key := []byte(getID(name, domain))
key := []byte(name)
if err := db.Delete(key, pebble.Sync); err != nil { if err := db.Delete(key, pebble.Sync); err != nil {
return err return err
@@ -89,9 +93,62 @@ func DeleteName(name string) error {
return nil return nil
} }
func ComputePIN(name string) string { func ComputePIN(name, domain string) string {
name = strings.ToLower(name)
mac := hmac.New(sha256.New, []byte(s.Secret)) mac := hmac.New(sha256.New, []byte(s.Secret))
mac.Write([]byte(name + "@" + s.Domain)) mac.Write([]byte(getID(name, domain)))
return hex.EncodeToString(mac.Sum(nil)) return hex.EncodeToString(mac.Sum(nil))
} }
func getID(name, domain string) string {
if s.GlobalUsers {
return strings.ToLower(name)
} else {
return strings.ToLower(fmt.Sprintf("%s@%s", name, domain))
}
}
func tryMigrate(old, new string) {
if _, err := os.Stat(old); os.IsNotExist(err) {
return
}
log.Info().Str("db", old).Msg("Migrating db")
newDb, err := pebble.Open(new, nil)
if err != nil {
log.Fatal().Err(err).Str("path", new).Msg("failed to open db.")
}
defer newDb.Close()
oldDb, err := pebble.Open(old, nil)
if err != nil {
log.Fatal().Err(err).Str("path", old).Msg("failed to open db.")
}
defer oldDb.Close()
iter := oldDb.NewIter(nil)
defer iter.Close()
for iter.First(); iter.Valid(); iter.Next() {
log.Debug().Str("key", string(iter.Key())).Msg("Migrating key")
var params Params
if err := json.Unmarshal(iter.Value(), &params); err != nil {
log.Debug().Err(err).Msg("Unmarshal error")
continue
}
params.Domain = old // old database name was domain
// save it
data, err := json.Marshal(params)
if err != nil {
log.Debug().Err(err).Msg("Marshal error")
continue
}
if err := newDb.Set([]byte(getID(params.Name, params.Domain)), data, pebble.Sync); err != nil {
log.Debug().Err(err).Msg("Set error")
continue
}
}
}

View File

@@ -13,7 +13,7 @@
<div class="title">Success!</div> <div class="title">Success!</div>
<div class="card"> <div class="card">
<div class="description"> <div class="description">
<b>{{ name }}@{{ domain }}</b> is your new Lightning Address! <b>{{ name }}@{{ actual_domain }}</b> is your new Lightning Address!
</div> </div>
<div class="bold-small"> <div class="bold-small">
In order to edit the configuration of this address in the future you In order to edit the configuration of this address in the future you

16
html.go
View File

@@ -8,18 +8,24 @@ import (
) )
type BaseData struct { type BaseData struct {
Domain string `json:"domain"` Domains []string `json:"domains"`
SiteOwnerName string `json:"siteOwnerName"` SiteOwnerName string `json:"siteOwnerName"`
SiteOwnerURL string `json:"siteOwnerURL"` SiteOwnerURL string `json:"siteOwnerURL"`
SiteName string `json:"siteName"` SiteName string `json:"siteName"`
UsernameInfo string `json:"usernameInfo"`
} }
func renderHTML(w http.ResponseWriter, html string, extraData interface{}) { func renderHTML(w http.ResponseWriter, html string, extraData interface{}) {
info := "Desired Username"
if s.GlobalUsers {
info = "Desired Username (unique across all domains)"
}
base, _ := json.Marshal(BaseData{ base, _ := json.Marshal(BaseData{
Domain: s.Domain, Domains: getDomains(s.Domain),
SiteOwnerName: s.SiteOwnerName, SiteOwnerName: s.SiteOwnerName,
SiteOwnerURL: s.SiteOwnerURL, SiteOwnerURL: s.SiteOwnerURL,
SiteName: s.SiteName, SiteName: s.SiteName,
UsernameInfo: info,
}) })
extra, _ := json.Marshal(extraData) extra, _ := json.Marshal(extraData)

View File

@@ -5,6 +5,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" type="image/png" href="https://i.imgur.com/4yaPtA2.png" /> <link rel="icon" type="image/png" href="https://i.imgur.com/4yaPtA2.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=PT+Sans" />
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
</head> </head>
<body> <body>
@@ -24,11 +25,15 @@
<form action="/grab" method="post"> <form action="/grab" method="post">
<div class="field"> <div class="field">
<div class="row"> <div class="row">
<label for="name"> Desired Username </label> <label for="name"> {{usernameInfo}} </label>
</div> </div>
<div class="domain-wrapper"> <div class="domain-wrapper">
<input class="input" name="name" id="name" /> <input class="input" name="name" id="name" />
<span class="suffix">@{{ domain }}</span> <span v-if="domains.length == 1">@{{ domains[0] }}</span>
<span v-if="domains.length > 1">@</span>
<select name="domain" id="domain" id="domain" v-if="domains.length > 1">
<option v-for="domain in domains" :value="domain">{{ domain }}</option>
</select>
</div> </div>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -5,23 +5,47 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/fiatjaf/go-lnurl" "github.com/fiatjaf/go-lnurl"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
func handleLNURL(w http.ResponseWriter, r *http.Request) { func handleLNURL(w http.ResponseWriter, r *http.Request) {
username := mux.Vars(r)["username"] username := mux.Vars(r)["user"]
params, err := GetName(username) domains := getDomains(s.Domain)
domain := ""
if len(domains) == 1 {
domain = domains[0]
} else {
hostname := r.URL.Host
if hostname == "" {
hostname = r.Host
}
for _, one := range getDomains(s.Domain) {
if strings.Contains(hostname, one) {
domain = one
break
}
}
if domain == "" {
json.NewEncoder(w).Encode(lnurl.ErrorResponse("incorrect domain"))
return
}
}
params, err := GetName(username, domain)
if err != nil { if err != nil {
log.Error().Err(err).Str("name", username).Msg("failed to get name") log.Error().Err(err).Str("name", username).Str("domain", domain).Msg("failed to get name")
json.NewEncoder(w).Encode(lnurl.ErrorResponse(fmt.Sprintf( json.NewEncoder(w).Encode(lnurl.ErrorResponse(fmt.Sprintf(
"failed to get name %s", username))) "failed to get name %s@%s", username, domain)))
return return
} }
log.Info().Str("username", username).Msg("got lnurl request") log.Info().Str("username", username).Str("domain", domain).Msg("got lnurl request")
if amount := r.URL.Query().Get("amount"); amount == "" { if amount := r.URL.Query().Get("amount"); amount == "" {
// check if the receiver accepts comments // check if the receiver accepts comments
@@ -41,7 +65,7 @@ func handleLNURL(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(lnurl.LNURLPayResponse1{ json.NewEncoder(w).Encode(lnurl.LNURLPayResponse1{
LNURLResponse: lnurl.LNURLResponse{Status: "OK"}, LNURLResponse: lnurl.LNURLResponse{Status: "OK"},
Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s", s.Domain, username), Callback: fmt.Sprintf("https://%s/.well-known/lnurlp/%s", domain, username),
MinSendable: minSendable, MinSendable: minSendable,
MaxSendable: maxSendable, MaxSendable: maxSendable,
EncodedMetadata: makeMetadata(params), EncodedMetadata: makeMetadata(params),

66
main.go
View File

@@ -17,15 +17,19 @@ import (
) )
type Settings struct { type Settings struct {
Host string `envconfig:"HOST" default:"0.0.0.0"` Host string `envconfig:"HOST" default:"0.0.0.0"`
Port string `envconfig:"PORT" required:"true"` Port string `envconfig:"PORT" required:"true"`
Domain string `envconfig:"DOMAIN" required:"true"` Domain string `envconfig:"DOMAIN" required:"true"`
// GlobalUsers means that user@ part is globally unique across all domains
// WARNING: if you toggle this existing users won't work anymore for safety reasons!
GlobalUsers bool `envconfig:"GLOBAL_USERS" required:"false" default:false`
Secret string `envconfig:"SECRET" required:"true"` Secret string `envconfig:"SECRET" required:"true"`
SiteOwnerName string `envconfig:"SITE_OWNER_NAME" required:"true"` SiteOwnerName string `envconfig:"SITE_OWNER_NAME" required:"true"`
SiteOwnerURL string `envconfig:"SITE_OWNER_URL" required:"true"` SiteOwnerURL string `envconfig:"SITE_OWNER_URL" required:"true"`
SiteName string `envconfig:"SITE_NAME" required:"true"` SiteName string `envconfig:"SITE_NAME" required:"true"`
TorProxyURL string `envconfig:"TOR_PROXY_URL"` ForceMigrate bool `envconfig:"FORCE_MIGRATE" required:"false" default:false`
TorProxyURL string `envconfig:"TOR_PROXY_URL"`
} }
var s Settings var s Settings
@@ -57,12 +61,19 @@ func main() {
makeinvoice.TorProxyURL = s.TorProxyURL makeinvoice.TorProxyURL = s.TorProxyURL
} }
db, err = pebble.Open(s.Domain, nil) dbName := fmt.Sprintf("%v-multiple.db", s.SiteName)
if err != nil { if _, err := os.Stat(dbName); os.IsNotExist(err) || s.ForceMigrate {
log.Fatal().Err(err).Str("path", s.Domain).Msg("failed to open db.") for _, one := range getDomains(s.Domain) {
tryMigrate(one, dbName)
}
} }
router.Path("/.well-known/lnurlp/{username}").Methods("GET"). db, err = pebble.Open(dbName, nil)
if err != nil {
log.Fatal().Err(err).Str("path", dbName).Msg("failed to open db.")
}
router.Path("/.well-known/lnurlp/{user}").Methods("GET").
HandlerFunc(handleLNURL) HandlerFunc(handleLNURL)
router.Path("/").HandlerFunc( router.Path("/").HandlerFunc(
@@ -76,8 +87,23 @@ func main() {
router.Path("/grab").HandlerFunc( router.Path("/grab").HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name") name := r.FormValue("name")
if name == "" || r.FormValue("kind") == "" {
sendError(w, 500, "internal error")
return
}
pin, inv, err := SaveName(name, &Params{ // might not get domain back
domain := r.FormValue("domain")
if domain == "" {
if !strings.Contains(s.Domain, ",") {
domain = s.Domain
} else {
sendError(w, 500, "internal error")
return
}
}
pin, inv, err := SaveName(name, domain, &Params{
Kind: r.FormValue("kind"), Kind: r.FormValue("kind"),
Host: r.FormValue("host"), Host: r.FormValue("host"),
Key: r.FormValue("key"), Key: r.FormValue("key"),
@@ -91,10 +117,11 @@ func main() {
} }
renderHTML(w, grabHTML, struct { renderHTML(w, grabHTML, struct {
PIN string `json:"pin"` PIN string `json:"pin"`
Invoice string `json:"invoice"` Invoice string `json:"invoice"`
Name string `json:"name"` Name string `json:"name"`
}{pin, inv, name}) ActualDomain string `json:"actual_domain"`
}{pin, inv, name, domain})
}, },
) )
@@ -105,9 +132,9 @@ func main() {
api.HandleFunc("/claim", ClaimAddress).Methods("POST") api.HandleFunc("/claim", ClaimAddress).Methods("POST")
// authenticated routes; X-Pin in header or in json request body // authenticated routes; X-Pin in header or in json request body
api.HandleFunc("/users/{name}", GetUser).Methods("GET") api.HandleFunc("/users/{name}@{domain}", GetUser).Methods("GET")
api.HandleFunc("/users/{name}", UpdateUser).Methods("PUT") api.HandleFunc("/users/{name}@{domain}", UpdateUser).Methods("PUT")
api.HandleFunc("/users/{name}", DeleteUser).Methods("DELETE") api.HandleFunc("/users/{name}@{domain}", DeleteUser).Methods("DELETE")
srv := &http.Server{ srv := &http.Server{
Handler: router, Handler: router,
@@ -118,3 +145,10 @@ func main() {
log.Debug().Str("addr", srv.Addr).Msg("listening") log.Debug().Str("addr", srv.Addr).Msg("listening")
srv.ListenAndServe() srv.ListenAndServe()
} }
func getDomains(s string) []string {
splitFn := func(c rune) bool {
return c == ','
}
return strings.FieldsFunc(s, splitFn)
}

View File

@@ -12,10 +12,10 @@ import (
func makeMetadata(params *Params) string { func makeMetadata(params *Params) string {
metadata, _ := sjson.Set("[]", "0.0", "text/identifier") metadata, _ := sjson.Set("[]", "0.0", "text/identifier")
metadata, _ = sjson.Set(metadata, "0.1", params.Name+"@"+s.Domain) metadata, _ = sjson.Set(metadata, "0.1", params.Name+"@"+params.Domain)
metadata, _ = sjson.Set(metadata, "1.0", "text/plain") metadata, _ = sjson.Set(metadata, "1.0", "text/plain")
metadata, _ = sjson.Set(metadata, "1.1", "Satoshis to "+params.Name+"@"+s.Domain+".") metadata, _ = sjson.Set(metadata, "1.1", "Satoshis to "+params.Name+"@"+params.Domain+".")
// TODO support image, custom description // TODO support image, custom description
@@ -61,12 +61,12 @@ func makeInvoice(
Msatoshi: int64(msat), Msatoshi: int64(msat),
Backend: backend, Backend: backend,
Label: s.Domain + "/" + strconv.FormatInt(time.Now().Unix(), 16), Label: params.Domain + "/" + strconv.FormatInt(time.Now().Unix(), 16),
} }
if pin != nil { if pin != nil {
// use this as the description // use this as the description
mip.Description = fmt.Sprintf("%s's PIN for '%s@%s' lightning address: %s", s.Domain, params.Name, s.Domain, *pin) mip.Description = fmt.Sprintf("%s's PIN for '%s@%s' lightning address: %s", params.Domain, params.Name, params.Domain, *pin)
} else { } else {
// make the lnurlpay description_hash // make the lnurlpay description_hash
h := sha256.Sum256([]byte(makeMetadata(params))) h := sha256.Sum256([]byte(makeMetadata(params)))

View File

@@ -1,3 +1,7 @@
* {
box-sizing: border-box;
}
body { body {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -128,21 +132,34 @@ label {
background-color: #f3f3f3; background-color: #f3f3f3;
} }
.input.full-width { input#name {
width: calc(100% - 20px); margin-right: 4px;
margin-bottom: 0;
} }
.suffix { select#domain {
height: 35px; min-height: 100%;
width: 50%; margin-left: 4px;
margin-bottom: 0;
outline: none;
padding: 0 10px; padding: 0 10px;
margin-bottom: 25px; font-size: 14px;
display: inline-flex; border-radius: 5px;
font-size: 16px;
font-weight: 600;
align-items: center;
word-break: keep-all;
letter-spacing: 0.5px; letter-spacing: 0.5px;
border: 1px solid #999;
background-color: #f3f3f3;
}
.input.full-width {
width: 100%;
}
.domain-wrapper {
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 20px;
} }
select { select {
@@ -214,11 +231,6 @@ select {
word-break: break-all; word-break: break-all;
} }
.domain-wrapper {
display: flex;
flex-direction: row;
}
#qr { #qr {
margin: 20px auto; margin: 20px auto;
display: block; display: block;
@@ -245,19 +257,29 @@ select {
.domain-wrapper { .domain-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
}
input#name {
margin: 0;
margin-bottom: 10px;
width: 100%;
max-width: 100%;
}
select#domain {
margin: 0;
margin-top: 10px;
width: 100%;
max-width: 100%;
} }
.input { .input {
margin-bottom: 5px; margin-bottom: 5px;
width: calc(100% - 25px); width: 100%;
} }
.input.full-width { .input.full-width {
margin-bottom: 25px; margin-bottom: 25px;
} }
.suffix {
padding: 0;
margin-bottom: 25px;
}
} }