Reviewed-on: #7 Co-authored-by: Elizabeth <elizabeth@simponic.xyz> Co-committed-by: Elizabeth <elizabeth@simponic.xyz>
This commit is contained in:
parent
ee49015cc9
commit
1d75bf7489
|
@ -1,3 +1,4 @@
|
||||||
.env
|
.env
|
||||||
hatecomputers.club
|
hatecomputers.club
|
||||||
*.db
|
*.db
|
||||||
|
uploads
|
||||||
|
|
|
@ -11,4 +11,4 @@ RUN go build -o /app/hatecomputers
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["/app/hatecomputers", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/hatecomputers.db", "--static-path", "/app/static", "--scheduler", "--dns", "--dns-port", "8053", "--dns-resolvers", "1.1.1.1:53,1.0.0.1:53"]
|
CMD ["/app/hatecomputers", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/hatecomputers.db", "--static-path", "/app/static", "--scheduler", "--dns", "--dns-port", "8053", "--dns-resolvers", "1.1.1.1:53,1.0.0.1:53", "--uploads", "/app/uploads"]
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package files
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
type FilesAdapter interface {
|
||||||
|
CreateFile(path string, content io.Reader) (string, error)
|
||||||
|
DeleteFile(path string) error
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FilesystemAdapter struct {
|
||||||
|
BasePath string
|
||||||
|
Permissions os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilesystemAdapter) CreateFile(path string, content io.Reader) (string, error) {
|
||||||
|
fullPath := f.BasePath + path
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
os.MkdirAll(dir, f.Permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(f.BasePath + path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, content)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FilesystemAdapter) DeleteFile(path string) error {
|
||||||
|
return os.Remove(f.BasePath + path)
|
||||||
|
}
|
|
@ -18,6 +18,18 @@ import (
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func ListUsersContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
users, err := database.ListUsers(context.DBConn)
|
||||||
|
if err != nil {
|
||||||
|
return failure(context, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
(*context.TemplateData)["Users"] = users
|
||||||
|
return success(context, req, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func StartSessionContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
func StartSessionContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
verifier := utils.RandomId() + utils.RandomId()
|
verifier := utils.RandomId() + utils.RandomId()
|
||||||
|
@ -158,6 +170,7 @@ func VerifySessionContinuation(context *types.RequestContext, req *http.Request,
|
||||||
|
|
||||||
func GoLoginContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
func GoLoginContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
log.Println("GoLoginContinuation")
|
||||||
http.SetCookie(resp, &http.Cookie{
|
http.SetCookie(resp, &http.Cookie{
|
||||||
Name: "redirect",
|
Name: "redirect",
|
||||||
Value: req.URL.Path,
|
Value: req.URL.Path,
|
||||||
|
@ -216,7 +229,7 @@ func getOauthUser(dbConn *sql.DB, client *http.Client, uri string) (*database.Us
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := database.FindOrSaveUser(dbConn, userStruct)
|
user, err := database.FindOrSaveBaseUser(dbConn, userStruct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,39 +8,15 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/external_dns"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/database"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/database"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/utils"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func userCanFuckWithDNSRecord(dbConn *sql.DB, user *database.User, record *database.DNSRecord, ownedInternalDomainFormats []string) bool {
|
const MaxUserRecords = 100
|
||||||
ownedByUser := (user.ID == record.UserID)
|
|
||||||
if !ownedByUser {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !record.Internal {
|
var UserOwnedInternalFmtDomains = []string{"%s", "%s.endpoints"}
|
||||||
for _, format := range ownedInternalDomainFormats {
|
|
||||||
domain := fmt.Sprintf(format, user.Username)
|
|
||||||
|
|
||||||
isInSubDomain := strings.HasSuffix(record.Name, "."+domain)
|
|
||||||
if domain == record.Name || isInSubDomain {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, err := database.FindFirstDomainOwnerId(dbConn, record.Name)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
userIsOwnerOfDomain := owner == user.ID
|
|
||||||
return ownedByUser && userIsOwnerOfDomain
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
@ -59,8 +35,8 @@ func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request
|
||||||
func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, maxUserRecords int, allowedUserDomainFormats []string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, maxUserRecords int, allowedUserDomainFormats []string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
formErrors := types.FormError{
|
formErrors := types.BannerMessages{
|
||||||
Errors: []string{},
|
Messages: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
internal := req.FormValue("internal") == "on" || req.FormValue("internal") == "true"
|
internal := req.FormValue("internal") == "on" || req.FormValue("internal") == "true"
|
||||||
|
@ -77,7 +53,7 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max
|
||||||
ttlNum, err := strconv.Atoi(ttl)
|
ttlNum, err := strconv.Atoi(ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resp.WriteHeader(http.StatusBadRequest)
|
resp.WriteHeader(http.StatusBadRequest)
|
||||||
formErrors.Errors = append(formErrors.Errors, "invalid ttl")
|
formErrors.Messages = append(formErrors.Messages, "invalid ttl")
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsRecordCount, err := database.CountUserDNSRecords(context.DBConn, context.User.ID)
|
dnsRecordCount, err := database.CountUserDNSRecords(context.DBConn, context.User.ID)
|
||||||
|
@ -88,7 +64,7 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max
|
||||||
}
|
}
|
||||||
if dnsRecordCount >= maxUserRecords {
|
if dnsRecordCount >= maxUserRecords {
|
||||||
resp.WriteHeader(http.StatusTooManyRequests)
|
resp.WriteHeader(http.StatusTooManyRequests)
|
||||||
formErrors.Errors = append(formErrors.Errors, "max records reached")
|
formErrors.Messages = append(formErrors.Messages, "max records reached")
|
||||||
}
|
}
|
||||||
|
|
||||||
dnsRecord := &database.DNSRecord{
|
dnsRecord := &database.DNSRecord{
|
||||||
|
@ -102,10 +78,10 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max
|
||||||
|
|
||||||
if !userCanFuckWithDNSRecord(context.DBConn, context.User, dnsRecord, allowedUserDomainFormats) {
|
if !userCanFuckWithDNSRecord(context.DBConn, context.User, dnsRecord, allowedUserDomainFormats) {
|
||||||
resp.WriteHeader(http.StatusUnauthorized)
|
resp.WriteHeader(http.StatusUnauthorized)
|
||||||
formErrors.Errors = append(formErrors.Errors, "'name' must end with "+context.User.Username+" or you must be a domain owner for internal domains")
|
formErrors.Messages = append(formErrors.Messages, "'name' must end with "+context.User.Username+" or you must be a domain owner for internal domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(formErrors.Errors) == 0 {
|
if len(formErrors.Messages) == 0 {
|
||||||
if dnsRecord.Internal {
|
if dnsRecord.Internal {
|
||||||
dnsRecord.ID = utils.RandomId()
|
dnsRecord.ID = utils.RandomId()
|
||||||
} else {
|
} else {
|
||||||
|
@ -113,24 +89,28 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
resp.WriteHeader(http.StatusInternalServerError)
|
resp.WriteHeader(http.StatusInternalServerError)
|
||||||
formErrors.Errors = append(formErrors.Errors, err.Error())
|
formErrors.Messages = append(formErrors.Messages, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(formErrors.Errors) == 0 {
|
if len(formErrors.Messages) == 0 {
|
||||||
_, err := database.SaveDNSRecord(context.DBConn, dnsRecord)
|
_, err := database.SaveDNSRecord(context.DBConn, dnsRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
formErrors.Errors = append(formErrors.Errors, "error saving record")
|
formErrors.Messages = append(formErrors.Messages, "error saving record")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(formErrors.Errors) == 0 {
|
if len(formErrors.Messages) == 0 {
|
||||||
|
formSuccess := types.BannerMessages{
|
||||||
|
Messages: []string{"record added."},
|
||||||
|
}
|
||||||
|
(*context.TemplateData)["Success"] = formSuccess
|
||||||
return success(context, req, resp)
|
return success(context, req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
(*context.TemplateData)["FormError"] = &formErrors
|
(*context.TemplateData)[""] = &formErrors
|
||||||
(*context.TemplateData)["RecordForm"] = dnsRecord
|
(*context.TemplateData)["RecordForm"] = dnsRecord
|
||||||
return failure(context, req, resp)
|
return failure(context, req, resp)
|
||||||
}
|
}
|
||||||
|
@ -168,7 +148,39 @@ func DeleteDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter) fun
|
||||||
return failure(context, req, resp)
|
return failure(context, req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formSuccess := types.BannerMessages{
|
||||||
|
Messages: []string{"record deleted."},
|
||||||
|
}
|
||||||
|
(*context.TemplateData)["Success"] = formSuccess
|
||||||
return success(context, req, resp)
|
return success(context, req, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userCanFuckWithDNSRecord(dbConn *sql.DB, user *database.User, record *database.DNSRecord, ownedInternalDomainFormats []string) bool {
|
||||||
|
ownedByUser := (user.ID == record.UserID)
|
||||||
|
if !ownedByUser {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !record.Internal {
|
||||||
|
for _, format := range ownedInternalDomainFormats {
|
||||||
|
domain := fmt.Sprintf(format, user.Username)
|
||||||
|
|
||||||
|
isInSubDomain := strings.HasSuffix(record.Name, "."+domain)
|
||||||
|
if domain == record.Name || isInSubDomain {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, err := database.FindFirstDomainOwnerId(dbConn, record.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
userIsOwnerOfDomain := owner == user.ID
|
||||||
|
return ownedByUser && userIsOwnerOfDomain
|
||||||
|
}
|
||||||
|
|
|
@ -39,7 +39,7 @@ func setup() (*sql.DB, *types.RequestContext, func()) {
|
||||||
Mail: "test@test.com",
|
Mail: "test@test.com",
|
||||||
DisplayName: "test",
|
DisplayName: "test",
|
||||||
}
|
}
|
||||||
database.FindOrSaveUser(testDb, user)
|
database.FindOrSaveBaseUser(testDb, user)
|
||||||
|
|
||||||
context := &types.RequestContext{
|
context := &types.RequestContext{
|
||||||
DBConn: testDb,
|
DBConn: testDb,
|
||||||
|
@ -246,7 +246,7 @@ func TestThatUserMustOwnRecordToRemove(t *testing.T) {
|
||||||
defer testServer.Close()
|
defer testServer.Close()
|
||||||
|
|
||||||
nonOwnerUser := &database.User{ID: "n/a", Username: "testuser"}
|
nonOwnerUser := &database.User{ID: "n/a", Username: "testuser"}
|
||||||
_, err := database.FindOrSaveUser(db, nonOwnerUser)
|
_, err := database.FindOrSaveBaseUser(db, nonOwnerUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,60 @@ import (
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/utils"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func SignGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
name := req.FormValue("name")
|
||||||
|
message := req.FormValue("message")
|
||||||
|
|
||||||
|
formErrors := types.BannerMessages{
|
||||||
|
Messages: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &database.GuestbookEntry{
|
||||||
|
ID: utils.RandomId(),
|
||||||
|
Name: name,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
formErrors.Messages = append(formErrors.Messages, validateGuestbookEntry(entry)...)
|
||||||
|
|
||||||
|
if len(formErrors.Messages) == 0 {
|
||||||
|
_, err := database.SaveGuestbookEntry(context.DBConn, entry)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
formErrors.Messages = append(formErrors.Messages, "failed to save entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(formErrors.Messages) > 0 {
|
||||||
|
(*context.TemplateData)["Error"] = formErrors
|
||||||
|
(*context.TemplateData)["EntryForm"] = entry
|
||||||
|
resp.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
||||||
|
return failure(context, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
formSuccess := types.BannerMessages{
|
||||||
|
Messages: []string{"entry added."},
|
||||||
|
}
|
||||||
|
(*context.TemplateData)["Success"] = formSuccess
|
||||||
|
return success(context, req, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
entries, err := database.GetGuestbookEntries(context.DBConn)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
resp.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return failure(context, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
(*context.TemplateData)["GuestbookEntries"] = entries
|
||||||
|
return success(context, req, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validateGuestbookEntry(entry *database.GuestbookEntry) []string {
|
func validateGuestbookEntry(entry *database.GuestbookEntry) []string {
|
||||||
errors := []string{}
|
errors := []string{}
|
||||||
|
|
||||||
|
@ -33,53 +87,3 @@ func validateGuestbookEntry(entry *database.GuestbookEntry) []string {
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func SignGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
|
||||||
name := req.FormValue("name")
|
|
||||||
message := req.FormValue("message")
|
|
||||||
|
|
||||||
formErrors := types.FormError{
|
|
||||||
Errors: []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := &database.GuestbookEntry{
|
|
||||||
ID: utils.RandomId(),
|
|
||||||
Name: name,
|
|
||||||
Message: message,
|
|
||||||
}
|
|
||||||
formErrors.Errors = append(formErrors.Errors, validateGuestbookEntry(entry)...)
|
|
||||||
|
|
||||||
if len(formErrors.Errors) == 0 {
|
|
||||||
_, err := database.SaveGuestbookEntry(context.DBConn, entry)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
formErrors.Errors = append(formErrors.Errors, "failed to save entry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(formErrors.Errors) > 0 {
|
|
||||||
(*context.TemplateData)["FormError"] = formErrors
|
|
||||||
(*context.TemplateData)["EntryForm"] = entry
|
|
||||||
resp.WriteHeader(http.StatusBadRequest)
|
|
||||||
|
|
||||||
return failure(context, req, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return success(context, req, resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ListGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
|
||||||
entries, err := database.GetGuestbookEntries(context.DBConn)
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
resp.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return failure(context, req, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
(*context.TemplateData)["GuestbookEntries"] = entries
|
|
||||||
return success(context, req, resp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -62,8 +62,8 @@ func CaptchaVerificationContinuation(context *types.RequestContext, req *http.Re
|
||||||
|
|
||||||
err := verifyCaptcha(secretKey, hCaptchaResponse)
|
err := verifyCaptcha(secretKey, hCaptchaResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
(*context.TemplateData)["FormError"] = types.FormError{
|
(*context.TemplateData)["Error"] = types.BannerMessages{
|
||||||
Errors: []string{"hCaptcha verification failed"},
|
Messages: []string{"hCaptcha verification failed"},
|
||||||
}
|
}
|
||||||
resp.WriteHeader(http.StatusBadRequest)
|
resp.WriteHeader(http.StatusBadRequest)
|
||||||
|
|
||||||
|
|
|
@ -27,8 +27,8 @@ func ListAPIKeysContinuation(context *types.RequestContext, req *http.Request, r
|
||||||
|
|
||||||
func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
formErrors := types.FormError{
|
formErrors := types.BannerMessages{
|
||||||
Errors: []string{},
|
Messages: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
numKeys, err := database.CountUserAPIKeys(context.DBConn, context.User.ID)
|
numKeys, err := database.CountUserAPIKeys(context.DBConn, context.User.ID)
|
||||||
|
@ -39,11 +39,11 @@ func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request,
|
||||||
}
|
}
|
||||||
|
|
||||||
if numKeys >= MAX_USER_API_KEYS {
|
if numKeys >= MAX_USER_API_KEYS {
|
||||||
formErrors.Errors = append(formErrors.Errors, "max types keys reached")
|
formErrors.Messages = append(formErrors.Messages, "max types keys reached")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(formErrors.Errors) > 0 {
|
if len(formErrors.Messages) > 0 {
|
||||||
(*context.TemplateData)["FormError"] = formErrors
|
(*context.TemplateData)["Error"] = formErrors
|
||||||
return failure(context, req, resp)
|
return failure(context, req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,6 +56,11 @@ func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request,
|
||||||
resp.WriteHeader(http.StatusInternalServerError)
|
resp.WriteHeader(http.StatusInternalServerError)
|
||||||
return failure(context, req, resp)
|
return failure(context, req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formSuccess := types.BannerMessages{
|
||||||
|
Messages: []string{"key created."},
|
||||||
|
}
|
||||||
|
(*context.TemplateData)["Success"] = formSuccess
|
||||||
return success(context, req, resp)
|
return success(context, req, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,6 +87,11 @@ func DeleteAPIKeyContinuation(context *types.RequestContext, req *http.Request,
|
||||||
return failure(context, req, resp)
|
return failure(context, req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formSuccess := types.BannerMessages{
|
||||||
|
Messages: []string{"key deleted."},
|
||||||
|
}
|
||||||
|
(*context.TemplateData)["Success"] = formSuccess
|
||||||
|
|
||||||
return success(context, req, resp)
|
return success(context, req, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,118 @@
|
||||||
|
package profiles
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/files"
|
||||||
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types"
|
||||||
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxAvatarSize = 1024 * 1024 * 2 // 2MB
|
||||||
|
const AvatarPath = "avatars/"
|
||||||
|
const AvatarPrefix = "/uploads/avatars/"
|
||||||
|
|
||||||
|
func GetProfileContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
if context.User == nil {
|
||||||
|
return failure(context, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
(*context.TemplateData)["Profile"] = context.User
|
||||||
|
return success(context, req, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateProfileContinuation(fileAdapter files.FilesAdapter, maxAvatarSize int, avatarPath string, avatarPrefix string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
|
return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
|
return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
|
||||||
|
formErrors := types.BannerMessages{
|
||||||
|
Messages: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := req.ParseMultipartForm(int64(maxAvatarSize))
|
||||||
|
if err != nil {
|
||||||
|
formErrors.Messages = append(formErrors.Messages, "avatar file too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(formErrors.Messages) == 0 {
|
||||||
|
file, _, err := req.FormFile("avatar")
|
||||||
|
if file != nil && err != nil {
|
||||||
|
formErrors.Messages = append(formErrors.Messages, "error uploading avatar")
|
||||||
|
} else if file != nil {
|
||||||
|
defer file.Close()
|
||||||
|
reader := http.MaxBytesReader(resp, file, int64(maxAvatarSize))
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
_, err = fileAdapter.CreateFile(avatarPath+context.User.ID, reader)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
formErrors.Messages = append(formErrors.Messages, "error saving avatar (is it too big?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.User.Bio = strings.Trim(req.FormValue("bio"), "\n")
|
||||||
|
context.User.Pronouns = req.FormValue("pronouns")
|
||||||
|
context.User.Location = req.FormValue("location")
|
||||||
|
context.User.Website = req.FormValue("website")
|
||||||
|
context.User.Avatar = avatarPrefix + context.User.ID
|
||||||
|
formErrors.Messages = append(formErrors.Messages, validateProfileUpdate(context.User)...)
|
||||||
|
|
||||||
|
if len(formErrors.Messages) == 0 {
|
||||||
|
_, err = database.SaveUser(context.DBConn, context.User)
|
||||||
|
if err != nil {
|
||||||
|
formErrors.Messages = append(formErrors.Messages, "error saving profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(*context.TemplateData)["Profile"] = context.User
|
||||||
|
(*context.TemplateData)["Error"] = formErrors
|
||||||
|
|
||||||
|
if len(formErrors.Messages) > 0 {
|
||||||
|
log.Println(formErrors.Messages)
|
||||||
|
|
||||||
|
resp.WriteHeader(http.StatusBadRequest)
|
||||||
|
return failure(context, req, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
formSuccess := types.BannerMessages{
|
||||||
|
Messages: []string{"profile updated"},
|
||||||
|
}
|
||||||
|
(*context.TemplateData)["Success"] = formSuccess
|
||||||
|
return success(context, req, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProfileUpdate(user *database.User) []string {
|
||||||
|
errors := []string{}
|
||||||
|
|
||||||
|
if (!strings.HasPrefix(user.Website, "https://") && !strings.HasPrefix(user.Website, "http://")) || len(user.Website) < 8 {
|
||||||
|
errors = append(errors, "website must be a valid URL")
|
||||||
|
}
|
||||||
|
if len(user.Website) > 64 {
|
||||||
|
errors = append(errors, "website cannot be longer than 64 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Pronouns) > 64 {
|
||||||
|
errors = append(errors, "pronouns cannot be longer than 64 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Bio) > 128 {
|
||||||
|
errors = append(errors, "bio cannot be longer than 128 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
newLines := strings.Count(user.Bio, "\n")
|
||||||
|
if newLines > 8 {
|
||||||
|
errors = append(errors, "message cannot contain more than 8 new lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.Location) > 32 {
|
||||||
|
errors = append(errors, "location cannot be longer than 64 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
36
api/serve.go
36
api/serve.go
|
@ -7,12 +7,14 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/cloudflare"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/external_dns/cloudflare"
|
||||||
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/files/filesystem"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/auth"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/auth"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/dns"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/dns"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/guestbook"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/guestbook"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/hcaptcha"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/hcaptcha"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/keys"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/keys"
|
||||||
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/profiles"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/template"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/template"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types"
|
||||||
"git.hatecomputers.club/hatecomputers/hatecomputers.club/args"
|
"git.hatecomputers.club/hatecomputers/hatecomputers.club/args"
|
||||||
|
@ -32,7 +34,6 @@ func LogRequestContinuation(context *types.RequestContext, req *http.Request, re
|
||||||
func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
|
||||||
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
|
return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
|
||||||
end := time.Now()
|
end := time.Now()
|
||||||
|
|
||||||
log.Println(context.Id, "took", end.Sub(context.Start))
|
log.Println(context.Id, "took", end.Sub(context.Start))
|
||||||
|
|
||||||
return success(context, req, resp)
|
return success(context, req, resp)
|
||||||
|
@ -70,13 +71,15 @@ func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler {
|
||||||
func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
|
func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
fileServer := http.FileServer(http.Dir(argv.StaticPath))
|
// "dependency injection"
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(fileServer, 3600)))
|
|
||||||
|
|
||||||
cloudflareAdapter := &cloudflare.CloudflareExternalDNSAdapter{
|
cloudflareAdapter := &cloudflare.CloudflareExternalDNSAdapter{
|
||||||
APIToken: argv.CloudflareToken,
|
APIToken: argv.CloudflareToken,
|
||||||
ZoneId: argv.CloudflareZone,
|
ZoneId: argv.CloudflareZone,
|
||||||
}
|
}
|
||||||
|
uploadAdapter := &filesystem.FilesystemAdapter{
|
||||||
|
BasePath: argv.UploadPath,
|
||||||
|
Permissions: 0777,
|
||||||
|
}
|
||||||
|
|
||||||
makeRequestContext := func() *types.RequestContext {
|
makeRequestContext := func() *types.RequestContext {
|
||||||
return &types.RequestContext{
|
return &types.RequestContext{
|
||||||
|
@ -86,9 +89,14 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
staticFileServer := http.FileServer(http.Dir(argv.StaticPath))
|
||||||
|
uploadFileServer := http.FileServer(http.Dir(argv.UploadPath))
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600)))
|
||||||
|
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", CacheControlMiddleware(uploadFileServer, 60)))
|
||||||
|
|
||||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
requestContext := makeRequestContext()
|
requestContext := makeRequestContext()
|
||||||
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(template.TemplateContinuation("home.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(auth.ListUsersContinuation, auth.ListUsersContinuation)(template.TemplateContinuation("home.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -111,16 +119,26 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server {
|
||||||
LogRequestContinuation(requestContext, r, w)(auth.LogoutContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
LogRequestContinuation(requestContext, r, w)(auth.LogoutContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /profile", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestContext := makeRequestContext()
|
||||||
|
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(profiles.GetProfileContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("profile.html", true), template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /profile", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestContext := makeRequestContext()
|
||||||
|
updateProfileContinuation := profiles.UpdateProfileContinuation(uploadAdapter, profiles.MaxAvatarSize, profiles.AvatarPath, profiles.AvatarPrefix)
|
||||||
|
|
||||||
|
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(updateProfileContinuation, auth.GoLoginContinuation)(profiles.GetProfileContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
||||||
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /dns", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /dns", func(w http.ResponseWriter, r *http.Request) {
|
||||||
requestContext := makeRequestContext()
|
requestContext := makeRequestContext()
|
||||||
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("dns.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("dns.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_USER_RECORDS = 100
|
|
||||||
var USER_OWNED_INTERNAL_FMT_DOMAINS = []string{"%s", "%s.endpoints"}
|
|
||||||
mux.HandleFunc("POST /dns", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /dns", func(w http.ResponseWriter, r *http.Request) {
|
||||||
requestContext := makeRequestContext()
|
requestContext := makeRequestContext()
|
||||||
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(dns.CreateDNSRecordContinuation(cloudflareAdapter, MAX_USER_RECORDS, USER_OWNED_INTERNAL_FMT_DOMAINS), FailurePassingContinuation)(dns.ListDNSRecordsContinuation, dns.ListDNSRecordsContinuation)(template.TemplateContinuation("dns.html", true), template.TemplateContinuation("dns.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(dns.CreateDNSRecordContinuation(cloudflareAdapter, dns.MaxUserRecords, dns.UserOwnedInternalFmtDomains), FailurePassingContinuation)(dns.ListDNSRecordsContinuation, dns.ListDNSRecordsContinuation)(template.TemplateContinuation("dns.html", true), template.TemplateContinuation("dns.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation)
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("POST /dns/delete", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /dns/delete", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -20,8 +20,8 @@ type RequestContext struct {
|
||||||
User *database.User
|
User *database.User
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormError struct {
|
type BannerMessages struct {
|
||||||
Errors []string
|
Messages []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
|
type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain
|
||||||
|
|
|
@ -13,6 +13,7 @@ type Arguments struct {
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
TemplatePath string
|
TemplatePath string
|
||||||
StaticPath string
|
StaticPath string
|
||||||
|
UploadPath string
|
||||||
|
|
||||||
Migrate bool
|
Migrate bool
|
||||||
Scheduler bool
|
Scheduler bool
|
||||||
|
@ -35,6 +36,13 @@ type Arguments struct {
|
||||||
|
|
||||||
func GetArgs() (*Arguments, error) {
|
func GetArgs() (*Arguments, error) {
|
||||||
databasePath := flag.String("database-path", "./hatecomputers.db", "Path to the SQLite database")
|
databasePath := flag.String("database-path", "./hatecomputers.db", "Path to the SQLite database")
|
||||||
|
|
||||||
|
uploadPath := flag.String("upload-path", "./uploads", "Path to the uploads directory")
|
||||||
|
uploadPathValue := *uploadPath
|
||||||
|
if uploadPathValue[len(uploadPathValue)-1] != '/' {
|
||||||
|
uploadPathValue += "/"
|
||||||
|
}
|
||||||
|
|
||||||
templatePath := flag.String("template-path", "./templates", "Path to the template directory")
|
templatePath := flag.String("template-path", "./templates", "Path to the template directory")
|
||||||
staticPath := flag.String("static-path", "./static", "Path to the static directory")
|
staticPath := flag.String("static-path", "./static", "Path to the static directory")
|
||||||
dnsResolvers := flag.String("dns-resolvers", "1.1.1.1:53,1.0.0.1:53", "Comma-separated list of DNS resolvers")
|
dnsResolvers := flag.String("dns-resolvers", "1.1.1.1:53,1.0.0.1:53", "Comma-separated list of DNS resolvers")
|
||||||
|
@ -96,6 +104,7 @@ func GetArgs() (*Arguments, error) {
|
||||||
arguments := &Arguments{
|
arguments := &Arguments{
|
||||||
DatabasePath: *databasePath,
|
DatabasePath: *databasePath,
|
||||||
TemplatePath: *templatePath,
|
TemplatePath: *templatePath,
|
||||||
|
UploadPath: uploadPathValue,
|
||||||
StaticPath: *staticPath,
|
StaticPath: *staticPath,
|
||||||
CloudflareToken: cloudflareToken,
|
CloudflareToken: cloudflareToken,
|
||||||
CloudflareZone: cloudflareZone,
|
CloudflareZone: cloudflareZone,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -127,6 +128,40 @@ func MigrateGuestBook(dbConn *sql.DB) (*sql.DB, error) {
|
||||||
return dbConn, nil
|
return dbConn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func MigrateProfiles(dbConn *sql.DB) (*sql.DB, error) {
|
||||||
|
log.Println("migrating profiles columns")
|
||||||
|
|
||||||
|
userColumns := map[string]bool{}
|
||||||
|
row, err := dbConn.Query(`PRAGMA table_info(users);`)
|
||||||
|
if err != nil {
|
||||||
|
return dbConn, err
|
||||||
|
}
|
||||||
|
defer row.Close()
|
||||||
|
|
||||||
|
for row.Next() {
|
||||||
|
var columnName string
|
||||||
|
row.Scan(nil, &columnName, nil, nil, nil, nil)
|
||||||
|
userColumns[columnName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
columns := map[string]string{}
|
||||||
|
columns["pronouns"] = "unspecified"
|
||||||
|
columns["bio"] = "a computer hater"
|
||||||
|
columns["location"] = "earth"
|
||||||
|
columns["website"] = "https://hatecomputers.club"
|
||||||
|
columns["avatar"] = "/static/img/default-avatar.png"
|
||||||
|
|
||||||
|
for column, defaultValue := range columns {
|
||||||
|
if userColumns[column] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Println("migrating column", column)
|
||||||
|
_, err = dbConn.Exec(`ALTER TABLE users ADD COLUMN ` + column + ` TEXT NOT NULL DEFAULT '` + defaultValue + `';`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
func Migrate(dbConn *sql.DB) (*sql.DB, error) {
|
func Migrate(dbConn *sql.DB) (*sql.DB, error) {
|
||||||
log.Println("migrating database")
|
log.Println("migrating database")
|
||||||
|
|
||||||
|
@ -137,6 +172,7 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) {
|
||||||
MigrateDomainOwners,
|
MigrateDomainOwners,
|
||||||
MigrateDNSRecords,
|
MigrateDNSRecords,
|
||||||
MigrateGuestBook,
|
MigrateGuestBook,
|
||||||
|
MigrateProfiles,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, migration := range migrations {
|
for _, migration := range migrations {
|
||||||
|
|
|
@ -24,6 +24,11 @@ type User struct {
|
||||||
Mail string `json:"email"`
|
Mail string `json:"email"`
|
||||||
Username string `json:"preferred_username"`
|
Username string `json:"preferred_username"`
|
||||||
DisplayName string `json:"name"`
|
DisplayName string `json:"name"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Pronouns string `json:"pronouns"` // liberals!! :O
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,13 +38,38 @@ type UserSession struct {
|
||||||
ExpireAt time.Time `json:"expire_at"`
|
ExpireAt time.Time `json:"expire_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListUsers(dbConn *sql.DB) ([]*User, error) {
|
||||||
|
log.Println("listing users")
|
||||||
|
|
||||||
|
rows, err := dbConn.Query(`SELECT id, mail, username, display_name, bio, location, website, avatar, pronouns, created_at FROM users;`)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []*User
|
||||||
|
for rows.Next() {
|
||||||
|
var user User
|
||||||
|
err := rows.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.Bio, &user.Location, &user.Website, &user.Avatar, &user.Pronouns, &user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
users = append(users, &user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetUser(dbConn *sql.DB, id string) (*User, error) {
|
func GetUser(dbConn *sql.DB, id string) (*User, error) {
|
||||||
log.Println("getting user", id)
|
log.Println("getting user", id)
|
||||||
|
|
||||||
row := dbConn.QueryRow(`SELECT id, mail, username, display_name, created_at FROM users WHERE id = ?;`, id)
|
row := dbConn.QueryRow(`SELECT id, mail, username, display_name, bio, location, website, avatar, pronouns, created_at FROM users WHERE id = ?;`, id)
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
err := row.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.CreatedAt)
|
err := row.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.Bio, &user.Location, &user.Website, &user.Avatar, &user.Pronouns, &user.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -48,7 +78,7 @@ func GetUser(dbConn *sql.DB, id string) (*User, error) {
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FindOrSaveUser(dbConn *sql.DB, user *User) (*User, error) {
|
func FindOrSaveBaseUser(dbConn *sql.DB, user *User) (*User, error) {
|
||||||
log.Println("finding or saving user", user.ID)
|
log.Println("finding or saving user", user.ID)
|
||||||
|
|
||||||
_, err := dbConn.Exec(`INSERT INTO users (id, mail, username, display_name) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET mail = excluded.mail, username = excluded.username, display_name = excluded.display_name;`, user.ID, user.Mail, user.Username, user.DisplayName)
|
_, err := dbConn.Exec(`INSERT INTO users (id, mail, username, display_name) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET mail = excluded.mail, username = excluded.username, display_name = excluded.display_name;`, user.ID, user.Mail, user.Username, user.DisplayName)
|
||||||
|
@ -59,6 +89,17 @@ func FindOrSaveUser(dbConn *sql.DB, user *User) (*User, error) {
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SaveUser(dbConn *sql.DB, user *User) (*User, error) {
|
||||||
|
log.Println("saving user", user.ID)
|
||||||
|
|
||||||
|
_, err := dbConn.Exec(`INSERT INTO users (id, mail, username, display_name, bio, location, website, pronouns, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET mail = excluded.mail, username = excluded.username, display_name = excluded.display_name, bio = excluded.bio, location = excluded.location, website = excluded.website, pronouns = excluded.pronouns, avatar = excluded.avatar;`, user.ID, user.Mail, user.Username, user.DisplayName, user.Bio, user.Location, user.Website, user.Pronouns, user.Avatar)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func MakeUserSessionFor(dbConn *sql.DB, user *User) (*UserSession, error) {
|
func MakeUserSessionFor(dbConn *sql.DB, user *User) (*UserSession, error) {
|
||||||
log.Println("making session for user", user.ID)
|
log.Println("making session for user", user.ID)
|
||||||
|
|
||||||
|
|
|
@ -15,5 +15,6 @@ services:
|
||||||
- ./db:/app/db
|
- ./db:/app/db
|
||||||
- ./templates:/app/templates
|
- ./templates:/app/templates
|
||||||
- ./static:/app/static
|
- ./static:/app/static
|
||||||
|
- ./uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:4455:8080"
|
- "127.0.0.1:4455:8080"
|
||||||
|
|
|
@ -27,7 +27,7 @@ func setup(arguments *args.Arguments) (*sql.DB, *dns.Server, string, func()) {
|
||||||
testUser := &database.User{
|
testUser := &database.User{
|
||||||
ID: "test",
|
ID: "test",
|
||||||
}
|
}
|
||||||
database.FindOrSaveUser(testDb, testUser)
|
database.FindOrSaveBaseUser(testDb, testUser)
|
||||||
|
|
||||||
dnsArguments := arguments
|
dnsArguments := arguments
|
||||||
if dnsArguments == nil {
|
if dnsArguments == nil {
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
.club-members {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: left;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.club-member {
|
||||||
|
flex: 1;
|
||||||
|
background-color: var(--background-color-2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 600px;
|
||||||
|
min-width: 400px;
|
||||||
|
line-break: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.club-bio {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar div {
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about {
|
||||||
|
flex: 2;
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
:root {
|
:root {
|
||||||
--background-color-light: #f4e8e9;
|
--background-color-light: #f4e8e9;
|
||||||
--background-color-light-2: #f7f7f7;
|
--background-color-light-2: #f5e6f3;
|
||||||
--text-color-light: #333;
|
--text-color-light: #333;
|
||||||
|
--confirm-color-light: #91d9bb;
|
||||||
--link-color-light: #d291bc;
|
--link-color-light: #d291bc;
|
||||||
--container-bg-light: #fff7f87a;
|
--container-bg-light: #fff7f87a;
|
||||||
--border-color-light: #692fcc;
|
--border-color-light: #692fcc;
|
||||||
|
@ -10,6 +11,7 @@
|
||||||
--background-color-dark: #333;
|
--background-color-dark: #333;
|
||||||
--background-color-dark-2: #2c2c2c;
|
--background-color-dark-2: #2c2c2c;
|
||||||
--text-color-dark: #f4e8e9;
|
--text-color-dark: #f4e8e9;
|
||||||
|
--confirm-color-dark: #4d8f73;
|
||||||
--link-color-dark: #b86b77;
|
--link-color-dark: #b86b77;
|
||||||
--container-bg-dark: #424242ea;
|
--container-bg-dark: #424242ea;
|
||||||
--border-color-dark: #956ade;
|
--border-color-dark: #956ade;
|
||||||
|
@ -24,6 +26,7 @@
|
||||||
--container-bg: var(--container-bg-dark);
|
--container-bg: var(--container-bg-dark);
|
||||||
--border-color: var(--border-color-dark);
|
--border-color: var(--border-color-dark);
|
||||||
--error-color: var(--error-color-dark);
|
--error-color: var(--error-color-dark);
|
||||||
|
--confirm-color: var(--confirm-color-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="LIGHT"] {
|
[data-theme="LIGHT"] {
|
||||||
|
@ -34,9 +37,15 @@
|
||||||
--container-bg: var(--container-bg-light);
|
--container-bg: var(--container-bg-light);
|
||||||
--border-color: var(--border-color-light);
|
--border-color: var(--border-color-light);
|
||||||
--error-color: var(--error-color-light);
|
--error-color: var(--error-color-light);
|
||||||
|
--confirm-color: var(--confirm-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
background-color: var(--error-color);
|
background-color: var(--error-color);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background-color: var(--confirm-color);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
|
@ -36,4 +36,7 @@ textarea {
|
||||||
margin: 0 0 1em;
|
margin: 0 0 1em;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--container-bg);
|
background: var(--container-bg);
|
||||||
|
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
max-width: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-name {
|
.entry-name {
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
@import "/static/css/table.css";
|
@import "/static/css/table.css";
|
||||||
@import "/static/css/form.css";
|
@import "/static/css/form.css";
|
||||||
@import "/static/css/guestbook.css";
|
@import "/static/css/guestbook.css";
|
||||||
|
@import "/static/css/club.css";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "ComicSans";
|
font-family: "ComicSans";
|
||||||
|
@ -22,15 +23,27 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@-webkit-keyframes cursor {
|
@-webkit-keyframes cursor {
|
||||||
0% {cursor: url("/static/img/cursor-2.png"), auto;}
|
0% {
|
||||||
50% {cursor: url("/static/img/cursor-1.png"), auto;}
|
cursor: url("/static/img/cursor-2.png"), auto;
|
||||||
100% {cursor: url("/static/img/cursor-2.png"), auto;}
|
}
|
||||||
|
50% {
|
||||||
|
cursor: url("/static/img/cursor-1.png"), auto;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
cursor: url("/static/img/cursor-2.png"), auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cursor {
|
@keyframes cursor {
|
||||||
0% {cursor: url("/static/img/cursor-2.png"), auto;}
|
0% {
|
||||||
50% {cursor: url("/static/img/cursor-1.png"), auto;}
|
cursor: url("/static/img/cursor-2.png"), auto;
|
||||||
100% {cursor: url("/static/img/cursor-2.png"), auto;}
|
}
|
||||||
|
50% {
|
||||||
|
cursor: url("/static/img/cursor-1.png"), auto;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
cursor: url("/static/img/cursor-2.png"), auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@ -70,3 +83,14 @@ hr {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
gap: 10px 10px;
|
gap: 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-width: 600px;
|
||||||
|
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 103 KiB |
|
@ -0,0 +1,6 @@
|
||||||
|
const infoBanners = document.querySelectorAll(".info");
|
||||||
|
Array.from(infoBanners).forEach((infoBanner) => {
|
||||||
|
infoBanner.addEventListener("click", () => {
|
||||||
|
infoBanner.remove();
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,5 +1,6 @@
|
||||||
const scripts = [
|
const scripts = [
|
||||||
"/static/js/components/themeSwitcher.js",
|
"/static/js/components/themeSwitcher.js",
|
||||||
"/static/js/components/formatDate.js",
|
"/static/js/components/formatDate.js",
|
||||||
|
"/static/js/components/infoBanners.js",
|
||||||
];
|
];
|
||||||
requirejs(scripts);
|
requirejs(scripts);
|
||||||
|
|
|
@ -28,12 +28,5 @@
|
||||||
<h2>generate key.</h2>
|
<h2>generate key.</h2>
|
||||||
<hr>
|
<hr>
|
||||||
<input type="submit" value="generate." />
|
<input type="submit" value="generate." />
|
||||||
{{ if .FormError }}
|
|
||||||
{{ if (len .FormError.Errors) }}
|
|
||||||
{{ range $error := .FormError.Errors }}
|
|
||||||
<div class="error">{{ $error }}</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
</form>
|
</form>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -37,7 +37,9 @@
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="/keys">api keys.</a>
|
<a href="/keys">api keys.</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="/logout">logout, {{ .User.DisplayName }}.</a>
|
<a href="/profile">{{ .User.DisplayName }}.</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="/logout">logout.</a>
|
||||||
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<a href="/login">login.</a>
|
<a href="/login">login.</a>
|
||||||
|
@ -46,6 +48,17 @@
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div id="content">
|
<div id="content">
|
||||||
|
{{ if and .Success (gt (len .Success.Messages) 0) }}
|
||||||
|
{{ range $message := .Success.Messages }}
|
||||||
|
<div class="info success">{{ $message }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if and .Error (gt (len .Error.Messages) 0) }}
|
||||||
|
{{ range $error := .Error.Messages }}
|
||||||
|
<div class="info error">{{ $error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -76,18 +76,6 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<input type="submit" value="add." />
|
||||||
|
|
||||||
<input type="submit" value="Add" />
|
|
||||||
|
|
||||||
{{ if .FormError }}
|
|
||||||
{{ if (len .FormError.Errors) }}
|
|
||||||
{{ range $error := .FormError.Errors }}
|
|
||||||
<div class="error">{{ $error }}</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -21,18 +21,9 @@
|
||||||
<div
|
<div
|
||||||
class="h-captcha"
|
class="h-captcha"
|
||||||
data-sitekey="{{ .HcaptchaArgs.SiteKey }}"
|
data-sitekey="{{ .HcaptchaArgs.SiteKey }}"
|
||||||
data-theme="dark"
|
|
||||||
></div>
|
></div>
|
||||||
<br>
|
<br>
|
||||||
<button type="submit" class="btn btn-primary">sign.</button>
|
<button type="submit" class="btn btn-primary">sign.</button>
|
||||||
<br>
|
|
||||||
{{ if .FormError }}
|
|
||||||
{{ if (len .FormError.Errors) }}
|
|
||||||
{{ range $error := .FormError.Errors }}
|
|
||||||
<div class="error">{{ $error }}</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<p class="blinky">under construction!</p>
|
<h2 class="blinky">hello there!</h2>
|
||||||
|
<p>current peeps in the club :D</p>
|
||||||
|
<div class="club-members">
|
||||||
|
{{ range $user := .Users }}
|
||||||
|
<div class="club-member">
|
||||||
|
<div class="avatar">
|
||||||
|
<div style="background-image: url('{{ $user.Avatar }}')"></div>
|
||||||
|
</div>
|
||||||
|
<div class="about">
|
||||||
|
<div>name: {{ $user.Username }}</div>
|
||||||
|
<div>pronouns: {{ $user.Pronouns }}</div>
|
||||||
|
<div><a href="{{ $user.Website }}">{{ $user.Website }}</a></div>
|
||||||
|
<div class="club-bio">{{ $user.Bio }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
|
||||||
|
<h1>hey {{ .Profile.DisplayName }}</h1>
|
||||||
|
<br>
|
||||||
|
<form action="/profile" method="POST" class="form" enctype="multipart/form-data">
|
||||||
|
<label for="file" class="file-upload">avatar.</label>
|
||||||
|
<input type="file" name="avatar">
|
||||||
|
|
||||||
|
<label for="location">location.</label>
|
||||||
|
<input type="text" name="location" value="{{ .Profile.Location }}">
|
||||||
|
|
||||||
|
<label for="website">website.</label>
|
||||||
|
<input type="text" name="website" value="{{ .Profile.Website }}">
|
||||||
|
|
||||||
|
<label for="pronouns">pronouns.</label>
|
||||||
|
<input type="text" name="pronouns" value="{{ .Profile.Pronouns }}">
|
||||||
|
|
||||||
|
<label for="bio">bio.</label>
|
||||||
|
<textarea name="bio">{{ .Profile.Bio }}</textarea>
|
||||||
|
|
||||||
|
<input type="submit" value="update">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ end }}
|
Loading…
Reference in New Issue