Assets for user avatars

This commit is contained in:
Asaf Gartner 2021-12-29 16:38:23 +02:00
parent eb32b04437
commit f4f439489d
19 changed files with 385 additions and 100 deletions

View File

@ -1,4 +1,4 @@
function ImageSelector(form, maxFileSize, container) { function ImageSelector(form, maxFileSize, container, defaultImageUrl) {
this.form = form; this.form = form;
this.maxFileSize = maxFileSize; this.maxFileSize = maxFileSize;
this.fileInput = container.querySelector(".image_input"); this.fileInput = container.querySelector(".image_input");
@ -8,6 +8,7 @@ function ImageSelector(form, maxFileSize, container) {
this.removeLink = container.querySelector(".remove"); this.removeLink = container.querySelector(".remove");
this.originalImageUrl = this.imageEl.getAttribute("data-original"); this.originalImageUrl = this.imageEl.getAttribute("data-original");
this.currentImageUrl = this.originalImageUrl; this.currentImageUrl = this.originalImageUrl;
this.defaultImageUrl = defaultImageUrl || "";
this.fileInput.value = ""; this.fileInput.value = "";
this.removeImageInput.value = ""; this.removeImageInput.value = "";
@ -45,7 +46,7 @@ ImageSelector.prototype.removeImage = function() {
this.updateSizeLimit(0); this.updateSizeLimit(0);
this.fileInput.value = ""; this.fileInput.value = "";
this.removeImageInput.value = "true"; this.removeImageInput.value = "true";
this.setImageUrl(""); this.setImageUrl(this.defaultImageUrl);
this.updateButtons(); this.updateButtons();
}; };
@ -82,13 +83,15 @@ ImageSelector.prototype.setImageUrl = function(url) {
}; };
ImageSelector.prototype.updateButtons = function() { 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"; this.resetLink.style.display = "inline-block";
} else { } else {
this.resetLink.style.display = "none"; 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"; this.removeLink.style.display = "inline-block";
} else { } else {
this.removeLink.style.display = "none"; this.removeLink.style.display = "none";

View File

@ -399,6 +399,7 @@ func init() {
} }
width := 0 width := 0
height := 0 height := 0
mime := ""
fileExtensionOverrides := []string{".svg"} fileExtensionOverrides := []string{".svg"}
fileExt := strings.ToLower(path.Ext(filepath)) fileExt := strings.ToLower(path.Ext(filepath))
tryDecode := true tryDecode := true
@ -414,9 +415,13 @@ func init() {
} }
width = config.Width width = config.Width
height = config.Height height = config.Height
mime = http.DetectContentType(contents)
} else {
if fileExt == ".svg" {
mime = "image/svg+xml"
}
} }
mime := http.DetectContentType(contents)
filename := path.Base(filepath) filename := path.Base(filepath)
asset, err := assets.Create(ctx, conn, assets.CreateInput{ asset, err := assets.Create(ctx, conn, assets.CreateInput{
@ -490,5 +495,109 @@ func init() {
} }
adminCommand.AddCommand(uploadProjectLogos) 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) addProjectCommands(adminCommand)
} }

View File

@ -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) { func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
destType := reflect.TypeOf(destExample) destType := reflect.TypeOf(destExample)
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, nil)
if err != nil { if err != nil {
return nil, oops.New(err, "failed to generate column names") 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) query = strings.Replace(query, "$columns", columnNamesString, -1)
rows, err := conn.Query(ctx, query, args...) rows, err := conn.Query(ctx, query, args...)
@ -282,8 +292,8 @@ func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{},
return it, nil return it, nil
} }
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) (names []string, paths [][]int, err error) { func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names [][]string, paths [][]int, err error) {
var columnNames []string var columnNames [][]string
var fieldPaths [][]int var fieldPaths [][]int
if destType.Kind() == reflect.Ptr { if destType.Kind() == reflect.Ptr {
@ -301,7 +311,10 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
var anonPrefixes []AnonPrefix var anonPrefixes []AnonPrefix
for _, field := range reflect.VisibleFields(destType) { 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 columnName := field.Tag.Get("db"); columnName != "" {
if field.Anonymous { if field.Anonymous {
@ -318,7 +331,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
} }
} }
if equal { if equal {
columnName = anonPrefix.Prefix + "." + columnName fieldColumnNames = append(fieldColumnNames, anonPrefix.Prefix)
break break
} }
} }
@ -329,11 +342,13 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
fieldType = fieldType.Elem() fieldType = fieldType.Elem()
} }
fieldColumnNames = append(fieldColumnNames, columnName)
if typeIsQueryable(fieldType) { if typeIsQueryable(fieldType) {
columnNames = append(columnNames, prefix+columnName) columnNames = append(columnNames, fieldColumnNames)
fieldPaths = append(fieldPaths, path) fieldPaths = append(fieldPaths, path)
} else if fieldType.Kind() == reflect.Struct { } else if fieldType.Kind() == reflect.Struct {
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".") subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -97,6 +97,7 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
FROM FROM
handmade_discorduser AS duser handmade_discorduser AS duser
JOIN auth_user ON duser.hmn_user_id = auth_user.id 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 WHERE
duser.userid = $1 duser.userid = $1
`, `,

View File

@ -640,6 +640,7 @@ func (bot *botInstance) messageDelete(ctx context.Context, msgDelete MessageDele
handmade_discordmessage AS msg handmade_discordmessage AS msg
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid 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 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 LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
WHERE msg.id = $1 AND msg.channel_id = $2 WHERE msg.id = $1 AND msg.channel_id = $2
`, `,

View File

@ -526,6 +526,7 @@ func FetchDiscordUser(ctx context.Context, dbConn db.ConnOrTx, discordUserID str
FROM FROM
handmade_discorduser AS duser handmade_discorduser AS duser
JOIN auth_user AS u ON duser.hmn_user_id = u.id 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 WHERE
duser.userid = $1 duser.userid = $1
`, `,

View File

@ -9,7 +9,6 @@ import (
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/perf" "git.handmade.network/hmn/hmn/src/perf"
"git.handmade.network/hmn/hmn/src/templates"
) )
type ProjectTypeQuery int 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( func FetchProjects(
ctx context.Context, ctx context.Context,
dbConn db.ConnOrTx, dbConn db.ConnOrTx,
@ -366,12 +361,16 @@ func FetchMultipleProjectsOwners(
userIds = append(userIds, userProject.UserID) 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 SELECT $columns
FROM auth_user FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE WHERE
id = ANY($1) auth_user.id = ANY($1)
`, `,
userIds, userIds,
) )
@ -398,9 +397,9 @@ func FetchMultipleProjectsOwners(
// Get the full user record we fetched // Get the full user record we fetched
var user *models.User var user *models.User
for _, iuser := range iusers { for _, iuser := range iusers {
u := iuser.(*models.User) u := iuser.(*userQuery).User
if u.ID == userProject.UserID { if u.ID == userProject.UserID {
user = u user = &u
} }
} }
if user == nil { if user == nil {

View File

@ -80,6 +80,7 @@ func FetchSnippets(
FROM FROM
handmade_snippet AS snippet handmade_snippet AS snippet
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id 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_asset AS asset ON snippet.asset_id = asset.id
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
WHERE WHERE

View File

@ -78,7 +78,9 @@ func FetchThreads(
JOIN handmade_postversion AS first_version ON first_version.id = first_post.current_id 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 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 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 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 ( LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id tlri.thread_id = thread.id
AND tlri.user_id = $? AND tlri.user_id = $?
@ -221,6 +223,7 @@ func CountThreads(
JOIN handmade_project AS project ON thread.project_id = project.id JOIN handmade_project AS project ON thread.project_id = project.id
JOIN handmade_post AS first_post ON first_post.id = thread.first_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 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 WHERE
NOT thread.deleted NOT thread.deleted
AND ( -- project has valid lifecycle AND ( -- project has valid lifecycle
@ -330,7 +333,9 @@ func FetchPosts(
JOIN handmade_project AS project ON post.project_id = project.id JOIN handmade_project AS project ON post.project_id = project.id
JOIN handmade_postversion AS ver ON ver.id = post.current_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 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 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 ( LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id tlri.thread_id = thread.id
AND tlri.user_id = $? AND tlri.user_id = $?
@ -344,6 +349,7 @@ func FetchPosts(
-- check fails. -- check fails.
LEFT JOIN handmade_post AS reply_post ON reply_post.id = post.reply_id 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 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 WHERE
NOT thread.deleted NOT thread.deleted
AND NOT post.deleted AND NOT post.deleted
@ -543,6 +549,7 @@ func CountPosts(
JOIN handmade_thread AS thread ON post.thread_id = thread.id JOIN handmade_thread AS thread ON post.thread_id = thread.id
JOIN handmade_project AS project ON post.project_id = project.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 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 WHERE
NOT thread.deleted NOT thread.deleted
AND NOT post.deleted AND NOT post.deleted

View File

@ -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
}

View File

@ -3,6 +3,8 @@ package models
import ( import (
"reflect" "reflect"
"time" "time"
"github.com/google/uuid"
) )
var UserType = reflect.TypeOf(User{}) var UserType = reflect.TypeOf(User{})
@ -29,11 +31,13 @@ type User struct {
IsStaff bool `db:"is_staff"` IsStaff bool `db:"is_staff"`
Status UserStatus `db:"status"` Status UserStatus `db:"status"`
Name string `db:"name"` Name string `db:"name"`
Bio string `db:"bio"` Bio string `db:"bio"`
Blurb string `db:"blurb"` Blurb string `db:"blurb"`
Signature string `db:"signature"` Signature string `db:"signature"`
Avatar *string `db:"avatar"` Avatar *string `db:"avatar"`
AvatarAssetID *uuid.UUID `db:"avatar_asset_id"`
AvatarAsset *Asset `db:"avatar"`
DarkTheme bool `db:"darktheme"` DarkTheme bool `db:"darktheme"`
Timezone string `db:"timezone"` Timezone string `db:"timezone"`

View File

@ -8,6 +8,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
) )
@ -100,8 +101,10 @@ func ProjectToTemplate(
} }
} }
func (p *Project) AddLogo(logoUrl string) { func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string, theme string) Project {
p.Logo = logoUrl res := ProjectToTemplate(&p.Project, url)
res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
return res
} }
var ProjectLifecycleValues = map[models.ProjectLifecycle]string{ 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 { func UserAvatarUrl(u *models.User, currentTheme string) string {
if currentTheme == "" { if currentTheme == "" {
currentTheme = "light" currentTheme = "light"
} }
avatar := "" avatar := ""
if u != nil && u.Avatar != nil && len(*u.Avatar) > 0 { if u != nil && (u.AvatarAsset != nil || (u.Avatar != nil && len(*u.Avatar) > 0)) {
avatar = hmnurl.BuildUserFile(*u.Avatar) if u.AvatarAsset != nil {
avatar = hmnurl.BuildS3Asset(u.AvatarAsset.S3Key)
} else {
avatar = hmnurl.BuildUserFile(*u.Avatar)
}
} else { } else {
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) avatar = UserAvatarDefaultUrl(currentTheme)
} }
return avatar return avatar
} }
@ -178,7 +189,7 @@ func UserToTemplate(u *models.User, currentTheme string) User {
if u == nil { if u == nil {
return User{ return User{
Name: "Deleted 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, Blurb: u.Blurb,
Signature: u.Signature, Signature: u.Signature,
DateJoined: u.DateJoined, DateJoined: u.DateJoined,
AvatarUrl: UserAvatarUrl(u, currentTheme),
ProfileUrl: hmnurl.BuildUserProfile(u.Username), ProfileUrl: hmnurl.BuildUserProfile(u.Username),
AvatarUrl: UserAvatarUrl(u, currentTheme),
DarkTheme: u.DarkTheme, DarkTheme: u.DarkTheme,
Timezone: u.Timezone, Timezone: u.Timezone,

View File

@ -2,6 +2,7 @@
{{ define "extrahead" }} {{ define "extrahead" }}
<script src="{{ static "js/tabs.js" }}"></script> <script src="{{ static "js/tabs.js" }}"></script>
<script src="{{ static "js/image_selector.js" }}"></script>
{{ end }} {{ end }}
{{ define "content" }} {{ define "content" }}
@ -41,12 +42,19 @@
</div> </div>
<div class="edit-form-row"> <div class="edit-form-row">
<div>Avatar:</div> <div>Avatar:</div>
<div> <div class="user_avatar">
<input type="file" name="avatar" id="avatar"> {{ template "image_selector.html" imageselectordata "avatar" .User.AvatarUrl false }}
<div class="avatar-preview"><img src="{{ .User.AvatarUrl }}" width="200px">
</div>
<div class="c--dim f7">(Allowed image types: PNG, JPEG and GIF. Avatars may weigh up to 1MB and will be resized if larger than 400x400 pixels)</div> <div class="c--dim f7">(Allowed image types: PNG, JPEG and GIF. Avatars may weigh up to 1MB and will be resized if larger than 400x400 pixels)</div>
</div> </div>
<script>
let avatarMaxFileSize = {{ .AvatarMaxFileSize }};
let avatarSelector = new ImageSelector(
document.querySelector("#user_form"),
avatarMaxFileSize,
document.querySelector(".user_avatar"),
{{ .DefaultAvatarUrl }}
);
</script>
</div> </div>
<div class="edit-form-row"> <div class="edit-form-row">
<div class="pt-input-ns">Short bio:</div> <div class="pt-input-ns">Short bio:</div>

View File

@ -162,9 +162,15 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
return RejectRequest(c, "User id can't be parsed") 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, userId,
) )
@ -175,7 +181,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
} }
} }
user := u.(*models.User) user := u.(*userQuery).User
whatHappened := "" whatHappened := ""
if action == ApprovalQueueActionApprove { 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_thread AS thread ON post.thread_id = thread.id
JOIN handmade_postversion AS ver ON ver.id = post.current_id JOIN handmade_postversion AS ver ON ver.id = post.current_id
JOIN auth_user AS author ON author.id = post.author_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 WHERE
NOT thread.deleted NOT thread.deleted
AND NOT post.deleted AND NOT post.deleted

View File

@ -19,11 +19,15 @@ func APICheckUsername(c *RequestContext) ResponseData {
requestedUsername := usernameArgs[0] requestedUsername := usernameArgs[0]
found = true found = true
c.Perf.StartBlock("SQL", "Fetch user") 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 SELECT $columns
FROM FROM
auth_user auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE WHERE
LOWER(auth_user.username) = LOWER($1) LOWER(auth_user.username) = LOWER($1)
AND status = ANY ($2) 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)) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername))
} }
} else { } else {
canonicalUsername = userResult.(*models.User).Username canonicalUsername = userResult.(*userQuery).User.Username
} }
} }

View File

@ -75,7 +75,18 @@ func Login(c *RequestContext) ResponseData {
return res 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 err != nil {
if errors.Is(err, db.NotFound) { if errors.Is(err, db.NotFound) {
return showLoginWithFailure(c, redirect) 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")) 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) success, err := tryLogin(c, user, password)
@ -446,10 +457,14 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
var user *models.User var user *models.User
c.Perf.StartBlock("SQL", "Fetching 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 SELECT $columns
FROM auth_user FROM auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE WHERE
LOWER(username) = LOWER($1) LOWER(username) = LOWER($1)
AND LOWER(email) = LOWER($2) AND LOWER(email) = LOWER($2)
@ -464,7 +479,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
} }
} }
if userRow != nil { if userRow != nil {
user = userRow.(*models.User) user = &userRow.(*userQuery).User
} }
if user != nil { if user != nil {
@ -776,6 +791,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
` `
SELECT $columns SELECT $columns
FROM auth_user 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 LEFT JOIN handmade_onetimetoken AS onetimetoken ON onetimetoken.owner_id = auth_user.id
WHERE WHERE
LOWER(auth_user.username) = LOWER($1) LOWER(auth_user.username) = LOWER($1)

View File

@ -9,6 +9,7 @@ import (
"math" "math"
"math/rand" "math/rand"
"net/http" "net/http"
"path"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -76,8 +77,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
var restProjects []templates.Project var restProjects []templates.Project
now := time.Now() now := time.Now()
for _, p := range officialProjects { for _, p := range officialProjects {
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
templateProject.AddLogo(p.LogoURL(c.Theme))
if p.Project.Slug == "hero" { if p.Project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. // 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 { if i >= maxPersonalProjects {
break break
} }
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
templateProject.AddLogo(p.LogoURL(c.Theme))
personalProjects = append(personalProjects, templateProject) personalProjects = append(personalProjects, templateProject)
} }
} }
@ -244,6 +243,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
handmade_post AS post handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
INNER JOIN auth_user AS author ON author.id = post.author_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 WHERE
post.project_id = $1 post.project_id = $1
ORDER BY post.postdate DESC ORDER BY post.postdate DESC
@ -272,8 +272,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details"))
} }
templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage()) templateData.Project = templates.ProjectAndStuffToTemplate(&p, c.UrlContext.BuildHomepage(), c.Theme)
templateData.Project.AddLogo(p.LogoURL(c.Theme))
for _, owner := range owners { for _, owner := range owners {
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme)) templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
} }
@ -514,11 +513,14 @@ func ProjectEdit(c *RequestContext) ResponseData {
} }
c.Perf.EndBlock() 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( projectSettings := templates.ProjectToProjectSettings(
&p.Project, &p.Project,
p.Owners, p.Owners,
p.TagText(), p.TagText(),
p.LogoURL("light"), p.LogoURL("dark"), lightLogoUrl, darkLogoUrl,
c.Theme, 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 SELECT $columns
FROM auth_user 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) WHERE LOWER(username) = ANY ($1)
`, `,
payload.OwnerUsernames, payload.OwnerUsernames,
@ -847,7 +853,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
VALUES VALUES
($1, $2) ($1, $2)
`, `,
ownerRow.(*models.User).ID, ownerRow.(*userQuery).User.ID,
payload.ProjectID, payload.ProjectID,
) )
if err != nil { if err != nil {
@ -914,13 +920,28 @@ func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
img.Read(res.Content) img.Read(res.Content)
img.Seek(0, io.SeekStart) img.Seek(0, io.SeekStart)
config, _, err := image.DecodeConfig(img) fileExtensionOverrides := []string{".svg"}
if err != nil { fileExt := strings.ToLower(path.Ext(res.Filename))
return FormImage{}, err 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 return res, nil

View File

@ -501,20 +501,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
panic("failed to load project data") panic("failed to load project data")
} }
canEditProject := false c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, owners)
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.UrlContext = hmndata.UrlContextForProject(c.CurrentProject) 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 err != nil {
if errors.Is(err, db.NotFound) { 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") 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") return nil, nil, oops.New(err, "failed to get user for session")
} }
} }
user := userRow.(*models.User) user := &userRow.(*userQuery).User
return user, session, nil return user, session, nil
} }

View File

@ -2,13 +2,12 @@ package website
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db" "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/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"github.com/google/uuid"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
) )
@ -52,11 +52,15 @@ func UserProfile(c *RequestContext) ResponseData {
profileUser = c.CurrentUser profileUser = c.CurrentUser
} else { } else {
c.Perf.StartBlock("SQL", "Fetch user") 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 SELECT $columns
FROM FROM
auth_user auth_user
LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
WHERE WHERE
LOWER(auth_user.username) = $1 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)) 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)) templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
numPersonalProjects := 0 numPersonalProjects := 0
for _, p := range projectsAndStuff { for _, p := range projectsAndStuff {
templateProject := templates.ProjectToTemplate(&p.Project, hmndata.UrlContextForProject(&p.Project).BuildHomepage()) templateProject := templates.ProjectAndStuffToTemplate(&p, hmndata.UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
templateProject.AddLogo(p.LogoURL(c.Theme))
templateProjects = append(templateProjects, templateProject) templateProjects = append(templateProjects, templateProject)
if p.Project.Personal { if p.Project.Personal {
@ -201,12 +204,17 @@ func UserProfile(c *RequestContext) ResponseData {
return res return res
} }
var UserAvatarMaxFileSize = 1 * 1024 * 1024
func UserSettings(c *RequestContext) ResponseData { func UserSettings(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
type UserSettingsTemplateData struct { type UserSettingsTemplateData struct {
templates.BaseData templates.BaseData
AvatarMaxFileSize int
DefaultAvatarUrl string
User templates.User User templates.User
Email string // these fields are handled specially on templates.User Email string // these fields are handled specially on templates.User
ShowEmail bool ShowEmail bool
@ -280,11 +288,13 @@ func UserSettings(c *RequestContext) ResponseData {
baseData := getBaseDataAutocrumb(c, templateUser.Name) baseData := getBaseDataAutocrumb(c, templateUser.Name)
res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{ res.MustWriteTemplate("user_settings.html", UserSettingsTemplateData{
BaseData: baseData, BaseData: baseData,
User: templateUser, AvatarMaxFileSize: UserAvatarMaxFileSize,
Email: c.CurrentUser.Email, DefaultAvatarUrl: templates.UserAvatarDefaultUrl(c.Theme),
ShowEmail: c.CurrentUser.ShowEmail, User: templateUser,
LinksText: linksText, Email: c.CurrentUser.Email,
ShowEmail: c.CurrentUser.ShowEmail,
LinksText: linksText,
SubmitUrl: hmnurl.BuildUserSettings(""), SubmitUrl: hmnurl.BuildUserSettings(""),
ContactUrl: hmnurl.BuildContactPage(), ContactUrl: hmnurl.BuildContactPage(),
@ -299,6 +309,14 @@ func UserSettings(c *RequestContext) ResponseData {
} }
func UserSettingsSave(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()) tx, err := c.Conn.Begin(c.Context())
if err != nil { if err != nil {
panic(err) panic(err)
@ -396,25 +414,39 @@ func UserSettingsSave(c *RequestContext) ResponseData {
} }
// Update avatar // Update avatar
imageSaveResult := SaveImageFile(c, tx, "avatar", 1*1024*1024, fmt.Sprintf("members/avatars/%s-%d", c.CurrentUser.Username, time.Now().UTC().Unix())) newAvatar, err := GetFormImage(c, "avatar")
if imageSaveResult.ValidationError != "" { if err != nil {
return RejectRequest(c, imageSaveResult.ValidationError) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read image from form"))
} else if imageSaveResult.FatalError != nil { }
return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "failed to save new avatar")) var avatarUUID *uuid.UUID
} else if imageSaveResult.ImageFile != nil { if newAvatar.Exists {
_, err = tx.Exec(c.Context(), 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 UPDATE auth_user
SET SET
avatar = $2 avatar_asset_id = $2
WHERE WHERE
id = $1 id = $1
`, `,
c.CurrentUser.ID, c.CurrentUser.ID,
imageSaveResult.ImageFile.File, avatarUUID,
) )
if err != nil { 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"))
} }
} }