profiles #7
			
				
			
		
		
		
	|  | @ -1,3 +1,4 @@ | |||
| .env | ||||
| hatecomputers.club | ||||
| *.db | ||||
| uploads | ||||
|  |  | |||
|  | @ -11,4 +11,4 @@ RUN go build -o /app/hatecomputers | |||
| 
 | ||||
| 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" | ||||
| ) | ||||
| 
 | ||||
| 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 { | ||||
| 	return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { | ||||
| 		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 { | ||||
| 	return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { | ||||
| 		log.Println("GoLoginContinuation") | ||||
| 		http.SetCookie(resp, &http.Cookie{ | ||||
| 			Name:     "redirect", | ||||
| 			Value:    req.URL.Path, | ||||
|  | @ -216,7 +229,7 @@ func getOauthUser(dbConn *sql.DB, client *http.Client, uri string) (*database.Us | |||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	user, err := database.FindOrSaveUser(dbConn, userStruct) | ||||
| 	user, err := database.FindOrSaveBaseUser(dbConn, userStruct) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  |  | |||
|  | @ -8,39 +8,15 @@ import ( | |||
| 	"strconv" | ||||
| 	"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/database" | ||||
| 	"git.hatecomputers.club/hatecomputers/hatecomputers.club/utils" | ||||
| ) | ||||
| 
 | ||||
| func userCanFuckWithDNSRecord(dbConn *sql.DB, user *database.User, record *database.DNSRecord, ownedInternalDomainFormats []string) bool { | ||||
| 	ownedByUser := (user.ID == record.UserID) | ||||
| 	if !ownedByUser { | ||||
| 		return false | ||||
| 	} | ||||
| const MaxUserRecords = 100 | ||||
| 
 | ||||
| 	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 | ||||
| } | ||||
| var UserOwnedInternalFmtDomains = []string{"%s", "%s.endpoints"} | ||||
| 
 | ||||
| func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) 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 { | ||||
| 	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.FormError{ | ||||
| 				Errors: []string{}, | ||||
| 			formErrors := types.BannerMessages{ | ||||
| 				Messages: []string{}, | ||||
| 			} | ||||
| 
 | ||||
| 			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) | ||||
| 			if err != nil { | ||||
| 				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) | ||||
|  | @ -88,7 +64,7 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max | |||
| 			} | ||||
| 			if dnsRecordCount >= maxUserRecords { | ||||
| 				resp.WriteHeader(http.StatusTooManyRequests) | ||||
| 				formErrors.Errors = append(formErrors.Errors, "max records reached") | ||||
| 				formErrors.Messages = append(formErrors.Messages, "max records reached") | ||||
| 			} | ||||
| 
 | ||||
| 			dnsRecord := &database.DNSRecord{ | ||||
|  | @ -102,10 +78,10 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max | |||
| 
 | ||||
| 			if !userCanFuckWithDNSRecord(context.DBConn, context.User, dnsRecord, allowedUserDomainFormats) { | ||||
| 				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 { | ||||
| 					dnsRecord.ID = utils.RandomId() | ||||
| 				} else { | ||||
|  | @ -113,24 +89,28 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max | |||
| 					if err != nil { | ||||
| 						log.Println(err) | ||||
| 						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) | ||||
| 				if err != nil { | ||||
| 					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) | ||||
| 			} | ||||
| 
 | ||||
| 			(*context.TemplateData)["FormError"] = &formErrors | ||||
| 			(*context.TemplateData)[""] = &formErrors | ||||
| 			(*context.TemplateData)["RecordForm"] = dnsRecord | ||||
| 			return failure(context, req, resp) | ||||
| 		} | ||||
|  | @ -168,7 +148,39 @@ func DeleteDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter) fun | |||
| 				return failure(context, req, resp) | ||||
| 			} | ||||
| 
 | ||||
| 			formSuccess := types.BannerMessages{ | ||||
| 				Messages: []string{"record deleted."}, | ||||
| 			} | ||||
| 			(*context.TemplateData)["Success"] = formSuccess | ||||
| 			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", | ||||
| 		DisplayName: "test", | ||||
| 	} | ||||
| 	database.FindOrSaveUser(testDb, user) | ||||
| 	database.FindOrSaveBaseUser(testDb, user) | ||||
| 
 | ||||
| 	context := &types.RequestContext{ | ||||
| 		DBConn:       testDb, | ||||
|  | @ -246,7 +246,7 @@ func TestThatUserMustOwnRecordToRemove(t *testing.T) { | |||
| 	defer testServer.Close() | ||||
| 
 | ||||
| 	nonOwnerUser := &database.User{ID: "n/a", Username: "testuser"} | ||||
| 	_, err := database.FindOrSaveUser(db, nonOwnerUser) | ||||
| 	_, err := database.FindOrSaveBaseUser(db, nonOwnerUser) | ||||
| 	if err != nil { | ||||
| 		t.Error(err) | ||||
| 	} | ||||
|  |  | |||
|  | @ -10,6 +10,60 @@ import ( | |||
| 	"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 { | ||||
| 	errors := []string{} | ||||
| 
 | ||||
|  | @ -33,53 +87,3 @@ func validateGuestbookEntry(entry *database.GuestbookEntry) []string { | |||
| 
 | ||||
| 	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) | ||||
| 		if err != nil { | ||||
| 			(*context.TemplateData)["FormError"] = types.FormError{ | ||||
| 				Errors: []string{"hCaptcha verification failed"}, | ||||
| 			(*context.TemplateData)["Error"] = types.BannerMessages{ | ||||
| 				Messages: []string{"hCaptcha verification failed"}, | ||||
| 			} | ||||
| 			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 { | ||||
| 	return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { | ||||
| 		formErrors := types.FormError{ | ||||
| 			Errors: []string{}, | ||||
| 		formErrors := types.BannerMessages{ | ||||
| 			Messages: []string{}, | ||||
| 		} | ||||
| 
 | ||||
| 		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 { | ||||
| 			formErrors.Errors = append(formErrors.Errors, "max types keys reached") | ||||
| 			formErrors.Messages = append(formErrors.Messages, "max types keys reached") | ||||
| 		} | ||||
| 
 | ||||
| 		if len(formErrors.Errors) > 0 { | ||||
| 			(*context.TemplateData)["FormError"] = formErrors | ||||
| 		if len(formErrors.Messages) > 0 { | ||||
| 			(*context.TemplateData)["Error"] = formErrors | ||||
| 			return failure(context, req, resp) | ||||
| 		} | ||||
| 
 | ||||
|  | @ -56,6 +56,11 @@ func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request, | |||
| 			resp.WriteHeader(http.StatusInternalServerError) | ||||
| 			return failure(context, req, resp) | ||||
| 		} | ||||
| 
 | ||||
| 		formSuccess := types.BannerMessages{ | ||||
| 			Messages: []string{"key created."}, | ||||
| 		} | ||||
| 		(*context.TemplateData)["Success"] = formSuccess | ||||
| 		return success(context, req, resp) | ||||
| 	} | ||||
| } | ||||
|  | @ -82,6 +87,11 @@ func DeleteAPIKeyContinuation(context *types.RequestContext, req *http.Request, | |||
| 			return failure(context, req, resp) | ||||
| 		} | ||||
| 
 | ||||
| 		formSuccess := types.BannerMessages{ | ||||
| 			Messages: []string{"key deleted."}, | ||||
| 		} | ||||
| 		(*context.TemplateData)["Success"] = formSuccess | ||||
| 
 | ||||
| 		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" | ||||
| 	"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/dns" | ||||
| 	"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/guestbook" | ||||
| 	"git.hatecomputers.club/hatecomputers/hatecomputers.club/api/hcaptcha" | ||||
| 	"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/types" | ||||
| 	"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 { | ||||
| 	return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { | ||||
| 		end := time.Now() | ||||
| 
 | ||||
| 		log.Println(context.Id, "took", end.Sub(context.Start)) | ||||
| 
 | ||||
| 		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 { | ||||
| 	mux := http.NewServeMux() | ||||
| 
 | ||||
| 	fileServer := http.FileServer(http.Dir(argv.StaticPath)) | ||||
| 	mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(fileServer, 3600))) | ||||
| 
 | ||||
| 	// "dependency injection"
 | ||||
| 	cloudflareAdapter := &cloudflare.CloudflareExternalDNSAdapter{ | ||||
| 		APIToken: argv.CloudflareToken, | ||||
| 		ZoneId:   argv.CloudflareZone, | ||||
| 	} | ||||
| 	uploadAdapter := &filesystem.FilesystemAdapter{ | ||||
| 		BasePath:    argv.UploadPath, | ||||
| 		Permissions: 0777, | ||||
| 	} | ||||
| 
 | ||||
| 	makeRequestContext := func() *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) { | ||||
| 		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) { | ||||
|  | @ -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) | ||||
| 	}) | ||||
| 
 | ||||
| 	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) { | ||||
| 		requestContext := makeRequestContext() | ||||
| 		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) { | ||||
| 		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) { | ||||
|  |  | |||
|  | @ -20,8 +20,8 @@ type RequestContext struct { | |||
| 	User         *database.User | ||||
| } | ||||
| 
 | ||||
| type FormError struct { | ||||
| 	Errors []string | ||||
| type BannerMessages struct { | ||||
| 	Messages []string | ||||
| } | ||||
| 
 | ||||
| type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ type Arguments struct { | |||
| 	DatabasePath string | ||||
| 	TemplatePath string | ||||
| 	StaticPath   string | ||||
| 	UploadPath   string | ||||
| 
 | ||||
| 	Migrate   bool | ||||
| 	Scheduler bool | ||||
|  | @ -35,6 +36,13 @@ type Arguments struct { | |||
| 
 | ||||
| func GetArgs() (*Arguments, error) { | ||||
| 	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") | ||||
| 	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") | ||||
|  | @ -96,6 +104,7 @@ func GetArgs() (*Arguments, error) { | |||
| 	arguments := &Arguments{ | ||||
| 		DatabasePath:    *databasePath, | ||||
| 		TemplatePath:    *templatePath, | ||||
| 		UploadPath:      uploadPathValue, | ||||
| 		StaticPath:      *staticPath, | ||||
| 		CloudflareToken: cloudflareToken, | ||||
| 		CloudflareZone:  cloudflareZone, | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ import ( | |||
| 	"log" | ||||
| 
 | ||||
| 	"database/sql" | ||||
| 
 | ||||
| 	_ "github.com/mattn/go-sqlite3" | ||||
| ) | ||||
| 
 | ||||
|  | @ -127,6 +128,40 @@ func MigrateGuestBook(dbConn *sql.DB) (*sql.DB, error) { | |||
| 	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) { | ||||
| 	log.Println("migrating database") | ||||
| 
 | ||||
|  | @ -137,6 +172,7 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) { | |||
| 		MigrateDomainOwners, | ||||
| 		MigrateDNSRecords, | ||||
| 		MigrateGuestBook, | ||||
| 		MigrateProfiles, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, migration := range migrations { | ||||
|  |  | |||
|  | @ -24,6 +24,11 @@ type User struct { | |||
| 	Mail        string    `json:"email"` | ||||
| 	Username    string    `json:"preferred_username"` | ||||
| 	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"` | ||||
| } | ||||
| 
 | ||||
|  | @ -33,13 +38,38 @@ type UserSession struct { | |||
| 	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) { | ||||
| 	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 | ||||
| 	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 { | ||||
| 		log.Println(err) | ||||
| 		return nil, err | ||||
|  | @ -48,7 +78,7 @@ func GetUser(dbConn *sql.DB, id string) (*User, error) { | |||
| 	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) | ||||
| 
 | ||||
| 	_, 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 | ||||
| } | ||||
| 
 | ||||
| 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) { | ||||
| 	log.Println("making session for user", user.ID) | ||||
| 
 | ||||
|  |  | |||
|  | @ -15,5 +15,6 @@ services: | |||
|       - ./db:/app/db | ||||
|       - ./templates:/app/templates | ||||
|       - ./static:/app/static | ||||
|       - ./uploads:/app/uploads | ||||
|     ports: | ||||
|       - "127.0.0.1:4455:8080" | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ func setup(arguments *args.Arguments) (*sql.DB, *dns.Server, string, func()) { | |||
| 	testUser := &database.User{ | ||||
| 		ID: "test", | ||||
| 	} | ||||
| 	database.FindOrSaveUser(testDb, testUser) | ||||
| 	database.FindOrSaveBaseUser(testDb, testUser) | ||||
| 
 | ||||
| 	dnsArguments := arguments | ||||
| 	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 { | ||||
|   --background-color-light: #f4e8e9; | ||||
|   --background-color-light-2: #f7f7f7; | ||||
|   --background-color-light-2: #f5e6f3; | ||||
|   --text-color-light: #333; | ||||
|   --confirm-color-light: #91d9bb; | ||||
|   --link-color-light: #d291bc; | ||||
|   --container-bg-light: #fff7f87a; | ||||
|   --border-color-light: #692fcc; | ||||
|  | @ -10,6 +11,7 @@ | |||
|   --background-color-dark: #333; | ||||
|   --background-color-dark-2: #2c2c2c; | ||||
|   --text-color-dark: #f4e8e9; | ||||
|   --confirm-color-dark: #4d8f73; | ||||
|   --link-color-dark: #b86b77; | ||||
|   --container-bg-dark: #424242ea; | ||||
|   --border-color-dark: #956ade; | ||||
|  | @ -24,6 +26,7 @@ | |||
|   --container-bg: var(--container-bg-dark); | ||||
|   --border-color: var(--border-color-dark); | ||||
|   --error-color: var(--error-color-dark); | ||||
|   --confirm-color: var(--confirm-color-dark); | ||||
| } | ||||
| 
 | ||||
| [data-theme="LIGHT"] { | ||||
|  | @ -34,9 +37,15 @@ | |||
|   --container-bg: var(--container-bg-light); | ||||
|   --border-color: var(--border-color-light); | ||||
|   --error-color: var(--error-color-light); | ||||
|   --confirm-color: var(--confirm-color-light); | ||||
| } | ||||
| 
 | ||||
| .error { | ||||
|   background-color: var(--error-color); | ||||
|   padding: 1rem; | ||||
| } | ||||
| 
 | ||||
| .success { | ||||
|   background-color: var(--confirm-color); | ||||
|   padding: 1rem; | ||||
| } | ||||
|  |  | |||
|  | @ -36,4 +36,7 @@ textarea { | |||
|   margin: 0 0 1em; | ||||
|   border: 1px solid var(--border-color); | ||||
|   background: var(--container-bg); | ||||
| 
 | ||||
|   resize: vertical; | ||||
|   min-height: 100px; | ||||
| } | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ | |||
|   border: 1px solid var(--border-color); | ||||
| 
 | ||||
|   padding: 10px; | ||||
|   max-width: 700px; | ||||
| } | ||||
| 
 | ||||
| .entry-name { | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ | |||
| @import "/static/css/table.css"; | ||||
| @import "/static/css/form.css"; | ||||
| @import "/static/css/guestbook.css"; | ||||
| @import "/static/css/club.css"; | ||||
| 
 | ||||
| @font-face { | ||||
|   font-family: "ComicSans"; | ||||
|  | @ -22,15 +23,27 @@ | |||
| } | ||||
| 
 | ||||
| @-webkit-keyframes cursor { | ||||
|   0% {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;} | ||||
|   0% { | ||||
|     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 { | ||||
|   0% {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;} | ||||
|   0% { | ||||
|     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 { | ||||
|  | @ -70,3 +83,14 @@ hr { | |||
|   max-width: 900px; | ||||
|   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 = [ | ||||
|   "/static/js/components/themeSwitcher.js", | ||||
|   "/static/js/components/formatDate.js", | ||||
|   "/static/js/components/infoBanners.js", | ||||
| ]; | ||||
| requirejs(scripts); | ||||
|  |  | |||
|  | @ -28,12 +28,5 @@ | |||
|     <h2>generate key.</h2> | ||||
|     <hr> | ||||
|     <input type="submit" value="generate." /> | ||||
|     {{ if .FormError }} | ||||
|     {{ if (len .FormError.Errors) }} | ||||
|     {{ range $error := .FormError.Errors }} | ||||
|     <div class="error">{{ $error }}</div> | ||||
|     {{ end }} | ||||
|     {{ end }} | ||||
|     {{ end }} | ||||
|   </form> | ||||
| {{ end }} | ||||
|  |  | |||
|  | @ -37,7 +37,9 @@ | |||
| 	<span> | </span> | ||||
| 	<a href="/keys">api keys.</a> | ||||
| 	<span> | </span> | ||||
| 	<a href="/logout">logout, {{ .User.DisplayName }}.</a> | ||||
| 	<a href="/profile">{{ .User.DisplayName }}.</a> | ||||
| 	<span> | </span> | ||||
| 	<a href="/logout">logout.</a> | ||||
| 	 | ||||
| 	{{ else }} | ||||
| 	<a href="/login">login.</a> | ||||
|  | @ -46,6 +48,17 @@ | |||
|       <hr> | ||||
| 
 | ||||
|       <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" . }} | ||||
|       </div> | ||||
| 
 | ||||
|  |  | |||
|  | @ -76,18 +76,6 @@ | |||
| 	   {{ end }} | ||||
| 	   /> | ||||
|     </label> | ||||
|      | ||||
| 
 | ||||
|     <input type="submit" value="Add" /> | ||||
| 
 | ||||
|     {{ if .FormError }} | ||||
|     {{ if (len .FormError.Errors) }} | ||||
|     {{ range $error := .FormError.Errors }} | ||||
|     <div class="error">{{ $error }}</div> | ||||
|     {{ end }} | ||||
|     {{ end }} | ||||
|     {{ end }} | ||||
|     <input type="submit" value="add." /> | ||||
|   </form> | ||||
| 
 | ||||
| 
 | ||||
| {{ end }} | ||||
|  |  | |||
|  | @ -21,18 +21,9 @@ | |||
|   <div | ||||
|     class="h-captcha" | ||||
|     data-sitekey="{{ .HcaptchaArgs.SiteKey }}" | ||||
|     data-theme="dark" | ||||
|     ></div> | ||||
|   <br> | ||||
|   <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> | ||||
| 
 | ||||
| <hr> | ||||
|  |  | |||
|  | @ -1,3 +1,19 @@ | |||
| {{ 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 }} | ||||
|  |  | |||
|  | @ -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