Skip to content

Commit 3a081bf

Browse files
committed
Merge feature/mobile-share-target: mobile Share-to-shoplit PWA
Installable PWA + Android Web Share Target, /add prefilled screen posting explicit fields (no IP-blocked fetch), share-text parser, and safe OAuth next= return-to. Images optional (URL+placeholder); photo upload deferred.
2 parents bc05608 + df40512 commit 3a081bf

20 files changed

Lines changed: 1651 additions & 10 deletions

internal/auth/google.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,26 @@ package auth
33
import (
44
"encoding/json"
55
"net/http"
6+
"strings"
67

78
"golang.org/x/oauth2"
89
)
910

11+
// safeNextPath returns p only if it is a safe same-site relative path
12+
// (begins with a single "/", no scheme, no protocol-relative "//"). Otherwise "".
13+
func safeNextPath(p string) string {
14+
if p == "" || p[0] != '/' {
15+
return ""
16+
}
17+
if strings.HasPrefix(p, "//") {
18+
return ""
19+
}
20+
if strings.Contains(p, "://") {
21+
return ""
22+
}
23+
return p
24+
}
25+
1026
// GoogleUserInfo matches the response from https://www.googleapis.com/oauth2/v3/userinfo.
1127
type GoogleUserInfo struct {
1228
Sub string `json:"sub"`
@@ -49,6 +65,9 @@ func HandleGoogleStart(cfg *oauth2.Config, sm *SessionManager) http.Handler {
4965
return
5066
}
5167
sm.SetTemp(w, "oauth_state", state)
68+
if next := safeNextPath(r.URL.Query().Get("next")); next != "" {
69+
sm.SetTemp(w, "oauth_next", next)
70+
}
5271
http.Redirect(w, r, cfg.AuthCodeURL(state, oauth2.AccessTypeOnline), http.StatusFound)
5372
})
5473
}
@@ -101,7 +120,15 @@ func HandleGoogleCallback(cfg *oauth2.Config, sm *SessionManager, upsert UpsertF
101120
}
102121

103122
sm.SetUser(w, uid)
104-
http.Redirect(w, r, frontendURL+"/dashboard", http.StatusFound)
123+
124+
dest := "/dashboard"
125+
if next, err := sm.GetTemp(r, "oauth_next"); err == nil {
126+
if safe := safeNextPath(next); safe != "" {
127+
dest = safe
128+
}
129+
}
130+
sm.ClearTemp(w, "oauth_next")
131+
http.Redirect(w, r, frontendURL+dest, http.StatusFound)
105132
})
106133
}
107134

internal/auth/google_test.go

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import (
1818
"golang.org/x/oauth2"
1919
)
2020

21+
// newTestSM returns a SessionManager using the shared test secret.
22+
func newTestSM(t *testing.T) *auth.SessionManager {
23+
t.Helper()
24+
return auth.NewSessionManager("test-secret")
25+
}
26+
2127
// fakeGoogleServer simulates Google's token + userinfo endpoints.
2228
func fakeGoogleServer(t *testing.T) *httptest.Server {
2329
t.Helper()
@@ -47,7 +53,7 @@ func TestGoogleCallback_HappyPath(t *testing.T) {
4753
fake := fakeGoogleServer(t)
4854
defer fake.Close()
4955

50-
sm := auth.NewSessionManager("test-secret")
56+
sm := newTestSM(t)
5157
cfg := &oauth2.Config{
5258
ClientID: "test-client",
5359
ClientSecret: "test-secret",
@@ -109,3 +115,76 @@ func TestHandleLogout(t *testing.T) {
109115
auth.HandleLogout(sm).ServeHTTP(rr, req)
110116
assert.Equal(t, http.StatusNoContent, rr.Code)
111117
}
118+
119+
func TestGoogleCallback_RedirectsToNext(t *testing.T) {
120+
sm := newTestSM(t)
121+
upsert := func(auth.GoogleUserInfo) (int64, error) { return 7, nil }
122+
srv := fakeGoogleServer(t)
123+
defer srv.Close()
124+
userinfoURL := srv.URL + "/userinfo"
125+
cfg := &oauth2.Config{Endpoint: oauth2.Endpoint{TokenURL: srv.URL + "/token"}}
126+
127+
handler := auth.HandleGoogleCallback(cfg, sm, upsert, "http://localhost:3000", userinfoURL)
128+
129+
rrPre := httptest.NewRecorder()
130+
sm.SetTemp(rrPre, "oauth_state", "fixed-state-value")
131+
sm.SetTemp(rrPre, "oauth_next", "/add?title=x&url=https%3A%2F%2Famzn.in%2Fd%2Fabc")
132+
req := httptest.NewRequest("GET", "/cb?state=fixed-state-value&code=fake-code", nil)
133+
for _, c := range rrPre.Result().Cookies() {
134+
req.AddCookie(c)
135+
}
136+
rr := httptest.NewRecorder()
137+
handler.ServeHTTP(rr, req)
138+
139+
if rr.Code != http.StatusFound {
140+
t.Fatalf("want 302, got %d", rr.Code)
141+
}
142+
if got := rr.Header().Get("Location"); got != "http://localhost:3000/add?title=x&url=https%3A%2F%2Famzn.in%2Fd%2Fabc" {
143+
t.Fatalf("redirect = %q", got)
144+
}
145+
}
146+
147+
func TestGoogleCallback_RejectsUnsafeNext(t *testing.T) {
148+
sm := newTestSM(t)
149+
upsert := func(auth.GoogleUserInfo) (int64, error) { return 7, nil }
150+
srv := fakeGoogleServer(t)
151+
defer srv.Close()
152+
cfg := &oauth2.Config{Endpoint: oauth2.Endpoint{TokenURL: srv.URL + "/token"}}
153+
handler := auth.HandleGoogleCallback(cfg, sm, upsert, "http://localhost:3000", srv.URL+"/userinfo")
154+
155+
rrPre := httptest.NewRecorder()
156+
sm.SetTemp(rrPre, "oauth_state", "fixed-state-value")
157+
sm.SetTemp(rrPre, "oauth_next", "//evil.example.com/phish")
158+
req := httptest.NewRequest("GET", "/cb?state=fixed-state-value&code=fake-code", nil)
159+
for _, c := range rrPre.Result().Cookies() {
160+
req.AddCookie(c)
161+
}
162+
rr := httptest.NewRecorder()
163+
handler.ServeHTTP(rr, req)
164+
165+
if got := rr.Header().Get("Location"); got != "http://localhost:3000/dashboard" {
166+
t.Fatalf("unsafe next should fall back to /dashboard, got %q", got)
167+
}
168+
}
169+
170+
func TestGoogleStart_StoresNext(t *testing.T) {
171+
sm := newTestSM(t)
172+
cfg := auth.GoogleConfig("id", "secret", "http://localhost:8080/cb")
173+
handler := auth.HandleGoogleStart(cfg, sm)
174+
req := httptest.NewRequest("GET", "/api/v1/auth/google?next=%2Fadd%3Ftitle%3Dx", nil)
175+
rr := httptest.NewRecorder()
176+
handler.ServeHTTP(rr, req)
177+
178+
if rr.Code != http.StatusFound {
179+
t.Fatalf("want 302, got %d", rr.Code)
180+
}
181+
var found bool
182+
for _, c := range rr.Result().Cookies() {
183+
if c.Name == sm.TempCookieName("oauth_next") {
184+
found = true
185+
}
186+
}
187+
if !found {
188+
t.Fatalf("expected an oauth_next temp cookie to be set")
189+
}
190+
}

internal/auth/session.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ func (s *SessionManager) GetTemp(r *http.Request, key string) (string, error) {
125125
return s.verify(c.Value)
126126
}
127127

128+
// TempCookieName returns the cookie name SetTemp/GetTemp use for key.
129+
func (s *SessionManager) TempCookieName(key string) string {
130+
return tempCookiePrefix + key
131+
}
132+
128133
// ClearTemp removes a previously-stored temp value.
129134
func (s *SessionManager) ClearTemp(w http.ResponseWriter, key string) {
130135
http.SetCookie(w, &http.Cookie{

web/app/(public)/get-extension/page.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ const desktopSteps = [
1818
];
1919

2020
const mobileSteps = [
21-
{ t: "Open the product in your shopping app", b: "On Nykaa, Myntra, Amazon, Flipkart or AJIO, open the product page." },
22-
{ t: "Tap Share → Copy", b: "Use the app's Share button and copy the link (or the whole “Check out this product…” text)." },
23-
{ t: "Paste into shoplit", b: "On shoplit.in open your cart, and in “Add a product” paste it. shoplit pulls the title automatically." },
24-
{ t: "Add", b: "Tweak the price/image if needed, pick the cart, and add. Done — no extension required." },
21+
{ t: "Install shoplit", b: "Open shoplit.in in Chrome on your phone → menu (⋮) → “Add to Home screen”. (On iPhone: Safari → Share → Add to Home Screen.)" },
22+
{ t: "Open a product & tap Share", b: "On Nykaa, Myntra, Amazon, Flipkart or AJIO, open the product and tap the app’s Share button." },
23+
{ t: "Choose shoplit (Android)", b: "Pick shoplit from the share sheet — it opens with the link and title already filled in. iPhone: copy the link, open shoplit and paste it on the Add screen." },
24+
{ t: "Pick a cart & add", b: "Choose the cart, tweak the price/image if you like, and add. Done — no typing it all out." },
2525
];
2626

2727
export default function GetExtensionPage() {
@@ -84,8 +84,8 @@ export default function GetExtensionPage() {
8484
</div>
8585
<p className="text-sm text-muted leading-relaxed mb-5">
8686
Heads up: <strong className="text-ink">Chrome on phones doesn&apos;t support extensions</strong> — that&apos;s
87-
an Android/iOS limitation, not a shoplit one. But you don&apos;t need it: add products by
88-
sharing the link into shoplit.
87+
an Android/iOS limitation, not a shoplit one. Install shoplit as a home-screen app and
88+
use the native share sheet instead — it&apos;s just as fast.
8989
</p>
9090
<ol className="space-y-5">
9191
{mobileSteps.map((s, i) => (
@@ -98,6 +98,12 @@ export default function GetExtensionPage() {
9898
</li>
9999
))}
100100
</ol>
101+
<Link
102+
href="/add"
103+
className="mt-6 inline-flex items-center gap-2 rounded-full bg-ink text-cream px-6 py-3 font-medium hover:opacity-90"
104+
>
105+
Add a product by link →
106+
</Link>
101107
<p className="text-xs text-muted mt-6 leading-relaxed">
102108
Power users on Android: <strong className="text-ink">Kiwi Browser</strong> does support Chrome
103109
extensions — you can load the same downloaded folder there via its Extensions menu.

web/app/add/add-form.tsx

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import Link from "next/link";
5+
import { toast } from "sonner";
6+
import { addSharedItem, createCart } from "@/lib/api-client";
7+
import { parseShare, type ParsedShare } from "@/lib/parse-share";
8+
9+
type CartOpt = { id: string; title: string; slug: string };
10+
const LAST_CART_KEY = "shoplit:lastCart";
11+
12+
const inputCls =
13+
"w-full rounded-md border border-rule bg-cream px-3 py-2 focus:outline-none focus:ring-2 focus:ring-accent";
14+
15+
export function AddForm({ carts, initial }: { carts: CartOpt[]; initial: ParsedShare }) {
16+
const [cartList, setCartList] = useState<CartOpt[]>(carts);
17+
const [cartId, setCartId] = useState(carts[0]?.id ?? "");
18+
19+
useEffect(() => {
20+
const last = localStorage.getItem(LAST_CART_KEY);
21+
if (last && carts.some((c) => c.id === last)) setCartId(last);
22+
}, [carts]);
23+
const [title, setTitle] = useState(initial.title);
24+
const [priceText, setPriceText] = useState(initial.priceText);
25+
const [imageUrl, setImageUrl] = useState("");
26+
const [originalUrl, setOriginalUrl] = useState(initial.productUrl);
27+
const [note, setNote] = useState("");
28+
const [busy, setBusy] = useState(false);
29+
const [cartBusy, setCartBusy] = useState(false);
30+
const [added, setAdded] = useState<CartOpt | null>(null);
31+
const [newCartTitle, setNewCartTitle] = useState("");
32+
33+
const onPaste = (raw: string) => {
34+
const p = parseShare({ text: raw, url: raw });
35+
if (p.productUrl) setOriginalUrl(p.productUrl);
36+
if (p.title) setTitle(p.title);
37+
if (p.priceText) setPriceText(p.priceText);
38+
};
39+
40+
const submit = async () => {
41+
if (!cartId) return toast.error("Pick a cart first.");
42+
if (!title.trim()) return toast.error("Add a title.");
43+
if (!originalUrl.trim()) return toast.error("Add the product link.");
44+
setBusy(true);
45+
try {
46+
await addSharedItem(cartId, { title: title.trim(), priceText, imageUrl, originalUrl: originalUrl.trim(), note });
47+
window.localStorage.setItem(LAST_CART_KEY, cartId);
48+
setAdded(cartList.find((c) => c.id === cartId) ?? null);
49+
} catch {
50+
toast.error("Couldn't add — try again.");
51+
} finally {
52+
setBusy(false);
53+
}
54+
};
55+
56+
const addAnother = () => {
57+
setAdded(null);
58+
setTitle("");
59+
setPriceText("");
60+
setImageUrl("");
61+
setOriginalUrl("");
62+
setNote("");
63+
};
64+
65+
const makeCart = async () => {
66+
if (!newCartTitle.trim()) return;
67+
if (cartBusy) return;
68+
setCartBusy(true);
69+
try {
70+
const c = await createCart(newCartTitle.trim());
71+
const opt = { id: c.id, title: c.title, slug: c.slug };
72+
setCartList((l) => [opt, ...l]);
73+
setCartId(opt.id);
74+
setNewCartTitle("");
75+
} catch {
76+
toast.error("Couldn't create the cart.");
77+
} finally {
78+
setCartBusy(false);
79+
}
80+
};
81+
82+
if (added) {
83+
return (
84+
<div className="rounded-2xl border border-rule bg-paper p-6 text-center">
85+
<p className="font-serif text-2xl mb-1">Added ✓</p>
86+
<p className="text-sm text-muted mb-5">Saved to &ldquo;{added.title}&rdquo;.</p>
87+
<div className="flex flex-col gap-3">
88+
<button onClick={addAnother} className="rounded-full bg-ink text-cream py-3 font-medium hover:opacity-90">
89+
Add another
90+
</button>
91+
<Link href={`/dashboard/carts/${added.id}`} className="rounded-full border border-ink py-3 font-medium hover:bg-paper">
92+
View cart
93+
</Link>
94+
</div>
95+
</div>
96+
);
97+
}
98+
99+
if (cartList.length === 0) {
100+
return (
101+
<div className="space-y-3">
102+
<p className="text-sm text-muted">You don&apos;t have a cart yet — create one to start adding.</p>
103+
<input value={newCartTitle} onChange={(e) => setNewCartTitle(e.target.value)} placeholder="Cart name (e.g. My picks)" className={inputCls} />
104+
<button onClick={makeCart} disabled={cartBusy} className="w-full rounded-full bg-ink text-cream py-3 font-medium hover:opacity-90 disabled:opacity-60">
105+
{cartBusy ? "Creating…" : "Create cart"}
106+
</button>
107+
</div>
108+
);
109+
}
110+
111+
return (
112+
<div className="space-y-4">
113+
{!originalUrl && (
114+
<label className="block">
115+
<span className="block text-sm font-medium mb-1">Paste a product link</span>
116+
<textarea
117+
rows={2}
118+
onChange={(e) => onPaste(e.target.value)}
119+
placeholder={'Paste the link or the whole “Check out this product…” text'}
120+
className={inputCls}
121+
/>
122+
</label>
123+
)}
124+
125+
<label className="block">
126+
<span className="block text-sm font-medium mb-1">Cart</span>
127+
<select value={cartId} onChange={(e) => setCartId(e.target.value)} className={inputCls}>
128+
{cartList.map((c) => (
129+
<option key={c.id} value={c.id}>{c.title}</option>
130+
))}
131+
</select>
132+
</label>
133+
134+
<label className="block">
135+
<span className="block text-sm font-medium mb-1">Title</span>
136+
<input value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} />
137+
</label>
138+
139+
<label className="block">
140+
<span className="block text-sm font-medium mb-1">Price</span>
141+
<input value={priceText} onChange={(e) => setPriceText(e.target.value)} placeholder="₹ price" className={inputCls} />
142+
</label>
143+
144+
<label className="block">
145+
<span className="block text-sm font-medium mb-1">Product link</span>
146+
<input value={originalUrl} onChange={(e) => setOriginalUrl(e.target.value)} className={inputCls} />
147+
</label>
148+
149+
<label className="block">
150+
<span className="block text-sm font-medium mb-1">Image URL <span className="text-muted font-normal">(optional)</span></span>
151+
<input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Leave blank for a placeholder" className={inputCls} />
152+
</label>
153+
154+
<label className="block">
155+
<span className="block text-sm font-medium mb-1">Note <span className="text-muted font-normal">(optional)</span></span>
156+
<input value={note} onChange={(e) => setNote(e.target.value)} className={inputCls} />
157+
</label>
158+
159+
<button onClick={submit} disabled={busy} className="w-full rounded-full bg-ink text-cream py-3 font-medium hover:opacity-90 disabled:opacity-60">
160+
{busy ? "Adding…" : "+ Add to cart"}
161+
</button>
162+
</div>
163+
);
164+
}

0 commit comments

Comments
 (0)