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
}