diff --git a/public/js/image_selector.js b/public/js/image_selector.js index 88ff332c..6c19b21c 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 cec84207..00424464 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 49492e8a..20aee1cc 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 debab30c..8b9fc641 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 8e3a4b2d..0fca35f7 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 81bb5964..3fa79a28 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 7c85ac26..b2edbc05 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 c2b22153..99356dea 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 f7452735..90b24411 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 00000000..049f4f1f --- /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 4b0699ac..ffb200dd 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 b2848db9..c18e35f9 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 3ddaaae6..fa1bfe87 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 @@