Compare commits

...

20 Commits

Author SHA1 Message Date
Asaf Gartner c8f401f738 Updated cmd description 2022-02-07 14:25:21 +02:00
Asaf Gartner 43651d98e8 Code review 2022-02-07 14:21:40 +02:00
Asaf Gartner 92d6a31aa9 Fixed tag removal issue. 2022-01-31 10:22:25 +02:00
Asaf Gartner 378d6eb836 Removed old comment. 2022-01-31 08:52:56 +02:00
Asaf Gartner 60a71d5dd1 Fixed indent. 2022-01-31 08:51:34 +02:00
Asaf Gartner ad888346ef Discord message handling reorganization. 2022-01-31 08:46:43 +02:00
Asaf Gartner f4f439489d Assets for user avatars 2021-12-29 16:38:23 +02:00
Asaf Gartner eb32b04437 Added project links to project edit page 2021-12-26 12:03:25 +02:00
Asaf Gartner 80f0e3b176 MD text renderer: handle backslashes
Handles the same characters as the html renderer.
2021-12-26 11:36:12 +02:00
Asaf Gartner f8e7779b7d Fixed discord linking issue 2021-12-21 08:14:51 +02:00
Asaf Gartner 321089ea8e Fixed forum editor file upload issue 2021-12-21 07:13:02 +02:00
Asaf Gartner 88776cbb72 Fixed user profile url double-escaping 2021-12-21 06:24:05 +02:00
Asaf Gartner 12eb172f98 Log out user after setting status to 'banned' 2021-12-21 06:07:55 +02:00
Asaf Gartner 83ef51374d Added admin script to upload project logos to S3 2021-12-21 06:04:20 +02:00
Ben Visness 6307589ee4 Merge remote-tracking branch 'origin/master' into beta 2021-12-14 20:54:23 -06:00
Asaf Gartner b5eb718615 Query automatically does ToSlice. Use QueryIterator if you need an
iterator.
2021-12-15 03:36:37 +02:00
Asaf Gartner c84b6842e2 Admin actions on user profile 2021-12-15 03:17:42 +02:00
Asaf Gartner 1c48aab863 Fixed admin queue 2021-12-13 18:58:26 +02:00
Ben Visness b3df1a306b I don't like the "complete" banner 2021-12-13 02:09:03 -06:00
Ben Visness 3b8b02a856 Watch for snippet updates on all messages
This captures stuff in jam-showcase and ryan's stuff in #projects
2021-12-13 01:40:54 -06:00
37 changed files with 1987 additions and 1266 deletions

View File

@ -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";

View File

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

View File

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

View File

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

View File

@ -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
`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,7 +78,9 @@ func FetchThreads(
JOIN handmade_postversion AS first_version ON first_version.id = first_post.current_id
JOIN handmade_postversion AS 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) {

View File

@ -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) {

View File

@ -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
*/

View File

@ -0,0 +1,47 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(UserAvatarAssetId{})
}
type UserAvatarAssetId struct{}
func (m UserAvatarAssetId) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 12, 26, 10, 16, 33, 0, time.UTC))
}
func (m UserAvatarAssetId) Name() string {
return "UserAvatarAssetId"
}
func (m UserAvatarAssetId) Description() string {
return "Add avatar_asset_id to users"
}
func (m UserAvatarAssetId) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE auth_user
ADD COLUMN avatar_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL;
`,
)
return err
}
func (m UserAvatarAssetId) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
ALTER TABLE auth_user
DROP COLUMN avatar_asset_id;
`,
)
return err
}

View File

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

View File

@ -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"`

View File

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

View File

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

View File

@ -116,7 +116,7 @@
});
// Do live Markdown previews
initLiveMarkdown({ inputEl: textField, previewEl: preview });
let doMarkdown = initLiveMarkdown({ inputEl: textField, previewEl: preview });
/*
/ Asset upload

View File

@ -102,5 +102,7 @@
doMarkdown();
inputEl.addEventListener('input', () => doMarkdown());
return doMarkdown;
}
</script>

View File

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

View File

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

View File

@ -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>
@ -198,4 +206,4 @@
}
</script>
</form>
{{ end }}
{{ end }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.")