aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
Diffstat (limited to 'backend')
-rw-r--r--backend/account/icon.go85
-rw-r--r--backend/admin/handler.go26
-rw-r--r--backend/admin/templates/users.html3
-rw-r--r--backend/auth/auth.go13
-rw-r--r--backend/auth/fortee/fortee.go46
-rw-r--r--backend/db/query.sql.go16
-rw-r--r--backend/fortee/fortee.go62
-rw-r--r--backend/fortee/generated.go (renamed from backend/auth/fortee/generated.go)117
-rw-r--r--backend/gen/oapi-codegen.fortee.yaml2
-rw-r--r--backend/main.go9
-rw-r--r--backend/query.sql5
11 files changed, 335 insertions, 49 deletions
diff --git a/backend/account/icon.go b/backend/account/icon.go
new file mode 100644
index 0000000..40632c6
--- /dev/null
+++ b/backend/account/icon.go
@@ -0,0 +1,85 @@
+package account
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "time"
+
+ "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db"
+ "github.com/nsfisis/iosdc-japan-2024-albatross/backend/fortee"
+)
+
+func FetchIcon(
+ ctx context.Context,
+ q *db.Queries,
+ userID int,
+) error {
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+
+ // Fetch user.
+ user, err := q.GetUserByID(ctx, int32(userID))
+ if err != nil {
+ return fmt.Errorf("failed to fetch user icon (uid=%d): %w", userID, err)
+ }
+ // Fetch user icon URL.
+ avatarURL, err := fortee.GetUserAvatarURL(ctx, user.Username)
+ if err != nil {
+ return fmt.Errorf("failed to fetch user icon (uid=%d): %w", userID, err)
+ }
+ // Download user icon file.
+ filePath := fmt.Sprintf("/files/%s/icon%s", url.PathEscape(user.Username), path.Ext(avatarURL))
+ if err := downloadFile(ctx, fortee.Endpoint+avatarURL, "/data"+filePath); err != nil {
+ return fmt.Errorf("failed to fetch user icon (uid=%d): %w", userID, err)
+ }
+ // Save user icon path.
+ if err := q.UpdateUserIconPath(ctx, db.UpdateUserIconPathParams{
+ UserID: int32(userID),
+ IconPath: &filePath,
+ }); err != nil {
+ return fmt.Errorf("failed to fetch user icon (uid=%d): %w", userID, err)
+ }
+ return nil
+}
+
+func downloadFile(ctx context.Context, url string, filePath string) error {
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to download file (%s): %w", url, err)
+ }
+
+ client := &http.Client{}
+ res, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to download file (%s): %w", url, err)
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to download file (%s): status %d", url, res.StatusCode)
+ }
+
+ fileDir := filepath.Dir(filePath)
+ if err := os.MkdirAll(fileDir, 0755); err != nil {
+ return fmt.Errorf("failed to create directory (%s): %w", fileDir, err)
+ }
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ return fmt.Errorf("failed to open file (%s): %w", filePath, err)
+ }
+ defer file.Close()
+
+ _, err = io.Copy(file, res.Body)
+ if err != nil {
+ return fmt.Errorf("failed to save file (%s): %w", filePath, err)
+ }
+
+ return nil
+}
diff --git a/backend/admin/handler.go b/backend/admin/handler.go
index 41eacd4..1b60de5 100644
--- a/backend/admin/handler.go
+++ b/backend/admin/handler.go
@@ -1,7 +1,9 @@
package admin
import (
+ "context"
"errors"
+ "log"
"net/http"
"strconv"
"time"
@@ -10,6 +12,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
+ "github.com/nsfisis/iosdc-japan-2024-albatross/backend/account"
"github.com/nsfisis/iosdc-japan-2024-albatross/backend/auth"
"github.com/nsfisis/iosdc-japan-2024-albatross/backend/db"
)
@@ -58,6 +61,7 @@ func (h *Handler) RegisterHandlers(g *echo.Group) {
g.GET("/dashboard", h.getDashboard)
g.GET("/users", h.getUsers)
g.GET("/users/:userID", h.getUserEdit)
+ g.POST("/users/:userID/fetch-icon", h.postUserFetchIcon)
g.GET("/games", h.getGames)
g.GET("/games/:gameID", h.getGameEdit)
g.POST("/games/:gameID", h.postGameEdit)
@@ -116,6 +120,28 @@ func (h *Handler) getUserEdit(c echo.Context) error {
})
}
+func (h *Handler) postUserFetchIcon(c echo.Context) error {
+ userID, err := strconv.Atoi(c.Param("userID"))
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid user id")
+ }
+ row, err := h.q.GetUserByID(c.Request().Context(), int32(userID))
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return echo.NewHTTPError(http.StatusNotFound)
+ }
+ return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+ }
+ go func() {
+ err := account.FetchIcon(context.Background(), h.q, int(row.UserID))
+ if err != nil {
+ log.Printf("%v", err)
+ // The failure is intentionally ignored. Retry manually if needed.
+ }
+ }()
+ return c.Redirect(http.StatusSeeOther, "/iosdc-japan/2024/code-battle/admin/users")
+}
+
func (h *Handler) getGames(c echo.Context) error {
rows, err := h.q.ListGames(c.Request().Context())
if err != nil {
diff --git a/backend/admin/templates/users.html b/backend/admin/templates/users.html
index 3543457..3dc03c0 100644
--- a/backend/admin/templates/users.html
+++ b/backend/admin/templates/users.html
@@ -11,6 +11,9 @@
<a href="/iosdc-japan/2024/code-battle/admin/users/{{ .UserID }}">
{{ .DisplayName }} (id={{ .UserID }} username={{ .Username }}){{ if .IsAdmin }} <em>admin</em>{{ end }}
</a>
+ <form method="post" action="/iosdc-japan/2024/code-battle/admin/users/{{ .UserID }}/fetch-icon">
+ <button type="submit">Fetch Icon</button>
+ </form>
</li>
{{ end }}
</ul>
diff --git a/backend/auth/auth.go b/backend/auth/auth.go
index 0e55d8d..2266c50 100644
--- a/backend/auth/auth.go
+++ b/backend/auth/auth.go
@@ -3,13 +3,15 @@ package auth
import (
"context"
"errors"
+ "log"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
- "github.com/nsfisis/iosdc-japan-2024-albatross/backend/auth/fortee"
+ "github.com/nsfisis/iosdc-japan-2024-albatross/backend/account"
"github.com/nsfisis/iosdc-japan-2024-albatross/backend/db"
+ "github.com/nsfisis/iosdc-japan-2024-albatross/backend/fortee"
)
var (
@@ -98,6 +100,13 @@ func signup(
}); err != nil {
return 0, err
}
+ go func() {
+ err := account.FetchIcon(context.Background(), queries, int(userID))
+ if err != nil {
+ log.Printf("%v", err)
+ // The failure is intentionally ignored. Retry manually if needed.
+ }
+ }()
return int(userID), nil
}
@@ -119,7 +128,7 @@ func verifyForteeAccount(ctx context.Context, username string, password string)
ctx, cancel := context.WithTimeout(ctx, forteeAPITimeout)
defer cancel()
- canonicalizedUsername, err := fortee.LoginFortee(ctx, username, password)
+ canonicalizedUsername, err := fortee.Login(ctx, username, password)
if errors.Is(err, context.DeadlineExceeded) {
return "", ErrForteeLoginTimeout
}
diff --git a/backend/auth/fortee/fortee.go b/backend/auth/fortee/fortee.go
deleted file mode 100644
index 25ca9c5..0000000
--- a/backend/auth/fortee/fortee.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package fortee
-
-import (
- "context"
- "errors"
- "net/http"
-)
-
-const (
- apiEndpoint = "https://fortee.jp"
-)
-
-var (
- ErrLoginFailed = errors.New("fortee login failed")
-)
-
-func LoginFortee(ctx context.Context, username string, password string) (string, error) {
- client, err := NewClientWithResponses(apiEndpoint, WithRequestEditorFn(addAcceptHeader))
- if err != nil {
- return "", err
- }
- res, err := client.PostLoginWithFormdataBodyWithResponse(ctx, PostLoginFormdataRequestBody{
- Username: username,
- Password: password,
- })
- if err != nil {
- return "", err
- }
- if res.StatusCode() != http.StatusOK {
- return "", ErrLoginFailed
- }
- resOk := res.JSON200
- if !resOk.LoggedIn {
- return "", ErrLoginFailed
- }
- if resOk.User == nil {
- return "", ErrLoginFailed
- }
- return resOk.User.Username, nil
-}
-
-// fortee API denies requests without Accept header.
-func addAcceptHeader(_ context.Context, req *http.Request) error {
- req.Header.Set("Accept", "application/json")
- return nil
-}
diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go
index 02e660a..35b7edd 100644
--- a/backend/db/query.sql.go
+++ b/backend/db/query.sql.go
@@ -584,3 +584,19 @@ func (q *Queries) UpdateGameState(ctx context.Context, arg UpdateGameStateParams
_, err := q.db.Exec(ctx, updateGameState, arg.GameID, arg.State)
return err
}
+
+const updateUserIconPath = `-- name: UpdateUserIconPath :exec
+UPDATE users
+SET icon_path = $2
+WHERE user_id = $1
+`
+
+type UpdateUserIconPathParams struct {
+ UserID int32
+ IconPath *string
+}
+
+func (q *Queries) UpdateUserIconPath(ctx context.Context, arg UpdateUserIconPathParams) error {
+ _, err := q.db.Exec(ctx, updateUserIconPath, arg.UserID, arg.IconPath)
+ return err
+}
diff --git a/backend/fortee/fortee.go b/backend/fortee/fortee.go
new file mode 100644
index 0000000..cfe4eff
--- /dev/null
+++ b/backend/fortee/fortee.go
@@ -0,0 +1,62 @@
+package fortee
+
+import (
+ "context"
+ "errors"
+ "net/http"
+)
+
+const (
+ Endpoint = "https://fortee.jp"
+)
+
+var (
+ ErrLoginFailed = errors.New("fortee login failed")
+ ErrUserNotFound = errors.New("fortee user not found")
+)
+
+func Login(ctx context.Context, username string, password string) (string, error) {
+ client, err := NewClientWithResponses(Endpoint, WithRequestEditorFn(addAcceptHeader))
+ if err != nil {
+ return "", err
+ }
+ res, err := client.PostLoginWithFormdataBodyWithResponse(ctx, PostLoginFormdataRequestBody{
+ Username: username,
+ Password: password,
+ })
+ if err != nil {
+ return "", err
+ }
+ if res.StatusCode() != http.StatusOK {
+ return "", ErrLoginFailed
+ }
+ resOk := res.JSON200
+ if !resOk.LoggedIn {
+ return "", ErrLoginFailed
+ }
+ if resOk.User == nil {
+ return "", ErrLoginFailed
+ }
+ return resOk.User.Username, nil
+}
+
+func GetUserAvatarURL(ctx context.Context, username string) (string, error) {
+ client, err := NewClientWithResponses(Endpoint, WithRequestEditorFn(addAcceptHeader))
+ if err != nil {
+ return "", err
+ }
+ res, err := client.GetUserWithResponse(ctx, username)
+ if err != nil {
+ return "", err
+ }
+ if res.StatusCode() != http.StatusOK {
+ return "", ErrUserNotFound
+ }
+ return res.JSON200.AvatarURL, nil
+}
+
+// fortee API denies requests without Accept header.
+func addAcceptHeader(_ context.Context, req *http.Request) error {
+ req.Header.Set("Accept", "application/json")
+ return nil
+}
diff --git a/backend/auth/fortee/generated.go b/backend/fortee/generated.go
index 53529f9..5291449 100644
--- a/backend/auth/fortee/generated.go
+++ b/backend/fortee/generated.go
@@ -101,6 +101,9 @@ type ClientInterface interface {
PostLoginWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
PostLoginWithFormdataBody(ctx context.Context, body PostLoginFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ // GetUser request
+ GetUser(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error)
}
func (c *Client) PostLoginWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
@@ -127,6 +130,18 @@ func (c *Client) PostLoginWithFormdataBody(ctx context.Context, body PostLoginFo
return c.Client.Do(req)
}
+func (c *Client) GetUser(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewGetUserRequest(c.Server, username)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
// NewPostLoginRequestWithFormdataBody calls the generic PostLogin builder with application/x-www-form-urlencoded body
func NewPostLoginRequestWithFormdataBody(server string, body PostLoginFormdataRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -167,6 +182,40 @@ func NewPostLoginRequestWithBody(server string, contentType string, body io.Read
return req, nil
}
+// NewGetUserRequest generates requests for GetUser
+func NewGetUserRequest(server string, username string) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithLocation("simple", false, "username", runtime.ParamLocationPath, username)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/api/user/view/%s", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error {
for _, r := range c.RequestEditors {
if err := r(ctx, req); err != nil {
@@ -214,6 +263,9 @@ type ClientWithResponsesInterface interface {
PostLoginWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostLoginResponse, error)
PostLoginWithFormdataBodyWithResponse(ctx context.Context, body PostLoginFormdataRequestBody, reqEditors ...RequestEditorFn) (*PostLoginResponse, error)
+
+ // GetUserWithResponse request
+ GetUserWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*GetUserResponse, error)
}
type PostLoginResponse struct {
@@ -243,6 +295,32 @@ func (r PostLoginResponse) StatusCode() int {
return 0
}
+type GetUserResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON200 *struct {
+ AvatarURL string `json:"avatar_url"`
+ Username string `json:"username"`
+ UUID string `json:"uuid"`
+ }
+}
+
+// Status returns HTTPResponse.Status
+func (r GetUserResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r GetUserResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
// PostLoginWithBodyWithResponse request with arbitrary body returning *PostLoginResponse
func (c *ClientWithResponses) PostLoginWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostLoginResponse, error) {
rsp, err := c.PostLoginWithBody(ctx, contentType, body, reqEditors...)
@@ -260,6 +338,15 @@ func (c *ClientWithResponses) PostLoginWithFormdataBodyWithResponse(ctx context.
return ParsePostLoginResponse(rsp)
}
+// GetUserWithResponse request returning *GetUserResponse
+func (c *ClientWithResponses) GetUserWithResponse(ctx context.Context, username string, reqEditors ...RequestEditorFn) (*GetUserResponse, error) {
+ rsp, err := c.GetUser(ctx, username, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseGetUserResponse(rsp)
+}
+
// ParsePostLoginResponse parses an HTTP response from a PostLoginWithResponse call
func ParsePostLoginResponse(rsp *http.Response) (*PostLoginResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
@@ -290,3 +377,33 @@ func ParsePostLoginResponse(rsp *http.Response) (*PostLoginResponse, error) {
return response, nil
}
+
+// ParseGetUserResponse parses an HTTP response from a GetUserWithResponse call
+func ParseGetUserResponse(rsp *http.Response) (*GetUserResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &GetUserResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
+ var dest struct {
+ AvatarURL string `json:"avatar_url"`
+ Username string `json:"username"`
+ UUID string `json:"uuid"`
+ }
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON200 = &dest
+
+ }
+
+ return response, nil
+}
diff --git a/backend/gen/oapi-codegen.fortee.yaml b/backend/gen/oapi-codegen.fortee.yaml
index 3bd5819..1799757 100644
--- a/backend/gen/oapi-codegen.fortee.yaml
+++ b/backend/gen/oapi-codegen.fortee.yaml
@@ -2,7 +2,7 @@ package: fortee
generate:
models: true
client: true
-output: ../auth/fortee/generated.go
+output: ../fortee/generated.go
output-options:
skip-prune: true
nullable-type: true
diff --git a/backend/main.go b/backend/main.go
index 3296957..890e666 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -96,6 +96,15 @@ func main() {
return c.Redirect(http.StatusPermanentRedirect, "http://localhost:5173/iosdc-japan/2024/code-battle/logout")
})
+ // For local dev: This is never used in production because the reverse
+ // proxy directly handles /files.
+ filesGroup := e.Group("/iosdc-japan/2024/code-battle/files")
+ filesGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
+ Root: "/",
+ Filesystem: http.Dir("/data/files"),
+ IgnoreBase: true,
+ }))
+
go gameHubs.Run()
go func() {
diff --git a/backend/query.sql b/backend/query.sql
index 6b0ecdd..dc5f384 100644
--- a/backend/query.sql
+++ b/backend/query.sql
@@ -13,6 +13,11 @@ INSERT INTO users (username, display_name, is_admin)
VALUES ($1, $1, false)
RETURNING user_id;
+-- name: UpdateUserIconPath :exec
+UPDATE users
+SET icon_path = $2
+WHERE user_id = $1;
+
-- name: ListUsers :many
SELECT * FROM users
ORDER BY users.user_id;