diff --git a/public/js/image_selector.js b/public/js/image_selector.js index 88ff332..6c19b21 100644 --- a/public/js/image_selector.js +++ b/public/js/image_selector.js @@ -1,4 +1,4 @@ -function ImageSelector(form, maxFileSize, container) { +function ImageSelector(form, maxFileSize, container, defaultImageUrl) { this.form = form; this.maxFileSize = maxFileSize; this.fileInput = container.querySelector(".image_input"); @@ -8,6 +8,7 @@ function ImageSelector(form, maxFileSize, container) { this.removeLink = container.querySelector(".remove"); this.originalImageUrl = this.imageEl.getAttribute("data-original"); this.currentImageUrl = this.originalImageUrl; + this.defaultImageUrl = defaultImageUrl || ""; this.fileInput.value = ""; this.removeImageInput.value = ""; @@ -45,7 +46,7 @@ ImageSelector.prototype.removeImage = function() { this.updateSizeLimit(0); this.fileInput.value = ""; this.removeImageInput.value = "true"; - this.setImageUrl(""); + this.setImageUrl(this.defaultImageUrl); this.updateButtons(); }; @@ -82,13 +83,15 @@ ImageSelector.prototype.setImageUrl = function(url) { }; ImageSelector.prototype.updateButtons = function() { - if (this.originalImageUrl.length > 0 && this.currentImageUrl != this.originalImageUrl) { + if ((this.originalImageUrl.length > 0 && this.originalImageUrl != this.defaultImageUrl) + && this.currentImageUrl != this.originalImageUrl) { + this.resetLink.style.display = "inline-block"; } else { this.resetLink.style.display = "none"; } - if (!this.fileInput.required && this.currentImageUrl != "") { + if (!this.fileInput.required && this.currentImageUrl != this.defaultImageUrl) { this.removeLink.style.display = "inline-block"; } else { this.removeLink.style.display = "none"; diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index cec8420..0042446 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -399,6 +399,7 @@ func init() { } width := 0 height := 0 + mime := "" fileExtensionOverrides := []string{".svg"} fileExt := strings.ToLower(path.Ext(filepath)) tryDecode := true @@ -414,9 +415,13 @@ func init() { } width = config.Width height = config.Height + mime = http.DetectContentType(contents) + } else { + if fileExt == ".svg" { + mime = "image/svg+xml" + } } - mime := http.DetectContentType(contents) filename := path.Base(filepath) asset, err := assets.Create(ctx, conn, assets.CreateInput{ @@ -490,5 +495,109 @@ func init() { } adminCommand.AddCommand(uploadProjectLogos) + uploadUserAvatars := &cobra.Command{ + Use: "uploaduseravatars", + Short: "Uploads avatar imagefiles to S3 and replaces them with assets", + Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + conn := db.NewConnPool(1, 1) + defer conn.Close() + + type userQuery struct { + User models.User `db:"auth_user"` + } + allUsers, err := db.Query(ctx, conn, userQuery{}, `SELECT $columns FROM auth_user LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id`) + if err != nil { + panic(oops.New(err, "Failed to fetch projects from db")) + } + + var fixupUsers []*models.User + numImages := 0 + for _, user := range allUsers { + u := &user.(*userQuery).User + if u.Avatar != nil && *u.Avatar != "" { + fixupUsers = append(fixupUsers, u) + numImages += 1 + } + } + + fmt.Printf("%d images to upload\n", numImages) + + uploadImage := func(ctx context.Context, conn db.ConnOrTx, filepath string, owner *models.User) *models.Asset { + filepath = "./public/media/" + filepath + contents, err := os.ReadFile(filepath) + if err != nil { + panic(oops.New(err, fmt.Sprintf("Failed to read file: %s", filepath))) + } + width := 0 + height := 0 + mime := "" + fileExtensionOverrides := []string{".svg"} + fileExt := strings.ToLower(path.Ext(filepath)) + tryDecode := true + for _, ext := range fileExtensionOverrides { + if fileExt == ext { + tryDecode = false + } + } + if tryDecode { + config, _, err := image.DecodeConfig(bytes.NewReader(contents)) + if err != nil { + panic(oops.New(err, fmt.Sprintf("Failed to decode file: %s", filepath))) + } + width = config.Width + height = config.Height + mime = http.DetectContentType(contents) + } else { + if fileExt == ".svg" { + mime = "image/svg+xml" + } + } + + filename := path.Base(filepath) + + asset, err := assets.Create(ctx, conn, assets.CreateInput{ + Content: contents, + Filename: filename, + ContentType: mime, + UploaderID: &owner.ID, + Width: width, + Height: height, + }) + if err != nil { + panic(oops.New(err, "Failed to create asset")) + } + + return asset + } + + for _, u := range fixupUsers { + if u.Avatar != nil && *u.Avatar != "" { + avatarAsset := uploadImage(ctx, conn, *u.Avatar, u) + _, err := conn.Exec(ctx, + ` + UPDATE auth_user + SET + avatar_asset_id = $2, + avatar = NULL + WHERE + id = $1 + `, + u.ID, + avatarAsset.ID, + ) + if err != nil { + panic(oops.New(err, "Failed to update user")) + } + numImages -= 1 + fmt.Printf(".") + } + } + + fmt.Printf("\nDone! %d images not patched for some reason.\n\n", numImages) + }, + } + adminCommand.AddCommand(uploadUserAvatars) + addProjectCommands(adminCommand) } diff --git a/src/db/db.go b/src/db/db.go index 49492e8..20aee1c 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -242,12 +242,22 @@ func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query st func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) { destType := reflect.TypeOf(destExample) - columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") + columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, nil) if err != nil { return nil, oops.New(err, "failed to generate column names") } - columnNamesString := strings.Join(columnNames, ", ") + columns := make([]string, 0, len(columnNames)) + for _, strSlice := range columnNames { + tableName := strings.Join(strSlice[0:len(strSlice)-1], "_") + fullName := strSlice[len(strSlice)-1] + if tableName != "" { + fullName = tableName + "." + fullName + } + columns = append(columns, fullName) + } + + columnNamesString := strings.Join(columns, ", ") query = strings.Replace(query, "$columns", columnNamesString, -1) rows, err := conn.Query(ctx, query, args...) @@ -282,8 +292,8 @@ func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, return it, nil } -func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) (names []string, paths [][]int, err error) { - var columnNames []string +func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names [][]string, paths [][]int, err error) { + var columnNames [][]string var fieldPaths [][]int if destType.Kind() == reflect.Ptr { @@ -301,7 +311,10 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin var anonPrefixes []AnonPrefix for _, field := range reflect.VisibleFields(destType) { - path := append(pathSoFar, field.Index...) + path := make([]int, len(pathSoFar)) + copy(path, pathSoFar) + path = append(path, field.Index...) + fieldColumnNames := prefix[:] if columnName := field.Tag.Get("db"); columnName != "" { if field.Anonymous { @@ -318,7 +331,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin } } if equal { - columnName = anonPrefix.Prefix + "." + columnName + fieldColumnNames = append(fieldColumnNames, anonPrefix.Prefix) break } } @@ -329,11 +342,13 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin fieldType = fieldType.Elem() } + fieldColumnNames = append(fieldColumnNames, columnName) + if typeIsQueryable(fieldType) { - columnNames = append(columnNames, prefix+columnName) + columnNames = append(columnNames, fieldColumnNames) fieldPaths = append(fieldPaths, path) } else if fieldType.Kind() == reflect.Struct { - subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".") + subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, fieldColumnNames) if err != nil { return nil, nil, err } diff --git a/src/discord/commands.go b/src/discord/commands.go index debab30..8b9fc64 100644 --- a/src/discord/commands.go +++ b/src/discord/commands.go @@ -97,6 +97,7 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction FROM handmade_discorduser AS duser JOIN auth_user ON duser.hmn_user_id = auth_user.id + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id WHERE duser.userid = $1 `, diff --git a/src/discord/gateway.go b/src/discord/gateway.go index 8e3a4b2..0fca35f 100644 --- a/src/discord/gateway.go +++ b/src/discord/gateway.go @@ -640,6 +640,7 @@ func (bot *botInstance) messageDelete(ctx context.Context, msgDelete MessageDele handmade_discordmessage AS msg LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid LEFT JOIN auth_user AS hmnuser ON duser.hmn_user_id = hmnuser.id + LEFT JOIN handmade_asset AS hmnuser_avatar ON hmnuser_avatar.id = hmnuser.avatar_asset_id LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id WHERE msg.id = $1 AND msg.channel_id = $2 `, diff --git a/src/discord/showcase.go b/src/discord/showcase.go index 81bb596..3fa79a2 100644 --- a/src/discord/showcase.go +++ b/src/discord/showcase.go @@ -526,6 +526,7 @@ func FetchDiscordUser(ctx context.Context, dbConn db.ConnOrTx, discordUserID str FROM handmade_discorduser AS duser JOIN auth_user AS u ON duser.hmn_user_id = u.id + LEFT JOIN handmade_asset AS u_avatar ON u_avatar.id = u.avatar_asset_id WHERE duser.userid = $1 `, diff --git a/src/hmndata/project_helper.go b/src/hmndata/project_helper.go index 7c85ac2..b2edbc0 100644 --- a/src/hmndata/project_helper.go +++ b/src/hmndata/project_helper.go @@ -9,7 +9,6 @@ import ( "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/perf" - "git.handmade.network/hmn/hmn/src/templates" ) type ProjectTypeQuery int @@ -52,10 +51,6 @@ func (p *ProjectAndStuff) TagText() string { } } -func (p *ProjectAndStuff) LogoURL(theme string) string { - return templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme) -} - func FetchProjects( ctx context.Context, dbConn db.ConnOrTx, @@ -366,12 +361,16 @@ func FetchMultipleProjectsOwners( userIds = append(userIds, userProject.UserID) } } - iusers, err := db.Query(ctx, tx, models.User{}, + type userQuery struct { + User models.User `db:"auth_user"` + } + iusers, err := db.Query(ctx, tx, userQuery{}, ` SELECT $columns FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id WHERE - id = ANY($1) + auth_user.id = ANY($1) `, userIds, ) @@ -398,9 +397,9 @@ func FetchMultipleProjectsOwners( // Get the full user record we fetched var user *models.User for _, iuser := range iusers { - u := iuser.(*models.User) + u := iuser.(*userQuery).User if u.ID == userProject.UserID { - user = u + user = &u } } if user == nil { diff --git a/src/hmndata/snippet_helper.go b/src/hmndata/snippet_helper.go index c2b2215..99356de 100644 --- a/src/hmndata/snippet_helper.go +++ b/src/hmndata/snippet_helper.go @@ -80,6 +80,7 @@ func FetchSnippets( FROM handmade_snippet AS snippet LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id + LEFT JOIN handmade_asset AS owner_avatar ON owner_avatar.id = owner.avatar_asset_id LEFT JOIN handmade_asset AS asset ON snippet.asset_id = asset.id LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id WHERE diff --git a/src/hmndata/threads_and_posts_helper.go b/src/hmndata/threads_and_posts_helper.go index f745273..90b2441 100644 --- a/src/hmndata/threads_and_posts_helper.go +++ b/src/hmndata/threads_and_posts_helper.go @@ -78,7 +78,9 @@ func FetchThreads( JOIN handmade_postversion AS first_version ON first_version.id = first_post.current_id JOIN handmade_postversion AS last_version ON last_version.id = last_post.current_id LEFT JOIN auth_user AS first_author ON first_author.id = first_post.author_id + LEFT JOIN handmade_asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id LEFT JOIN auth_user AS last_author ON last_author.id = last_post.author_id + LEFT JOIN handmade_asset AS last_author_avatar ON last_author_avatar.id = last_author.avatar_asset_id LEFT JOIN handmade_threadlastreadinfo AS tlri ON ( tlri.thread_id = thread.id AND tlri.user_id = $? @@ -221,6 +223,7 @@ func CountThreads( JOIN handmade_project AS project ON thread.project_id = project.id JOIN handmade_post AS first_post ON first_post.id = thread.first_id LEFT JOIN auth_user AS first_author ON first_author.id = first_post.author_id + LEFT JOIN handmade_asset AS first_author_avatar ON first_author_avatar.id = first_author.avatar_asset_id WHERE NOT thread.deleted AND ( -- project has valid lifecycle @@ -330,7 +333,9 @@ func FetchPosts( JOIN handmade_project AS project ON post.project_id = project.id JOIN handmade_postversion AS ver ON ver.id = post.current_id LEFT JOIN auth_user AS author ON author.id = post.author_id + LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id + LEFT JOIN handmade_asset AS editor_avatar ON editor_avatar.id = editor.avatar_asset_id LEFT JOIN handmade_threadlastreadinfo AS tlri ON ( tlri.thread_id = thread.id AND tlri.user_id = $? @@ -344,6 +349,7 @@ func FetchPosts( -- check fails. LEFT JOIN handmade_post AS reply_post ON reply_post.id = post.reply_id LEFT JOIN auth_user AS reply_author ON reply_post.author_id = reply_author.id + LEFT JOIN handmade_asset AS reply_author_avatar ON reply_author_avatar.id = reply_author.avatar_asset_id WHERE NOT thread.deleted AND NOT post.deleted @@ -543,6 +549,7 @@ func CountPosts( JOIN handmade_thread AS thread ON post.thread_id = thread.id JOIN handmade_project AS project ON post.project_id = project.id LEFT JOIN auth_user AS author ON author.id = post.author_id + LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id WHERE NOT thread.deleted AND NOT post.deleted diff --git a/src/migration/migrations/2021-12-26T101633Z_UserAvatarAssetId.go b/src/migration/migrations/2021-12-26T101633Z_UserAvatarAssetId.go new file mode 100644 index 0000000..049f4f1 --- /dev/null +++ b/src/migration/migrations/2021-12-26T101633Z_UserAvatarAssetId.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(UserAvatarAssetId{}) +} + +type UserAvatarAssetId struct{} + +func (m UserAvatarAssetId) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 12, 26, 10, 16, 33, 0, time.UTC)) +} + +func (m UserAvatarAssetId) Name() string { + return "UserAvatarAssetId" +} + +func (m UserAvatarAssetId) Description() string { + return "Add avatar_asset_id to users" +} + +func (m UserAvatarAssetId) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE auth_user + ADD COLUMN avatar_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL; + `, + ) + return err +} + +func (m UserAvatarAssetId) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE auth_user + DROP COLUMN avatar_asset_id; + `, + ) + return err +} diff --git a/src/models/user.go b/src/models/user.go index 4b0699a..ffb200d 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -3,6 +3,8 @@ package models import ( "reflect" "time" + + "github.com/google/uuid" ) var UserType = reflect.TypeOf(User{}) @@ -29,11 +31,13 @@ type User struct { IsStaff bool `db:"is_staff"` Status UserStatus `db:"status"` - Name string `db:"name"` - Bio string `db:"bio"` - Blurb string `db:"blurb"` - Signature string `db:"signature"` - Avatar *string `db:"avatar"` + Name string `db:"name"` + Bio string `db:"bio"` + Blurb string `db:"blurb"` + Signature string `db:"signature"` + Avatar *string `db:"avatar"` + AvatarAssetID *uuid.UUID `db:"avatar_asset_id"` + AvatarAsset *Asset `db:"avatar"` DarkTheme bool `db:"darktheme"` Timezone string `db:"timezone"` diff --git a/src/templates/mapping.go b/src/templates/mapping.go index b2848db..c18e35f 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" ) @@ -100,8 +101,10 @@ func ProjectToTemplate( } } -func (p *Project) AddLogo(logoUrl string) { - p.Logo = logoUrl +func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string, theme string) Project { + res := ProjectToTemplate(&p.Project, url) + res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme) + return res } var ProjectLifecycleValues = map[models.ProjectLifecycle]string{ @@ -161,15 +164,23 @@ func ThreadToTemplate(t *models.Thread) Thread { } } +func UserAvatarDefaultUrl(currentTheme string) string { + return hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) +} + func UserAvatarUrl(u *models.User, currentTheme string) string { if currentTheme == "" { currentTheme = "light" } avatar := "" - if u != nil && u.Avatar != nil && len(*u.Avatar) > 0 { - avatar = hmnurl.BuildUserFile(*u.Avatar) + if u != nil && (u.AvatarAsset != nil || (u.Avatar != nil && len(*u.Avatar) > 0)) { + if u.AvatarAsset != nil { + avatar = hmnurl.BuildS3Asset(u.AvatarAsset.S3Key) + } else { + avatar = hmnurl.BuildUserFile(*u.Avatar) + } } else { - avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) + avatar = UserAvatarDefaultUrl(currentTheme) } return avatar } @@ -178,7 +189,7 @@ func UserToTemplate(u *models.User, currentTheme string) User { if u == nil { return User{ Name: "Deleted user", - AvatarUrl: UserAvatarUrl(u, currentTheme), + AvatarUrl: UserAvatarUrl(nil, currentTheme), } } @@ -200,8 +211,8 @@ func UserToTemplate(u *models.User, currentTheme string) User { Blurb: u.Blurb, Signature: u.Signature, DateJoined: u.DateJoined, - AvatarUrl: UserAvatarUrl(u, currentTheme), ProfileUrl: hmnurl.BuildUserProfile(u.Username), + AvatarUrl: UserAvatarUrl(u, currentTheme), DarkTheme: u.DarkTheme, Timezone: u.Timezone, diff --git a/src/templates/src/user_settings.html b/src/templates/src/user_settings.html index 3ddaaae..fa1bfe8 100644 --- a/src/templates/src/user_settings.html +++ b/src/templates/src/user_settings.html @@ -2,6 +2,7 @@ {{ define "extrahead" }} + {{ end }} {{ define "content" }} @@ -41,12 +42,19 @@
Avatar:
-
- -
-
+
+ {{ template "image_selector.html" imageselectordata "avatar" .User.AvatarUrl false }}
(Allowed image types: PNG, JPEG and GIF. Avatars may weigh up to 1MB and will be resized if larger than 400x400 pixels)
+
Short bio:
@@ -198,4 +206,4 @@ } -{{ end }} \ No newline at end of file +{{ end }} diff --git a/src/website/admin.go b/src/website/admin.go index 2557ce0..9a77f49 100644 --- a/src/website/admin.go +++ b/src/website/admin.go @@ -162,9 +162,15 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData { return RejectRequest(c, "User id can't be parsed") } - u, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + type userQuery struct { + User models.User `db:"auth_user"` + } + u, err := db.QueryOne(c.Context(), c.Conn, userQuery{}, ` - SELECT $columns FROM auth_user WHERE id = $1 + SELECT $columns + FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id + WHERE auth_user.id = $1 `, userId, ) @@ -175,7 +181,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user")) } } - user := u.(*models.User) + user := u.(*userQuery).User whatHappened := "" if action == ApprovalQueueActionApprove { @@ -241,6 +247,7 @@ func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) { JOIN handmade_thread AS thread ON post.thread_id = thread.id JOIN handmade_postversion AS ver ON ver.id = post.current_id JOIN auth_user AS author ON author.id = post.author_id + LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id WHERE NOT thread.deleted AND NOT post.deleted diff --git a/src/website/api.go b/src/website/api.go index 7257737..f1c5b33 100644 --- a/src/website/api.go +++ b/src/website/api.go @@ -19,11 +19,15 @@ func APICheckUsername(c *RequestContext) ResponseData { requestedUsername := usernameArgs[0] found = true c.Perf.StartBlock("SQL", "Fetch user") - userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + type userQuery struct { + User models.User `db:"auth_user"` + } + userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{}, ` SELECT $columns FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id WHERE LOWER(auth_user.username) = LOWER($1) AND status = ANY ($2) @@ -39,7 +43,7 @@ func APICheckUsername(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername)) } } else { - canonicalUsername = userResult.(*models.User).Username + canonicalUsername = userResult.(*userQuery).User.Username } } diff --git a/src/website/auth.go b/src/website/auth.go index 5b208fd..991f403 100644 --- a/src/website/auth.go +++ b/src/website/auth.go @@ -75,7 +75,18 @@ func Login(c *RequestContext) ResponseData { return res } - userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE LOWER(username) = LOWER($1)", username) + type userQuery struct { + User models.User `db:"auth_user"` + } + userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{}, + ` + SELECT $columns + FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id + WHERE LOWER(username) = LOWER($1) + `, + username, + ) if err != nil { if errors.Is(err, db.NotFound) { return showLoginWithFailure(c, redirect) @@ -83,7 +94,7 @@ func Login(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username")) } } - user := userRow.(*models.User) + user := &userRow.(*userQuery).User success, err := tryLogin(c, user, password) @@ -446,10 +457,14 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData { var user *models.User c.Perf.StartBlock("SQL", "Fetching user") - userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + type userQuery struct { + User models.User `db:"auth_user"` + } + userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{}, ` SELECT $columns FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id WHERE LOWER(username) = LOWER($1) AND LOWER(email) = LOWER($2) @@ -464,7 +479,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData { } } if userRow != nil { - user = userRow.(*models.User) + user = &userRow.(*userQuery).User } if user != nil { @@ -776,6 +791,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string, ` SELECT $columns FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id WHERE LOWER(auth_user.username) = LOWER($1) diff --git a/src/website/projects.go b/src/website/projects.go index b86d6d1..3488854 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -9,6 +9,7 @@ import ( "math" "math/rand" "net/http" + "path" "sort" "strings" "time" @@ -76,8 +77,7 @@ func ProjectIndex(c *RequestContext) ResponseData { var restProjects []templates.Project now := time.Now() for _, p := range officialProjects { - templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) - templateProject.AddLogo(p.LogoURL(c.Theme)) + templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme) if p.Project.Slug == "hero" { // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. @@ -139,8 +139,7 @@ func ProjectIndex(c *RequestContext) ResponseData { if i >= maxPersonalProjects { break } - templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) - templateProject.AddLogo(p.LogoURL(c.Theme)) + templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme) personalProjects = append(personalProjects, templateProject) } } @@ -244,6 +243,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { handmade_post AS post INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id INNER JOIN auth_user AS author ON author.id = post.author_id + LEFT JOIN handmade_asset AS author_avatar ON author_avatar.id = author.avatar_asset_id WHERE post.project_id = $1 ORDER BY post.postdate DESC @@ -272,8 +272,7 @@ func ProjectHomepage(c *RequestContext) ResponseData { if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details")) } - templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage()) - templateData.Project.AddLogo(p.LogoURL(c.Theme)) + templateData.Project = templates.ProjectAndStuffToTemplate(&p, c.UrlContext.BuildHomepage(), c.Theme) for _, owner := range owners { templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme)) } @@ -514,11 +513,14 @@ func ProjectEdit(c *RequestContext) ResponseData { } c.Perf.EndBlock() + lightLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "light") + darkLogoUrl := templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, "dark") + projectSettings := templates.ProjectToProjectSettings( &p.Project, p.Owners, p.TagText(), - p.LogoURL("light"), p.LogoURL("dark"), + lightLogoUrl, darkLogoUrl, c.Theme, ) @@ -816,10 +818,14 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P } } - ownerRows, err := db.Query(ctx, tx, models.User{}, + type userQuery struct { + User models.User `db:"auth_user"` + } + ownerRows, err := db.Query(ctx, tx, userQuery{}, ` SELECT $columns FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id WHERE LOWER(username) = ANY ($1) `, payload.OwnerUsernames, @@ -847,7 +853,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P VALUES ($1, $2) `, - ownerRow.(*models.User).ID, + ownerRow.(*userQuery).User.ID, payload.ProjectID, ) if err != nil { @@ -914,13 +920,28 @@ func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) { img.Read(res.Content) img.Seek(0, io.SeekStart) - config, _, err := image.DecodeConfig(img) - if err != nil { - return FormImage{}, err + fileExtensionOverrides := []string{".svg"} + fileExt := strings.ToLower(path.Ext(res.Filename)) + tryDecode := true + for _, ext := range fileExtensionOverrides { + if fileExt == ext { + tryDecode = false + } + } + + if tryDecode { + config, _, err := image.DecodeConfig(img) + if err != nil { + return FormImage{}, err + } + res.Width = config.Width + res.Height = config.Height + res.Mime = http.DetectContentType(res.Content) + } else { + if fileExt == ".svg" { + res.Mime = "image/svg+xml" + } } - res.Width = config.Width - res.Height = config.Height - res.Mime = http.DetectContentType(res.Content) } return res, nil diff --git a/src/website/routes.go b/src/website/routes.go index e9c948d..c5fc792 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -501,20 +501,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { panic("failed to load project data") } - canEditProject := false - if c.CurrentUser != nil { - if c.CurrentUser.IsStaff { - canEditProject = true - } else { - for _, o := range owners { - if o.ID == c.CurrentUser.ID { - canEditProject = true - break - } - } - } - } - c.CurrentUserCanEditCurrentProject = canEditProject + c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, owners) c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject) } @@ -557,7 +544,18 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User } } - userRow, err := db.QueryOne(c.Context(), c.Conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username) + type userQuery struct { + User models.User `db:"auth_user"` + } + userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{}, + ` + SELECT $columns + FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id + WHERE username = $1 + `, + session.Username, + ) if err != nil { if errors.Is(err, db.NotFound) { logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found") @@ -566,7 +564,7 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User return nil, nil, oops.New(err, "failed to get user for session") } } - user := userRow.(*models.User) + user := &userRow.(*userQuery).User return user, session, nil } diff --git a/src/website/user.go b/src/website/user.go index 61f6b92..defb9c1 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -2,13 +2,12 @@ package website import ( "errors" - "fmt" "net/http" "sort" "strconv" "strings" - "time" + "git.handmade.network/hmn/hmn/src/assets" "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" @@ -19,6 +18,7 @@ import ( "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" + "github.com/google/uuid" "github.com/jackc/pgx/v4" ) @@ -52,11 +52,15 @@ func UserProfile(c *RequestContext) ResponseData { profileUser = c.CurrentUser } else { c.Perf.StartBlock("SQL", "Fetch user") - userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + type userQuery struct { + User models.User `db:"auth_user"` + } + userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{}, ` SELECT $columns FROM auth_user + LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id WHERE LOWER(auth_user.username) = $1 `, @@ -70,7 +74,7 @@ func UserProfile(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username)) } } - profileUser = userResult.(*models.User) + profileUser = &userResult.(*userQuery).User } { @@ -114,8 +118,7 @@ func UserProfile(c *RequestContext) ResponseData { templateProjects := make([]templates.Project, 0, len(projectsAndStuff)) numPersonalProjects := 0 for _, p := range projectsAndStuff { - templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) - templateProject.AddLogo(p.LogoURL(c.Theme)) + templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme) templateProjects = append(templateProjects, templateProject) if p.Project.Personal { @@ -201,12 +204,17 @@ func UserProfile(c *RequestContext) ResponseData { return res } +var UserAvatarMaxFileSize = 1 * 1024 * 1024 + func UserSettings(c *RequestContext) ResponseData { var res ResponseData type UserSettingsTemplateData struct { templates.BaseData + AvatarMaxFileSize int + DefaultAvatarUrl string + User templates.User Email string // these fields are handled specially on templates.User ShowEmail bool @@ -280,11 +288,13 @@ func UserSettings(c *RequestContext) ResponseData { baseData := getBaseDataAutocrumb(c, templateUser.Name) res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{ - BaseData: baseData, - User: templateUser, - Email: c.CurrentUser.Email, - ShowEmail: c.CurrentUser.ShowEmail, - LinksText: linksText, + BaseData: baseData, + AvatarMaxFileSize: UserAvatarMaxFileSize, + DefaultAvatarUrl: templates.UserAvatarDefaultUrl(c.Theme), + User: templateUser, + Email: c.CurrentUser.Email, + ShowEmail: c.CurrentUser.ShowEmail, + LinksText: linksText, SubmitUrl: hmnurl.BuildUserSettings(""), ContactUrl: hmnurl.BuildContactPage(), @@ -299,6 +309,14 @@ func UserSettings(c *RequestContext) ResponseData { } func UserSettingsSave(c *RequestContext) ResponseData { + maxBodySize := int64(UserAvatarMaxFileSize + 2*1024*1024) + c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize) + err := c.Req.ParseMultipartForm(maxBodySize) + if err != nil { + // NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here. + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form")) + } + tx, err := c.Conn.Begin(c.Context()) if err != nil { panic(err) @@ -396,25 +414,39 @@ func UserSettingsSave(c *RequestContext) ResponseData { } // Update avatar - imageSaveResult := SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s-%d", c.CurrentUser.Username, time.Now().UTC().Unix())) - if imageSaveResult.ValidationError != "" { - return RejectRequest(c, imageSaveResult.ValidationError) - } else if imageSaveResult.FatalError != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "failed to save new avatar")) - } else if imageSaveResult.ImageFile != nil { - _, err = tx.Exec(c.Context(), + newAvatar, err := GetFormImage(c, "avatar") + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read image from form")) + } + var avatarUUID *uuid.UUID + if newAvatar.Exists { + avatarAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{ + Content: newAvatar.Content, + Filename: newAvatar.Filename, + ContentType: newAvatar.Mime, + UploaderID: &c.CurrentUser.ID, + Width: newAvatar.Width, + Height: newAvatar.Height, + }) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to upload avatar")) + } + avatarUUID = &avatarAsset.ID + } + if newAvatar.Exists || newAvatar.Remove { + _, err := tx.Exec(c.Context(), ` UPDATE auth_user SET - avatar = $2 + avatar_asset_id = $2 WHERE id = $1 `, c.CurrentUser.ID, - imageSaveResult.ImageFile.File, + avatarUUID, ) if err != nil { - return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user")) + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user's avatar")) } }