Compare commits
20 Commits
6d609f1fae
...
c8f401f738
Author | SHA1 | Date |
---|---|---|
Asaf Gartner | c8f401f738 | |
Asaf Gartner | 43651d98e8 | |
Asaf Gartner | 92d6a31aa9 | |
Asaf Gartner | 378d6eb836 | |
Asaf Gartner | 60a71d5dd1 | |
Asaf Gartner | ad888346ef | |
Asaf Gartner | f4f439489d | |
Asaf Gartner | eb32b04437 | |
Asaf Gartner | 80f0e3b176 | |
Asaf Gartner | f8e7779b7d | |
Asaf Gartner | 321089ea8e | |
Asaf Gartner | 88776cbb72 | |
Asaf Gartner | 12eb172f98 | |
Asaf Gartner | 83ef51374d | |
Ben Visness | 6307589ee4 | |
Asaf Gartner | b5eb718615 | |
Asaf Gartner | c84b6842e2 | |
Asaf Gartner | 1c48aab863 | |
Ben Visness | b3df1a306b | |
Ben Visness | 3b8b02a856 |
|
@ -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";
|
||||
|
|
|
@ -1,16 +1,23 @@
|
|||
package admintools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/assets"
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/email"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
|
@ -353,5 +360,244 @@ func init() {
|
|||
moveThreadsToSubforumCommand.MarkFlagRequired("subforum_slug")
|
||||
adminCommand.AddCommand(moveThreadsToSubforumCommand)
|
||||
|
||||
uploadProjectLogos := &cobra.Command{
|
||||
Use: "uploadprojectlogos",
|
||||
Short: "Uploads project 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()
|
||||
|
||||
allProjects, err := db.Query(ctx, conn, models.Project{}, `SELECT $columns FROM handmade_project`)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "Failed to fetch projects from db"))
|
||||
}
|
||||
|
||||
var fixupProjects []*models.Project
|
||||
numImages := 0
|
||||
for _, project := range allProjects {
|
||||
p := project.(*models.Project)
|
||||
if p.LogoLight != "" || p.LogoDark != "" {
|
||||
fixupProjects = append(fixupProjects, p)
|
||||
}
|
||||
|
||||
if p.LogoLight != "" {
|
||||
numImages += 1
|
||||
}
|
||||
if p.LogoDark != "" {
|
||||
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 _, p := range fixupProjects {
|
||||
owners, err := hmndata.FetchProjectOwners(ctx, conn, p.ID)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "Failed to fetch project owners"))
|
||||
}
|
||||
if len(owners) == 0 {
|
||||
fmt.Printf("PROBLEM!! Project %d (%s) doesn't have owners!!\n", p.ID, p.Name)
|
||||
continue
|
||||
}
|
||||
if p.LogoLight != "" {
|
||||
lightAsset := uploadImage(ctx, conn, p.LogoLight, owners[0])
|
||||
_, err := conn.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_project
|
||||
SET
|
||||
logolight_asset_id = $2,
|
||||
logolight = NULL
|
||||
WHERE
|
||||
id = $1
|
||||
`,
|
||||
p.ID,
|
||||
lightAsset.ID,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "Failed to update project"))
|
||||
}
|
||||
numImages -= 1
|
||||
fmt.Printf(".")
|
||||
}
|
||||
if p.LogoDark != "" {
|
||||
darkAsset := uploadImage(ctx, conn, p.LogoDark, owners[0])
|
||||
_, err := conn.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_project
|
||||
SET
|
||||
logodark_asset_id = $2,
|
||||
logodark = NULL
|
||||
WHERE
|
||||
id = $1
|
||||
`,
|
||||
p.ID,
|
||||
darkAsset.ID,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "Failed to update project"))
|
||||
}
|
||||
numImages -= 1
|
||||
fmt.Printf(".")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nDone! %d images not patched for some reason.\n\n", numImages)
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
44
src/db/db.go
44
src/db/db.go
|
@ -231,14 +231,33 @@ func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.V
|
|||
return val, field
|
||||
}
|
||||
|
||||
func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
|
||||
func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) ([]interface{}, error) {
|
||||
it, err := QueryIterator(ctx, conn, destExample, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
return it.ToSlice(), nil
|
||||
}
|
||||
}
|
||||
|
||||
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...)
|
||||
|
@ -273,8 +292,8 @@ func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query st
|
|||
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 {
|
||||
|
@ -292,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 {
|
||||
|
@ -309,7 +331,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
|||
}
|
||||
}
|
||||
if equal {
|
||||
columnName = anonPrefix.Prefix + "." + columnName
|
||||
fieldColumnNames = append(fieldColumnNames, anonPrefix.Prefix)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
@ -320,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
|
||||
}
|
||||
|
@ -347,7 +371,7 @@ result but find nothing.
|
|||
var NotFound = errors.New("not found")
|
||||
|
||||
func QueryOne(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
|
||||
rows, err := Query(ctx, conn, destExample, query, args...)
|
||||
rows, err := QueryIterator(ctx, conn, destExample, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -2,17 +2,27 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/discord"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/website"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCommand := &cobra.Command{
|
||||
Use: "discord",
|
||||
Short: "Commands for interacting with Discord",
|
||||
}
|
||||
website.WebsiteCommand.AddCommand(rootCommand)
|
||||
|
||||
scrapeCommand := &cobra.Command{
|
||||
Use: "discordscrapechannel [<channel id>...]",
|
||||
Use: "scrapechannel [<channel id>...]",
|
||||
Short: "Scrape the entire history of Discord channels",
|
||||
Long: "Scrape the entire history of Discord channels, saving message content (but not creating snippets)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
|
@ -25,6 +35,49 @@ func init() {
|
|||
}
|
||||
},
|
||||
}
|
||||
rootCommand.AddCommand(scrapeCommand)
|
||||
|
||||
website.WebsiteCommand.AddCommand(scrapeCommand)
|
||||
makeSnippetCommand := &cobra.Command{
|
||||
Use: "makesnippet <channel id> [<message id>...]",
|
||||
Short: "Make snippets from Discord messages",
|
||||
Long: "Creates snippets from the specified messages in the specified channel. Will create a snippet as long as the poster of the message linked their account regardless of user settings.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) < 2 {
|
||||
cmd.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx := context.Background()
|
||||
conn := db.NewConnPool(1, 1)
|
||||
defer conn.Close()
|
||||
|
||||
chanID := args[0]
|
||||
|
||||
count := 0
|
||||
|
||||
for _, msgID := range args[1:] {
|
||||
message, err := discord.GetChannelMessage(ctx, chanID, msgID)
|
||||
if errors.Is(err, discord.NotFound) {
|
||||
logging.Warn().Msg(fmt.Sprintf("no message found on discord for id %s", msgID))
|
||||
continue
|
||||
} else if err != nil {
|
||||
logging.Error().Msg(fmt.Sprintf("failed to fetch discord message id %s", msgID))
|
||||
continue
|
||||
}
|
||||
err = discord.InternMessage(ctx, conn, message)
|
||||
if err != nil {
|
||||
logging.Error().Msg(fmt.Sprintf("failed to intern discord message id %s", msgID))
|
||||
continue
|
||||
}
|
||||
err = discord.HandleInternedMessage(ctx, conn, message, false, true)
|
||||
if err != nil {
|
||||
logging.Error().Msg(fmt.Sprintf("failed to handle interned message id %s", msgID))
|
||||
continue
|
||||
}
|
||||
count += 1
|
||||
}
|
||||
|
||||
logging.Info().Msg(fmt.Sprintf("Handled %d messages", count))
|
||||
},
|
||||
}
|
||||
rootCommand.AddCommand(makeSnippetCommand)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
`,
|
||||
|
|
|
@ -408,7 +408,7 @@ func (bot *botInstance) doSender(ctx context.Context) {
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
itMessages, err := db.Query(ctx, tx, models.DiscordOutgoingMessage{}, `
|
||||
msgs, err := db.Query(ctx, tx, models.DiscordOutgoingMessage{}, `
|
||||
SELECT $columns
|
||||
FROM discord_outgoingmessages
|
||||
ORDER BY id ASC
|
||||
|
@ -418,7 +418,6 @@ func (bot *botInstance) doSender(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
msgs := itMessages.ToSlice()
|
||||
for _, imsg := range msgs {
|
||||
msg := imsg.(*models.DiscordOutgoingMessage)
|
||||
if time.Now().After(msg.ExpiresAt) {
|
||||
|
@ -592,104 +591,29 @@ func (bot *botInstance) messageCreateOrUpdate(ctx context.Context, msg *Message)
|
|||
return nil
|
||||
}
|
||||
|
||||
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
|
||||
err := bot.processShowcaseMsg(ctx, msg)
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to process showcase message")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// if msg.ChannelID == config.Config.Discord.JamShowcaseChannelID {
|
||||
// err := bot.processShowcaseMsg(ctx, msg)
|
||||
// if err != nil {
|
||||
// logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to process jam showcase message")
|
||||
// return nil
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
|
||||
if msg.ChannelID == config.Config.Discord.LibraryChannelID {
|
||||
err := bot.processLibraryMsg(ctx, msg)
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to process library message")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
err := HandleIncomingMessage(ctx, bot.dbConn, msg, true)
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to handle incoming message")
|
||||
}
|
||||
|
||||
// NOTE(asaf): Since any error from HandleIncomingMessage is an internal error and not a discord
|
||||
// error, we only want to log it and not restart the bot. So we're not returning the error.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *botInstance) messageDelete(ctx context.Context, msgDelete MessageDelete) {
|
||||
log := logging.ExtractLogger(ctx)
|
||||
|
||||
tx, err := bot.dbConn.Begin(ctx)
|
||||
interned, err := FetchInternedMessage(ctx, bot.dbConn, msgDelete.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
type deleteMessageQuery struct {
|
||||
Message models.DiscordMessage `db:"msg"`
|
||||
DiscordUser *models.DiscordUser `db:"duser"`
|
||||
HMNUser *models.User `db:"hmnuser"`
|
||||
SnippetID *int `db:"snippet.id"`
|
||||
}
|
||||
iresult, err := db.QueryOne(ctx, tx, deleteMessageQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
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_snippet AS snippet ON snippet.discord_message_id = msg.id
|
||||
WHERE msg.id = $1 AND msg.channel_id = $2
|
||||
`,
|
||||
msgDelete.ID, msgDelete.ChannelID,
|
||||
)
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return
|
||||
} else if err != nil {
|
||||
log.Error().Err(err).Msg("failed to check for message to delete")
|
||||
return
|
||||
}
|
||||
result := iresult.(*deleteMessageQuery)
|
||||
|
||||
log.Debug().Msg("deleting Discord message")
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_discordmessage
|
||||
WHERE id = $1 AND channel_id = $2
|
||||
`,
|
||||
msgDelete.ID,
|
||||
msgDelete.ChannelID,
|
||||
)
|
||||
|
||||
shouldDeleteSnippet := result.HMNUser != nil && result.HMNUser.DiscordDeleteSnippetOnMessageDelete
|
||||
if result.SnippetID != nil && shouldDeleteSnippet {
|
||||
log.Debug().
|
||||
Int("snippet_id", *result.SnippetID).
|
||||
Int("user_id", result.HMNUser.ID).
|
||||
Msg("deleting snippet from Discord message")
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_snippet
|
||||
WHERE id = $1
|
||||
`,
|
||||
result.SnippetID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to delete snippet")
|
||||
return
|
||||
if !errors.Is(err, db.NotFound) {
|
||||
log.Error().Err(err).Msg("failed to fetch interned message")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
err = DeleteInternedMessage(ctx, bot.dbConn, interned)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to delete Discord message")
|
||||
log.Error().Err(err).Msg("failed to delete interned message")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -702,7 +626,7 @@ type MessageToSend struct {
|
|||
|
||||
func SendMessages(
|
||||
ctx context.Context,
|
||||
conn *pgxpool.Pool,
|
||||
conn db.ConnOrTx,
|
||||
msgs ...MessageToSend,
|
||||
) error {
|
||||
tx, err := conn.Begin(ctx)
|
||||
|
|
|
@ -9,7 +9,6 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
)
|
||||
|
||||
|
@ -31,12 +30,28 @@ func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{
|
|||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
backfillInterval := 1 * time.Hour
|
||||
|
||||
newUserTicker := time.NewTicker(5 * time.Second)
|
||||
backfillTicker := time.NewTicker(backfillInterval)
|
||||
|
||||
lastBackfillTime := time.Now().Add(-backfillInterval)
|
||||
backfillFirstRun := make(chan struct{}, 1)
|
||||
backfillFirstRun <- struct{}{}
|
||||
backfillTicker := time.NewTicker(1 * time.Hour)
|
||||
|
||||
lastBackfillTime := time.Now().Add(-3 * time.Hour)
|
||||
|
||||
runBackfill := func() {
|
||||
log.Info().Msg("Running backfill")
|
||||
// Run a backfill to patch up places where the Discord bot missed (does create snippets)
|
||||
now := time.Now()
|
||||
done := Scrape(ctx, dbConn,
|
||||
config.Config.Discord.ShowcaseChannelID,
|
||||
lastBackfillTime,
|
||||
true,
|
||||
)
|
||||
if done {
|
||||
lastBackfillTime = now
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
@ -44,13 +59,10 @@ func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{
|
|||
case <-newUserTicker.C:
|
||||
// Get content for messages when a user links their account (but do not create snippets)
|
||||
fetchMissingContent(ctx, dbConn)
|
||||
case <-backfillFirstRun:
|
||||
runBackfill()
|
||||
case <-backfillTicker.C:
|
||||
// Run a backfill to patch up places where the Discord bot missed (does create snippets)
|
||||
Scrape(ctx, dbConn,
|
||||
config.Config.Discord.ShowcaseChannelID,
|
||||
lastBackfillTime,
|
||||
true,
|
||||
)
|
||||
runBackfill()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
@ -64,7 +76,7 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
type query struct {
|
||||
Message models.DiscordMessage `db:"msg"`
|
||||
}
|
||||
result, err := db.Query(ctx, dbConn, query{},
|
||||
imessagesWithoutContent, err := db.Query(ctx, dbConn, query{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -82,7 +94,6 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
log.Error().Err(err).Msg("failed to check for messages without content")
|
||||
return
|
||||
}
|
||||
imessagesWithoutContent := result.ToSlice()
|
||||
|
||||
if len(imessagesWithoutContent) > 0 {
|
||||
log.Info().Msgf("There are %d Discord messages without content, fetching their content now...", len(imessagesWithoutContent))
|
||||
|
@ -100,13 +111,16 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
discordMsg, err := GetChannelMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if errors.Is(err, NotFound) {
|
||||
// This message has apparently been deleted; delete it from our database
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
interned, err := FetchInternedMessage(ctx, dbConn, msg.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.NotFound) {
|
||||
log.Error().Str("Message ID", msg.ID).Msg("couldn't find interned message")
|
||||
} else {
|
||||
log.Error().Err(err).Msg("failed to fetch interned message")
|
||||
}
|
||||
continue
|
||||
}
|
||||
err = DeleteInternedMessage(ctx, dbConn, interned)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to delete missing message")
|
||||
continue
|
||||
|
@ -120,7 +134,7 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
|
||||
log.Info().Str("msg", discordMsg.ShortString()).Msg("fetched message for content")
|
||||
|
||||
err = handleHistoryMessage(ctx, dbConn, discordMsg, false)
|
||||
err = HandleInternedMessage(ctx, dbConn, discordMsg, false, false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to save content for message")
|
||||
continue
|
||||
|
@ -130,7 +144,7 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
|
|||
}
|
||||
}
|
||||
|
||||
func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earliestMessageTime time.Time, createSnippets bool) {
|
||||
func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earliestMessageTime time.Time, createSnippets bool) bool {
|
||||
log := logging.ExtractLogger(ctx)
|
||||
|
||||
log.Info().Msg("Starting scrape")
|
||||
|
@ -144,19 +158,19 @@ func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earlies
|
|||
})
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to get messages while scraping")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if len(msgs) == 0 {
|
||||
logging.Debug().Msg("out of messages, stopping scrape")
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
for _, msg := range msgs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("Scrape was canceled")
|
||||
return
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
|
@ -164,15 +178,13 @@ func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earlies
|
|||
|
||||
if !earliestMessageTime.IsZero() && msg.Time().Before(earliestMessageTime) {
|
||||
logging.ExtractLogger(ctx).Info().Time("earliest", earliestMessageTime).Msg("Saw a message before the specified earliest time; exiting")
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
err := handleHistoryMessage(ctx, dbConn, &msg, createSnippets)
|
||||
err := HandleIncomingMessage(ctx, dbConn, &msg, createSnippets)
|
||||
|
||||
if err != nil {
|
||||
errLog := logging.ExtractLogger(ctx).Error()
|
||||
if errors.Is(err, errNotEnoughInfo) {
|
||||
errLog = logging.ExtractLogger(ctx).Warn()
|
||||
}
|
||||
errLog.Err(err).Msg("failed to process Discord message")
|
||||
}
|
||||
|
||||
|
@ -180,39 +192,3 @@ func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earlies
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleHistoryMessage(ctx context.Context, dbConn *pgxpool.Pool, msg *Message, createSnippets bool) error {
|
||||
var tx pgx.Tx
|
||||
for {
|
||||
var err error
|
||||
tx, err = dbConn.Begin(ctx)
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to start transaction for message")
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
newMsg, err := SaveMessageAndContents(ctx, tx, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if createSnippets {
|
||||
if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
|
||||
_, err := CreateMessageSnippet(ctx, tx, newMsg.UserID, msg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
func (bot *botInstance) processLibraryMsg(ctx context.Context, msg *Message) error {
|
||||
switch msg.Type {
|
||||
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if !msg.OriginalHasFields("content") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !messageHasLinks(msg.Content) {
|
||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete message")
|
||||
}
|
||||
|
||||
if !msg.Author.IsBot {
|
||||
channel, err := CreateDM(ctx, msg.Author.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create DM channel")
|
||||
}
|
||||
|
||||
err = SendMessages(ctx, bot.dbConn, MessageToSend{
|
||||
ChannelID: channel.ID,
|
||||
Req: CreateMessageRequest{
|
||||
Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to send showcase warning message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,949 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/assets"
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, createSnippets bool) error {
|
||||
deleted := false
|
||||
var err error
|
||||
|
||||
// NOTE(asaf): All functions called here should verify that the message applies to them.
|
||||
|
||||
if !deleted && err == nil {
|
||||
deleted, err = CleanUpLibrary(ctx, dbConn, msg)
|
||||
}
|
||||
|
||||
if !deleted && err == nil {
|
||||
deleted, err = CleanUpShowcase(ctx, dbConn, msg)
|
||||
}
|
||||
|
||||
if !deleted && err == nil {
|
||||
err = MaybeInternMessage(ctx, dbConn, msg)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = HandleInternedMessage(ctx, dbConn, msg, deleted, createSnippets)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) {
|
||||
deleted := false
|
||||
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
|
||||
switch msg.Type {
|
||||
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
||||
default:
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
hasGoodContent := true
|
||||
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
||||
hasGoodContent = false
|
||||
}
|
||||
|
||||
hasGoodAttachments := true
|
||||
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
||||
hasGoodAttachments = false
|
||||
}
|
||||
|
||||
if !hasGoodContent && !hasGoodAttachments {
|
||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to delete message")
|
||||
}
|
||||
deleted = true
|
||||
|
||||
if !msg.Author.IsBot {
|
||||
channel, err := CreateDM(ctx, msg.Author.ID)
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to create DM channel")
|
||||
}
|
||||
|
||||
err = SendMessages(ctx, dbConn, MessageToSend{
|
||||
ChannelID: channel.ID,
|
||||
Req: CreateMessageRequest{
|
||||
Content: "Posts in #project-showcase are required to have either an image/video or a link. Discuss showcase content in #projects.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to send showcase warning message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func CleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool, error) {
|
||||
deleted := false
|
||||
if msg.ChannelID == config.Config.Discord.LibraryChannelID {
|
||||
switch msg.Type {
|
||||
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
||||
default:
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
if !msg.OriginalHasFields("content") {
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
if !messageHasLinks(msg.Content) {
|
||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to delete message")
|
||||
}
|
||||
deleted = true
|
||||
|
||||
if !msg.Author.IsBot {
|
||||
channel, err := CreateDM(ctx, msg.Author.ID)
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to create DM channel")
|
||||
}
|
||||
|
||||
err = SendMessages(ctx, dbConn, MessageToSend{
|
||||
ChannelID: channel.ID,
|
||||
Req: CreateMessageRequest{
|
||||
Content: "Posts in #the-library are required to have a link. Discuss library content in other relevant channels.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to send showcase warning message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func MaybeInternMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
||||
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
|
||||
err := InternMessage(ctx, dbConn, msg)
|
||||
if errors.Is(err, errNotEnoughInfo) {
|
||||
logging.ExtractLogger(ctx).Warn().
|
||||
Interface("msg", msg).
|
||||
Msg("didn't have enough info to intern Discord message")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")
|
||||
|
||||
/*
|
||||
Ensures that a Discord message is stored in the database. This function is
|
||||
idempotent and can be called regardless of whether the item already exists in
|
||||
the database.
|
||||
|
||||
This does not create snippets or save content or do anything besides save the message itself.
|
||||
*/
|
||||
func InternMessage(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
msg *Message,
|
||||
) error {
|
||||
_, err := db.QueryOne(ctx, dbConn, models.DiscordMessage{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if errors.Is(err, db.NotFound) {
|
||||
if !msg.OriginalHasFields("author", "timestamp") {
|
||||
return errNotEnoughInfo
|
||||
}
|
||||
|
||||
guildID := msg.GuildID
|
||||
if guildID == nil {
|
||||
/*
|
||||
This is weird, but it can happen when we fetch messages from
|
||||
history instead of receiving it from the gateway. In this case
|
||||
we just assume it's from the HMN server.
|
||||
*/
|
||||
guildID = &config.Config.Discord.GuildID
|
||||
}
|
||||
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessage (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
msg.ID,
|
||||
msg.ChannelID,
|
||||
*guildID,
|
||||
msg.JumpURL(),
|
||||
msg.Author.ID,
|
||||
msg.Time(),
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to save new discord message")
|
||||
}
|
||||
} else if err != nil {
|
||||
return oops.New(err, "failed to check for existing Discord message")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type InternedMessage struct {
|
||||
Message models.DiscordMessage `db:"message"`
|
||||
MessageContent *models.DiscordMessageContent `db:"content"`
|
||||
HMNUser *models.User `db:"hmnuser"`
|
||||
DiscordUser *models.DiscordUser `db:"duser"`
|
||||
}
|
||||
|
||||
func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string) (*InternedMessage, error) {
|
||||
result, err := db.QueryOne(ctx, dbConn, InternedMessage{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_discordmessage AS message
|
||||
LEFT JOIN handmade_discordmessagecontent AS content ON content.message_id = message.id
|
||||
LEFT JOIN handmade_discorduser AS duser ON duser.userid = message.user_id
|
||||
LEFT JOIN auth_user AS hmnuser ON hmnuser.id = duser.hmn_user_id
|
||||
LEFT JOIN handmade_asset AS hmnuser_avatar ON hmnuser_avatar.id = hmnuser.avatar_asset_id
|
||||
WHERE message.id = $1
|
||||
`,
|
||||
msgId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
interned := result.(*InternedMessage)
|
||||
return interned, nil
|
||||
}
|
||||
|
||||
// Checks if a message is interned and handles it to the extent possible:
|
||||
// 1. Saves/updates content
|
||||
// 2. Saves/updates snippet
|
||||
// 3. Deletes content/snippet
|
||||
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, deleted bool, createSnippet bool) error {
|
||||
tx, err := dbConn.Begin(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to start transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
interned, err := FetchInternedMessage(ctx, tx, msg.ID)
|
||||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
return err
|
||||
} else if err == nil {
|
||||
if !deleted {
|
||||
err = SaveMessageContents(ctx, tx, interned, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
}
|
||||
if createSnippet {
|
||||
err = HandleSnippetForInternedMessage(ctx, tx, interned, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = DeleteInternedMessage(ctx, tx, interned)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to commit Discord message updates")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage) error {
|
||||
isnippet, err := db.QueryOne(ctx, dbConn, models.Snippet{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_snippet
|
||||
WHERE discord_message_id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
return oops.New(err, "failed to fetch snippet for discord message")
|
||||
}
|
||||
var snippet *models.Snippet
|
||||
if !errors.Is(err, db.NotFound) {
|
||||
snippet = isnippet.(*models.Snippet)
|
||||
}
|
||||
|
||||
// NOTE(asaf): Also deletes the following through a db cascade:
|
||||
// * handmade_discordmessageattachment
|
||||
// * handmade_discordmessagecontent
|
||||
// * handmade_discordmessageembed
|
||||
// DOES NOT DELETE ASSETS FOR CONTENT/EMBEDS
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
|
||||
if snippet != nil {
|
||||
userApprovesDeletion := interned.HMNUser != nil && snippet.OwnerID == interned.HMNUser.ID && interned.HMNUser.DiscordDeleteSnippetOnMessageDelete
|
||||
if !snippet.EditedOnWebsite && userApprovesDeletion {
|
||||
// NOTE(asaf): Does not delete asset!
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_snippet
|
||||
WHERE id = $1
|
||||
`,
|
||||
snippet.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete snippet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
Processes a single Discord message, saving as much of the message's content
|
||||
and attachments as allowed by our rules and user settings. Does NOT create
|
||||
snippets.
|
||||
|
||||
Idempotent; can be called any time whether the contents exist or not.
|
||||
|
||||
NOTE!!: Replaces interned.MessageContent if it was created or updated!!
|
||||
*/
|
||||
func SaveMessageContents(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
interned *InternedMessage,
|
||||
msg *Message,
|
||||
) error {
|
||||
if interned.DiscordUser != nil {
|
||||
// We have a linked Discord account, so save the message contents (regardless of
|
||||
// whether we create a snippet or not).
|
||||
if msg.OriginalHasFields("content") {
|
||||
_, err := dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessagecontent (message_id, discord_id, last_content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (message_id) DO UPDATE SET
|
||||
discord_id = EXCLUDED.discord_id,
|
||||
last_content = EXCLUDED.last_content
|
||||
`,
|
||||
interned.Message.ID,
|
||||
interned.DiscordUser.ID,
|
||||
CleanUpMarkdown(ctx, msg.Content),
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create or update message contents")
|
||||
}
|
||||
|
||||
icontent, err := db.QueryOne(ctx, dbConn, models.DiscordMessageContent{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_discordmessagecontent
|
||||
WHERE
|
||||
handmade_discordmessagecontent.message_id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch message contents")
|
||||
}
|
||||
interned.MessageContent = icontent.(*models.DiscordMessageContent)
|
||||
}
|
||||
|
||||
// Save attachments
|
||||
if msg.OriginalHasFields("attachments") {
|
||||
for _, attachment := range msg.Attachments {
|
||||
_, err := saveAttachment(ctx, dbConn, &attachment, interned.DiscordUser.HMNUserId, msg.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to save attachment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save / delete embeds
|
||||
if msg.OriginalHasFields("embeds") {
|
||||
numSavedEmbeds, err := db.QueryInt(ctx, dbConn,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to count existing embeds")
|
||||
}
|
||||
if numSavedEmbeds == 0 {
|
||||
// No embeds yet, so save new ones
|
||||
for _, embed := range msg.Embeds {
|
||||
_, err := saveEmbed(ctx, dbConn, &embed, interned.DiscordUser.HMNUserId, msg.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to save embed")
|
||||
}
|
||||
}
|
||||
} else if len(msg.Embeds) > 0 {
|
||||
// Embeds were removed from the message
|
||||
_, err := dbConn.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete embeds")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var discordDownloadClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
type DiscordResourceBadStatusCode error
|
||||
|
||||
func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", oops.New(err, "failed to make Discord download request")
|
||||
}
|
||||
res, err := discordDownloadClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", oops.New(err, "failed to fetch Discord resource data")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || 299 < res.StatusCode {
|
||||
return nil, "", DiscordResourceBadStatusCode(fmt.Errorf("status code %d from Discord resource: %s", res.StatusCode, url))
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return content, res.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Saves a Discord attachment as an HMN asset. Idempotent; will not create an attachment
|
||||
that already exists
|
||||
*/
|
||||
func saveAttachment(
|
||||
ctx context.Context,
|
||||
tx db.ConnOrTx,
|
||||
attachment *Attachment,
|
||||
hmnUserID int,
|
||||
discordMessageID string,
|
||||
) (*models.DiscordMessageAttachment, error) {
|
||||
iexisting, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE id = $1
|
||||
`,
|
||||
attachment.ID,
|
||||
)
|
||||
if err == nil {
|
||||
return iexisting.(*models.DiscordMessageAttachment), nil
|
||||
} else if errors.Is(err, db.NotFound) {
|
||||
// this is fine, just create it
|
||||
} else {
|
||||
return nil, oops.New(err, "failed to check for existing attachment")
|
||||
}
|
||||
|
||||
width := 0
|
||||
height := 0
|
||||
if attachment.Width != nil {
|
||||
width = *attachment.Width
|
||||
}
|
||||
if attachment.Height != nil {
|
||||
height = *attachment.Height
|
||||
}
|
||||
|
||||
content, _, err := downloadDiscordResource(ctx, attachment.Url)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to download Discord attachment")
|
||||
}
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if attachment.ContentType != nil {
|
||||
contentType = *attachment.ContentType
|
||||
}
|
||||
|
||||
asset, err := assets.Create(ctx, tx, assets.CreateInput{
|
||||
Content: content,
|
||||
Filename: attachment.Filename,
|
||||
ContentType: contentType,
|
||||
|
||||
UploaderID: &hmnUserID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save asset for Discord attachment")
|
||||
}
|
||||
|
||||
// TODO(db): RETURNING plz thanks
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessageattachment (id, asset_id, message_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
attachment.ID,
|
||||
asset.ID,
|
||||
discordMessageID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save Discord attachment data")
|
||||
}
|
||||
|
||||
iDiscordAttachment, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE id = $1
|
||||
`,
|
||||
attachment.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch new Discord attachment data")
|
||||
}
|
||||
|
||||
return iDiscordAttachment.(*models.DiscordMessageAttachment), nil
|
||||
}
|
||||
|
||||
// Saves an embed from Discord. NOTE: This is _not_ idempotent, so only call it
|
||||
// if you do not have any embeds saved for this message yet.
|
||||
func saveEmbed(
|
||||
ctx context.Context,
|
||||
tx db.ConnOrTx,
|
||||
embed *Embed,
|
||||
hmnUserID int,
|
||||
discordMessageID string,
|
||||
) (*models.DiscordMessageEmbed, error) {
|
||||
isOkImageType := func(contentType string) bool {
|
||||
return strings.HasPrefix(contentType, "image/")
|
||||
}
|
||||
|
||||
isOkVideoType := func(contentType string) bool {
|
||||
return strings.HasPrefix(contentType, "video/")
|
||||
}
|
||||
|
||||
maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) {
|
||||
content, contentType, err := downloadDiscordResource(ctx, *i.Url)
|
||||
if err != nil {
|
||||
var statusError DiscordResourceBadStatusCode
|
||||
if errors.As(err, &statusError) {
|
||||
return nil, nil
|
||||
} else {
|
||||
return nil, oops.New(err, "failed to save Discord embed")
|
||||
}
|
||||
}
|
||||
if contentTypeCheck(contentType) {
|
||||
in := assets.CreateInput{
|
||||
Content: content,
|
||||
Filename: "embed",
|
||||
ContentType: contentType,
|
||||
UploaderID: &hmnUserID,
|
||||
}
|
||||
|
||||
if i.Width != nil {
|
||||
in.Width = *i.Width
|
||||
}
|
||||
if i.Height != nil {
|
||||
in.Height = *i.Height
|
||||
}
|
||||
|
||||
asset, err := assets.Create(ctx, tx, in)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to create asset from embed")
|
||||
}
|
||||
return &asset.ID, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var imageAssetId *uuid.UUID
|
||||
var videoAssetId *uuid.UUID
|
||||
var err error
|
||||
|
||||
if embed.Video != nil && embed.Video.Url != nil {
|
||||
videoAssetId, err = maybeSaveImageish(embed.Video.EmbedImageish, isOkVideoType)
|
||||
} else if embed.Image != nil && embed.Image.Url != nil {
|
||||
imageAssetId, err = maybeSaveImageish(embed.Image.EmbedImageish, isOkImageType)
|
||||
} else if embed.Thumbnail != nil && embed.Thumbnail.Url != nil {
|
||||
imageAssetId, err = maybeSaveImageish(embed.Thumbnail.EmbedImageish, isOkImageType)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save the embed into the db
|
||||
// TODO(db): Insert, RETURNING
|
||||
var savedEmbedId int
|
||||
err = tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessageembed (title, description, url, message_id, image_id, video_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`,
|
||||
embed.Title,
|
||||
embed.Description,
|
||||
embed.Url,
|
||||
discordMessageID,
|
||||
imageAssetId,
|
||||
videoAssetId,
|
||||
).Scan(&savedEmbedId)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to insert new embed")
|
||||
}
|
||||
|
||||
iDiscordEmbed, err := db.QueryOne(ctx, tx, models.DiscordMessageEmbed{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE id = $1
|
||||
`,
|
||||
savedEmbedId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch new Discord embed data")
|
||||
}
|
||||
|
||||
return iDiscordEmbed.(*models.DiscordMessageEmbed), nil
|
||||
}
|
||||
|
||||
func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID string) (*models.Snippet, error) {
|
||||
iresult, err := db.QueryOne(ctx, dbConn, models.Snippet{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_snippet
|
||||
WHERE discord_message_id = $1
|
||||
`,
|
||||
msgID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return nil, nil
|
||||
} else {
|
||||
return nil, oops.New(err, "failed to fetch existing snippet for message %s", msgID)
|
||||
}
|
||||
}
|
||||
|
||||
return iresult.(*models.Snippet), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Potentially creates or updates a snippet for the given interned message.
|
||||
It uses the content saved in the database to do this. If we do not have any
|
||||
content saved, nothing will happen.
|
||||
|
||||
If a user does not have their Discord account linked, this function will
|
||||
naturally do nothing because we have no message content saved.
|
||||
If forceCreate is true, it does not check any user settings such as automatically creating snippets from
|
||||
#project-showcase. If we have the content, it will make a snippet for it, no
|
||||
questions asked. Bear that in mind.
|
||||
*/
|
||||
func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage, forceCreate bool) error {
|
||||
if interned.HMNUser == nil {
|
||||
// NOTE(asaf): Can't handle snippets when there's no linked user
|
||||
return nil
|
||||
}
|
||||
|
||||
if interned.MessageContent == nil {
|
||||
// NOTE(asaf): Can't have a snippet without content
|
||||
// NOTE(asaf): Messages that only have an attachment also have blank content
|
||||
// TODO(asaf): Do we need to delete existing snippets in this case??? Not entirely sure how to trigger this through discord
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := dbConn.Begin(ctx)
|
||||
if err != nil {
|
||||
oops.New(err, "failed to start transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
existingSnippet, err := FetchSnippetForMessage(ctx, tx, interned.Message.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to check for existing snippet for message %s", interned.Message.ID)
|
||||
}
|
||||
|
||||
if existingSnippet != nil {
|
||||
// TODO(asaf): We're not handling the case where embeds were removed or modified.
|
||||
// Also not handling the case where a message had both an attachment and an embed
|
||||
// and the attachment was removed (leaving only the embed).
|
||||
linkedUserIsSnippetOwner := existingSnippet.OwnerID == interned.DiscordUser.HMNUserId
|
||||
if linkedUserIsSnippetOwner && !existingSnippet.EditedOnWebsite {
|
||||
contentMarkdown := interned.MessageContent.LastContent
|
||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_snippet
|
||||
SET
|
||||
description = $1,
|
||||
_description_html = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
contentMarkdown,
|
||||
contentHTML,
|
||||
existingSnippet.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to update content of snippet on message edit")
|
||||
}
|
||||
existingSnippet.Description = contentMarkdown
|
||||
existingSnippet.DescriptionHtml = contentHTML
|
||||
}
|
||||
} else {
|
||||
userAllowsSnippet := interned.HMNUser.DiscordSaveShowcase || forceCreate
|
||||
shouldCreate := !interned.Message.SnippetCreated && userAllowsSnippet
|
||||
|
||||
if shouldCreate {
|
||||
// Get an asset ID or URL to make a snippet from
|
||||
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &interned.Message)
|
||||
if assetId != nil || url != nil {
|
||||
contentMarkdown := interned.MessageContent.LastContent
|
||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
url,
|
||||
interned.Message.SentAt,
|
||||
contentMarkdown,
|
||||
contentHTML,
|
||||
assetId,
|
||||
interned.Message.ID,
|
||||
interned.HMNUser.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create snippet from attachment")
|
||||
}
|
||||
|
||||
existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch newly-created snippet")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_discordmessage
|
||||
SET snippet_created = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to mark message as having snippet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if existingSnippet != nil {
|
||||
// Update tags
|
||||
|
||||
// Try to associate tags in the message with project tags in HMN.
|
||||
// Match only tags for projects in which the current user is a collaborator.
|
||||
messageTags := getDiscordTags(existingSnippet.Description)
|
||||
|
||||
var desiredTags []int
|
||||
var allTags []int
|
||||
|
||||
// Fetch projects so we know what tags the user can apply to their snippet.
|
||||
projects, err := hmndata.FetchProjects(ctx, tx, interned.HMNUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{interned.HMNUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to look up user projects")
|
||||
}
|
||||
|
||||
projectIDs := make([]int, len(projects))
|
||||
for i, p := range projects {
|
||||
projectIDs[i] = p.Project.ID
|
||||
}
|
||||
|
||||
type tagsRow struct {
|
||||
Tag models.Tag `db:"tags"`
|
||||
}
|
||||
iUserTags, err := db.Query(ctx, tx, tagsRow{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
tags
|
||||
JOIN handmade_project AS project ON project.tag = tags.id
|
||||
WHERE
|
||||
project.id = ANY ($1)
|
||||
`,
|
||||
projectIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch tags for user projects")
|
||||
}
|
||||
|
||||
for _, itag := range iUserTags {
|
||||
tag := itag.(*tagsRow).Tag
|
||||
allTags = append(allTags, tag.ID)
|
||||
for _, messageTag := range messageTags {
|
||||
if strings.EqualFold(tag.Text, messageTag) {
|
||||
desiredTags = append(desiredTags, tag.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM snippet_tags
|
||||
WHERE
|
||||
snippet_id = $1
|
||||
AND tag_id = ANY ($2)
|
||||
`,
|
||||
existingSnippet.ID,
|
||||
allTags,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to clear tags from snippet")
|
||||
}
|
||||
|
||||
for _, tagID := range desiredTags {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet_tags (snippet_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
existingSnippet.ID,
|
||||
tagID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to associate snippet with tag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO(asaf): I believe this will also match https://example.com?hello=1&whatever=5
|
||||
// Probably need to add word boundaries.
|
||||
var REDiscordTag = regexp.MustCompile(`&([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)`)
|
||||
|
||||
func getDiscordTags(content string) []string {
|
||||
matches := REDiscordTag.FindAllStringSubmatch(content, -1)
|
||||
result := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
result[i] = m[1]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// NOTE(ben): This is maybe redundant with the regexes we use for markdown. But
|
||||
// do we actually want to reuse those, or should we keep them separate?
|
||||
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
|
||||
|
||||
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
|
||||
// Check attachments
|
||||
attachments, err := db.Query(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, oops.New(err, "failed to fetch message attachments")
|
||||
}
|
||||
for _, iattachment := range attachments {
|
||||
attachment := iattachment.(*models.DiscordMessageAttachment)
|
||||
return &attachment.AssetID, nil, nil
|
||||
}
|
||||
|
||||
// Check embeds
|
||||
embeds, err := db.Query(ctx, tx, models.DiscordMessageEmbed{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, oops.New(err, "failed to fetch discord embeds")
|
||||
}
|
||||
for _, iembed := range embeds {
|
||||
embed := iembed.(*models.DiscordMessageEmbed)
|
||||
if embed.VideoID != nil {
|
||||
return embed.VideoID, nil, nil
|
||||
} else if embed.ImageID != nil {
|
||||
return embed.ImageID, nil, nil
|
||||
} else if embed.URL != nil {
|
||||
if RESnippetableUrl.MatchString(*embed.URL) {
|
||||
return nil, embed.URL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)
|
||||
|
||||
func messageHasLinks(content string) bool {
|
||||
links := reDiscordMessageLink.FindAllString(content, -1)
|
||||
for _, link := range links {
|
||||
_, err := url.Parse(strings.TrimSpace(link))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -13,7 +13,6 @@ import (
|
|||
"net/textproto"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
|
@ -301,16 +300,7 @@ func ExchangeOAuthCode(ctx context.Context, code, redirectURI string) (*OAuthCod
|
|||
bodyStr := body.Encode()
|
||||
|
||||
res, err := doWithRateLimiting(ctx, name, func(ctx context.Context) *http.Request {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"https://discord.com/api/oauth2/token",
|
||||
strings.NewReader(bodyStr),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
req.Header.Add("User-Agent", UserAgent)
|
||||
req := makeRequest(ctx, http.MethodPost, "/oauth2/token", []byte(bodyStr))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
return req
|
||||
|
@ -613,7 +603,7 @@ func GetAuthorizeUrl(state string) string {
|
|||
params.Set("scope", "identify")
|
||||
params.Set("state", state)
|
||||
params.Set("redirect_uri", hmnurl.BuildDiscordOAuthCallback())
|
||||
return fmt.Sprintf("https://discord.com/api/oauth2/authorize?%s", params.Encode())
|
||||
return fmt.Sprintf("%s?%s", buildUrl("/oauth2/authorize"), params.Encode())
|
||||
}
|
||||
|
||||
type FileUpload struct {
|
||||
|
|
|
@ -1,840 +0,0 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/assets"
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var reDiscordMessageLink = regexp.MustCompile(`https?://.+?(\s|$)`)
|
||||
|
||||
var errNotEnoughInfo = errors.New("Discord didn't send enough info in this event for us to do this")
|
||||
|
||||
func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message) error {
|
||||
switch msg.Type {
|
||||
case MessageTypeDefault, MessageTypeReply, MessageTypeApplicationCommand:
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
didDelete, err := bot.maybeDeleteShowcaseMsg(ctx, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if didDelete {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := bot.dbConn.Begin(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// save the message, maybe save its contents, and maybe make a snippet too
|
||||
newMsg, err := SaveMessageAndContents(ctx, tx, msg)
|
||||
if errors.Is(err, errNotEnoughInfo) {
|
||||
logging.ExtractLogger(ctx).Warn().
|
||||
Interface("msg", msg).
|
||||
Msg("didn't have enough info to process Discord message")
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if doSnippet, err := AllowedToCreateMessageSnippet(ctx, tx, newMsg.UserID); doSnippet && err == nil {
|
||||
_, err := CreateMessageSnippet(ctx, tx, newMsg.UserID, msg.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create snippet in gateway")
|
||||
}
|
||||
} else if err != nil {
|
||||
return oops.New(err, "failed to check snippet permissions in gateway")
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to commit Discord message updates")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bot *botInstance) maybeDeleteShowcaseMsg(ctx context.Context, msg *Message) (didDelete bool, err error) {
|
||||
hasGoodContent := true
|
||||
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
||||
hasGoodContent = false
|
||||
}
|
||||
|
||||
hasGoodAttachments := true
|
||||
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
||||
hasGoodAttachments = false
|
||||
}
|
||||
|
||||
didDelete = false
|
||||
if !hasGoodContent && !hasGoodAttachments {
|
||||
didDelete = true
|
||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if err != nil {
|
||||
return false, oops.New(err, "failed to delete message")
|
||||
}
|
||||
|
||||
if !msg.Author.IsBot {
|
||||
channel, err := CreateDM(ctx, msg.Author.ID)
|
||||
if err != nil {
|
||||
return false, oops.New(err, "failed to create DM channel")
|
||||
}
|
||||
|
||||
err = SendMessages(ctx, bot.dbConn, MessageToSend{
|
||||
ChannelID: channel.ID,
|
||||
Req: CreateMessageRequest{
|
||||
Content: "Posts in #project-showcase are required to have either an image/video or a link. Discuss showcase content in #projects.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return false, oops.New(err, "failed to send showcase warning message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return didDelete, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Ensures that a Discord message is stored in the database. This function is
|
||||
idempotent and can be called regardless of whether the item already exists in
|
||||
the database.
|
||||
|
||||
This does not create snippets or do anything besides save the message itself.
|
||||
*/
|
||||
func SaveMessage(
|
||||
ctx context.Context,
|
||||
tx db.ConnOrTx,
|
||||
msg *Message,
|
||||
) (*models.DiscordMessage, error) {
|
||||
iDiscordMessage, err := db.QueryOne(ctx, tx, models.DiscordMessage{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if errors.Is(err, db.NotFound) {
|
||||
if !msg.OriginalHasFields("author", "timestamp") {
|
||||
return nil, errNotEnoughInfo
|
||||
}
|
||||
|
||||
guildID := msg.GuildID
|
||||
if guildID == nil {
|
||||
/*
|
||||
This is weird, but it can happen when we fetch messages from
|
||||
history instead of receiving it from the gateway. In this case
|
||||
we just assume it's from the HMN server.
|
||||
*/
|
||||
guildID = &config.Config.Discord.GuildID
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessage (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
msg.ID,
|
||||
msg.ChannelID,
|
||||
*guildID,
|
||||
msg.JumpURL(),
|
||||
msg.Author.ID,
|
||||
msg.Time(),
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save new discord message")
|
||||
}
|
||||
|
||||
/*
|
||||
TODO(db): This is a spot where it would be really nice to be able
|
||||
to use RETURNING, and avoid this second query.
|
||||
*/
|
||||
iDiscordMessage, err = db.QueryOne(ctx, tx, models.DiscordMessage{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessage
|
||||
WHERE id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, oops.New(err, "failed to check for existing Discord message")
|
||||
}
|
||||
|
||||
return iDiscordMessage.(*models.DiscordMessage), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Processes a single Discord message, saving as much of the message's content
|
||||
and attachments as allowed by our rules and user settings. Does NOT create
|
||||
snippets.
|
||||
|
||||
Idempotent; can be called any time whether the message exists or not.
|
||||
*/
|
||||
func SaveMessageAndContents(
|
||||
ctx context.Context,
|
||||
tx db.ConnOrTx,
|
||||
msg *Message,
|
||||
) (*models.DiscordMessage, error) {
|
||||
newMsg, err := SaveMessage(ctx, tx, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for linked Discord user
|
||||
iDiscordUser, err := db.QueryOne(ctx, tx, models.DiscordUser{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discorduser
|
||||
WHERE userid = $1
|
||||
`,
|
||||
newMsg.UserID,
|
||||
)
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return newMsg, nil
|
||||
} else if err != nil {
|
||||
return nil, oops.New(err, "failed to look up linked Discord user")
|
||||
}
|
||||
discordUser := iDiscordUser.(*models.DiscordUser)
|
||||
|
||||
// We have a linked Discord account, so save the message contents (regardless of
|
||||
// whether we create a snippet or not).
|
||||
|
||||
if msg.OriginalHasFields("content") {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessagecontent (message_id, discord_id, last_content)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (message_id) DO UPDATE SET
|
||||
discord_id = EXCLUDED.discord_id,
|
||||
last_content = EXCLUDED.last_content
|
||||
`,
|
||||
newMsg.ID,
|
||||
discordUser.ID,
|
||||
CleanUpMarkdown(ctx, msg.Content),
|
||||
)
|
||||
}
|
||||
|
||||
// Save attachments
|
||||
if msg.OriginalHasFields("attachments") {
|
||||
for _, attachment := range msg.Attachments {
|
||||
_, err := saveAttachment(ctx, tx, &attachment, discordUser.HMNUserId, msg.ID)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save attachment")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save / delete embeds
|
||||
if msg.OriginalHasFields("embeds") {
|
||||
numSavedEmbeds, err := db.QueryInt(ctx, tx,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to count existing embeds")
|
||||
}
|
||||
if numSavedEmbeds == 0 {
|
||||
// No embeds yet, so save new ones
|
||||
for _, embed := range msg.Embeds {
|
||||
_, err := saveEmbed(ctx, tx, &embed, discordUser.HMNUserId, msg.ID)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save embed")
|
||||
}
|
||||
}
|
||||
} else if len(msg.Embeds) > 0 {
|
||||
// Embeds were removed from the message
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to delete embeds")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newMsg, nil
|
||||
}
|
||||
|
||||
var discordDownloadClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
type DiscordResourceBadStatusCode error
|
||||
|
||||
func downloadDiscordResource(ctx context.Context, url string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", oops.New(err, "failed to make Discord download request")
|
||||
}
|
||||
res, err := discordDownloadClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", oops.New(err, "failed to fetch Discord resource data")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || 299 < res.StatusCode {
|
||||
return nil, "", DiscordResourceBadStatusCode(fmt.Errorf("status code %d from Discord resource: %s", res.StatusCode, url))
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return content, res.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Saves a Discord attachment as an HMN asset. Idempotent; will not create an attachment
|
||||
that already exists
|
||||
*/
|
||||
func saveAttachment(
|
||||
ctx context.Context,
|
||||
tx db.ConnOrTx,
|
||||
attachment *Attachment,
|
||||
hmnUserID int,
|
||||
discordMessageID string,
|
||||
) (*models.DiscordMessageAttachment, error) {
|
||||
iexisting, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE id = $1
|
||||
`,
|
||||
attachment.ID,
|
||||
)
|
||||
if err == nil {
|
||||
return iexisting.(*models.DiscordMessageAttachment), nil
|
||||
} else if errors.Is(err, db.NotFound) {
|
||||
// this is fine, just create it
|
||||
} else {
|
||||
return nil, oops.New(err, "failed to check for existing attachment")
|
||||
}
|
||||
|
||||
width := 0
|
||||
height := 0
|
||||
if attachment.Width != nil {
|
||||
width = *attachment.Width
|
||||
}
|
||||
if attachment.Height != nil {
|
||||
height = *attachment.Height
|
||||
}
|
||||
|
||||
content, _, err := downloadDiscordResource(ctx, attachment.Url)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to download Discord attachment")
|
||||
}
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if attachment.ContentType != nil {
|
||||
contentType = *attachment.ContentType
|
||||
}
|
||||
|
||||
asset, err := assets.Create(ctx, tx, assets.CreateInput{
|
||||
Content: content,
|
||||
Filename: attachment.Filename,
|
||||
ContentType: contentType,
|
||||
|
||||
UploaderID: &hmnUserID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save asset for Discord attachment")
|
||||
}
|
||||
|
||||
// TODO(db): RETURNING plz thanks
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessageattachment (id, asset_id, message_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
attachment.ID,
|
||||
asset.ID,
|
||||
discordMessageID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to save Discord attachment data")
|
||||
}
|
||||
|
||||
iDiscordAttachment, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE id = $1
|
||||
`,
|
||||
attachment.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch new Discord attachment data")
|
||||
}
|
||||
|
||||
return iDiscordAttachment.(*models.DiscordMessageAttachment), nil
|
||||
}
|
||||
|
||||
// Saves an embed from Discord. NOTE: This is _not_ idempotent, so only call it
|
||||
// if you do not have any embeds saved for this message yet.
|
||||
func saveEmbed(
|
||||
ctx context.Context,
|
||||
tx db.ConnOrTx,
|
||||
embed *Embed,
|
||||
hmnUserID int,
|
||||
discordMessageID string,
|
||||
) (*models.DiscordMessageEmbed, error) {
|
||||
isOkImageType := func(contentType string) bool {
|
||||
return strings.HasPrefix(contentType, "image/")
|
||||
}
|
||||
|
||||
isOkVideoType := func(contentType string) bool {
|
||||
return strings.HasPrefix(contentType, "video/")
|
||||
}
|
||||
|
||||
maybeSaveImageish := func(i EmbedImageish, contentTypeCheck func(string) bool) (*uuid.UUID, error) {
|
||||
content, contentType, err := downloadDiscordResource(ctx, *i.Url)
|
||||
if err != nil {
|
||||
var statusError DiscordResourceBadStatusCode
|
||||
if errors.As(err, &statusError) {
|
||||
return nil, nil
|
||||
} else {
|
||||
return nil, oops.New(err, "failed to save Discord embed")
|
||||
}
|
||||
}
|
||||
if contentTypeCheck(contentType) {
|
||||
in := assets.CreateInput{
|
||||
Content: content,
|
||||
Filename: "embed",
|
||||
ContentType: contentType,
|
||||
UploaderID: &hmnUserID,
|
||||
}
|
||||
|
||||
if i.Width != nil {
|
||||
in.Width = *i.Width
|
||||
}
|
||||
if i.Height != nil {
|
||||
in.Height = *i.Height
|
||||
}
|
||||
|
||||
asset, err := assets.Create(ctx, tx, in)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to create asset from embed")
|
||||
}
|
||||
return &asset.ID, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var imageAssetId *uuid.UUID
|
||||
var videoAssetId *uuid.UUID
|
||||
var err error
|
||||
|
||||
if embed.Video != nil && embed.Video.Url != nil {
|
||||
videoAssetId, err = maybeSaveImageish(embed.Video.EmbedImageish, isOkVideoType)
|
||||
} else if embed.Image != nil && embed.Image.Url != nil {
|
||||
imageAssetId, err = maybeSaveImageish(embed.Image.EmbedImageish, isOkImageType)
|
||||
} else if embed.Thumbnail != nil && embed.Thumbnail.Url != nil {
|
||||
imageAssetId, err = maybeSaveImageish(embed.Thumbnail.EmbedImageish, isOkImageType)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save the embed into the db
|
||||
// TODO(db): Insert, RETURNING
|
||||
var savedEmbedId int
|
||||
err = tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_discordmessageembed (title, description, url, message_id, image_id, video_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`,
|
||||
embed.Title,
|
||||
embed.Description,
|
||||
embed.Url,
|
||||
discordMessageID,
|
||||
imageAssetId,
|
||||
videoAssetId,
|
||||
).Scan(&savedEmbedId)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to insert new embed")
|
||||
}
|
||||
|
||||
iDiscordEmbed, err := db.QueryOne(ctx, tx, models.DiscordMessageEmbed{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE id = $1
|
||||
`,
|
||||
savedEmbedId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch new Discord embed data")
|
||||
}
|
||||
|
||||
return iDiscordEmbed.(*models.DiscordMessageEmbed), nil
|
||||
}
|
||||
|
||||
type DiscordUserAndStuff struct {
|
||||
DiscordUser models.DiscordUser `db:"duser"`
|
||||
HMNUser models.User `db:"u"`
|
||||
}
|
||||
|
||||
func FetchDiscordUser(ctx context.Context, dbConn db.ConnOrTx, discordUserID string) (*DiscordUserAndStuff, error) {
|
||||
iuser, err := db.QueryOne(ctx, dbConn, DiscordUserAndStuff{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_discorduser AS duser
|
||||
JOIN auth_user AS u ON duser.hmn_user_id = u.id
|
||||
WHERE
|
||||
duser.userid = $1
|
||||
`,
|
||||
discordUserID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return iuser.(*DiscordUserAndStuff), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Checks settings and permissions to decide whether we are allowed to create
|
||||
snippets for a user.
|
||||
*/
|
||||
func AllowedToCreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, discordUserId string) (bool, error) {
|
||||
u, err := FetchDiscordUser(ctx, tx, discordUserId)
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, oops.New(err, "failed to check if we can save Discord message")
|
||||
}
|
||||
|
||||
return u.HMNUser.DiscordSaveShowcase, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Attempts to create a snippet from a Discord message. If a snippet already
|
||||
exists, it will be returned and no new snippets will be created.
|
||||
|
||||
It uses the content saved in the database to do this. If we do not have
|
||||
any content saved, nothing will happen.
|
||||
|
||||
Does not check user preferences around snippets.
|
||||
*/
|
||||
func CreateMessageSnippet(ctx context.Context, tx db.ConnOrTx, userID, msgID string) (snippet *models.Snippet, err error) {
|
||||
defer func() {
|
||||
err := updateSnippetTags(ctx, tx, userID, snippet)
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to update tags for Discord snippet")
|
||||
}
|
||||
}()
|
||||
|
||||
// Check for existing snippet, maybe return it
|
||||
type existingSnippetResult struct {
|
||||
Message models.DiscordMessage `db:"msg"`
|
||||
MessageContent *models.DiscordMessageContent `db:"c"`
|
||||
Snippet *models.Snippet `db:"snippet"`
|
||||
DiscordUser *models.DiscordUser `db:"duser"`
|
||||
}
|
||||
iexisting, err := db.QueryOne(ctx, tx, existingSnippetResult{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_discordmessage AS msg
|
||||
LEFT JOIN handmade_discordmessagecontent AS c ON c.message_id = msg.id
|
||||
LEFT JOIN handmade_snippet AS snippet ON snippet.discord_message_id = msg.id
|
||||
LEFT JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid
|
||||
WHERE
|
||||
msg.id = $1
|
||||
`,
|
||||
msgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to check for existing snippet for message %s", msgID)
|
||||
}
|
||||
existing := iexisting.(*existingSnippetResult)
|
||||
|
||||
if existing.Snippet != nil {
|
||||
// A snippet already exists - maybe update its content, then return it
|
||||
if existing.MessageContent != nil && !existing.Snippet.EditedOnWebsite {
|
||||
contentMarkdown := existing.MessageContent.LastContent
|
||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||
|
||||
iSnippet, err := db.QueryOne(ctx, tx, models.Snippet{},
|
||||
`
|
||||
UPDATE handmade_snippet
|
||||
SET
|
||||
description = $1,
|
||||
_description_html = $2
|
||||
WHERE id = $3
|
||||
RETURNING $columns
|
||||
`,
|
||||
contentMarkdown,
|
||||
contentHTML,
|
||||
existing.Snippet.ID,
|
||||
)
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Warn().Err(err).Msg("failed to update content of snippet on message edit")
|
||||
}
|
||||
return iSnippet.(*models.Snippet), nil
|
||||
} else {
|
||||
return existing.Snippet, nil
|
||||
}
|
||||
}
|
||||
|
||||
if existing.Message.SnippetCreated {
|
||||
// A snippet once existed but no longer does
|
||||
// (we do not create another one in this case)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if existing.MessageContent == nil || existing.DiscordUser == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get an asset ID or URL to make a snippet from
|
||||
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &existing.Message)
|
||||
if assetId == nil && url == nil {
|
||||
// Nothing to make a snippet from!
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
contentMarkdown := existing.MessageContent.LastContent
|
||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||
|
||||
// TODO(db): Insert
|
||||
isnippet, err := db.QueryOne(ctx, tx, models.Snippet{},
|
||||
`
|
||||
INSERT INTO handmade_snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING $columns
|
||||
`,
|
||||
url,
|
||||
existing.Message.SentAt,
|
||||
contentMarkdown,
|
||||
contentHTML,
|
||||
assetId,
|
||||
msgID,
|
||||
existing.DiscordUser.HMNUserId,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to create snippet from attachment")
|
||||
}
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_discordmessage
|
||||
SET snippet_created = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
msgID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to mark message as having snippet")
|
||||
}
|
||||
|
||||
return isnippet.(*models.Snippet), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Associates any Discord tags with website tags. Idempotent; will clear
|
||||
out any existing tags and then add new ones.
|
||||
*/
|
||||
func updateSnippetTags(ctx context.Context, dbConn db.ConnOrTx, userID string, snippet *models.Snippet) error {
|
||||
tx, err := dbConn.Begin(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to start transaction")
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
u, err := FetchDiscordUser(ctx, tx, userID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to look up HMN user information from Discord user")
|
||||
// we shouldn't see a "not found" here because of the AllowedToBlahBlahBlah check earlier in the process
|
||||
}
|
||||
|
||||
projects, err := hmndata.FetchProjects(ctx, tx, &u.HMNUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{u.HMNUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to look up user projects")
|
||||
}
|
||||
projectIDs := make([]int, len(projects))
|
||||
for i, p := range projects {
|
||||
projectIDs[i] = p.Project.ID
|
||||
}
|
||||
|
||||
// Delete any existing tags for this snippet
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
DELETE FROM snippet_tags
|
||||
WHERE snippet_id = $1
|
||||
`,
|
||||
snippet.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to delete existing snippet tags")
|
||||
}
|
||||
|
||||
// Try to associate tags in the message with project tags in HMN.
|
||||
// Match only tags for projects in which the current user is a collaborator.
|
||||
messageTags := getDiscordTags(snippet.Description)
|
||||
type tagsRow struct {
|
||||
Tag models.Tag `db:"tags"`
|
||||
}
|
||||
itUserTags, err := db.Query(ctx, tx, tagsRow{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
tags
|
||||
JOIN handmade_project AS project ON project.tag = tags.id
|
||||
JOIN handmade_user_projects AS user_project ON user_project.project_id = project.id
|
||||
WHERE
|
||||
project.id = ANY ($1)
|
||||
`,
|
||||
projectIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch tags for user projects")
|
||||
}
|
||||
iUserTags := itUserTags.ToSlice()
|
||||
|
||||
var tagIDs []int
|
||||
for _, itag := range iUserTags {
|
||||
tag := itag.(*tagsRow).Tag
|
||||
for _, messageTag := range messageTags {
|
||||
if tag.Text == messageTag {
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tagID := range tagIDs {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet_tags (snippet_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
snippet.ID,
|
||||
tagID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to add tag to snippet")
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NOTE(ben): This is maybe redundant with the regexes we use for markdown. But
|
||||
// do we actually want to reuse those, or should we keep them separate?
|
||||
var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\.com/watch)`)
|
||||
|
||||
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
|
||||
// Check attachments
|
||||
itAttachments, err := db.Query(ctx, tx, models.DiscordMessageAttachment{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageattachment
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, oops.New(err, "failed to fetch message attachments")
|
||||
}
|
||||
attachments := itAttachments.ToSlice()
|
||||
for _, iattachment := range attachments {
|
||||
attachment := iattachment.(*models.DiscordMessageAttachment)
|
||||
return &attachment.AssetID, nil, nil
|
||||
}
|
||||
|
||||
// Check embeds
|
||||
itEmbeds, err := db.Query(ctx, tx, models.DiscordMessageEmbed{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_discordmessageembed
|
||||
WHERE message_id = $1
|
||||
`,
|
||||
msg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, oops.New(err, "failed to fetch discord embeds")
|
||||
}
|
||||
embeds := itEmbeds.ToSlice()
|
||||
for _, iembed := range embeds {
|
||||
embed := iembed.(*models.DiscordMessageEmbed)
|
||||
if embed.VideoID != nil {
|
||||
return embed.VideoID, nil, nil
|
||||
} else if embed.ImageID != nil {
|
||||
return embed.ImageID, nil, nil
|
||||
} else if embed.URL != nil {
|
||||
if RESnippetableUrl.MatchString(*embed.URL) {
|
||||
return nil, embed.URL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func messageHasLinks(content string) bool {
|
||||
links := reDiscordMessageLink.FindAllString(content, -1)
|
||||
for _, link := range links {
|
||||
_, err := url.Parse(strings.TrimSpace(link))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var REDiscordTag = regexp.MustCompile(`&([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)`)
|
||||
|
||||
func getDiscordTags(content string) []string {
|
||||
matches := REDiscordTag.FindAllStringSubmatch(content, -1)
|
||||
result := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
result[i] = strings.ToLower(m[1])
|
||||
}
|
||||
return result
|
||||
}
|
|
@ -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,
|
||||
|
@ -145,11 +140,10 @@ func FetchProjects(
|
|||
}
|
||||
|
||||
// Do the query
|
||||
itProjects, err := db.Query(ctx, dbConn, projectRow{}, qb.String(), qb.Args()...)
|
||||
iprojects, err := db.Query(ctx, dbConn, projectRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch projects")
|
||||
}
|
||||
iprojects := itProjects.ToSlice()
|
||||
|
||||
// Fetch project owners to do permission checks
|
||||
projectIds := make([]int, len(iprojects))
|
||||
|
@ -340,7 +334,7 @@ func FetchMultipleProjectsOwners(
|
|||
UserID int `db:"user_id"`
|
||||
ProjectID int `db:"project_id"`
|
||||
}
|
||||
it, err := db.Query(ctx, tx, userProject{},
|
||||
iuserprojects, err := db.Query(ctx, tx, userProject{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_user_projects
|
||||
|
@ -351,7 +345,6 @@ func FetchMultipleProjectsOwners(
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch project IDs")
|
||||
}
|
||||
iuserprojects := it.ToSlice()
|
||||
|
||||
// Get the unique user IDs from this set and fetch the users from the db
|
||||
var userIds []int
|
||||
|
@ -368,19 +361,22 @@ func FetchMultipleProjectsOwners(
|
|||
userIds = append(userIds, userProject.UserID)
|
||||
}
|
||||
}
|
||||
it, 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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch users for projects")
|
||||
}
|
||||
iusers := it.ToSlice()
|
||||
|
||||
// Build the final result set with real user data
|
||||
res := make([]ProjectOwners, len(projectIds))
|
||||
|
@ -401,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 {
|
||||
|
@ -467,7 +463,7 @@ func SetProjectTag(
|
|||
IncludeHidden: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, oops.New(err, "Failed to fetch project")
|
||||
}
|
||||
|
||||
var resultTag *models.Tag
|
||||
|
|
|
@ -10,9 +10,10 @@ import (
|
|||
)
|
||||
|
||||
type SnippetQuery struct {
|
||||
IDs []int
|
||||
OwnerIDs []int
|
||||
Tags []int
|
||||
IDs []int
|
||||
OwnerIDs []int
|
||||
Tags []int
|
||||
DiscordMessageIDs []string
|
||||
|
||||
Limit, Offset int // if empty, no pagination
|
||||
}
|
||||
|
@ -46,7 +47,7 @@ func FetchSnippets(
|
|||
type snippetIDRow struct {
|
||||
SnippetID int `db:"snippet_id"`
|
||||
}
|
||||
itSnippetIDs, err := db.Query(ctx, tx, snippetIDRow{},
|
||||
iSnippetIDs, err := db.Query(ctx, tx, snippetIDRow{},
|
||||
`
|
||||
SELECT DISTINCT snippet_id
|
||||
FROM
|
||||
|
@ -60,7 +61,6 @@ func FetchSnippets(
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to get snippet IDs for tag")
|
||||
}
|
||||
iSnippetIDs := itSnippetIDs.ToSlice()
|
||||
|
||||
// special early-out: no snippets found for these tags at all
|
||||
if len(iSnippetIDs) == 0 {
|
||||
|
@ -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
|
||||
|
@ -92,6 +93,9 @@ func FetchSnippets(
|
|||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||
}
|
||||
if len(q.DiscordMessageIDs) > 0 {
|
||||
qb.Add(`AND snippet.discord_message_id = ANY ($?)`, q.DiscordMessageIDs)
|
||||
}
|
||||
if currentUser == nil {
|
||||
qb.Add(
|
||||
`AND owner.status = $? -- snippet owner is Approved`,
|
||||
|
@ -121,11 +125,10 @@ func FetchSnippets(
|
|||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||
}
|
||||
|
||||
it, err := db.Query(ctx, tx, resultRow{}, qb.String(), qb.Args()...)
|
||||
iresults, err := db.Query(ctx, tx, resultRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch threads")
|
||||
}
|
||||
iresults := it.ToSlice()
|
||||
|
||||
result := make([]SnippetAndStuff, len(iresults)) // allocate extra space because why not
|
||||
snippetIDs := make([]int, len(iresults))
|
||||
|
@ -147,7 +150,7 @@ func FetchSnippets(
|
|||
SnippetID int `db:"snippet_tags.snippet_id"`
|
||||
Tag *models.Tag `db:"tags"`
|
||||
}
|
||||
itSnippetTags, err := db.Query(ctx, tx, snippetTagRow{},
|
||||
iSnippetTags, err := db.Query(ctx, tx, snippetTagRow{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -161,7 +164,6 @@ func FetchSnippets(
|
|||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch tags for snippets")
|
||||
}
|
||||
iSnippetTags := itSnippetTags.ToSlice()
|
||||
|
||||
// associate tags with snippets
|
||||
resultBySnippetId := make(map[int]*SnippetAndStuff)
|
||||
|
@ -204,3 +206,26 @@ func FetchSnippet(
|
|||
|
||||
return res[0], nil
|
||||
}
|
||||
|
||||
func FetchSnippetForDiscordMessage(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
currentUser *models.User,
|
||||
discordMessageID string,
|
||||
q SnippetQuery,
|
||||
) (SnippetAndStuff, error) {
|
||||
q.DiscordMessageIDs = []string{discordMessageID}
|
||||
q.Limit = 1
|
||||
q.Offset = 0
|
||||
|
||||
res, err := FetchSnippets(ctx, dbConn, currentUser, q)
|
||||
if err != nil {
|
||||
return SnippetAndStuff{}, oops.New(err, "failed to fetch snippet for Discord message")
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return SnippetAndStuff{}, db.NotFound
|
||||
}
|
||||
|
||||
return res[0], nil
|
||||
}
|
||||
|
|
|
@ -40,11 +40,10 @@ func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.T
|
|||
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||
}
|
||||
|
||||
it, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...)
|
||||
itags, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch tags")
|
||||
}
|
||||
itags := it.ToSlice()
|
||||
|
||||
res := make([]*models.Tag, len(itags))
|
||||
for i, itag := range itags {
|
||||
|
|
|
@ -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 = $?
|
||||
|
@ -143,11 +145,10 @@ func FetchThreads(
|
|||
ForumLastReadTime *time.Time `db:"slri.lastread"`
|
||||
}
|
||||
|
||||
it, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||
iresults, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch threads")
|
||||
}
|
||||
iresults := it.ToSlice()
|
||||
|
||||
result := make([]ThreadAndStuff, len(iresults))
|
||||
for i, iresult := range iresults {
|
||||
|
@ -222,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
|
||||
|
@ -331,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 = $?
|
||||
|
@ -345,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
|
||||
|
@ -398,11 +403,10 @@ func FetchPosts(
|
|||
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||
}
|
||||
|
||||
it, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||
iresults, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch posts")
|
||||
}
|
||||
iresults := it.ToSlice()
|
||||
|
||||
result := make([]PostAndStuff, len(iresults))
|
||||
for i, iresult := range iresults {
|
||||
|
@ -545,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
|
||||
|
@ -696,20 +701,28 @@ func DeletePost(
|
|||
tx pgx.Tx,
|
||||
threadId, postId int,
|
||||
) (threadDeleted bool) {
|
||||
isFirstPost, err := db.QueryBool(ctx, tx,
|
||||
type threadInfo struct {
|
||||
FirstPostID int `db:"first_id"`
|
||||
Deleted bool `db:"deleted"`
|
||||
}
|
||||
ti, err := db.QueryOne(ctx, tx, threadInfo{},
|
||||
`
|
||||
SELECT thread.first_id = $1
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_thread AS thread
|
||||
WHERE
|
||||
thread.id = $2
|
||||
thread.id = $1
|
||||
`,
|
||||
postId,
|
||||
threadId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to check if post was the first post in the thread"))
|
||||
panic(oops.New(err, "failed to fetch thread info"))
|
||||
}
|
||||
info := ti.(*threadInfo)
|
||||
if info.Deleted {
|
||||
return true
|
||||
}
|
||||
isFirstPost := info.FirstPostID == postId
|
||||
|
||||
if isFirstPost {
|
||||
// Just delete the whole thread and all its posts.
|
||||
|
@ -848,7 +861,7 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
|
|||
|
||||
var values [][]interface{}
|
||||
|
||||
for _, asset := range assetResult.ToSlice() {
|
||||
for _, asset := range assetResult {
|
||||
values = append(values, []interface{}{postId, asset.(*assetId).AssetID})
|
||||
}
|
||||
|
||||
|
@ -884,7 +897,7 @@ func FixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
|
|||
}
|
||||
|
||||
var firstPost, lastPost *models.Post
|
||||
for _, ipost := range postsIter.ToSlice() {
|
||||
for _, ipost := range postsIter {
|
||||
post := ipost.(*models.Post)
|
||||
|
||||
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
|
||||
|
|
|
@ -105,6 +105,8 @@ func TestUserSettings(t *testing.T) {
|
|||
func TestAdmin(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildAdminAtomFeed(), RegexAdminAtomFeed, nil)
|
||||
AssertRegexMatch(t, BuildAdminApprovalQueue(), RegexAdminApprovalQueue, nil)
|
||||
AssertRegexMatch(t, BuildAdminSetUserStatus(), RegexAdminSetUserStatus, nil)
|
||||
AssertRegexMatch(t, BuildAdminNukeUser(), RegexAdminNukeUser, nil)
|
||||
}
|
||||
|
||||
func TestSnippet(t *testing.T) {
|
||||
|
|
|
@ -189,7 +189,7 @@ func BuildUserProfile(username string) string {
|
|||
if len(username) == 0 {
|
||||
panic(oops.New(nil, "Username must not be blank"))
|
||||
}
|
||||
return Url("/m/"+url.PathEscape(username), nil)
|
||||
return Url("/m/"+username, nil)
|
||||
}
|
||||
|
||||
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
|
||||
|
@ -216,6 +216,20 @@ func BuildAdminApprovalQueue() string {
|
|||
return Url("/admin/approvals", nil)
|
||||
}
|
||||
|
||||
var RegexAdminSetUserStatus = regexp.MustCompile(`^/admin/setuserstatus$`)
|
||||
|
||||
func BuildAdminSetUserStatus() string {
|
||||
defer CatchPanic()
|
||||
return Url("/admin/setuserstatus", nil)
|
||||
}
|
||||
|
||||
var RegexAdminNukeUser = regexp.MustCompile(`^/admin/nukeuser$`)
|
||||
|
||||
func BuildAdminNukeUser() string {
|
||||
defer CatchPanic()
|
||||
return Url("/admin/nukeuser", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* Snippets
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -47,7 +47,7 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
|
|||
type subforumRow struct {
|
||||
Subforum Subforum `db:"sf"`
|
||||
}
|
||||
rows, err := db.Query(ctx, conn, subforumRow{},
|
||||
rowsSlice, err := db.Query(ctx, conn, subforumRow{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -59,7 +59,6 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
|
|||
panic(oops.New(err, "failed to fetch subforum tree"))
|
||||
}
|
||||
|
||||
rowsSlice := rows.ToSlice()
|
||||
sfTreeMap := make(map[int]*SubforumTreeNode, len(rowsSlice))
|
||||
for _, row := range rowsSlice {
|
||||
sf := row.(*subforumRow).Subforum
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -2,6 +2,7 @@ package parsing
|
|||
|
||||
import (
|
||||
"io"
|
||||
"regexp"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
|
@ -11,6 +12,8 @@ type plaintextRenderer struct{}
|
|||
|
||||
var _ renderer.Renderer = plaintextRenderer{}
|
||||
|
||||
var backslashRegex = regexp.MustCompile("\\\\(?P<char>[\\\\\\x60!\"#$%&'()*+,-./:;<=>?@\\[\\]^_{|}~])")
|
||||
|
||||
func (r plaintextRenderer) Render(w io.Writer, source []byte, n ast.Node) error {
|
||||
return ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
|
@ -20,8 +23,7 @@ func (r plaintextRenderer) Render(w io.Writer, source []byte, n ast.Node) error
|
|||
switch n.Kind() {
|
||||
case ast.KindText:
|
||||
n := n.(*ast.Text)
|
||||
|
||||
_, err := w.Write(n.Text(source))
|
||||
_, err := w.Write(backslashRegex.ReplaceAll(n.Text(source), []byte("$1")))
|
||||
if err != nil {
|
||||
return ast.WalkContinue, err
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -193,14 +204,15 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
|||
Username: u.Username,
|
||||
Email: email,
|
||||
IsStaff: u.IsStaff,
|
||||
Status: int(u.Status),
|
||||
|
||||
Name: u.BestName(),
|
||||
Bio: u.Bio,
|
||||
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,
|
||||
|
|
|
@ -116,7 +116,7 @@
|
|||
});
|
||||
|
||||
// Do live Markdown previews
|
||||
initLiveMarkdown({ inputEl: textField, previewEl: preview });
|
||||
let doMarkdown = initLiveMarkdown({ inputEl: textField, previewEl: preview });
|
||||
|
||||
/*
|
||||
/ Asset upload
|
||||
|
|
|
@ -102,5 +102,7 @@
|
|||
|
||||
doMarkdown();
|
||||
inputEl.addEventListener('input', () => doMarkdown());
|
||||
|
||||
return doMarkdown;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -137,6 +137,19 @@
|
|||
<div class="c--dim f7">Plaintext only. No links or markdown.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Project links:</div>
|
||||
<div>
|
||||
<textarea class="links" name="links" id="links" maxlength="2048" data-max-chars="2048">
|
||||
{{- .ProjectSettings.LinksText -}}
|
||||
</textarea>
|
||||
<div class="c--dim f7">
|
||||
<div>Relevant links to put on the project page.</div>
|
||||
<div>Format: url [Title] (e.g. <code>http://example.com/ Example Site</code>)</div>
|
||||
<div>(1 per line, 10 max)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Full description:</div>
|
||||
<div>
|
||||
|
@ -227,7 +240,7 @@
|
|||
});
|
||||
projectForm.addEventListener('submit', () => clearDescription());
|
||||
|
||||
initLiveMarkdown({ inputEl: description, previewEl: descPreview });
|
||||
let doMarkdown = initLiveMarkdown({ inputEl: description, previewEl: descPreview });
|
||||
|
||||
//////////////////////
|
||||
// Owner management //
|
||||
|
|
|
@ -1,5 +1,32 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "extrahead" }}
|
||||
<style>
|
||||
.led {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: 1.5px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.led.yellow {
|
||||
background-color: #64501f;
|
||||
border-color: #4f3700;
|
||||
}
|
||||
|
||||
.led.yellow.on {
|
||||
background-color: #fdf2d8;
|
||||
border-color: #f9ad04;
|
||||
box-shadow: 0 0 7px #ee9e06;
|
||||
}
|
||||
|
||||
.admin .cover {
|
||||
background: repeating-linear-gradient( -45deg, #ff6c00, #ff6c00 12px, #000000 5px, #000000 25px );
|
||||
}
|
||||
</style>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="flex flex-column flex-row-l">
|
||||
<div class="
|
||||
|
@ -34,6 +61,68 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .User }}
|
||||
{{ if .User.IsStaff }}
|
||||
<div class="mt3 mt0-ns mt3-l ml3-ns ml0-l flex flex-column items-start bg--card pa2 br2 admin">
|
||||
<div class="flex flex-row w-100 items-center">
|
||||
<b class="flex-grow-1">Admin actions</b>
|
||||
<div class="led yellow" style="height: 12px; margin: 3px;"></div>
|
||||
<a href="javascript:;" class="unlock">Unlock</a>
|
||||
</div>
|
||||
<div class="relative w-100">
|
||||
<div class="bg--card cover absolute w-100 h-100 br2"></div>
|
||||
<div class="mt3">
|
||||
<div>User status:</div>
|
||||
<form id="admin_set_status_form" method="POST" action="{{ .AdminSetStatusUrl }}">
|
||||
{{ csrftoken .Session }}
|
||||
<input type="hidden" name="user_id" value="{{ .ProfileUser.ID }}" />
|
||||
<input type="hidden" name="username" value="{{ .ProfileUser.Username }}" />
|
||||
<select name="status">
|
||||
<option value="inactive" {{ if eq .ProfileUser.Status 1 }}selected{{ end }}>Brand new</option>
|
||||
<option value="confirmed" {{ if eq .ProfileUser.Status 2 }}selected{{ end }}>Email confirmed</option>
|
||||
<option value="approved" {{ if eq .ProfileUser.Status 3 }}selected{{ end }}>Admin approved</option>
|
||||
<option value="banned" {{ if eq .ProfileUser.Status 4 }}selected{{ end }}>Banned</option>
|
||||
</select>
|
||||
<input type="submit" value="Set" />
|
||||
<div class="c--dim f7">Only sets status. Doesn't delete anything.</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mt3">
|
||||
<div>Danger zone:</div>
|
||||
<form id="admin_nuke_form" method="POST" action="{{ .AdminNukeUrl }}">
|
||||
{{ csrftoken .Session }}
|
||||
<input type="hidden" name="user_id" value="{{ .ProfileUser.ID }}" />
|
||||
<input type="hidden" name="username" value="{{ .ProfileUser.Username }}" />
|
||||
<input type="submit" value="Nuke posts" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let unlockEl = document.querySelector(".admin .unlock");
|
||||
let adminUnlockLed = document.querySelector(".admin .led");
|
||||
let adminUnlocked = false;
|
||||
let panelEl = document.querySelector(".admin .cover");
|
||||
unlockEl.addEventListener("click", function() {
|
||||
adminUnlocked = true;
|
||||
adminUnlockLed.classList.add("on");
|
||||
panelEl.style.display = "none";
|
||||
});
|
||||
|
||||
document.querySelector("#admin_set_status_form").addEventListener("submit", function(ev) {
|
||||
if (!adminUnlocked) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#admin_nuke_form").addEventListener("submit", function(ev) {
|
||||
if (!adminUnlocked) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
{{ if or .OwnProfile .ProfileUserProjects }}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
{{ define "extrahead" }}
|
||||
<script src="{{ static "js/tabs.js" }}"></script>
|
||||
<script src="{{ static "js/image_selector.js" }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
|
@ -41,12 +42,19 @@
|
|||
</div>
|
||||
<div class="edit-form-row">
|
||||
<div>Avatar:</div>
|
||||
<div>
|
||||
<input type="file" name="avatar" id="avatar">
|
||||
<div class="avatar-preview"><img src="{{ .User.AvatarUrl }}" width="200px">
|
||||
</div>
|
||||
<div class="user_avatar">
|
||||
{{ template "image_selector.html" imageselectordata "avatar" .User.AvatarUrl false }}
|
||||
<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>
|
||||
<script>
|
||||
let avatarMaxFileSize = {{ .AvatarMaxFileSize }};
|
||||
let avatarSelector = new ImageSelector(
|
||||
document.querySelector("#user_form"),
|
||||
avatarMaxFileSize,
|
||||
document.querySelector(".user_avatar"),
|
||||
{{ .DefaultAvatarUrl }}
|
||||
);
|
||||
</script>
|
||||
</div>
|
||||
<div class="edit-form-row">
|
||||
<div class="pt-input-ns">Short bio:</div>
|
||||
|
|
|
@ -146,6 +146,7 @@ type ProjectSettings struct {
|
|||
|
||||
Blurb string
|
||||
Description string
|
||||
LinksText string
|
||||
Owners []User
|
||||
|
||||
LightLogo string
|
||||
|
@ -157,6 +158,7 @@ type User struct {
|
|||
Username string
|
||||
Email string
|
||||
IsStaff bool
|
||||
Status int
|
||||
|
||||
Name string
|
||||
Blurb string
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
@ -172,10 +178,10 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
if errors.Is(err, db.NotFound) {
|
||||
return RejectRequest(c, "User not found")
|
||||
} else {
|
||||
return c.ErrorResponse(http.StatusBadRequest, 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 := ""
|
||||
if action == ApprovalQueueActionApprove {
|
||||
|
@ -189,7 +195,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to set user to approved"))
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to set user to approved"))
|
||||
}
|
||||
whatHappened = fmt.Sprintf("%s approved successfully", user.Username)
|
||||
} else if action == ApprovalQueueActionSpammer {
|
||||
|
@ -203,13 +209,16 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to set user to banned"))
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to set user to banned"))
|
||||
}
|
||||
err = auth.DeleteSessionForUser(c.Context(), c.Conn, user.Username)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to log out user"))
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to log out user"))
|
||||
}
|
||||
err = deleteAllPostsForUser(c.Context(), c.Conn, user.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete spammer's posts"))
|
||||
}
|
||||
whatHappened = fmt.Sprintf("%s banned successfully", user.Username)
|
||||
} else {
|
||||
whatHappened = fmt.Sprintf("Unrecognized action: %s", action)
|
||||
|
@ -238,18 +247,20 @@ 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 author.status = $1
|
||||
AND NOT post.deleted
|
||||
AND author.status = ANY($1)
|
||||
ORDER BY post.postdate DESC
|
||||
`,
|
||||
models.UserStatusConfirmed,
|
||||
[]models.UserStatus{models.UserStatusConfirmed},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch unapproved posts")
|
||||
}
|
||||
var res []*UnapprovedPost
|
||||
for _, iresult := range it.ToSlice() {
|
||||
for _, iresult := range it {
|
||||
res = append(res, iresult.(*UnapprovedPost))
|
||||
}
|
||||
return res, nil
|
||||
|
@ -281,7 +292,7 @@ func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int)
|
|||
return oops.New(err, "failed to fetch posts to delete for user")
|
||||
}
|
||||
|
||||
for _, iResult := range it.ToSlice() {
|
||||
for _, iResult := range it {
|
||||
row := iResult.(*toDelete)
|
||||
hmndata.DeletePost(ctx, tx, row.ThreadID, row.PostID)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -143,21 +143,10 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
|||
}
|
||||
duser := iduser.(*models.DiscordUser)
|
||||
|
||||
ok, err := discord.AllowedToCreateMessageSnippet(c.Context(), c.Conn, duser.UserID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
// Not allowed to do this, bail out
|
||||
c.Logger.Warn().Msg("was not allowed to save user snippets")
|
||||
return c.Redirect(hmnurl.BuildUserProfile(c.CurrentUser.Username), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type messageIdQuery struct {
|
||||
MessageID string `db:"msg.id"`
|
||||
}
|
||||
imsgIds, err := db.Query(c.Context(), c.Conn, messageIdQuery{},
|
||||
iMsgIDs, err := db.Query(c.Context(), c.Conn, messageIdQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -169,14 +158,24 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
|||
duser.UserID,
|
||||
config.Config.Discord.ShowcaseChannelID,
|
||||
)
|
||||
msgIds := imsgIds.ToSlice()
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
for _, imsgId := range msgIds {
|
||||
msgId := imsgId.(*messageIdQuery)
|
||||
_, err := discord.CreateMessageSnippet(c.Context(), c.Conn, duser.UserID, msgId.MessageID)
|
||||
if err != nil {
|
||||
c.Logger.Warn().Err(err).Msg("failed to create snippet from showcase backlog")
|
||||
continue
|
||||
var msgIDs []string
|
||||
for _, imsgId := range iMsgIDs {
|
||||
msgIDs = append(msgIDs, imsgId.(*messageIdQuery).MessageID)
|
||||
}
|
||||
for _, msgID := range msgIDs {
|
||||
interned, err := discord.FetchInternedMessage(c.Context(), c.Conn, msgID)
|
||||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
} else if err == nil {
|
||||
// NOTE(asaf): Creating snippet even if the checkbox is off because the user asked us to.
|
||||
err = discord.HandleSnippetForInternedMessage(c.Context(), c.Conn, interned, true)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
)
|
||||
|
||||
type ParsedLink struct {
|
||||
Name string
|
||||
Url string
|
||||
}
|
||||
|
||||
func ParseLinks(text string) []ParsedLink {
|
||||
lines := strings.Split(text, "\n")
|
||||
res := make([]ParsedLink, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
linkParts := strings.SplitN(line, " ", 2)
|
||||
url := strings.TrimSpace(linkParts[0])
|
||||
name := ""
|
||||
if len(linkParts) > 1 {
|
||||
name = strings.TrimSpace(linkParts[1])
|
||||
}
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
continue
|
||||
}
|
||||
res = append(res, ParsedLink{Name: name, Url: url})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func LinksToText(links []interface{}) string {
|
||||
linksText := ""
|
||||
for _, l := range links {
|
||||
link := l.(*models.Link)
|
||||
linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
|
||||
}
|
||||
return linksText
|
||||
}
|
|
@ -573,7 +573,7 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
if err != nil {
|
||||
return result, oops.New(err, "failed to fetch podcast episodes")
|
||||
}
|
||||
for _, episodeRow := range podcastEpisodeQueryResult.ToSlice() {
|
||||
for _, episodeRow := range podcastEpisodeQueryResult {
|
||||
result.Episodes = append(result.Episodes, &episodeRow.(*podcastEpisodeQuery).Episode)
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
@ -315,23 +314,18 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
"lts-reqd",
|
||||
"NOTICE: This project is awaiting approval for maintenance-mode status.",
|
||||
)
|
||||
case models.ProjectLifecycleLTS:
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"lts",
|
||||
"NOTICE: This project has reached a state of completion.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
||||
for _, screenshot := range screenshotQueryResult {
|
||||
templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
|
||||
}
|
||||
|
||||
for _, link := range projectLinkResult.ToSlice() {
|
||||
for _, link := range projectLinkResult {
|
||||
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
|
||||
}
|
||||
|
||||
for _, post := range postQueryResult.ToSlice() {
|
||||
for _, post := range postQueryResult {
|
||||
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
|
||||
c.UrlContext,
|
||||
lineageBuilder,
|
||||
|
@ -501,14 +495,37 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project links")
|
||||
projectLinkResult, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_links as link
|
||||
WHERE
|
||||
link.project_id = $1
|
||||
ORDER BY link.ordering ASC
|
||||
`,
|
||||
p.Project.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
projectSettings.LinksText = LinksToText(projectLinkResult)
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("project_edit.html", ProjectEditData{
|
||||
BaseData: getBaseDataAutocrumb(c, "Edit Project"),
|
||||
|
@ -563,6 +580,7 @@ type ProjectPayload struct {
|
|||
ProjectID int
|
||||
Name string
|
||||
Blurb string
|
||||
Links []ParsedLink
|
||||
Description string
|
||||
ParsedDescription string
|
||||
Lifecycle models.ProjectLifecycle
|
||||
|
@ -605,6 +623,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
|||
res.RejectionReason = "Projects must have a short description"
|
||||
return res
|
||||
}
|
||||
links := ParseLinks(c.Req.Form.Get("links"))
|
||||
description := c.Req.Form.Get("description")
|
||||
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
||||
|
||||
|
@ -655,6 +674,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
|||
res.Payload = ProjectPayload{
|
||||
Name: projectName,
|
||||
Blurb: shortDesc,
|
||||
Links: links,
|
||||
Description: description,
|
||||
ParsedDescription: parsedDescription,
|
||||
Lifecycle: lifecycle,
|
||||
|
@ -719,33 +739,31 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername)
|
||||
}
|
||||
|
||||
var qb db.QueryBuilder
|
||||
qb.Add(
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_project SET
|
||||
name = $?,
|
||||
blurb = $?,
|
||||
description = $?,
|
||||
descparsed = $?,
|
||||
lifecycle = $?
|
||||
name = $2,
|
||||
blurb = $3,
|
||||
description = $4,
|
||||
descparsed = $5,
|
||||
lifecycle = $6
|
||||
WHERE id = $1
|
||||
`,
|
||||
payload.ProjectID,
|
||||
payload.Name,
|
||||
payload.Blurb,
|
||||
payload.Description,
|
||||
payload.ParsedDescription,
|
||||
payload.Lifecycle,
|
||||
)
|
||||
if user.IsStaff {
|
||||
qb.Add(`, hidden = $?`, payload.Hidden)
|
||||
}
|
||||
qb.Add(`WHERE id = $?`, payload.ProjectID)
|
||||
|
||||
_, err := tx.Exec(ctx, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to update project")
|
||||
}
|
||||
|
||||
hmndata.SetProjectTag(ctx, tx, payload.ProjectID, payload.Tag)
|
||||
_, err = hmndata.SetProjectTag(ctx, tx, payload.ProjectID, payload.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.IsStaff {
|
||||
_, err = tx.Exec(ctx,
|
||||
|
@ -753,7 +771,8 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
UPDATE handmade_project SET
|
||||
slug = $2,
|
||||
featured = $3,
|
||||
personal = $4
|
||||
personal = $4,
|
||||
hidden = $5
|
||||
WHERE
|
||||
id = $1
|
||||
`,
|
||||
|
@ -761,6 +780,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
payload.Slug,
|
||||
payload.Featured,
|
||||
payload.Personal,
|
||||
payload.Hidden,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to update project with admin fields")
|
||||
|
@ -801,10 +821,14 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
}
|
||||
}
|
||||
|
||||
ownerResult, 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,
|
||||
|
@ -812,7 +836,6 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
if err != nil {
|
||||
return oops.New(err, "Failed to query users")
|
||||
}
|
||||
ownerRows := ownerResult.ToSlice()
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
|
@ -833,7 +856,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 {
|
||||
|
@ -841,6 +864,26 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
|
|||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `DELETE FROM handmade_links WHERE project_id = $1`, payload.ProjectID)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to delete project links")
|
||||
}
|
||||
for i, link := range payload.Links {
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO handmade_links (name, url, ordering, project_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`,
|
||||
link.Name,
|
||||
link.Url,
|
||||
i,
|
||||
payload.ProjectID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to insert new project link")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -880,13 +923,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
|
||||
|
|
|
@ -190,6 +190,8 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
hmnOnly.GET(hmnurl.RegexAdminAtomFeed, AdminAtomFeed)
|
||||
hmnOnly.GET(hmnurl.RegexAdminApprovalQueue, adminMiddleware(AdminApprovalQueue))
|
||||
hmnOnly.POST(hmnurl.RegexAdminApprovalQueue, adminMiddleware(csrfMiddleware(AdminApprovalQueueSubmit)))
|
||||
hmnOnly.POST(hmnurl.RegexAdminSetUserStatus, adminMiddleware(csrfMiddleware(UserProfileAdminSetStatus)))
|
||||
hmnOnly.POST(hmnurl.RegexAdminNukeUser, adminMiddleware(csrfMiddleware(UserProfileAdminNuke)))
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexFeed, Feed)
|
||||
hmnOnly.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
||||
|
@ -499,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)
|
||||
}
|
||||
|
@ -555,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")
|
||||
|
@ -564,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
|
||||
}
|
||||
|
|
|
@ -2,12 +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"
|
||||
|
@ -18,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"
|
||||
)
|
||||
|
||||
|
@ -32,6 +33,9 @@ type UserProfileTemplateData struct {
|
|||
|
||||
CanAddProject bool
|
||||
NewProjectUrl string
|
||||
|
||||
AdminSetStatusUrl string
|
||||
AdminNukeUrl string
|
||||
}
|
||||
|
||||
func UserProfile(c *RequestContext) ResponseData {
|
||||
|
@ -48,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
|
||||
`,
|
||||
|
@ -66,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
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -81,7 +89,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
type userLinkQuery struct {
|
||||
UserLink models.Link `db:"link"`
|
||||
}
|
||||
userLinkQueryResult, err := db.Query(c.Context(), c.Conn, userLinkQuery{},
|
||||
userLinksSlice, err := db.Query(c.Context(), c.Conn, userLinkQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -95,7 +103,6 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
|
||||
}
|
||||
userLinksSlice := userLinkQueryResult.ToSlice()
|
||||
profileUserLinks := make([]templates.Link, 0, len(userLinksSlice))
|
||||
for _, l := range userLinksSlice {
|
||||
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink))
|
||||
|
@ -111,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 {
|
||||
|
@ -191,16 +197,24 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
|
||||
CanAddProject: numPersonalProjects < maxPersonalProjects,
|
||||
NewProjectUrl: hmnurl.BuildProjectNew(),
|
||||
|
||||
AdminSetStatusUrl: hmnurl.BuildAdminSetUserStatus(),
|
||||
AdminNukeUrl: hmnurl.BuildAdminNukeUser(),
|
||||
}, c.Perf)
|
||||
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
|
||||
|
@ -216,7 +230,7 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
DiscordShowcaseBacklogUrl string
|
||||
}
|
||||
|
||||
ilinks, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
links, err := db.Query(c.Context(), c.Conn, models.Link{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_links
|
||||
|
@ -228,13 +242,8 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
|
||||
}
|
||||
links := ilinks.ToSlice()
|
||||
|
||||
linksText := ""
|
||||
for _, ilink := range links {
|
||||
link := ilink.(*models.Link)
|
||||
linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
|
||||
}
|
||||
linksText := LinksToText(links)
|
||||
|
||||
var tduser *templates.DiscordUser
|
||||
var numUnsavedMessages int
|
||||
|
@ -279,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(),
|
||||
|
@ -298,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)
|
||||
|
@ -360,31 +379,19 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
|
||||
// Process links
|
||||
linksText := form.Get("links")
|
||||
links := strings.Split(linksText, "\n")
|
||||
links := ParseLinks(linksText)
|
||||
_, err = tx.Exec(c.Context(), `DELETE FROM handmade_links WHERE user_id = $1`, c.CurrentUser.ID)
|
||||
if err != nil {
|
||||
c.Logger.Warn().Err(err).Msg("failed to delete old links")
|
||||
} else {
|
||||
for i, link := range links {
|
||||
link = strings.TrimSpace(link)
|
||||
linkParts := strings.SplitN(link, " ", 2)
|
||||
url := strings.TrimSpace(linkParts[0])
|
||||
name := ""
|
||||
if len(linkParts) > 1 {
|
||||
name = strings.TrimSpace(linkParts[1])
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err := tx.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO handmade_links (name, url, ordering, user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`,
|
||||
name,
|
||||
url,
|
||||
link.Name,
|
||||
link.Url,
|
||||
i,
|
||||
c.CurrentUser.ID,
|
||||
)
|
||||
|
@ -407,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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -440,6 +461,70 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
return res
|
||||
}
|
||||
|
||||
func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
|
||||
c.Req.ParseForm()
|
||||
|
||||
userIdStr := c.Req.Form.Get("user_id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "No user id provided")
|
||||
}
|
||||
|
||||
status := c.Req.Form.Get("status")
|
||||
var desiredStatus models.UserStatus
|
||||
switch status {
|
||||
case "inactive":
|
||||
desiredStatus = models.UserStatusInactive
|
||||
case "confirmed":
|
||||
desiredStatus = models.UserStatusConfirmed
|
||||
case "approved":
|
||||
desiredStatus = models.UserStatusApproved
|
||||
case "banned":
|
||||
desiredStatus = models.UserStatusBanned
|
||||
default:
|
||||
return RejectRequest(c, "No legal user status provided")
|
||||
}
|
||||
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE auth_user
|
||||
SET status = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
desiredStatus,
|
||||
userId,
|
||||
)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user status"))
|
||||
}
|
||||
if desiredStatus == models.UserStatusBanned {
|
||||
err = auth.DeleteSessionForUser(c.Context(), c.Conn, c.Req.Form.Get("username"))
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to log out user"))
|
||||
}
|
||||
}
|
||||
res := c.Redirect(hmnurl.BuildUserProfile(c.Req.Form.Get("username")), http.StatusSeeOther)
|
||||
res.AddFutureNotice("success", "Successfully set status")
|
||||
return res
|
||||
}
|
||||
|
||||
func UserProfileAdminNuke(c *RequestContext) ResponseData {
|
||||
c.Req.ParseForm()
|
||||
userIdStr := c.Req.Form.Get("user_id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "No user id provided")
|
||||
}
|
||||
|
||||
err = deleteAllPostsForUser(c.Context(), c.Conn, userId)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete user posts"))
|
||||
}
|
||||
res := c.Redirect(hmnurl.BuildUserProfile(c.Req.Form.Get("username")), http.StatusSeeOther)
|
||||
res.AddFutureNotice("success", "Successfully nuked user")
|
||||
return res
|
||||
}
|
||||
|
||||
func updatePassword(c *RequestContext, tx pgx.Tx, old, new, confirm string) *ResponseData {
|
||||
if new != confirm {
|
||||
res := RejectRequest(c, "Your password and password confirmation did not match.")
|
||||
|
|
Loading…
Reference in New Issue