diff --git a/assets/css/bootstrap-style.css b/assets/css/bootstrap-style.css index 017b772..81dcd26 100644 --- a/assets/css/bootstrap-style.css +++ b/assets/css/bootstrap-style.css @@ -14139,6 +14139,18 @@ h6, .h6 { display: inline; } +.follow-spinner { + display: none; +} + +.htmx-request .follow-spinner { + display: inline; +} + +.htmx-request.follow-spinner { + display: inline; +} + @media (scripting: none) { .js-required { display: none !important; diff --git a/assets/css/bootstrap-style.scss b/assets/css/bootstrap-style.scss index ba4332b..11002f3 100644 --- a/assets/css/bootstrap-style.scss +++ b/assets/css/bootstrap-style.scss @@ -317,6 +317,17 @@ h6 { display: inline; } +.follow-spinner { + display: none; +} +.htmx-request .follow-spinner { + display: inline; +} +.htmx-request.follow-spinner { + display: inline; +} + + /// Hide elements that require JS when scripting is disabled /// NOTE: This only seems to work when JS is actually disabled browser-wide, not via an extension like uBO /// See the callout on https://developer.mozilla.org/en-US/docs/Web/CSS/@media/scripting diff --git a/assets/views/fragments/followButtons.jet.html b/assets/views/fragments/followButtons.jet.html new file mode 100644 index 0000000..8a932c5 --- /dev/null +++ b/assets/views/fragments/followButtons.jet.html @@ -0,0 +1,41 @@ +{* used only on user page *} +{{- if .User.IsFollowed == true && LoggedIn }} + + {{- else }} + +
+ + + +
+{{- end }} diff --git a/assets/views/user.jet.html b/assets/views/user.jet.html index d19863a..002910d 100644 --- a/assets/views/user.jet.html +++ b/assets/views/user.jet.html @@ -8,15 +8,21 @@
{{- if .User.BackgroundImage }} - {{ .User.Name }} profile background image +
+ {{ .User.Name }} profile background image + User avatar +
+ {{- end }} -
-
+
+
-
+
User avatar @@ -25,11 +31,18 @@
- -
-

{{ .User.Name }}

-

{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv

-
+ +
+ +
+

{{ .User.Name }}

+

{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv

+ {{- include "fragments/followButtons" . }} +
+ + +

{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv

+
{{- if .User.Webpage }} @@ -49,6 +62,10 @@

{{ raw: parsePixivRedirect(.User.Comment) }}

+ {* NOTE: this button definitely needs to be in a different place *} +
+ View on pixiv.net +

Frequently used tags

diff --git a/core/requests.go b/core/requests.go index da1e524..0899137 100644 --- a/core/requests.go +++ b/core/requests.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "errors" + "fmt" "io" + "mime/multipart" "net/http" "time" @@ -35,6 +37,45 @@ func init() { retryClient.Logger = nil // Disables the default logger in go-retryablehttp } +// Helper function to handle common request logic +func makeRequest(ctx context.Context, reqFunc func(context.Context, string) (*retryablehttp.Request, error), token *token_manager.Token, url string) (*SimpleHTTPResponse, error) { + req, err := reqFunc(ctx, token.Value) + if err != nil { + return nil, err + } + + start := time.Now() + resp, err := retryClient.Do(req) + end := time.Now() + + if err != nil { + return nil, i18n.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + audit.LogAPIRoundTrip(audit.APIRequestSpan{ + StartTime: start, + EndTime: end, + RequestId: request_context.GetFromContext(ctx).RequestId, + Response: resp, + Error: err, + Method: req.Method, + Url: url, + Token: token.Value, + Body: string(body), + }) + + return &SimpleHTTPResponse{ + StatusCode: resp.StatusCode, + Body: string(body), + }, nil +} + // retryRequest performs a request with automatic retries and token management func retryRequest( ctx context.Context, @@ -46,15 +87,22 @@ func retryRequest( var lastErr error tokenManager := config.GlobalConfig.TokenManager + if isPost { + // For POST requests, perform the request once without retrying + token := &token_manager.Token{Value: userToken} + return makeRequest(ctx, reqFunc, token, url) + } + + // For GET requests, use the retry logic for i := 0; i < config.GlobalConfig.APIMaxRetries; i++ { var token *token_manager.Token if userToken != "" { token = &token_manager.Token{Value: userToken} - } else if !isPost { + } else { token = tokenManager.GetToken() } - if token == nil && !isPost { + if token == nil { tokenManager.ResetAllTokens() return nil, i18n.Errorf( `All tokens (%d) are timed out, resetting all tokens to their initial good state. @@ -65,62 +113,20 @@ Please refer the following documentation for additional information: len(config.GlobalConfig.Token)) } - tokenValue := "" - if token != nil { - tokenValue = token.Value - } - - req, err := reqFunc(ctx, tokenValue) + resp, err := makeRequest(ctx, reqFunc, token, url) if err != nil { - return nil, err - } - - start := time.Now() - resp, err := retryClient.Do(req) - end := time.Now() - // Unwrap the body here so that we could log stuff correctly - if err != nil { - return nil, i18n.Errorf("failed to make request: %w", err) - } - // Validate response isn't nil - if resp == nil { - lastErr = i18n.Errorf("Received nil response after request: %w", err) + lastErr = err + tokenManager.MarkTokenStatus(token, token_manager.TimedOut) continue } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil || resp.Body == nil { - return nil, err - } - - audit.LogAPIRoundTrip(audit.APIRequestSpan{ - StartTime: start, - EndTime: end, - RequestId: request_context.GetFromContext(ctx).RequestId, - Response: resp, - Error: err, - Method: req.Method, - Url: url, - Token: tokenValue, - Body: string(body), - }) if resp.StatusCode == http.StatusOK { - if userToken == "" && !isPost { - tokenManager.MarkTokenStatus(token, token_manager.Good) - } - return &SimpleHTTPResponse{ - StatusCode: resp.StatusCode, - Body: string(body), - }, nil + tokenManager.MarkTokenStatus(token, token_manager.Good) + return resp, nil } lastErr = i18n.Errorf("HTTP status code: %d", resp.StatusCode) - - if userToken == "" && !isPost { - tokenManager.MarkTokenStatus(token, token_manager.TimedOut) - } + tokenManager.MarkTokenStatus(token, token_manager.TimedOut) select { case <-ctx.Done(): @@ -130,7 +136,7 @@ Please refer the following documentation for additional information: } } - return nil, i18n.Errorf("Max retries reached. Last error: %v", lastErr) + return nil, i18n.Errorf("Max retries reached for GET request. Last error: %v", lastErr) } // API_GET performs a GET request to the Pixiv API with automatic retries @@ -175,17 +181,59 @@ func API_GET_UnwrapJson(ctx context.Context, url string, userToken string) (stri return gjson.Get(resp.Body, "body").String(), nil } -// API_POST performs a POST request to the Pixiv API with automatic retries -func API_POST(ctx context.Context, url, payload, userToken, csrf string, isJSON bool) error { +// createMultipartFormData is a helper function to create multipart form data +func createMultipartFormData(fields map[string]string) (*bytes.Buffer, string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + for key, value := range fields { + err := writer.WriteField(key, value) + if err != nil { + return nil, "", err + } + } + err := writer.Close() + if err != nil { + return nil, "", err + } + return body, writer.FormDataContentType(), nil +} + +// API_POST performs a POST request to the Pixiv API +func API_POST( + ctx context.Context, + url string, + payload interface{}, + userToken, csrf string, + contentType string, +) (*SimpleHTTPResponse, error) { if userToken == "" { - return i18n.Error("userToken is required for POST requests") + return nil, i18n.Error("userToken is required for POST requests") } - _, err := retryRequest(ctx, func(ctx context.Context, token string) (*retryablehttp.Request, error) { - req, err := retryablehttp.NewRequest("POST", url, bytes.NewBuffer([]byte(payload))) + resp, err := retryRequest(ctx, func(ctx context.Context, token string) (*retryablehttp.Request, error) { + var req *retryablehttp.Request + var err error + + switch v := payload.(type) { + case string: + req, err = retryablehttp.NewRequest("POST", url, bytes.NewBuffer([]byte(v))) + case map[string]string: + body, formContentType, err := createMultipartFormData(v) + if err != nil { + return nil, err + } + req, err = retryablehttp.NewRequest("POST", url, body) + if err == nil { + contentType = formContentType + } + default: + return nil, i18n.Error("Unsupported payload type") + } + if err != nil { return nil, err } + req = req.WithContext(ctx) req.Header.Add("User-Agent", config.GetRandomUserAgent()) req.Header.Add("Accept", "application/json") @@ -194,15 +242,19 @@ func API_POST(ctx context.Context, url, payload, userToken, csrf string, isJSON Name: "PHPSESSID", Value: token, }) - if isJSON { - req.Header.Add("Content-Type", "application/json; charset=utf-8") - } else { - req.Header.Add("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") - } + req.Header.Add("Content-Type", contentType) + return req, nil }, userToken, true, url) + if err != nil { + return nil, err + } - return err + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status code: %d, body: %s", resp.StatusCode, resp.Body) + } + + return resp, nil } // ProxyRequest forwards an HTTP request to the target server and copies the response back diff --git a/core/user.go b/core/user.go index 9c3a45c..7614e65 100644 --- a/core/user.go +++ b/core/user.go @@ -70,6 +70,8 @@ type User struct { NovelSeries []NovelSeries MangaSeries []MangaSeries + IsFollowed bool `json:"isFollowed"` // Denotes whether the logged in user currently following the given user + // The following fields are internal to PixivFE, used to display the number of works for a given category CountInfo CountInfo } diff --git a/i18n/locale/en/code.json b/i18n/locale/en/code.json index 0cca096..b1d7beb 100644 --- a/i18n/locale/en/code.json +++ b/i18n/locale/en/code.json @@ -19,15 +19,15 @@ "core/ranking.go:Xy1LpykXW4E": "Retrying in %v", "core/ranking.go:_YcW1wsLOAg": "JSON unmarshalling successful on attempt %d", "core/ranking.go:sk-7WI2RS-Q": "Attempt %d of %d", - "core/requests.go:0Q07bJLKfvU": "Received nil response after request: %w", "core/requests.go:0hOvqlK-HwY": "HTTP status code: %d", "core/requests.go:7heLIvyBB2M": "Invalid JSON: %v", + "core/requests.go:7pvvVOIzWew": "Max retries reached for GET request. Last error: %v", "core/requests.go:CF6mVT22R2w": "Incompatible request body", "core/requests.go:Odhyy2SoIGU": "failed to make request: %w", "core/requests.go:XGjPq4l3s2o": "userToken is required for POST requests", "core/requests.go:XdMN7Q7DY3k": "failed to proxy request: %w", + "core/requests.go:f78uqyomFJk": "Unsupported payload type", "core/requests.go:lLy9SHFUtQQ": "failed to copy response body: %w", - "core/requests.go:o7gE7trSD7c": "Max retries reached. Last error: %v", "core/requests.go:y3v5nQwdU0s": "All tokens (%d) are timed out, resetting all tokens to their initial good state.\nConsider providing additional tokens in PIXIVFE_TOKEN or reviewing API request level backoff configuration.\nPlease refer the following documentation for additional information:\n- https://pixivfe-docs.pages.dev/hosting/obtaining-pixivfe-token/\n- https://pixivfe-docs.pages.dev/hosting/environment-variables/#exponential-backoff-configuration", "core/user.go:1itpJBE-3VI": "Invalid page number.", "core/user.go:T3RwaHcnsYQ": "Invalid work category: %#v. Only \"%s\", \"%s\", \"%s\", \"%s\", \"%s\" and \"%s\" are available", @@ -38,6 +38,7 @@ "server/middleware/router.go:Z9UG-DYmQCk": "Route not found", "server/routes/actions.go:2RxigvKdaDk": "Method not allowed", "server/routes/actions.go:PG4HiUvZvVA": "No ID provided.", + "server/routes/actions.go:ZamBnL56VXw": "No user ID provided.", "server/routes/artwork.go:X4U1Et_mKik": "Invalid ID: %s", "server/routes/artworkMulti.go:X4U1Et_mKik": "Invalid ID: %s", "server/routes/mangaseries.go:H6vNDk8s4ig": "Invalid Series ID: %s", diff --git a/i18n/locale/en/template.json b/i18n/locale/en/template.json index 446ab40..5662025 100644 --- a/i18n/locale/en/template.json +++ b/i18n/locale/en/template.json @@ -329,10 +329,13 @@ "assets/views/tag.jet.html:yHft19aQloM": "Minimum image width", "assets/views/unauthorized.jet.html:Q84lx6mjuV0": "Unauthorized", "assets/views/unauthorized.jet.html:SNRVjaimXDQ": "You need to log in to use this feature.", + "assets/views/user.jet.html:3MldbPX4xm8": "Loading...", "assets/views/user.jet.html:3_oq3Nw2b2U": "View on pixiv.net", "assets/views/user.jet.html:5xhgcv5yNyM": "Frequently used tags", + "assets/views/user.jet.html:7i2xxiolkqI": "Follow", "assets/views/user.jet.html:8ga3WlgRnzQ": "Illustrations and manga", "assets/views/user.jet.html:9EbrMpLP6_U": "Bookmarks", + "assets/views/user.jet.html:KSE10VbnTzI": "Following", "assets/views/user.jet.html:P6fO_UTqr7E": "Manga", "assets/views/user.jet.html:b84CRs16kKI": "View all", "assets/views/user.jet.html:clIffJhX-50": "{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv", diff --git a/main.go b/main.go index 62859fa..e11f748 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,10 @@ func main() { log.Fatalln(err) } - audit.Init(config.GlobalConfig.InDevelopment) + if err := audit.Init(config.GlobalConfig.InDevelopment); err != nil { + log.Fatalf("Failed to initialize audit logger: %v", err) + } + log.Println("Audit logger initialized") i18n.Init() template.Init(config.GlobalConfig.InDevelopment, "assets/views") diff --git a/server/audit/audit_init.go b/server/audit/audit_init.go index 38049ef..2e94072 100644 --- a/server/audit/audit_init.go +++ b/server/audit/audit_init.go @@ -12,7 +12,7 @@ import ( var ( optionSaveResponse bool MaxRecordedCount = 0 - logger *zap.Logger + Logger *zap.Logger ) // Init initializes the audit package and sets up response saving if enabled. @@ -70,7 +70,7 @@ func Init(saveResponse bool) error { // Build and assign the logger var err error - logger, err = zapConfig.Build() + Logger, err = zapConfig.Build() if err != nil { return i18n.Errorf("failed to initialize zap logger: %w", err) } @@ -82,7 +82,7 @@ func Init(saveResponse bool) error { // Handle saving responses MaxRecordedCount = 128 if err := os.MkdirAll(savePath, 0o700); err != nil { - logger.Error("Failed to create response save directory", + Logger.Error("Failed to create response save directory", zap.Error(err), zap.String("path", savePath), ) @@ -91,3 +91,8 @@ func Init(saveResponse bool) error { return nil } + +// GetLogger returns the initialized zap logger +func GetLogger() *zap.Logger { + return Logger +} diff --git a/server/audit/audit_log.go b/server/audit/audit_log.go index 4a7b46c..b0912d5 100644 --- a/server/audit/audit_log.go +++ b/server/audit/audit_log.go @@ -20,7 +20,7 @@ var RecordedRequestSpans = []Span{} func LogAndRecord(requestSpan Span) { duration := float64(Duration(requestSpan)) / float64(time.Second) - logger.Info("Request", + Logger.Info("Request", // Zap already prefixes the log with a timestamp // zap.String("timestamp", span.GetStartTime().Format(time.RFC3339)), zap.String("component", requestSpan.Component()), @@ -44,7 +44,7 @@ func LogAndRecord(requestSpan Span) { // It also logs any internal server errors that occurred during the request. func LogServerRoundTrip(requestSpan ServerRequestSpan) { if requestSpan.Error != nil { - logger.Error("Internal Server Error", + Logger.Error("Internal Server Error", zap.Error(requestSpan.Error), zap.String("requestId", requestSpan.RequestId), ) @@ -62,7 +62,7 @@ func LogAPIRoundTrip(requestSpan APIRequestSpan) { var err error requestSpan.ResponseFilename, err = writeResponseBodyToFile(requestSpan.Body) if err != nil { - logger.Error("Failed to save response to file", + Logger.Error("Failed to save response to file", zap.Error(err), zap.String("requestId", requestSpan.RequestId), ) @@ -70,7 +70,7 @@ func LogAPIRoundTrip(requestSpan APIRequestSpan) { } // Log a warning for non-2xx status codes if !(300 > requestSpan.Response.StatusCode && requestSpan.Response.StatusCode >= 200) { - logger.Warn("Non-2xx response from pixiv", + Logger.Warn("Non-2xx response from pixiv", zap.Int("status", requestSpan.Response.StatusCode), zap.String("requestId", requestSpan.RequestId), ) diff --git a/server/middleware/router.go b/server/middleware/router.go index 5077c5e..9109410 100644 --- a/server/middleware/router.go +++ b/server/middleware/router.go @@ -116,6 +116,8 @@ func DefineRoutes() *mux.Router { router.HandleFunc("/self/addBookmark/{id}", CatchError(routes.AddBookmarkRoute)).Methods("POST") router.HandleFunc("/self/deleteBookmark/{id}", CatchError(routes.DeleteBookmarkRoute)).Methods("POST") router.HandleFunc("/self/like/{id}", CatchError(routes.LikeRoute)).Methods("POST") + router.HandleFunc("/self/followUser/{id}", CatchError(routes.FollowUserRoute)).Methods("POST") + router.HandleFunc("/self/unfollowUser/{id}", CatchError(routes.UnfollowUserRoute)).Methods("POST") // oEmbed endpoint for embedding Pixiv content router.HandleFunc("/oembed", CatchError(routes.Oembed)).Methods("GET") diff --git a/server/routes/actions.go b/server/routes/actions.go index f1668b2..14efa95 100644 --- a/server/routes/actions.go +++ b/server/routes/actions.go @@ -1,7 +1,9 @@ package routes import ( + "bytes" "fmt" + "mime/multipart" "net/http" "codeberg.org/vnpower/pixivfe/v2/core" @@ -31,12 +33,15 @@ func AddBookmarkRoute(w http.ResponseWriter, r *http.Request) error { URL := "https://www.pixiv.net/ajax/illusts/bookmarks/add" payload := fmt.Sprintf(`{ -"illust_id": "%s", -"restrict": 0, -"comment": "", -"tags": [] -}`, id) - if err := core.API_POST(r.Context(), URL, payload, token, csrf, true); err != nil { + "illust_id": "%s", + "restrict": 0, + "comment": "", + "tags": [] + }`, id) + + contentType := "application/json; charset=utf-8" + _, err := core.API_POST(r.Context(), URL, payload, token, csrf, contentType) + if err != nil { return err } @@ -61,10 +66,12 @@ func DeleteBookmarkRoute(w http.ResponseWriter, r *http.Request) error { return i18n.Error("No ID provided.") } - // You can't unlike URL := "https://www.pixiv.net/ajax/illusts/bookmarks/delete" - payload := fmt.Sprintf(`bookmark_id=%s`, id) - if err := core.API_POST(r.Context(), URL, payload, token, csrf, false); err != nil { + payload := fmt.Sprintf("bookmark_id=%s", id) + + contentType := "application/x-www-form-urlencoded; charset=utf-8" + _, err := core.API_POST(r.Context(), URL, payload, token, csrf, contentType) + if err != nil { return err } @@ -91,7 +98,107 @@ func LikeRoute(w http.ResponseWriter, r *http.Request) error { URL := "https://www.pixiv.net/ajax/illusts/like" payload := fmt.Sprintf(`{"illust_id": "%s"}`, id) - if err := core.API_POST(r.Context(), URL, payload, token, csrf, true); err != nil { + + contentType := "application/json; charset=utf-8" + _, err := core.API_POST(r.Context(), URL, payload, token, csrf, contentType) + if err != nil { + return err + } + + utils.RedirectToWhenceYouCame(w, r) + return nil +} + +/* +NOTE: we're using the mobile API for FollowUserRoute and UnfollowUserRoute since it's an actual AJAX API + instead of some weird php thing for the usual desktop routes (/bookmark_add.php and /rpc_group_setting.php) + + the desktop routes return HTML for the pixiv SPA when they feel like it and don't return helpful responses + when you send a request that doesn't perfectly meet their specifications, making troubleshooting a nightmare + + for comparison, the mobile API worked first try without any issues + + interestingly enough, replicating the requests for the desktop routes via cURL worked fine but a Go implementation + just refused to work +*/ + +func FollowUserRoute(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return i18n.Error("Method not allowed") + } + + token := session.GetUserToken(r) + csrf := session.GetCookie(r, session.Cookie_CSRF) + + if token == "" || csrf == "" { + return PromptUserToLoginPage(w, r) + } + + id := GetPathVar(r, "id") + if id == "" { + return i18n.Error("No user ID provided.") + } + + isPrivate := r.FormValue("private") == "true" + restrict := "0" + if isPrivate { + restrict = "1" + } + + URL := "https://www.pixiv.net/touch/ajax_api/ajax_api.php" + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("mode", "add_bookmark_user") + writer.WriteField("restrict", restrict) + writer.WriteField("user_id", id) + writer.Close() + + fields := map[string]string{ + "mode": "add_bookmark_user", + "restrict": restrict, + "user_id": id, + } + _, err := core.API_POST(r.Context(), URL, fields, token, csrf, "") + if err != nil { + return err + } + + utils.RedirectToWhenceYouCame(w, r) + return nil +} + +func UnfollowUserRoute(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + return i18n.Error("Method not allowed") + } + + token := session.GetUserToken(r) + csrf := session.GetCookie(r, session.Cookie_CSRF) + + if token == "" || csrf == "" { + return PromptUserToLoginPage(w, r) + } + + id := GetPathVar(r, "id") + if id == "" { + return i18n.Error("No user ID provided.") + } + + URL := "https://www.pixiv.net/touch/ajax_api/ajax_api.php" + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + writer.WriteField("mode", "delete_bookmark_user") + writer.WriteField("user_id", id) + writer.Close() + + fields := map[string]string{ + "mode": "delete_bookmark_user", + "user_id": id, + } + _, err := core.API_POST(r.Context(), URL, fields, token, csrf, "") + if err != nil { return err }