Select Git revision
Aaron Dötsch authored
Until now the timestamp of the last listed item was used to query the next entries with an earlier timestamp. That works well most of the time but sometimes it can happen that entries have the exact same timestamp, especially for item transactions for when you buy multiple products at once. Now it is using the ids for each type respectively and not the timestamp anymore, which should make it work perfectly.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
+server.js 9.45 KiB
import * as devalue from "devalue";
import { getPriceModifier } from "../../admin/api/articles";
import { Flags } from "$lib/flags";
import { getArticles, getBuyableVouchers } from "../articles";
import { buyArticles, fetchTransactions, transferMoney, useVoucher } from "../transactions";
import { getUserById } from "../users";
import { Codes } from "$lib/customcodes";
import { AllOrNothingRateLimiter, SlidingWindowRateLimiter } from "../../../lib/ratelimit";
import { sendNotification } from "$lib/server/notifications/handler";
import { NotificationType } from "$lib/notifications/notificationTypes";
function isValidCart(cart, articles, voucherCart, vouchers, priceModifier){
return cart.every(item => {
if(item.code == Codes.Pfand && item.price > 0 && item.premium == 0) return true;
if(item.code == Codes.Custom && item.price > 0 && item.premium == 0) return true;
const article = articles[item.code];
if (!article) return false;
return item.price === article.price && item.premium === priceModifier(article.price);
}) && voucherCart.every(item => {
const voucher = vouchers[item.code];
if (!voucher) return false;
return item.price === voucher.amount && item.premium == 0;
});
}
const voucherRedeemRateLimiter = new AllOrNothingRateLimiter(
new SlidingWindowRateLimiter({duration: 60*1000, max: 5}), // 5 per minute
new SlidingWindowRateLimiter({duration: 60*60*1000, max: 20}), // 20 per hour
new SlidingWindowRateLimiter({duration: 24*60*60*1000, max: 50}) // 50 per day
);
export async function POST(event) {
switch(event.params.slug){
case "buy": {
try{
const data = await event.request.json();
if(!data.items || !data.vouchers) return new Response(JSON.stringify({message: "Missing required fields"}), {headers: {'content-type': 'application/json', 'status': 400}});
if(!Array.isArray(data.items) || !Array.isArray(data.vouchers) || (data.items.length === 0 && data.vouchers.length === 0)) return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}});
const user = event.locals.user;
// only fetch if needed
const articles = data.items.length == 0 ? {} : (await getArticles()).reduce((acc, item) => {acc[item.code] = item; return acc;}, {});
const vouchers = data.vouchers.length == 0 ? {} : (await getBuyableVouchers()).reduce((acc, voucher) => {acc[voucher.code] = voucher; return acc;}, {});
const priceModifier = getPriceModifier(user.balance, !!(user.perms & Flags.NO_PREMIUM));
if(!isValidCart(data.items, articles, data.vouchers, vouchers, priceModifier)) return new Response(JSON.stringify({message: "Invalid cart"}), {headers: {'content-type': 'application/json', 'status': 400}});
const items = [...data.items, ...data.vouchers.map(voucher => ({...voucher, code: Codes.Voucher}))];
if(user.perms & Flags.CANT_GO_NEGATIVE && items.reduce((acc, item) => acc + item.price + item.premium, 0) > user.balance) return new Response(JSON.stringify({message: "Not enough balance"}), {headers: {'content-type': 'application/json', 'status': 400}});
const res = await buyArticles(user.id, user.card, items, data.vouchers, !!(user.perms & Flags.NO_BALANCE));
const total = items.reduce((acc, item) => acc + item.price + item.premium, 0);
// no need to await
sendNotification(user, NotificationType.BUY, {items: items.map(item=>Object.assign({}, item, {name: articles[item.code]?.name})), total, balanceBefore: user.balance, balanceAfter: user.balance - total});
return new Response(JSON.stringify(res), {headers: {'content-type': 'application/json', 'status': 200}});
}catch(e){
console.error(e);
return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}});
}
}
case "transfer": {
try{
if(event.locals.user.perms & Flags.CANT_TRANSFER) return new Response(JSON.stringify({message: "You can't transfer money"}), {headers: {'content-type': 'application/json', 'status': 403}});
const data = await event.request.json();
if(!Number.isInteger(data.amount) || data.amount <= 0 || !data.target) return new Response(JSON.stringify({message: "Missing required fields"}), {headers: {'content-type': 'application/json', 'status': 400}});
const user = event.locals.user;
if(user.id == data.target) return new Response(JSON.stringify({message: "Can't transfer to yourself"}), {headers: {'content-type': 'application/json', 'status': 400}});
if(user.balance < data.amount) return new Response(JSON.stringify({message: "Not enough balance"}), {headers: {'content-type': 'application/json', 'status': 400}});
const target = await getUserById(data.target, false, true);
if(!target) return new Response(JSON.stringify({message: "Target not found"}), {headers: {'content-type': 'application/json', 'status': 404}});
const { from } = await transferMoney(user.id, user.card, target.id, data.amount);
// no need to await
sendNotification(user, NotificationType.SEND_TRANSFER, {amount: data.amount, balanceBefore: user.balance, balanceAfter: user.balance - data.amount, receiver: {name: target.name, id: target.id}});
sendNotification(target, NotificationType.RECEIVE_TRANSFER, {amount: data.amount, balanceBefore: target.balance, balanceAfter: target.balance + data.amount, sender: {name: user.name, id: user.id}});
// don't return the to-user, because it contains the target's information
return new Response(JSON.stringify(from), {headers: {'content-type': 'application/json', 'status': 200}});
}catch(e){
return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}});
}
}
case "voucher": {
const remaining = voucherRedeemRateLimiter.consume(event.locals.user.id);
if(remaining < 0) return new Response(JSON.stringify({message: "Rate limit exceeded"}), {headers: {'content-type': 'application/json', 'status': 429}});
try{
const data = await event.request.json();
const voucherCode = data.code;
if(!voucherCode) return new Response(JSON.stringify({message: "Missing required fields"}), {headers: {'content-type': 'application/json', 'status': 400}});
if(typeof voucherCode !== "string" || voucherCode.length === 0) return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}});
const result = await useVoucher(event.locals.user.id, voucherCode);
if(typeof result === "number"){
// voucher exists but is not activated yet
return new Response(JSON.stringify({message: "Voucher not activated", canBuyToActivate: true, price: result}), {headers: {'content-type': 'application/json', 'status': 400}});
}else if(!result){
return new Response(JSON.stringify({message: "Invalid voucher"}), {headers: {'content-type': 'application/json', 'status': 400}});
}
// no need to await
sendNotification(event.locals.user, NotificationType.USE_VOUCHER, {
voucher: {code: result.code, value: result.amount},
balanceBefore: event.locals.user.balance,
balanceAfter: event.locals.user.balance + result.amount
});
return new Response(JSON.stringify(result), {headers: {'content-type': 'application/json', 'status': 200}});
}catch(e){
console.error(e);
return new Response(JSON.stringify({message: "Invalid data"}), {headers: {'content-type': 'application/json', 'status': 400}});
}
}
default:
return new Response("Unknown endpoint", {status: 400});
}
}
export async function GET(req){
switch(req.params.slug){
case "items":
return getArticles().then(articles => new Response(JSON.stringify(articles), {status: 200}));
case "transactions": {
try {
const userId = parseInt(req.url.searchParams.get("userId"));
const balance = parseInt(req.url.searchParams.get("balance"));
const lastItemTransactionId = req.url.searchParams.get("lastItemTransactionId");
const lastMoneyTransactionId = req.url.searchParams.get("lastMoneyTransactionId");
const lastMoneyTransferId = req.url.searchParams.get("lastMoneyTransferId");
const lastVoucherUseId = req.url.searchParams.get("lastVoucherUseId");
const beforeIds = {
itemTransactions: lastItemTransactionId ? parseInt(lastItemTransactionId) : undefined,
moneyTransactions: lastMoneyTransactionId ? parseInt(lastMoneyTransactionId) : undefined,
moneyTransfers: lastMoneyTransferId ? parseInt(lastMoneyTransferId) : undefined,
voucherUses: lastVoucherUseId ? parseInt(lastVoucherUseId) : undefined
}
if (isNaN(userId) || isNaN(balance) || Object.values(beforeIds).some(id => id!==undefined && isNaN(id))) {
return new Response(JSON.stringify({ message: "Invalid fields" }), { status: 400 });
}
if(userId !== req.locals.user.id && !(req.locals.user.perms & Flags.ADMIN)){
return new Response(JSON.stringify({ message: "You can't view other users' transactions" }), { status: 403 });
}
try {
const transactions = await fetchTransactions(userId, balance, beforeIds, 100);
// use devalue because JSON.stringify/JSON.parse doesn't really support Date
return new Response(devalue.stringify(transactions), { status: 200 });
} catch (error) {
console.error(error);
return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500 });
}
} catch (e) {
return new Response(JSON.stringify({ message: "Invalid data" }), { headers: { 'content-type': 'application/json', 'status': 400 } });
}
}
default:
return new Response("Unknown endpoint", {status: 400});
}
}