Merge pull request 'user-follow' (#129) from user-follow into v2

Reviewed-on: https://codeberg.org/VnPower/PixivFE/pulls/129
This commit is contained in:
perennial
2024-10-13 12:49:32 +00:00
13 changed files with 349 additions and 93 deletions
+12
View File
@@ -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;
+11
View File
@@ -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
@@ -0,0 +1,41 @@
{* used only on user page *}
{{- if .User.IsFollowed == true && LoggedIn }}
<button type="button" class="custom-btn-secondary text-nowrap me-2" hx-post="/self/unfollowUser/{{ .User.ID }}" hx-target="body" hx-indicator="#follow-spinner" hx-push-url="true">
<div class="d-flex">
<div><i class="bi bi-person-fill me-2"></i>Following</div>
<div class="follow-spinner ms-2" id="follow-spinner">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</button>
{{- else }}
<!-- NOTE: bootstrap doesnt have custom hover states so the dropdown stays as a separate button -->
<div class="btn-group">
<button type="button" class="custom-btn-secondary text-nowrap me-2">
<div class="d-flex">
<div><i class="bi bi-person-plus me-2"></i>Follow</div>
<div class="follow-spinner ms-2" id="follow-spinner">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</button>
<button type="button" class="custom-btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-md">
<li>
<a class="dropdown-item" href="#"
hx-post="/self/followUser/{{ .User.ID }}"
hx-target="body"
hx-indicator="#follow-spinner"
hx-push-url="true"
hx-vals='{"private": true}'>
Follow privately
</a>
</li>
</ul>
</div>
{{- end }}
+28 -11
View File
@@ -8,15 +8,21 @@
<div class="custom-card bg-charcoal-surface1">
<!-- Profile background image -->
{{- if .User.BackgroundImage }}
<img src="{{ .User.BackgroundImage }}" alt="{{ .User.Name }} profile background image"
class="img-fluid w-100 rounded-bottom rounded-5 object-fit-cover mb-3"
style="max-height: 200px;" />
<div class="d-flex position-relative">
<img src="{{ .User.BackgroundImage }}" alt="{{ .User.Name }} profile background image"
class="img-fluid w-100 rounded-bottom rounded-5 object-fit-cover"
style="max-height: 200px;" />
<img src="{{.User.Avatar}}" alt="User avatar"
class="d-inline-block d-md-none border border-5 border-neutral-800 rounded-circle img-fluid object-fit-cover position-absolute z-1"
style="width: 150px; height: 150px; bottom: 0; left: 50%; transform: translate(-50%, 50%);">
</div>
{{- end }}
<div class="custom-card-body p-4">
<div class="row g-4">
<div class="custom-card-body p-4 mt-5 mt-md-0">
<div class="row g-4 mt-3 mt-md-0">
<!-- User avatar and external link to pixiv.net -->
<!-- TODO: make these elements appear to the left alongside the user name and social media links -->
<div class="col-12 col-md-3">
<div class="col-12 col-md-3 d-none d-md-inline-block mt-0">
<div class="d-flex flex-column align-items-center">
<img src="{{.User.Avatar}}" alt="User avatar" class="rounded-circle img-fluid object-fit-cover mb-3" style="width: 150px; height: 150px">
@@ -25,11 +31,18 @@
</a>
</div>
</div>
<!-- User name and social media links -->
<div class="col-12 col-md-9">
<h1>{{ .User.Name }}</h1>
<p class="text-muted">{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv</p>
<div class="mb-3">
<div class="col-12 col-md-9 mt-0">
<!-- User name and follow and unfollow buttons -->
<div class="d-flex flex-column flex-md-row justify-content-start flex-wrap justify-content-xl-between align-items-center mb-3 mb-md-2">
<h1 class="flex-grow-1 mb-2 mb-md-0">{{ .User.Name }}</h1>
<p class="d-inline-block d-md-none text-body-secondary text-center text-md-start">{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv</p>
{{- include "fragments/followButtons" . }}
</div>
<!-- Social media links -->
<p class="d-none d-md-inline-block text-body-secondary text-center text-md-start">{{ .User.Following }} Following | {{ .User.MyPixiv }} MyPixiv</p>
<div class="d-flex justify-content-center justify-content-md-start mb-3">
{{- if .User.Webpage }}
<a href="{{ .User.Webpage }}" class="btn btn-custom-color text-body rounded-pill me-2">
<i class="bi bi-globe"></i>
@@ -49,6 +62,10 @@
</div>
<!-- User bio and frequently used tags -->
<p class="mb-4">{{ raw: parsePixivRedirect(.User.Comment) }}</p>
{* NOTE: this button definitely needs to be in a different place *}
<a href="https://pixiv.net/u/{{ .User.ID }}" class="d-inline-block d-md-none custom-btn-secondary btn-sm bg-charcoal-surface2 mb-3">
<i class="bi bi-box-arrow-up-right me-2"></i>View on pixiv.net
</a>
<div class="custom-card bg-charcoal-surface2 p-4">
<h3><i class="bi bi-tags-fill"></i> Frequently used tags</h3>
<div class="d-flex flex-wrap">
+114 -62
View File
@@ -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
+2
View File
@@ -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
}
+3 -2
View File
@@ -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",
+3
View File
@@ -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 <a href=\"/settings#login\">log in</a> 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",
+4 -1
View File
@@ -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")
+8 -3
View File
@@ -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
}
+4 -4
View File
@@ -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),
)
+2
View File
@@ -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")
+117 -10
View File
@@ -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
}