mirror of
https://codeberg.org/VnPower/PixivFE
synced 2024-12-06 19:16:23 +01:00
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:
Vendored
+12
@@ -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;
|
||||
|
||||
Vendored
+11
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user