Skip to content

Commit 442e526

Browse files
committed
points shop
1 parent 543575a commit 442e526

10 files changed

Lines changed: 220 additions & 12 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ If your configuration is not valid, sqtracker will fail to start.
8080
| SQ_ALLOW_REGISTER | envs | `invite` | Registration mode. Either `open`, `invite` or `closed` |
8181
| SQ_ALLOW_ANONYMOUS_UPLOADS | envs | `false` | Whether or not users can upload torrents anonymously. Either `true` or `false` |
8282
| SQ_MINIMUM_RATIO | envs | 0.75 | Minimum allowed ratio. Below this users will not be able to download |
83-
| SQ_BP_PER_GB | envs | 1 | Number of bonus points awarded to a user for each GB they upload |
83+
| SQ_BP_EARNED_PER_GB | envs | 1 | Number of bonus points awarded to a user for each GB they upload |
84+
| SQ_BP_COST_PER_INVITE | envs | 3 | Number of bonus it costs a user to buy 1 invite (set to 0 to disable buying invites) |
85+
| SQ_BP_COST_PER_GB | envs | 3 | Number of bonus it costs a user to buy 1 GB of upload (set to 0 to disable buying upload) |
8486
| SQ_TORRENT_CATEGORIES | envs | `["Movies", "TV"]` | An array of categories available on your tracker site |
8587
| SQ_BASE_URL | envs | https://demo.sqtracker.dev | The URL of your tracker site |
8688
| SQ_API_URL | envs | https://demo.sqtracker.dev/api | The URL of your API. Under the recommended setup, it should be `${SQ_BASE_URL}/api` |

api/src/controllers/user.js

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import jwt from 'jsonwebtoken'
33
import crypto from 'crypto'
44
import User from '../schema/user'
55
import Invite from '../schema/invite'
6+
import Progress from '../schema/progress'
67
import { getTorrentsPage } from './torrent'
78
import { getUserRatio } from '../utils/ratio'
89
import { mail } from '../index'
10+
import { BYTES_GB } from '../middleware/announce'
911

1012
export const sendVerificationEmail = async (address, token) => {
1113
await mail.sendMail({
@@ -421,6 +423,7 @@ export const fetchUser = async (req, res) => {
421423
...(req.userRole === 'admin' ? { email: 1, invitedBy: 1 } : {}),
422424
remainingInvites: 1,
423425
banned: 1,
426+
bonusPoints: 1,
424427
},
425428
},
426429
{
@@ -526,7 +529,7 @@ export const fetchUser = async (req, res) => {
526529
{
527530
$match: {
528531
$expr: { $eq: ['$userId', '$$userId'] },
529-
downloaded: { $gt: 0 },
532+
'downloaded.total': { $gt: 0 },
530533
},
531534
},
532535
{
@@ -548,7 +551,7 @@ export const fetchUser = async (req, res) => {
548551
{
549552
$match: {
550553
$expr: { $eq: ['$userId', '$$userId'] },
551-
uploaded: { $gt: 0 },
554+
'uploaded.total': { $gt: 0 },
552555
},
553556
},
554557
{
@@ -680,6 +683,81 @@ export const banUser = async (req, res) => {
680683
}
681684
}
682685

686+
export const buyItems = async (req, res) => {
687+
if (req.body.type && req.body.amount) {
688+
try {
689+
const amount = parseInt(req.body.amount)
690+
691+
if (amount < 1) {
692+
res.status(400).send('Amount must be a number >=1')
693+
return
694+
}
695+
696+
const user = await User.findOne({ _id: req.userId }).lean()
697+
698+
if (req.body.type === 'invite') {
699+
const cost = amount * process.env.SQ_BP_COST_PER_INVITE
700+
if (cost > user.bonusPoints) {
701+
res.status(403).send('Not enough points for transaction')
702+
return
703+
}
704+
705+
await User.findOneAndUpdate(
706+
{ _id: req.userId },
707+
{
708+
$inc: {
709+
remainingInvites: amount,
710+
bonusPoints: cost * -1,
711+
},
712+
}
713+
)
714+
715+
res.status(200).send((user.bonusPoints - cost).toString())
716+
} else if (req.body.type === 'upload') {
717+
const cost = amount * process.env.SQ_BP_COST_PER_GB
718+
if (cost > user.bonusPoints) {
719+
res.status(403).send('Not enough points for transaction')
720+
return
721+
}
722+
723+
await User.findOneAndUpdate(
724+
{ _id: req.userId },
725+
{
726+
$inc: {
727+
bonusPoints: cost * -1,
728+
},
729+
}
730+
)
731+
732+
const progressRecord = new Progress({
733+
infoHash: `purchase-${Date.now()}`,
734+
userId: req.userId,
735+
uploaded: {
736+
session: BYTES_GB * amount,
737+
total: BYTES_GB * amount,
738+
},
739+
downloaded: {
740+
session: 0,
741+
total: 0,
742+
},
743+
left: 0,
744+
})
745+
746+
await progressRecord.save()
747+
748+
res.status(200).send((user.bonusPoints - cost).toString())
749+
} else {
750+
res.status(400).send('Type must be one of invite, upload')
751+
return
752+
}
753+
} catch (e) {
754+
res.status(500).send(e.message)
755+
}
756+
} else {
757+
res.status(400).send('Request must include type, amount')
758+
}
759+
}
760+
683761
export const unbanUser = async (req, res) => {
684762
try {
685763
if (req.userRole !== 'admin') {

api/src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
verifyUserEmail,
2424
banUser,
2525
unbanUser,
26+
buyItems,
2627
} from './controllers/user'
2728
import {
2829
uploadTorrent,
@@ -155,6 +156,7 @@ app.post('/account/generate-invite', generateInvite)
155156
app.post('/account/change-password', changePassword)
156157
app.get('/account/get-role', getUserRole)
157158
app.get('/account/get-verified', getUserVerifiedEmailStatus)
159+
app.post('/account/buy', buyItems)
158160
app.get('/user/:username', fetchUser)
159161
app.post('/user/ban/:username', banUser)
160162
app.post('/user/unban/:username', unbanUser)

api/src/middleware/announce.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Torrent from '../schema/torrent'
55
import Progress from '../schema/progress'
66
import { getUserRatio } from '../utils/ratio'
77

8-
const BYTES_GB = 1.074e9
8+
export const BYTES_GB = 1e9
99

1010
export const binaryToHex = (b) => Buffer.from(b, 'binary').toString('hex')
1111
export const hexToBinary = (h) => Buffer.from(h, 'hex').toString('binary')
@@ -110,7 +110,7 @@ const handleAnnounce = async (req, res, next) => {
110110
if ((bytes + uploadDeltaSession) / BYTES_GB >= nextGb) {
111111
await User.findOneAndUpdate(
112112
{ _id: user._id },
113-
{ $inc: { bonusPoints: process.env.SQ_BP_PER_GB } }
113+
{ $inc: { bonusPoints: process.env.SQ_BP_EARNED_PER_GB } }
114114
)
115115
}
116116

api/src/utils/validateConfig.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ const configSchema = yup
1616
.required(),
1717
SQ_ALLOW_ANONYMOUS_UPLOADS: yup.boolean().required(),
1818
SQ_MINIMUM_RATIO: yup.number().min(0).required(),
19-
SQ_BP_PER_GB: yup.number().min(0).required(),
19+
SQ_BP_EARNED_PER_GB: yup.number().min(0).required(),
20+
SQ_BP_COST_PER_INVITE: yup.number().min(0).required(),
21+
SQ_BP_COST_PER_GB: yup.number().min(0).required(),
2022
SQ_TORRENT_CATEGORIES: yup
2123
.array()
2224
.of(yup.string())

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"lodash": "^4.17.21",
2020
"moment": "^2.29.1",
2121
"next": "12.2.5",
22+
"pluralize": "^8.0.0",
2223
"polished": "^4.1.3",
2324
"pretty-bytes": "^5.6.0",
2425
"qs": "^6.11.0",

client/pages/account.js

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import getConfig from 'next/config'
33
import moment from 'moment'
44
import copy from 'copy-to-clipboard'
55
import jwt from 'jsonwebtoken'
6+
import pluralize from 'pluralize'
67
import { Copy } from '@styled-icons/boxicons-regular/Copy'
78
import { Check } from '@styled-icons/boxicons-regular/Check'
89
import { X } from '@styled-icons/boxicons-regular/X'
@@ -18,14 +19,59 @@ import List from '../components/List'
1819
import { NotificationContext } from '../components/Notifications'
1920
import Modal from '../components/Modal'
2021

22+
const BuyItem = ({ text, cost, wallet, handleBuy }) => {
23+
const [amount, setAmount] = useState(1)
24+
return (
25+
<Box
26+
display="flex"
27+
alignItems="center"
28+
justifyContent="space-between"
29+
border="1px solid"
30+
borderColor="border"
31+
borderRadius={1}
32+
p={3}
33+
pl={4}
34+
>
35+
<Text>{text}</Text>
36+
<form onSubmit={handleBuy}>
37+
<Box display="flex" alignItems="center">
38+
<Text color="grey" mr={4}>
39+
Cost: {amount * cost} points
40+
</Text>
41+
<Input
42+
type="number"
43+
name="amount"
44+
value={amount}
45+
onChange={(e) => setAmount(parseInt(e.currentTarget.value))}
46+
min={1}
47+
max={Math.floor(wallet / cost)}
48+
width="100px"
49+
mr={3}
50+
/>
51+
<Button disabled={amount * cost > wallet}>Buy</Button>
52+
</Box>
53+
</form>
54+
</Box>
55+
)
56+
}
57+
2158
const Account = ({ token, invites = [], user, userRole }) => {
59+
const [remainingInvites, setRemainingInvites] = useState(
60+
user.remainingInvites ?? 0
61+
)
2262
const [invitesList, setInvitesList] = useState(invites)
2363
const [showInviteModal, setShowInviteModal] = useState(false)
64+
const [bonusPoints, setBonusPoints] = useState(user.bonusPoints ?? 0)
2465

2566
const { addNotification } = useContext(NotificationContext)
2667

2768
const {
28-
publicRuntimeConfig: { SQ_API_URL },
69+
publicRuntimeConfig: {
70+
SQ_API_URL,
71+
SQ_BP_EARNED_PER_GB,
72+
SQ_BP_COST_PER_INVITE,
73+
SQ_BP_COST_PER_GB,
74+
},
2975
} = getConfig()
3076

3177
const handleGenerateInvite = async (e) => {
@@ -59,6 +105,8 @@ const Account = ({ token, invites = [], user, userRole }) => {
59105

60106
addNotification('success', 'Invite sent successfully')
61107

108+
setRemainingInvites((r) => r - 1)
109+
62110
setShowInviteModal(false)
63111
} catch (e) {
64112
addNotification('error', `Could not send invite: ${e.message}`)
@@ -104,6 +152,48 @@ const Account = ({ token, invites = [], user, userRole }) => {
104152
}
105153
}
106154

155+
const handleBuy = async (e, type) => {
156+
e.preventDefault()
157+
const form = new FormData(e.target)
158+
159+
try {
160+
const amount = parseInt(form.get('amount'))
161+
162+
const buyRes = await fetch(`${SQ_API_URL}/account/buy`, {
163+
method: 'POST',
164+
headers: {
165+
'Content-Type': 'application/json',
166+
Authorization: `Bearer ${token}`,
167+
},
168+
body: JSON.stringify({
169+
type,
170+
amount,
171+
}),
172+
})
173+
174+
if (buyRes.status !== 200) {
175+
const reason = await buyRes.text()
176+
throw new Error(reason)
177+
}
178+
179+
addNotification('success', 'Items purchased successfully')
180+
181+
const pointsRemaining = await buyRes.text()
182+
setBonusPoints(parseInt(pointsRemaining))
183+
184+
if (type === 'invite') setRemainingInvites((r) => r + amount)
185+
186+
const fields = e.target.querySelectorAll('input')
187+
for (const field of fields) {
188+
field.value = 1
189+
field.blur()
190+
}
191+
} catch (e) {
192+
addNotification('error', `Could not buy items: ${e.message}`)
193+
console.error(e)
194+
}
195+
}
196+
107197
return (
108198
<>
109199
<SEO title="My account" />
@@ -115,6 +205,28 @@ const Account = ({ token, invites = [], user, userRole }) => {
115205
<Text>This is an admin account.</Text>
116206
</Infobox>
117207
)}
208+
<Text as="h2" mb={4}>
209+
Bonus points
210+
</Text>
211+
<Text mb={4}>
212+
You currently have <strong>{bonusPoints}</strong> bonus points. You will
213+
earn {SQ_BP_EARNED_PER_GB} {pluralize('point', SQ_BP_EARNED_PER_GB)} for
214+
every GB you upload.
215+
</Text>
216+
<Box _css={{ '> * + *': { mt: 3 } }} mb={5}>
217+
<BuyItem
218+
text="Purchase invites"
219+
cost={SQ_BP_COST_PER_INVITE}
220+
wallet={bonusPoints}
221+
handleBuy={(e) => handleBuy(e, 'invite')}
222+
/>
223+
<BuyItem
224+
text="Purchase upload (1 GB)"
225+
cost={SQ_BP_COST_PER_GB}
226+
wallet={bonusPoints}
227+
handleBuy={(e) => handleBuy(e, 'upload')}
228+
/>
229+
</Box>
118230
<Box
119231
display="flex"
120232
alignItems="center"
@@ -131,11 +243,11 @@ const Account = ({ token, invites = [], user, userRole }) => {
131243
pl={4}
132244
>
133245
<Text color="grey" mr={4}>
134-
{user.remainingInvites || 0} remaining
246+
{remainingInvites.toLocaleString()} remaining
135247
</Text>
136248
<Button
137249
onClick={() => setShowInviteModal(true)}
138-
disabled={(user.remainingInvites || 0) < 1}
250+
disabled={remainingInvites < 1}
139251
>
140252
Send invite
141253
</Button>

client/pages/user/[username].js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,12 @@ const User = ({ token, user, userRole }) => {
152152
Ratio
153153
</Text>
154154
<Text fontSize={5}>
155-
{user.ratio === -1 ? 'N/A' : user.ratio.toFixed(2)}
156-
{user.ratio !== -1 && (
155+
{typeof user.ratio === 'number'
156+
? user.ratio === -1
157+
? 'N/A'
158+
: user.ratio.toFixed(2)
159+
: '?'}
160+
{typeof user.ratio === 'number' && user.ratio !== -1 && (
157161
<Text
158162
as="span"
159163
fontSize={3}

config.example.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ module.exports = {
1010
SQ_ALLOW_REGISTER: 'invite',
1111
SQ_ALLOW_ANONYMOUS_UPLOADS: false,
1212
SQ_MINIMUM_RATIO: 0.75,
13-
SQ_BP_PER_GB: 1,
13+
SQ_BP_EARNED_PER_GB: 1,
14+
SQ_BP_COST_PER_INVITE: 3,
15+
SQ_BP_COST_PER_GB: 3,
1416
SQ_TORRENT_CATEGORIES: ['Movies', 'TV', 'Music', 'Books'],
1517
SQ_BASE_URL: 'https://sqtracker.dev',
1618
SQ_API_URL: 'https://sqtracker.dev/api',

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4811,6 +4811,11 @@ pkg-dir@^3.0.0:
48114811
dependencies:
48124812
find-up "^3.0.0"
48134813

4814+
pluralize@^8.0.0:
4815+
version "8.0.0"
4816+
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
4817+
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
4818+
48144819
polished@^4.1.3:
48154820
version "4.1.3"
48164821
resolved "https://registry.yarnpkg.com/polished/-/polished-4.1.3.tgz#7a3abf2972364e7d97770b827eec9a9e64002cfc"

0 commit comments

Comments
 (0)