forked from heierbtc/satdress-mirror
Allow multiple comma-separated domains
This commit is contained in:
parent
e0f240a3d9
commit
15794baee6
|
@ -6,6 +6,8 @@ Federated Lightning Address Server
|
|||
|
||||
1. Download the binary from the releases page (or compile with `go build` or `go get`)
|
||||
2. Set the following environment variables somehow (using example values from bitmia.com):
|
||||
(note that DOMAIN can be a comma-seperated list or a single domain, when using multiple domains
|
||||
you need to make sure "Host" HTTP header is forwarded to satdress process if you have some reverse-proxy)
|
||||
|
||||
```
|
||||
PORT=17422
|
||||
|
|
42
api.go
42
api.go
|
@ -18,6 +18,7 @@ type Response struct {
|
|||
|
||||
type SuccessClaim struct {
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain"`
|
||||
PIN string `json:"pin"`
|
||||
Invoice string `json:"invoice"`
|
||||
}
|
||||
|
@ -25,7 +26,7 @@ type SuccessClaim struct {
|
|||
// not authenticated, if correct pin is provided call returns the SuccessClaim
|
||||
func ClaimAddress(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
sendError(w, 400, "could not register name: %s", err.Error())
|
||||
return
|
||||
|
@ -33,8 +34,8 @@ func ClaimAddress(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
response := Response{
|
||||
Ok: true,
|
||||
Message: fmt.Sprintf("claimed %v@%v", params.Name, s.Domain),
|
||||
Data: SuccessClaim{params.Name, pin, inv},
|
||||
Message: fmt.Sprintf("claimed %v@%v", params.Name, params.Domain),
|
||||
Data: SuccessClaim{params.Name, params.Domain, pin, inv},
|
||||
}
|
||||
|
||||
// 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) {
|
||||
name := mux.Vars(r)["name"]
|
||||
params, err := GetName(name)
|
||||
domain := mux.Vars(r)["domain"]
|
||||
params, err := GetName(name, domain)
|
||||
if err != nil {
|
||||
sendError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 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{
|
||||
Ok: true,
|
||||
Message: fmt.Sprintf("%v@%v found", params.Name, s.Domain),
|
||||
Message: fmt.Sprintf("%v@%v found", params.Name, domain),
|
||||
Data: params,
|
||||
}
|
||||
|
||||
|
@ -68,6 +70,7 @@ func GetUser(w http.ResponseWriter, r *http.Request) {
|
|||
func UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
params := parseParams(r)
|
||||
name := mux.Vars(r)["name"]
|
||||
domain := mux.Vars(r)["domain"]
|
||||
|
||||
// if pin not in json request body get it from header
|
||||
if params.Pin == "" {
|
||||
|
@ -75,12 +78,12 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
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())
|
||||
return
|
||||
}
|
||||
|
||||
updatedParams, err := GetName(name)
|
||||
updatedParams, err := GetName(name, domain)
|
||||
if err != nil {
|
||||
sendError(w, 500, err.Error())
|
||||
return
|
||||
|
@ -89,7 +92,7 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
// return the updated values or just http.StatusCreated?
|
||||
response := Response{
|
||||
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,
|
||||
}
|
||||
|
||||
|
@ -100,14 +103,15 @@ func UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
return
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Ok: true,
|
||||
Message: fmt.Sprintf("deleted %v@%v", name, s.Domain),
|
||||
Message: fmt.Sprintf("deleted %v@%v", name, domain),
|
||||
Data: nil,
|
||||
}
|
||||
|
||||
|
@ -119,6 +123,20 @@ func DeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||
// authentication middleware
|
||||
func authenticate(next http.Handler) http.Handler {
|
||||
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;
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v1/claim") {
|
||||
next.ServeHTTP(w, r)
|
||||
|
@ -136,7 +154,7 @@ func authenticate(next http.Handler) http.Handler {
|
|||
providedPin = parseParams(r).Pin
|
||||
}
|
||||
|
||||
if providedPin != ComputePIN(name) {
|
||||
if providedPin != ComputePIN(name, domain) {
|
||||
err = fmt.Errorf("wrong pin")
|
||||
}
|
||||
|
||||
|
|
81
db.go
81
db.go
|
@ -7,6 +7,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cockroachdb/pebble"
|
||||
|
@ -14,6 +15,7 @@ import (
|
|||
|
||||
type Params struct {
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Host string `json:"host"`
|
||||
Key string `json:"key"`
|
||||
|
@ -26,13 +28,16 @@ type Params struct {
|
|||
|
||||
func SaveName(
|
||||
name string,
|
||||
domain string,
|
||||
params *Params,
|
||||
providedPin string,
|
||||
) (pin string, inv string, err error) {
|
||||
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 {
|
||||
defer closer.Close()
|
||||
|
@ -45,6 +50,7 @@ func SaveName(
|
|||
}
|
||||
|
||||
params.Name = name
|
||||
params.Domain = domain
|
||||
|
||||
// check if the given data works
|
||||
if inv, err = makeInvoice(params, 1000, &pin); err != nil {
|
||||
|
@ -60,10 +66,8 @@ func SaveName(
|
|||
return pin, inv, nil
|
||||
}
|
||||
|
||||
func GetName(name string) (*Params, error) {
|
||||
name = strings.ToLower(name)
|
||||
|
||||
val, closer, err := db.Get([]byte(name))
|
||||
func GetName(name, domain string) (*Params, error) {
|
||||
val, closer, err := db.Get([]byte(getID(name, domain)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -75,12 +79,12 @@ func GetName(name string) (*Params, error) {
|
|||
}
|
||||
|
||||
params.Name = name
|
||||
params.Domain = domain
|
||||
return ¶ms, nil
|
||||
}
|
||||
|
||||
func DeleteName(name string) error {
|
||||
name = strings.ToLower(name)
|
||||
key := []byte(name)
|
||||
func DeleteName(name, domain string) error {
|
||||
key := []byte(getID(name, domain))
|
||||
|
||||
if err := db.Delete(key, pebble.Sync); err != nil {
|
||||
return err
|
||||
|
@ -89,9 +93,62 @@ func DeleteName(name string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func ComputePIN(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
func ComputePIN(name, domain string) string {
|
||||
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))
|
||||
}
|
||||
|
||||
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("db", string(iter.Key())).Msg("Migrating key")
|
||||
var params Params
|
||||
if err := json.Unmarshal(iter.Value(), ¶ms); 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<div class="title">Success!</div>
|
||||
<div class="card">
|
||||
<div class="description">
|
||||
<b>{{ name }}@{{ domain }}</b> is your new Lightning Address!
|
||||
<b>{{ name }}@{{ actual_domain }}</b> is your new Lightning Address!
|
||||
</div>
|
||||
<div class="bold-small">
|
||||
In order to edit the configuration of this address in the future you
|
||||
|
|
10
html.go
10
html.go
|
@ -8,18 +8,24 @@ import (
|
|||
)
|
||||
|
||||
type BaseData struct {
|
||||
Domain string `json:"domain"`
|
||||
Domains []string `json:"domains"`
|
||||
SiteOwnerName string `json:"siteOwnerName"`
|
||||
SiteOwnerURL string `json:"siteOwnerURL"`
|
||||
SiteName string `json:"siteName"`
|
||||
UsernameInfo string `json:"usernameInfo"`
|
||||
}
|
||||
|
||||
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{
|
||||
Domain: s.Domain,
|
||||
Domains: getDomains(s.Domain),
|
||||
SiteOwnerName: s.SiteOwnerName,
|
||||
SiteOwnerURL: s.SiteOwnerURL,
|
||||
SiteName: s.SiteName,
|
||||
UsernameInfo: info,
|
||||
})
|
||||
extra, _ := json.Marshal(extraData)
|
||||
|
||||
|
|
|
@ -24,11 +24,15 @@
|
|||
<form action="/grab" method="post">
|
||||
<div class="field">
|
||||
<div class="row">
|
||||
<label for="name"> Desired Username </label>
|
||||
<label for="name"> {{usernameInfo}} </label>
|
||||
</div>
|
||||
<div class="domain-wrapper">
|
||||
<input class="input" name="name" id="name" />
|
||||
<span class="suffix">@{{ domain }}</span>
|
||||
<span class="suffix" v-if="domains.length == 1">@{{ domains[0] }}</span>
|
||||
<span class="suffix-hack" 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 class="field">
|
||||
|
|
36
lnurl.go
36
lnurl.go
|
@ -5,23 +5,47 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fiatjaf/go-lnurl"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
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 {
|
||||
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(
|
||||
"failed to get name %s", username)))
|
||||
"failed to get name %s@%s", username, domain)))
|
||||
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 == "" {
|
||||
// check if the receiver accepts comments
|
||||
|
@ -41,7 +65,7 @@ func handleLNURL(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
json.NewEncoder(w).Encode(lnurl.LNURLPayResponse1{
|
||||
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,
|
||||
MaxSendable: maxSendable,
|
||||
EncodedMetadata: makeMetadata(params),
|
||||
|
|
52
main.go
52
main.go
|
@ -20,11 +20,15 @@ type Settings struct {
|
|||
Host string `envconfig:"HOST" default:"0.0.0.0"`
|
||||
Port string `envconfig:"PORT" 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"`
|
||||
SiteOwnerName string `envconfig:"SITE_OWNER_NAME" required:"true"`
|
||||
SiteOwnerURL string `envconfig:"SITE_OWNER_URL" required:"true"`
|
||||
SiteName string `envconfig:"SITE_NAME" required:"true"`
|
||||
|
||||
ForceMigrate bool `envconfig:"FORCE_MIGRATE" required:"false" default:false`
|
||||
TorProxyURL string `envconfig:"TOR_PROXY_URL"`
|
||||
}
|
||||
|
||||
|
@ -57,12 +61,19 @@ func main() {
|
|||
makeinvoice.TorProxyURL = s.TorProxyURL
|
||||
}
|
||||
|
||||
db, err = pebble.Open(s.Domain, nil)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Str("path", s.Domain).Msg("failed to open db.")
|
||||
dbName := fmt.Sprintf("%v-multiple.db", s.SiteName)
|
||||
if _, err := os.Stat(dbName); os.IsNotExist(err) || s.ForceMigrate {
|
||||
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)
|
||||
|
||||
router.Path("/").HandlerFunc(
|
||||
|
@ -76,8 +87,23 @@ func main() {
|
|||
router.Path("/grab").HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
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"),
|
||||
Host: r.FormValue("host"),
|
||||
Key: r.FormValue("key"),
|
||||
|
@ -94,7 +120,8 @@ func main() {
|
|||
PIN string `json:"pin"`
|
||||
Invoice string `json:"invoice"`
|
||||
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")
|
||||
|
||||
// authenticated routes; X-Pin in header or in json request body
|
||||
api.HandleFunc("/users/{name}", GetUser).Methods("GET")
|
||||
api.HandleFunc("/users/{name}", UpdateUser).Methods("PUT")
|
||||
api.HandleFunc("/users/{name}", DeleteUser).Methods("DELETE")
|
||||
api.HandleFunc("/users/{name}@{domain}", GetUser).Methods("GET")
|
||||
api.HandleFunc("/users/{name}@{domain}", UpdateUser).Methods("PUT")
|
||||
api.HandleFunc("/users/{name}@{domain}", DeleteUser).Methods("DELETE")
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: router,
|
||||
|
@ -118,3 +145,10 @@ func main() {
|
|||
log.Debug().Str("addr", srv.Addr).Msg("listening")
|
||||
srv.ListenAndServe()
|
||||
}
|
||||
|
||||
func getDomains(s string) []string {
|
||||
splitFn := func(c rune) bool {
|
||||
return c == ','
|
||||
}
|
||||
return strings.FieldsFunc(s, splitFn)
|
||||
}
|
||||
|
|
|
@ -12,10 +12,10 @@ import (
|
|||
|
||||
func makeMetadata(params *Params) string {
|
||||
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.1", "Satoshis to "+params.Name+"@"+s.Domain+".")
|
||||
metadata, _ = sjson.Set(metadata, "1.1", "Satoshis to "+params.Name+"@"+params.Domain+".")
|
||||
|
||||
// TODO support image, custom description
|
||||
|
||||
|
|
|
@ -128,6 +128,19 @@ label {
|
|||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
select#domain {
|
||||
width: 50%;
|
||||
height: 35px;
|
||||
outline: none;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 25px;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid #999;
|
||||
background-color: #f3f3f3;
|
||||
}
|
||||
|
||||
.input.full-width {
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
@ -145,6 +158,18 @@ label {
|
|||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.suffix-hack {
|
||||
height: 35px;
|
||||
padding: 0 10px;
|
||||
margin-bottom: 25px;
|
||||
display: inline-flex;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
word-break: keep-all;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
select {
|
||||
height: 35px;
|
||||
outline: none;
|
||||
|
@ -260,4 +285,9 @@ select {
|
|||
padding: 0;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.suffix-hack {
|
||||
padding: 0;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user