commit e99b56e4346ac4ae5c1e0747e51ff3a0259440d3 Author: Dominik Polakovics Date: Thu Mar 20 22:19:48 2025 +0100 Initialize Tinder API Wrapper with server configuration and Docker setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c92725 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Tinder API Wrapper + +A Go wrapper for the Tinder API that provides easy access to basic Tinder functionality such as liking/passing users and getting recommendations. + +## Features + +- Simple, idiomatic Go API +- Support for Tinder's core endpoints: + - Like a user + - Pass on a user + - Get user recommendations +- Configurable API endpoint + +## Installation + +```bash +go get tinder-api-wrapper +``` + +## Usage + +```go +package main + +import ( + "fmt" + "log" + + tinder "tinder-api-wrapper" +) + +func main() { + // Create a new client with the desired API endpoint + client, err := tinder.NewClient("https://tinder.cloonar.com") + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + // Get recommendations + recs, err := client.GetRecommendations("en") + if err != nil { + log.Fatalf("Failed to get recommendations: %v", err) + } + + // Like a user + likeResp, err := client.LikeUser("user123") + if err != nil { + log.Fatalf("Failed to like user: %v", err) + } + fmt.Printf("Match: %v, Likes remaining: %d\n", likeResp.Match, likeResp.LikesRemaining) + + // Pass on a user + passResp, err := client.PassUser("user456") + if err != nil { + log.Fatalf("Failed to pass on user: %v", err) + } + fmt.Printf("Status: %s\n", passResp.Status) +} +``` + +See the `example` directory for more detailed usage examples. + +## API Methods + +### NewClient(endpoint string) (*Client, error) + +Creates a new Tinder API client with the specified API endpoint. + +### (c *Client) LikeUser(userID string) (*LikeResponse, error) + +Likes a user with the given ID. + +### (c *Client) PassUser(userID string) (*PassResponse, error) + +Passes on (dislikes) a user with the given ID. + +### (c *Client) GetRecommendations(locale string) (*RecsResponse, error) + +Retrieves a list of recommended user profiles. The `locale` parameter is optional and can be an empty string. + +## Types + +The wrapper includes the following main types for API responses: + +- `LikeResponse`: Response from liking a user +- `PassResponse`: Response from passing on a user +- `RecsResponse`: Recommendations response containing user profiles +- `UserRecommendation`: Detailed user profile information +- `Photo`: User photo information +- `InstagramPhoto`: Instagram photo information + +## License + +This project is available under the MIT License. \ No newline at end of file diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..9a7744b --- /dev/null +++ b/auth.go @@ -0,0 +1,127 @@ +package tinder + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" +) + +// AuthRequest represents the request body for authentication +type AuthRequest struct { + PhoneNumber string `json:"phone_number,omitempty"` + Email string `json:"email,omitempty"` + FacebookID string `json:"facebook_id,omitempty"` + FacebookToken string `json:"facebook_token,omitempty"` + OTP string `json:"otp,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} + +// AuthResponse represents the response from authentication endpoints +type AuthResponse struct { + Meta struct { + Status int `json:"status"` + Message string `json:"message,omitempty"` + } `json:"meta"` + Data struct { + APIToken string `json:"api_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IsNewUser bool `json:"is_new_user,omitempty"` + SMSSent bool `json:"sms_sent,omitempty"` + } `json:"data"` +} + +// ProfileResponse represents the response from the profile endpoint +type ProfileResponse struct { + Meta struct { + Status int `json:"status"` + } `json:"meta"` + Data struct { + User struct { + ID string `json:"_id"` + Bio string `json:"bio"` + Name string `json:"name"` + } `json:"user"` + } `json:"data"` +} + +// SendPhoneLogin initiates a phone login by requesting an OTP code +func (c *Client) SendPhoneLogin(phoneNumber string) (*AuthResponse, error) { + authReq := AuthRequest{ + PhoneNumber: phoneNumber, + } + return c.doAuthRequest("/v2/auth/sms/send", authReq) +} + +// LoginWithOTP completes the phone login using the provided OTP code +func (c *Client) LoginWithOTP(phoneNumber, otp string) (*AuthResponse, error) { + authReq := AuthRequest{ + PhoneNumber: phoneNumber, + OTP: otp, + } + return c.doAuthRequest("/v2/auth/sms/validate", authReq) +} + +// LoginWithFacebook authenticates using a Facebook ID and token +func (c *Client) LoginWithFacebook(facebookID, facebookToken string) (*AuthResponse, error) { + authReq := AuthRequest{ + FacebookID: facebookID, + FacebookToken: facebookToken, + } + return c.doAuthRequest("/v2/auth/facebook", authReq) +} + +// RefreshAuth refreshes the authentication token +func (c *Client) RefreshAuth(refreshToken string) (*AuthResponse, error) { + authReq := AuthRequest{ + RefreshToken: refreshToken, + } + return c.doAuthRequest("/v2/auth/login/refresh", authReq) +} + +// GetProfile retrieves the current user's profile +func (c *Client) GetProfile() (*ProfileResponse, error) { + resp, err := c.doRequest(http.MethodGet, "/v2/profile", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s (status code: %d)", body, resp.StatusCode) + } + + var profileResp ProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&profileResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + return &profileResp, nil +} + +// doAuthRequest performs an authentication request and handles common auth response processing +func (c *Client) doAuthRequest(path string, authReq AuthRequest) (*AuthResponse, error) { + resp, err := c.doRequest(http.MethodPost, path, authReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s (status code: %d)", body, resp.StatusCode) + } + + var authResp AuthResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + // Set the auth token if it was returned + if authResp.Data.APIToken != "" { + c.authToken = authResp.Data.APIToken + } + + return &authResp, nil +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..ead8971 --- /dev/null +++ b/client.go @@ -0,0 +1,143 @@ +package tinder + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +// Client represents a Tinder API client +type Client struct { + baseURL *url.URL + httpClient *http.Client + authToken string +} + +// NewClient creates a new Tinder API client with the specified endpoint +func NewClient(endpoint string) (*Client, error) { + baseURL, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("invalid API endpoint: %v", err) + } + + return &Client{ + baseURL: baseURL, + httpClient: &http.Client{}, + }, nil +} + +// WithAuthToken sets the authentication token for the client +func (c *Client) WithAuthToken(token string) *Client { + c.authToken = token + return c +} + +// doRequest performs an HTTP request with proper authentication headers +func (c *Client) doRequest(method, path string, body interface{}) (*http.Response, error) { + endpoint := c.baseURL.ResolveReference(&url.URL{Path: path}) + + var req *http.Request + var err error + + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %v", err) + } + req, err = http.NewRequest(method, endpoint.String(), bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + } else { + req, err = http.NewRequest(method, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + } + + if c.authToken != "" { + req.Header.Set("X-Auth-Token", c.authToken) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("API request failed: %v", err) + } + + return resp, nil +} + +// LikeUser sends a like action to a specific user +func (c *Client) LikeUser(userID string) (*LikeResponse, error) { + resp, err := c.doRequest(http.MethodGet, fmt.Sprintf("/like/%s", userID), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s (status code: %d)", body, resp.StatusCode) + } + + var likeResp LikeResponse + if err := json.NewDecoder(resp.Body).Decode(&likeResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + return &likeResp, nil +} + +// PassUser sends a pass action for a specific user +func (c *Client) PassUser(userID string) (*PassResponse, error) { + resp, err := c.doRequest(http.MethodGet, fmt.Sprintf("/pass/%s", userID), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s (status code: %d)", body, resp.StatusCode) + } + + var passResp PassResponse + if err := json.NewDecoder(resp.Body).Decode(&passResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + return &passResp, nil +} + +// GetRecommendations retrieves a list of recommended user profiles +func (c *Client) GetRecommendations(locale string) (*RecsResponse, error) { + endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/v2/recs/core"}) + + if locale != "" { + query := endpoint.Query() + query.Set("locale", locale) + endpoint.RawQuery = query.Encode() + } + + resp, err := c.doRequest(http.MethodGet, endpoint.Path+"?"+endpoint.RawQuery, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := ioutil.ReadAll(resp.Body) + return nil, fmt.Errorf("API error: %s (status code: %d)", body, resp.StatusCode) + } + + var recsResp RecsResponse + if err := json.NewDecoder(resp.Body).Decode(&recsResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + + return &recsResp, nil +} diff --git a/cmd/server/Dockerfile b/cmd/server/Dockerfile new file mode 100644 index 0000000..5e3ea33 --- /dev/null +++ b/cmd/server/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.18-alpine AS builder + +WORKDIR /app + +# Copy go mod files +COPY go.mod ./ +COPY go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN go build -o /tinder-proxy ./cmd/server + +# Use a smaller image for the final container +FROM alpine:latest + +WORKDIR / + +COPY --from=builder /tinder-proxy /tinder-proxy + +EXPOSE 8080 + +ENTRYPOINT ["/tinder-proxy"] \ No newline at end of file diff --git a/cmd/server/config/config.go b/cmd/server/config/config.go new file mode 100644 index 0000000..cbf3464 --- /dev/null +++ b/cmd/server/config/config.go @@ -0,0 +1,15 @@ +package config + +// Config holds the server configuration +type Config struct { + ListenAddr string + TargetAPI string +} + +// New creates a new Config with default values +func New() *Config { + return &Config{ + ListenAddr: ":8080", + TargetAPI: "https://tinder.cloonar.com", + } +} diff --git a/cmd/server/handlers/auth.go b/cmd/server/handlers/auth.go new file mode 100644 index 0000000..e268f57 --- /dev/null +++ b/cmd/server/handlers/auth.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "fmt" + "net/http" + + tinder "tinder-api-wrapper" +) + +// HandleSendPhoneAuth handles the phone authentication request +func HandleSendPhoneAuth(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var authReq struct { + PhoneNumber string `json:"phone_number"` + } + if err := readJSON(r, &authReq); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := client.SendPhoneLogin(authReq.PhoneNumber) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} + +// HandleValidateOTP handles the OTP validation request +func HandleValidateOTP(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var authReq struct { + PhoneNumber string `json:"phone_number"` + OTP string `json:"otp"` + } + if err := readJSON(r, &authReq); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := client.LoginWithOTP(authReq.PhoneNumber, authReq.OTP) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} + +// HandleFacebookAuth handles Facebook authentication +func HandleFacebookAuth(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var authReq struct { + FacebookID string `json:"facebook_id"` + FacebookToken string `json:"facebook_token"` + } + if err := readJSON(r, &authReq); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := client.LoginWithFacebook(authReq.FacebookID, authReq.FacebookToken) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} + +// HandleRefreshAuth handles authentication token refresh +func HandleRefreshAuth(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var authReq struct { + RefreshToken string `json:"refresh_token"` + } + if err := readJSON(r, &authReq); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := client.RefreshAuth(authReq.RefreshToken) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} + +// HandleGetProfile handles profile retrieval +func HandleGetProfile(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + authToken := r.Header.Get("X-Auth-Token") + if authToken == "" { + http.Error(w, "Missing authentication token", http.StatusUnauthorized) + return + } + + clientWithAuth := client.WithAuthToken(authToken) + resp, err := clientWithAuth.GetProfile() + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} diff --git a/cmd/server/handlers/core.go b/cmd/server/handlers/core.go new file mode 100644 index 0000000..86a31ee --- /dev/null +++ b/cmd/server/handlers/core.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "fmt" + "net/http" + "strings" + + tinder "tinder-api-wrapper" +) + +// HandleLike handles the /like/{userId} endpoint +func HandleLike(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/like/") + if path == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + resp, err := client.LikeUser(path) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} + +// HandlePass handles the /pass/{userId} endpoint +func HandlePass(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/pass/") + if path == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + resp, err := client.PassUser(path) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} + +// HandleGetRecs handles the /v2/recs/core endpoint +func HandleGetRecs(client *tinder.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + locale := r.URL.Query().Get("locale") + resp, err := client.GetRecommendations(locale) + if err != nil { + http.Error(w, fmt.Sprintf("API error: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) + } +} diff --git a/cmd/server/handlers/utils.go b/cmd/server/handlers/utils.go new file mode 100644 index 0000000..00f2c06 --- /dev/null +++ b/cmd/server/handlers/utils.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" +) + +// readJSON reads and decodes JSON from request body +func readJSON(r *http.Request, v interface{}) error { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("failed to read request body: %v", err) + } + + if err := json.Unmarshal(body, v); err != nil { + return fmt.Errorf("invalid request format: %v", err) + } + + return nil +} + +// writeJSON writes JSON response with proper headers +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// LogMiddleware logs all HTTP requests +func LogMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s", r.Method, r.URL.Path) + next.ServeHTTP(w, r) + }) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..02a7396 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + + tinder "tinder-api-wrapper" + "tinder-api-wrapper/cmd/server/config" + "tinder-api-wrapper/cmd/server/router" +) + +func main() { + // Initialize configuration + cfg := config.New() + + // Parse command line flags + flag.StringVar(&cfg.ListenAddr, "listen", cfg.ListenAddr, "Address to listen on (e.g., :8080)") + flag.StringVar(&cfg.TargetAPI, "target", cfg.TargetAPI, "Target Tinder API endpoint") + flag.Parse() + + // Create Tinder client + client, err := tinder.NewClient(cfg.TargetAPI) + if err != nil { + log.Fatalf("Failed to create Tinder client: %v", err) + } + + // Setup router + handler := router.Setup(client) + + // Start server + fmt.Printf("Starting Tinder API proxy server on %s -> %s\n", cfg.ListenAddr, cfg.TargetAPI) + log.Fatal(http.ListenAndServe(cfg.ListenAddr, handler)) +} diff --git a/cmd/server/router/router.go b/cmd/server/router/router.go new file mode 100644 index 0000000..da05e25 --- /dev/null +++ b/cmd/server/router/router.go @@ -0,0 +1,28 @@ +package router + +import ( + "net/http" + + tinder "tinder-api-wrapper" + "tinder-api-wrapper/cmd/server/handlers" +) + +// Setup creates and configures the HTTP router +func Setup(client *tinder.Client) http.Handler { + mux := http.NewServeMux() + + // Core endpoints + mux.HandleFunc("/like/", handlers.HandleLike(client)) + mux.HandleFunc("/pass/", handlers.HandlePass(client)) + mux.HandleFunc("/v2/recs/core", handlers.HandleGetRecs(client)) + + // Auth endpoints + mux.HandleFunc("/v2/auth/sms/send", handlers.HandleSendPhoneAuth(client)) + mux.HandleFunc("/v2/auth/sms/validate", handlers.HandleValidateOTP(client)) + mux.HandleFunc("/v2/auth/facebook", handlers.HandleFacebookAuth(client)) + mux.HandleFunc("/v2/auth/login/refresh", handlers.HandleRefreshAuth(client)) + mux.HandleFunc("/v2/profile", handlers.HandleGetProfile(client)) + + // Wrap with logging middleware + return handlers.LogMiddleware(mux) +} diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..6d39dea --- /dev/null +++ b/default.nix @@ -0,0 +1,21 @@ +{ lib, buildGoModule, fetchGit }: + +buildGoModule rec { + pname = "tinder-api-wrapper"; + version = "0.1.0"; + + src = fetchGit { + url = "ssh://git@your-gitea-server.com/owner/tinder-api-wrapper.git"; + ref = "main"; # Or specific branch/tag + # rev = "specific-commit-hash"; # Optionally pin to specific commit + }; + + vendorSha256 = null; # Will be replaced with actual hash on first build + + meta = with lib; { + description = "Tinder API Wrapper Service"; + homepage = "https://your-gitea-server.com/owner/tinder-api-wrapper"; + license = licenses.mit; + maintainers = with maintainers; [ /* add maintainers */ ]; + }; +} \ No newline at end of file diff --git a/docs/api.yaml b/docs/api.yaml new file mode 100644 index 0000000..dcf54f3 --- /dev/null +++ b/docs/api.yaml @@ -0,0 +1,472 @@ +openapi: 3.1.0 +info: + title: Tinder API + description: > + This is an OpenAPI specification for a subset of the Tinder API endpoints. + It covers basic functionality including authentication, user actions (like/pass), + and recommendations. + version: "1.0.0" +servers: + - url: https://tinder.cloonar.com +paths: + /like/{user_id}: + get: + operationId: likeUser + summary: Like a user + description: > + Call this endpoint to "like" a user. In the Tinder app the like action is performed + when you swipe right or tap the heart. + parameters: + - name: user_id + in: path + description: The Tinder user ID of the profile to like. + required: true + schema: + type: string + responses: + '200': + description: Successful like response. + content: + application/json: + schema: + type: object + properties: + match: + type: boolean + likes_remaining: + type: integer + X-Padding: + type: string + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pass/{user_id}: + get: + operationId: passUser + summary: Pass on a user + description: > + Call this endpoint to “pass” (dislike) a user. In the Tinder app this action happens + when you swipe left or tap the red X. + parameters: + - name: user_id + in: path + description: The Tinder user ID of the profile to pass. + required: true + schema: + type: string + responses: + '200': + description: Successful pass response. + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: A status message or code. + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /v2/recs/core: + get: + operationId: getRecsCore + summary: Get Recommendations + description: > + Retrieves a list of recommended Tinder profiles. An optional locale query parameter can be provided. + parameters: + - name: locale + in: query + description: Locale for recommendations (e.g. "en"). + required: false + schema: + type: string + responses: + '200': + description: A list of recommended profiles. + content: + application/json: + schema: + type: object + properties: + meta: + type: object + properties: + status: + type: integer + data: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/UserRecommendation' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /v2/auth/sms/send: + post: + operationId: sendPhoneLogin + summary: Send OTP code + description: Initiate phone authentication by requesting an OTP code + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + phone_number: + type: string + description: The phone number to send the OTP to + required: + - phone_number + responses: + '200': + description: SMS sent successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + default: + $ref: '#/components/responses/Error' + + /v2/auth/sms/validate: + post: + operationId: validateOTP + summary: Validate OTP + description: Complete phone authentication by validating the OTP code + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + phone_number: + type: string + description: The phone number the OTP was sent to + otp: + type: string + description: The OTP code received via SMS + required: + - phone_number + - otp + responses: + '200': + description: OTP validated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + default: + $ref: '#/components/responses/Error' + + /v2/auth/facebook: + post: + operationId: facebookAuth + summary: Facebook Authentication + description: Authenticate using Facebook credentials + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + facebook_id: + type: string + description: Facebook user ID + facebook_token: + type: string + description: Facebook access token + required: + - facebook_id + - facebook_token + responses: + '200': + description: Facebook authentication successful + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + default: + $ref: '#/components/responses/Error' + + /v2/auth/login/refresh: + post: + operationId: refreshAuth + summary: Refresh Authentication + description: Refresh the authentication token using a refresh token + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + description: The refresh token obtained from a previous authentication + required: + - refresh_token + responses: + '200': + description: Token refreshed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + default: + $ref: '#/components/responses/Error' + + /v2/profile: + get: + operationId: getProfile + summary: Get User Profile + description: Retrieve the authenticated user's profile information + security: + - BearerAuth: [] + responses: + '200': + description: Profile retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponse' + default: + $ref: '#/components/responses/Error' + +components: + schemas: + Error: + type: object + properties: + message: + type: string + UserRecommendation: + type: object + properties: + type: + type: string + user: + type: object + properties: + _id: + type: string + bio: + type: string + birth_date: + type: string + format: date-time + name: + type: string + photos: + type: array + items: + $ref: '#/components/schemas/Photo' + gender: + type: integer + jobs: + type: array + items: + type: object + schools: + type: array + items: + type: object + properties: + name: + type: string + instagram: + type: object + properties: + last_fetch_time: + type: string + format: date-time + completed_initial_fetch: + type: boolean + photos: + type: array + items: + $ref: '#/components/schemas/InstagramPhoto' + media_count: + type: integer + profile_picture: + type: string + facebook: + type: object + properties: + common_connections: + type: array + items: + type: object + connection_count: + type: integer + common_interests: + type: array + items: + type: object + spotify: + type: object + properties: + spotify_connected: + type: boolean + spotify_theme_track: + type: object + description: Track info (partial schema – extend as needed) + distance_mi: + type: integer + content_hash: + type: string + s_number: + type: integer + teaser: + type: object + properties: + type: + type: string + string: + type: string + teasers: + type: array + items: + type: object + properties: + type: + type: string + string: + type: string + Photo: + type: object + properties: + id: + type: string + crop_info: + type: object + properties: + user: + type: object + properties: + width_pct: + type: number + x_offset_pct: + type: number + height_pct: + type: number + y_offset_pct: + type: number + algo: + type: object + properties: + width_pct: + type: number + x_offset_pct: + type: number + height_pct: + type: number + y_offset_pct: + type: number + processed_by_bullseye: + type: boolean + user_customized: + type: boolean + url: + type: string + processedFiles: + type: array + items: + type: object + properties: + url: + type: string + height: + type: integer + width: + type: integer + fileName: + type: string + extension: + type: string + main: + type: boolean + InstagramPhoto: + type: object + properties: + image: + type: string + thumbnail: + type: string + ts: + type: string + link: + type: string + AuthResponse: + type: object + properties: + meta: + type: object + properties: + status: + type: integer + message: + type: string + data: + type: object + properties: + api_token: + type: string + description: The authentication token to use for subsequent requests + refresh_token: + type: string + description: Token that can be used to refresh the authentication + is_new_user: + type: boolean + description: Whether this is a new user account + sms_sent: + type: boolean + description: Whether an SMS was sent (for phone authentication) + + ProfileResponse: + type: object + properties: + meta: + type: object + properties: + status: + type: integer + data: + type: object + properties: + user: + type: object + properties: + _id: + type: string + bio: + type: string + name: + type: string + + responses: + Error: + description: Error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + securitySchemes: + BearerAuth: + type: apiKey + in: header + name: X-Auth-Token diff --git a/example-configuration.nix b/example-configuration.nix new file mode 100644 index 0000000..d11f6cd --- /dev/null +++ b/example-configuration.nix @@ -0,0 +1,19 @@ +# Example NixOS configuration +{ config, pkgs, ... }: + +{ + imports = [ + ./nixos-module.nix + ]; + + services.tinder-api-wrapper = { + enable = true; + port = 8080; # default port + # Optionally override user/group if needed + # user = "custom-user"; + # group = "custom-group"; + }; + + # Open firewall port + networking.firewall.allowedTCPPorts = [ 8080 ]; +} \ No newline at end of file diff --git a/example/example.go b/example/example.go new file mode 100644 index 0000000..4c96cf5 --- /dev/null +++ b/example/example.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + + tinder "tinder-api-wrapper" +) + +func main() { + // Create a new Tinder client with the API endpoint + client, err := tinder.NewClient("https://tinder.cloonar.com") + if err != nil { + log.Fatalf("Failed to create Tinder client: %v", err) + } + + // Example: Get recommendations + fmt.Println("Getting recommendations...") + recs, err := client.GetRecommendations("en") + if err != nil { + log.Fatalf("Failed to get recommendations: %v", err) + } + + // Print out the recommendations + fmt.Printf("Found %d recommendations\n", len(recs.Data.Results)) + for i, rec := range recs.Data.Results { + fmt.Printf("%d. %s, %s\n", i+1, rec.User.Name, rec.User.Bio) + } + + // Example: Like a user + if len(recs.Data.Results) > 0 { + userID := recs.Data.Results[0].User.ID + fmt.Printf("Liking user %s...\n", userID) + + likeResp, err := client.LikeUser(userID) + if err != nil { + log.Fatalf("Failed to like user: %v", err) + } + + fmt.Printf("Like response: Match=%v, LikesRemaining=%d\n", + likeResp.Match, likeResp.LikesRemaining) + } + + // Example: Pass on a user + if len(recs.Data.Results) > 1 { + userID := recs.Data.Results[1].User.ID + fmt.Printf("Passing on user %s...\n", userID) + + passResp, err := client.PassUser(userID) + if err != nil { + log.Fatalf("Failed to pass on user: %v", err) + } + + fmt.Printf("Pass response: Status=%s\n", passResp.Status) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..249645e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module tinder-api-wrapper + +go 1.23.6 diff --git a/nixos-module.nix b/nixos-module.nix new file mode 100644 index 0000000..2af50eb --- /dev/null +++ b/nixos-module.nix @@ -0,0 +1,78 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.tinder-api-wrapper; +in { + options.services.tinder-api-wrapper = with lib; { + enable = mkEnableOption "Tinder API wrapper service"; + + port = mkOption { + type = types.port; + default = 8080; + description = "Port to listen on"; + }; + + user = mkOption { + type = types.str; + default = "tinder-api"; + description = "User account to run service."; + }; + + group = mkOption { + type = types.str; + default = "tinder-api"; + description = "Group account to run service."; + }; + }; + + config = lib.mkIf cfg.enable { + nixpkgs.overlays = [ + (self: super: { + tinder-api-wrapper = self.callPackage ./default.nix {}; + }) + ]; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + description = "Tinder API wrapper service user"; + }; + + users.groups.${cfg.group} = {}; + + systemd.services.tinder-api-wrapper = { + description = "Tinder API Wrapper Service"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStart = "${pkgs.tinder-api-wrapper}/bin/server -port ${toString cfg.port}"; + Restart = "always"; + RestartSec = "10"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectHostname = true; + ProtectClock = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + }; + }; + }; +} \ No newline at end of file diff --git a/types.go b/types.go new file mode 100644 index 0000000..99c5917 --- /dev/null +++ b/types.go @@ -0,0 +1,91 @@ +package tinder + +// LikeResponse contains the response data from a like action +type LikeResponse struct { + Match bool `json:"match"` + LikesRemaining int `json:"likes_remaining"` + XPadding string `json:"X-Padding,omitempty"` +} + +// PassResponse contains the response data from a pass action +type PassResponse struct { + Status string `json:"status"` +} + +// RecsResponse contains recommendation data +type RecsResponse struct { + Meta struct { + Status int `json:"status"` + } `json:"meta"` + Data struct { + Results []UserRecommendation `json:"results"` + } `json:"data"` +} + +// UserRecommendation represents a recommended user profile +type UserRecommendation struct { + Type string `json:"type"` + DistanceMi int `json:"distance_mi"` + ContentHash string `json:"content_hash"` + SNumber int `json:"s_number"` + User struct { + ID string `json:"_id"` + Bio string `json:"bio"` + BirthDate string `json:"birth_date"` + Name string `json:"name"` + Photos []Photo `json:"photos"` + Gender int `json:"gender"` + Jobs []any `json:"jobs"` + Schools []struct { + Name string `json:"name"` + } `json:"schools"` + } `json:"user"` + Instagram struct { + LastFetchTime string `json:"last_fetch_time"` + CompletedInitialFetch bool `json:"completed_initial_fetch"` + Photos []InstagramPhoto `json:"photos"` + MediaCount int `json:"media_count"` + ProfilePicture string `json:"profile_picture"` + } `json:"instagram"` + Spotify struct { + SpotifyConnected bool `json:"spotify_connected"` + SpotifyThemeTrack any `json:"spotify_theme_track"` + } `json:"spotify"` +} + +// Photo represents a user photo +type Photo struct { + ID string `json:"id"` + URL string `json:"url"` + FileName string `json:"fileName"` + Extension string `json:"extension"` + Main bool `json:"main"` + ProcessedFiles []struct { + URL string `json:"url"` + Height int `json:"height"` + Width int `json:"width"` + } `json:"processedFiles"` + CropInfo struct { + User struct { + WidthPct float64 `json:"width_pct"` + XOffsetPct float64 `json:"x_offset_pct"` + HeightPct float64 `json:"height_pct"` + YOffsetPct float64 `json:"y_offset_pct"` + } `json:"user"` + ProcessedByBullseye bool `json:"processed_by_bullseye"` + UserCustomized bool `json:"user_customized"` + } `json:"crop_info"` +} + +// InstagramPhoto represents a photo from Instagram +type InstagramPhoto struct { + Image string `json:"image"` + Thumbnail string `json:"thumbnail"` + Ts string `json:"ts"` + Link string `json:"link"` +} + +// Error represents an API error +type Error struct { + Message string `json:"message"` +}