stacker.news/api/resolvers/item.js

1278 lines
45 KiB
JavaScript
Raw Normal View History

import { GraphQLError } from 'graphql'
2022-11-15 23:51:00 +00:00
import { ensureProtocol, removeTracking } from '../../lib/url'
2023-12-27 16:15:18 +00:00
import serialize, { serializeInvoicable } from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
2021-08-22 15:25:17 +00:00
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import { ruleSet as publicationDateRuleSet } from '../../lib/timedate-scraper'
2021-08-22 15:25:17 +00:00
import domino from 'domino'
2022-09-21 19:57:36 +00:00
import {
2023-08-24 00:06:26 +00:00
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
2023-12-26 22:51:47 +00:00
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
2023-12-10 22:56:06 +00:00
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER
2022-09-21 19:57:36 +00:00
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
2023-01-11 22:20:14 +00:00
import { parse } from 'tldts'
2023-01-12 18:05:47 +00:00
import uu from 'url-unshort'
2023-12-26 22:51:47 +00:00
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
Service worker rework, Web Target Share API & Web Push API (#324) * npm uninstall next-pwa next-pwa was last updated in August 2022. There is also an issue which mentions that next-pwa is abandoned (?): https://github.com/shadowwalker/next-pwa/issues/482 But the main reason for me uninstalling it is that it adds a lot of preconfigured stuff which is not necessary for us. It even lead to a bug since pages were cached without our knowledge. So I will go with a different PWA approach. This different approach should do the following: - make it more transparent what the service worker is doing - gives us more control to configure the service worker and thus making it easier * Use workbox-webpack-plugin Every other plugin (`next-offline`, `next-workbox-webpack-plugin`, `next-with-workbox`, ...) added unnecessary configuration which felt contrary to how PWAs should be built. (PWAs should progressivly enhance the website in small steps, see https://web.dev/learn/pwa/getting-started/#focus-on-a-feature) These default configurations even lead to worse UX since they made invalid assumptions about stacker.news: We _do not_ want to cache our start url and we _do not_ want to cache anything unless explicitly told to. Almost every page on SN should be fresh for the best UX. To achieve this, by default, the service worker falls back to the network (as if the service worker wasn't there). Therefore, this should be the simplest configuration with a valid precache and cache busting support. In the future, we can try to use prefetching to improve performance of navigation requests. * Add support for Web Share Target API See https://developer.chrome.com/articles/web-share-target/ * Use Web Push API for push notifications I followed this (very good!) guide: https://web.dev/notifications/ * Refactor code related to Web Push * Send push notification to users on events * Merge notifications * Send notification to author of every parent recursively * Remove unused userId param in savePushSubscription As it should be, the user id is retrieved from the authenticated user in the backend. * Resubscribe user if push subscription changed * Update old subscription if oldEndpoint was given * Allow users to unsubscribe * Use LTREE operator instead of recursive query * Always show checkbox for push notifications * Justify checkbox to end * Update title of first push notification * Fix warning from uncontrolled to controlled * Add comment about Notification.requestPermission * Fix timestamp * Catch error on push subscription toggle * Wrap function bodies in try/catch * Use Promise.allSettled * Filter subscriptions by user notification settings * Fix user notification filter * Use skipWaiting --------- Co-authored-by: ekzyis <ek@stacker.news>
2023-07-04 19:36:07 +00:00
import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
2023-11-14 16:23:44 +00:00
import { datePivot, whenRange } from '../../lib/time'
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
import { imageFeesInfo, uploadIdsFromText } from './image'
2023-12-14 17:30:51 +00:00
import assertGofacYourself from './ofac'
2023-09-14 15:46:59 +00:00
function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') {
return 'ORDER BY "Item".created_at DESC, "Item".id DESC'
2021-12-21 21:29:42 +00:00
}
2023-09-14 15:46:59 +00:00
if (me) {
if (sort === 'top') {
return `ORDER BY COALESCE(
personal_top_score,
${orderByNumerator(models, 0)}) DESC NULLS LAST,
"Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
} else {
return `ORDER BY COALESCE(
personal_hot_score,
${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3)) DESC NULLS LAST,
"Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
}
} else {
if (sort === 'top') {
return `ORDER BY ${orderByNumerator(models, 0)} DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
} else {
return `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
}
}
}
async function comments (me, models, id, sort) {
const orderBy = commentsOrderByClause(me, models, sort)
2023-12-28 00:14:22 +00:00
const filter = '' // empty filter as we filter clientside now
2023-05-07 01:25:00 +00:00
if (me) {
2023-09-14 15:46:59 +00:00
const [{ item_comments_zaprank_with_me: comments }] = await models.$queryRawUnsafe(
'SELECT item_comments_zaprank_with_me($1::INTEGER, $2::INTEGER, $3::INTEGER, $4::INTEGER, $5, $6)', Number(id), GLOBAL_SEED, Number(me.id), COMMENT_DEPTH_LIMIT, filter, orderBy)
2023-05-07 01:25:00 +00:00
return comments
}
2023-07-26 16:01:31 +00:00
const [{ item_comments: comments }] = await models.$queryRawUnsafe(
2023-07-27 00:18:42 +00:00
'SELECT item_comments($1::INTEGER, $2::INTEGER, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, filter, orderBy)
2023-05-06 21:51:17 +00:00
return comments
2021-04-29 15:56:28 +00:00
}
2022-09-21 19:57:36 +00:00
export async function getItem (parent, { id }, { me, models }) {
const [item] = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE id = $1`
}, Number(id))
2021-09-23 17:42:00 +00:00
return item
}
2023-09-14 15:46:59 +00:00
const orderByClause = (by, me, models, type) => {
switch (by) {
2022-10-25 21:35:32 +00:00
case 'comments':
return 'ORDER BY "Item".ncomments DESC'
2022-10-25 21:35:32 +00:00
case 'sats':
return 'ORDER BY "Item".msats DESC'
case 'zaprank':
2023-09-14 15:46:59 +00:00
return topOrderByWeightedSats(me, models)
default:
2023-07-29 23:27:32 +00:00
return `ORDER BY ${type === 'bookmarks' ? '"bookmarkCreatedAt"' : '"Item".created_at'} DESC`
2022-10-25 21:35:32 +00:00
}
}
2023-09-14 15:46:59 +00:00
export function orderByNumerator (models, commentScaler = 0.5) {
return `(CASE WHEN "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0 THEN
GREATEST("Item"."weightedVotes" - "Item"."weightedDownVotes", POWER("Item"."weightedVotes" - "Item"."weightedDownVotes", 1.2))
ELSE
"Item"."weightedVotes" - "Item"."weightedDownVotes"
END + "Item"."weightedComments"*${commentScaler})`
2022-09-21 19:57:36 +00:00
}
2023-09-14 15:46:59 +00:00
export function joinZapRankPersonalView (me, models) {
let join = ` JOIN zap_rank_personal_view g ON g.id = "Item".id AND g."viewerId" = ${GLOBAL_SEED} `
2023-05-23 14:21:04 +00:00
if (me) {
2023-09-14 15:46:59 +00:00
join += ` LEFT JOIN zap_rank_personal_view l ON l.id = g.id AND l."viewerId" = ${me.id} `
2023-05-23 14:21:04 +00:00
}
2023-09-14 15:46:59 +00:00
return join
2023-05-23 14:21:04 +00:00
}
2023-05-07 01:25:00 +00:00
// this grabs all the stuff we need to display the item list and only
// hits the db once ... orderBy needs to be duplicated on the outer query because
// joining does not preserve the order of the inner query
async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) {
2023-05-07 01:25:00 +00:00
if (!me) {
2023-07-26 16:01:31 +00:00
return await models.$queryRawUnsafe(`
SELECT "Item".*, to_json(users.*) as user, to_jsonb("Sub".*) as sub
2023-05-07 01:25:00 +00:00
FROM (
${query}
) "Item"
JOIN users ON "Item"."userId" = users.id
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
${orderBy}`, ...args)
2023-05-07 01:25:00 +00:00
} else {
2023-07-26 16:01:31 +00:00
return await models.$queryRawUnsafe(`
2023-09-28 20:02:25 +00:00
SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user,
COALESCE("ItemAct"."meMsats", 0) as "meMsats",
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward",
2023-12-31 01:41:16 +00:00
to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL) as sub
2023-05-07 01:25:00 +00:00
FROM (
${query}
) "Item"
JOIN users ON "Item"."userId" = users.id
2023-09-28 20:02:25 +00:00
LEFT JOIN "Mute" ON "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId"
2023-07-29 23:27:32 +00:00
LEFT JOIN "Bookmark" b ON b."itemId" = "Item".id AND b."userId" = ${me.id}
LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."itemId" = "Item".id AND "ThreadSubscription"."userId" = ${me.id}
2023-08-28 14:40:29 +00:00
LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id}
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
2023-12-31 01:41:16 +00:00
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id}
2023-05-07 01:25:00 +00:00
LEFT JOIN LATERAL (
SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats",
sum("ItemAct".msats) FILTER (WHERE act = 'DONT_LIKE_THIS') AS "meDontLikeMsats"
2023-05-07 01:25:00 +00:00
FROM "ItemAct"
WHERE "ItemAct"."userId" = ${me.id}
AND "ItemAct"."itemId" = "Item".id
GROUP BY "ItemAct"."itemId"
) "ItemAct" ON true
${orderBy}`, ...args)
2023-05-07 01:25:00 +00:00
}
2023-05-06 23:17:47 +00:00
}
const relationClause = (type) => {
2023-09-28 20:02:25 +00:00
let clause = ''
switch (type) {
case 'comments':
2023-09-28 20:02:25 +00:00
clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id '
break
case 'bookmarks':
2023-09-28 20:02:25 +00:00
clause += ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" '
break
case 'outlawed':
case 'borderland':
case 'freebies':
case 'all':
2023-09-28 20:02:25 +00:00
clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id '
break
default:
2023-09-28 20:02:25 +00:00
clause += ' FROM "Item" '
}
2023-09-28 20:02:25 +00:00
return clause
}
2023-07-29 23:27:32 +00:00
const selectClause = (type) => type === 'bookmarks'
? `${SELECT}, "Bookmark"."created_at" as "bookmarkCreatedAt"`
: SELECT
const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item'
2023-09-28 20:02:25 +00:00
export const whereClause = (...clauses) => {
const clause = clauses.flat(Infinity).filter(c => c).join(' AND ')
return clause ? ` WHERE ${clause} ` : ''
}
function whenClause (when, table) {
return `"${table}".created_at <= $2 and "${table}".created_at >= $1`
}
const activeOrMine = (me) => {
2023-09-28 20:02:25 +00:00
return me ? `("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})` : '"Item".status <> \'STOPPED\''
}
export const muteClause = me =>
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
2023-12-15 18:10:29 +00:00
const subClause = (sub, num, table, me) => {
return sub
? `${table ? `"${table}".` : ''}"subName" = $${num}`
2023-12-15 18:10:29 +00:00
: me
? `NOT EXISTS (SELECT 1 FROM "MuteSub" WHERE "MuteSub"."userId" = ${me.id} AND "MuteSub"."subName" = ${table ? `"${table}".` : ''}"subName")`
: ''
2023-09-28 20:02:25 +00:00
}
export async function filterClause (me, models, type) {
// if you are explicitly asking for marginal content, don't filter them
if (['outlawed', 'borderland', 'freebies'].includes(type)) {
if (me && ['outlawed', 'borderland'].includes(type)) {
// unless the item is mine
return `"Item"."userId" <> ${me.id}`
}
return ''
}
// handle freebies
// by default don't include freebies unless they have upvotes
let freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" > 0']
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
if (user.wildWestMode) {
return ''
}
// greeter mode includes freebies if feebies haven't been flagged
if (user.greeterMode) {
freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0']
}
// always include if it's mine
freebieClauses.push(`"Item"."userId" = ${me.id}`)
}
const freebieClause = '(' + freebieClauses.join(' OR ') + ')'
// handle outlawed
// if the item is above the threshold or is mine
const outlawClauses = [`"Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD} AND NOT "Item".outlawed`]
2023-09-28 20:02:25 +00:00
if (me) {
outlawClauses.push(`"Item"."userId" = ${me.id}`)
}
const outlawClause = '(' + outlawClauses.join(' OR ') + ')'
return [freebieClause, outlawClause]
}
function typeClause (type) {
switch (type) {
case 'links':
return ['"Item".url IS NOT NULL', '"Item"."parentId" IS NULL']
case 'discussions':
return ['"Item".url IS NULL', '"Item".bio = false', '"Item"."pollCost" IS NULL', '"Item"."parentId" IS NULL']
case 'polls':
return ['"Item"."pollCost" IS NOT NULL', '"Item"."parentId" IS NULL']
case 'bios':
return ['"Item".bio = true', '"Item"."parentId" IS NULL']
case 'bounties':
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL']
case 'comments':
return '"Item"."parentId" IS NOT NULL'
case 'freebies':
return '"Item".freebie'
case 'outlawed':
return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD} OR "Item".outlawed`
2023-09-28 20:02:25 +00:00
case 'borderland':
return '"Item"."weightedVotes" - "Item"."weightedDownVotes" < 0'
case 'all':
case 'bookmarks':
return ''
case 'jobs':
return '"Item"."subName" = \'jobs\''
default:
return '"Item"."parentId" IS NULL'
}
}
2021-04-12 18:05:09 +00:00
export default {
Query: {
2022-08-10 15:06:31 +00:00
itemRepetition: async (parent, { parentId }, { me, models }) => {
if (!me) return 0
// how many of the parents starting at parentId belong to me
2023-07-27 00:18:42 +00:00
const [{ item_spam: count }] = await models.$queryRawUnsafe(`SELECT item_spam($1::INTEGER, $2::INTEGER, '${ITEM_SPAM_INTERVAL}')`,
2022-09-01 21:06:11 +00:00
Number(parentId), Number(me.id))
2022-08-10 15:06:31 +00:00
return count
},
2023-12-14 18:01:09 +00:00
items: async (parent, { sub, sort, type, cursor, name, when, from, to, by, limit = LIMIT }, { me, models }) => {
2021-06-22 17:47:49 +00:00
const decodedCursor = decodeCursor(cursor)
let items, user, pins, subFull, table
2021-10-26 20:49:37 +00:00
// special authorization for bookmarks depending on owning users' privacy settings
if (type === 'bookmarks' && name && me?.name !== name) {
// the calling user is either not logged in, or not the user upon which the query is made,
// so we need to check authz
user = await models.user.findUnique({ where: { name } })
// additionally check if the user ids are not the same since if the nym changed
// since the last session update we would hide bookmarks from their owners
// see https://github.com/stackernews/stacker.news/issues/586
if (user?.hideBookmarks && user.id !== me.id) {
// early return with no results if bookmarks are hidden
return {
cursor: null,
items: [],
pins: []
}
}
}
2023-05-05 18:06:53 +00:00
// HACK we want to optionally include the subName in the query
// but the query planner doesn't like unused parameters
const subArr = sub ? [sub] : []
2021-06-24 23:56:01 +00:00
switch (sort) {
case 'user':
2021-10-26 20:49:37 +00:00
if (!name) {
throw new GraphQLError('must supply name', { extensions: { code: 'BAD_INPUT' } })
2021-10-26 20:49:37 +00:00
}
user ??= await models.user.findUnique({ where: { name } })
2021-10-26 20:49:37 +00:00
if (!user) {
throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } })
2021-10-26 20:49:37 +00:00
}
table = type === 'bookmarks' ? 'Bookmark' : 'Item'
items = await itemQueryWithMeta({
me,
models,
query: `
2023-07-29 23:27:32 +00:00
${selectClause(type)}
${relationClause(type)}
2023-09-28 20:02:25 +00:00
${whereClause(
`"${table}"."userId" = $3`,
2023-09-28 20:02:25 +00:00
activeOrMine(me),
await filterClause(me, models, type),
typeClause(type),
whenClause(when || 'forever', table))}
2023-09-14 15:46:59 +00:00
${orderByClause(by, me, models, type)}
OFFSET $4
LIMIT $5`,
2023-09-14 15:46:59 +00:00
orderBy: orderByClause(by, me, models, type)
}, ...whenRange(when, from, to || decodedCursor.time), user.id, decodedCursor.offset, limit)
2021-06-24 23:56:01 +00:00
break
2022-02-17 17:23:43 +00:00
case 'recent':
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
${relationClause(type)}
2023-09-28 20:02:25 +00:00
${whereClause(
'"Item".created_at <= $1',
2023-12-15 18:10:29 +00:00
subClause(sub, 4, subClauseTable(type), me),
2023-09-28 20:02:25 +00:00
activeOrMine(me),
await filterClause(me, models, type),
typeClause(type),
muteClause(me)
)}
ORDER BY "Item".created_at DESC
OFFSET $2
LIMIT $3`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
2022-02-17 17:23:43 +00:00
break
case 'top':
2023-09-14 15:46:59 +00:00
if (me && (!by || by === 'zaprank') && (when === 'day' || when === 'week')) {
// personalized zaprank only goes back 7 days
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, GREATEST(g.tf_top_score, l.tf_top_score) AS rank
${relationClause(type)}
${joinZapRankPersonalView(me, models)}
${whereClause(
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
2023-12-15 18:10:29 +00:00
subClause(sub, 5, subClauseTable(type), me),
2023-09-14 15:46:59 +00:00
typeClause(type),
whenClause(when, 'Item'),
2023-09-14 15:46:59 +00:00
await filterClause(me, models, type),
muteClause(me))}
ORDER BY rank DESC
OFFSET $3
LIMIT $4`,
2023-09-14 15:46:59 +00:00
orderBy: 'ORDER BY rank DESC'
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
2023-09-14 15:46:59 +00:00
} else {
items = await itemQueryWithMeta({
me,
models,
query: `
2023-07-29 23:27:32 +00:00
${selectClause(type)}
${relationClause(type)}
2023-09-28 20:02:25 +00:00
${whereClause(
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
2023-12-15 18:10:29 +00:00
subClause(sub, 5, subClauseTable(type), me),
2023-09-28 20:02:25 +00:00
typeClause(type),
whenClause(when, 'Item'),
2023-09-28 20:02:25 +00:00
await filterClause(me, models, type),
muteClause(me))}
2023-09-14 15:46:59 +00:00
${orderByClause(by || 'zaprank', me, models, type)}
OFFSET $3
LIMIT $4`,
2023-09-14 15:46:59 +00:00
orderBy: orderByClause(by || 'zaprank', me, models, type)
}, ...whenRange(when, from, to || decodedCursor.time), decodedCursor.offset, limit, ...subArr)
2023-09-14 15:46:59 +00:00
}
2021-06-24 23:56:01 +00:00
break
2022-02-17 17:23:43 +00:00
default:
// sub so we know the default ranking
if (sub) {
subFull = await models.sub.findUnique({ where: { name: sub } })
}
switch (subFull?.rankingType) {
case 'AUCTION':
items = await itemQueryWithMeta({
me,
models,
query: `
2023-05-08 22:32:37 +00:00
${SELECT},
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
THEN 0 ELSE 1 END AS group_rank,
CASE WHEN status = 'ACTIVE' AND "maxBid" > 0
THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC)
ELSE rank() OVER (ORDER BY created_at DESC) END AS rank
FROM "Item"
2023-09-28 20:02:25 +00:00
${whereClause(
'"parentId" IS NULL',
'created_at <= $1',
'"pinId" IS NULL',
subClause(sub, 4),
"status IN ('ACTIVE', 'NOSATS')"
)}
2023-05-08 22:32:37 +00:00
ORDER BY group_rank, rank
OFFSET $2
LIMIT $3`,
2023-05-08 22:32:37 +00:00
orderBy: 'ORDER BY group_rank, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
2022-02-17 17:23:43 +00:00
break
default:
2023-05-23 14:21:04 +00:00
items = await itemQueryWithMeta({
me,
models,
query: `
2023-09-14 15:46:59 +00:00
${SELECT}, ${me ? 'GREATEST(g.tf_hot_score, l.tf_hot_score)' : 'g.tf_hot_score'} AS rank
2023-05-06 23:17:47 +00:00
FROM "Item"
2023-09-14 15:46:59 +00:00
${joinZapRankPersonalView(me, models)}
2023-09-28 20:02:25 +00:00
${whereClause(
2023-09-14 15:46:59 +00:00
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
2023-12-15 18:10:29 +00:00
subClause(sub, 3, 'Item', me),
2023-09-28 20:02:25 +00:00
muteClause(me))}
2023-09-14 15:46:59 +00:00
ORDER BY rank DESC
2023-05-23 14:21:04 +00:00
OFFSET $1
LIMIT $2`,
2023-09-14 15:46:59 +00:00
orderBy: 'ORDER BY rank DESC'
}, decodedCursor.offset, limit, ...subArr)
2022-02-17 17:23:43 +00:00
2023-11-21 23:32:22 +00:00
// XXX this is just for subs that are really empty
if (decodedCursor.offset === 0 && items.length < limit) {
2023-09-14 15:46:59 +00:00
items = await itemQueryWithMeta({
me,
models,
query: `
2023-11-21 23:32:22 +00:00
${SELECT}
2023-09-14 15:46:59 +00:00
FROM "Item"
${whereClause(
2023-12-15 18:10:29 +00:00
subClause(sub, 3, 'Item', me),
2023-12-10 22:56:06 +00:00
muteClause(me),
2023-12-26 22:51:47 +00:00
'"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL',
'"Item".bio = false',
2023-12-10 22:56:06 +00:00
await filterClause(me, models, type))}
2023-11-21 23:32:22 +00:00
ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC
2023-09-14 15:46:59 +00:00
OFFSET $1
LIMIT $2`,
2023-11-21 23:32:22 +00:00
orderBy: `ORDER BY ${orderByNumerator(models, 0)}/POWER(GREATEST(3, EXTRACT(EPOCH FROM (now_utc() - "Item".created_at))/3600), 1.3) DESC NULLS LAST, "Item".msats DESC, ("Item".freebie IS FALSE) DESC, "Item".id DESC`
2023-09-14 15:46:59 +00:00
}, decodedCursor.offset, limit, ...subArr)
}
2022-02-17 17:23:43 +00:00
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await itemQueryWithMeta({
me,
models,
query: `
SELECT rank_filter.*
FROM (
${SELECT},
rank() OVER (
PARTITION BY "pinId"
2023-09-28 20:02:25 +00:00
ORDER BY "Item".created_at DESC
)
FROM "Item"
2023-09-28 20:02:25 +00:00
${whereClause(
'"pinId" IS NOT NULL',
2023-09-14 15:46:59 +00:00
sub ? '("subName" = $1 OR "subName" IS NULL)' : '"subName" IS NULL',
2023-09-29 19:43:25 +00:00
muteClause(me))}
) rank_filter WHERE RANK = 1`
}, ...subArr)
2022-02-17 17:23:43 +00:00
}
break
}
break
2021-06-24 23:56:01 +00:00
}
2021-06-22 17:47:49 +00:00
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
2022-01-07 16:32:31 +00:00
items,
pins
2021-06-22 17:47:49 +00:00
}
2021-04-24 21:05:07 +00:00
},
2021-09-23 17:42:00 +00:00
item: getItem,
2023-01-12 18:05:47 +00:00
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
const res = {}
2021-08-22 15:25:17 +00:00
try {
const response = await fetch(ensureProtocol(url), { redirect: 'follow' })
const html = await response.text()
const doc = domino.createWindow(html).document
const metadata = getMetadata(doc, url, { title: metadataRuleSets.title, publicationDate: publicationDateRuleSet })
const dateHint = ` (${metadata.publicationDate?.getFullYear()})`
const moreThanOneYearAgo = metadata.publicationDate && metadata.publicationDate < datePivot(new Date(), { years: -1 })
2023-01-12 18:05:47 +00:00
res.title = metadata?.title
if (moreThanOneYearAgo) res.title += dateHint
2023-01-12 18:05:47 +00:00
} catch { }
try {
const unshorted = await uu().expand(url)
if (unshorted) {
res.unshorted = unshorted
}
} catch { }
return res
2021-10-28 20:49:51 +00:00
},
2023-05-07 01:25:00 +00:00
dupes: async (parent, { url }, { me, models }) => {
2021-10-28 20:49:51 +00:00
const urlObj = new URL(ensureProtocol(url))
let { hostname, pathname } = urlObj
hostname = hostname + '(:[0-9]+)?'
2023-01-11 22:20:14 +00:00
const parseResult = parse(urlObj.hostname)
if (parseResult?.subdomain?.length) {
const { subdomain } = parseResult
hostname = hostname.replace(subdomain, '(%)?')
2023-01-11 22:20:14 +00:00
} else {
hostname = `(%.)?${hostname}`
2023-01-11 22:20:14 +00:00
}
// escape postgres regex meta characters
pathname = pathname.replace(/\+/g, '\\+')
pathname = pathname.replace(/%/g, '\\%')
pathname = pathname.replace(/_/g, '\\_')
let uri = hostname + pathname
uri = uri.endsWith('/') ? uri.slice(0, -1) : uri
2023-01-11 22:20:14 +00:00
let similar = `(http(s)?://)?${uri}/?`
const whitelist = ['news.ycombinator.com/item', 'bitcointalk.org/index.php']
2023-01-11 22:04:50 +00:00
const youtube = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be']
if (whitelist.includes(uri)) {
similar += `\\${urlObj.search}`
} else if (youtube.includes(urlObj.hostname)) {
// extract id and create both links
const matches = url.match(/(https?:\/\/)?((www\.)?(youtube(-nocookie)?|youtube.googleapis)\.com.*(v\/|v=|vi=|vi\/|e\/|embed\/|user\/.*\/u\/\d+\/)|youtu\.be\/)(?<id>[_0-9a-z-]+)/i)
2023-01-24 14:37:33 +00:00
similar = `(http(s)?://)?((www.|m.)?youtube.com/(watch\\?v=|v/|live/)${matches?.groups?.id}|youtu.be/${matches?.groups?.id})((\\?|&|#)%)?`
} else {
2022-05-18 18:21:24 +00:00
similar += '((\\?|#)%)?'
}
return await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE LOWER(url) SIMILAR TO LOWER($1)
ORDER BY created_at DESC
LIMIT 3`
}, similar)
2021-12-21 21:29:42 +00:00
},
2022-09-29 20:42:33 +00:00
auctionPosition: async (parent, { id, sub, bid }, { models, me }) => {
2022-10-05 18:55:30 +00:00
const createdAt = id ? (await getItem(parent, { id }, { models, me })).createdAt : new Date()
let where
2022-09-29 20:42:33 +00:00
if (bid > 0) {
2022-10-05 18:55:30 +00:00
// if there's a bid
// it's ACTIVE and has a larger bid than ours, or has an equal bid and is older
// count items: (bid > ours.bid OR (bid = ours.bid AND create_at < ours.created_at)) AND status = 'ACTIVE'
where = {
status: 'ACTIVE',
OR: [
{ maxBid: { gt: bid } },
{ maxBid: bid, createdAt: { lt: createdAt } }
]
}
2022-09-29 20:42:33 +00:00
} else {
2022-10-05 18:55:30 +00:00
// else
// it's an active with a bid gt ours, or its newer than ours and not STOPPED
// count items: ((bid > ours.bid AND status = 'ACTIVE') OR (created_at > ours.created_at AND status <> 'STOPPED'))
where = {
OR: [
{ maxBid: { gt: 0 }, status: 'ACTIVE' },
{ createdAt: { gt: createdAt }, status: { not: 'STOPPED' } }
]
}
2022-09-29 20:42:33 +00:00
}
2022-10-05 18:55:30 +00:00
where.subName = sub
2022-02-17 17:23:43 +00:00
if (id) {
2022-10-05 18:55:30 +00:00
where.id = { not: Number(id) }
2022-02-17 17:23:43 +00:00
}
2022-10-05 18:55:30 +00:00
return await models.item.count({ where }) + 1
2021-04-12 18:05:09 +00:00
}
},
Mutation: {
bookmarkItem: async (parent, { id }, { me, models }) => {
const data = { itemId: Number(id), userId: me.id }
const old = await models.bookmark.findUnique({ where: { userId_itemId: data } })
if (old) {
await models.bookmark.delete({ where: { userId_itemId: data } })
} else await models.bookmark.create({ data })
return { id }
},
subscribeItem: async (parent, { id }, { me, models }) => {
const data = { itemId: Number(id), userId: me.id }
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
if (old) {
await models.threadSubscription.delete({ where: { userId_itemId: data } })
} else await models.threadSubscription.create({ data })
return { id }
},
2023-01-12 23:53:09 +00:00
deleteItem: async (parent, { id }, { me, models }) => {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
2023-01-12 23:53:09 +00:00
}
2023-11-19 20:16:35 +00:00
if (old.bio) {
throw new GraphQLError('cannot delete bio', { extensions: { code: 'BAD_INPUT' } })
}
2023-01-12 23:53:09 +00:00
return await deleteItemByAuthor({ models, id, item: old })
2023-01-12 23:53:09 +00:00
},
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(linkSchema, item, { models, me })
2023-02-08 19:38:04 +00:00
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
2021-08-11 20:13:10 +00:00
}
},
upsertDiscussion: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(discussionSchema, item, { models, me })
2023-02-08 19:38:04 +00:00
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
2021-08-11 20:13:10 +00:00
}
},
upsertBounty: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(bountySchema, item, { models, me })
2023-01-26 16:11:55 +00:00
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
2023-01-26 16:11:55 +00:00
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
2023-01-26 16:11:55 +00:00
}
},
upsertPoll: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
const numExistingChoices = id
2023-02-08 19:38:04 +00:00
? await models.pollOption.count({
2023-07-25 14:14:45 +00:00
where: {
itemId: Number(id)
}
})
2023-02-08 19:38:04 +00:00
: 0
await ssValidate(pollSchema, item, { models, me, numExistingChoices })
2022-07-30 13:25:46 +00:00
2022-08-18 18:15:24 +00:00
if (id) {
return await updateItem(parent, { id, ...item }, { me, models, lnd, hash, hmac })
2022-07-30 13:25:46 +00:00
} else {
2023-08-24 00:06:26 +00:00
item.pollCost = item.pollCost || POLL_COST
return await createItem(parent, item, { me, models, lnd, hash, hmac })
2022-07-30 13:25:46 +00:00
}
},
upsertJob: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
2022-02-17 17:23:43 +00:00
if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
2022-02-17 17:23:43 +00:00
}
2023-08-24 00:06:26 +00:00
item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location
await ssValidate(jobSchema, item, { models })
2023-09-14 15:35:13 +00:00
if (item.logo !== undefined) {
2023-08-24 00:06:26 +00:00
item.uploadId = item.logo
delete item.logo
2022-02-17 17:23:43 +00:00
}
2023-08-24 00:06:26 +00:00
item.maxBid ??= 0
2022-02-17 17:23:43 +00:00
if (id) {
2023-08-24 00:06:26 +00:00
return await updateItem(parent, { id, ...item }, { me, models })
2022-09-29 20:42:33 +00:00
} else {
return await createItem(parent, item, { me, models, lnd, hash, hmac })
2022-02-17 17:23:43 +00:00
}
},
upsertComment: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
2023-08-24 00:06:26 +00:00
await ssValidate(commentSchema, item)
Service worker rework, Web Target Share API & Web Push API (#324) * npm uninstall next-pwa next-pwa was last updated in August 2022. There is also an issue which mentions that next-pwa is abandoned (?): https://github.com/shadowwalker/next-pwa/issues/482 But the main reason for me uninstalling it is that it adds a lot of preconfigured stuff which is not necessary for us. It even lead to a bug since pages were cached without our knowledge. So I will go with a different PWA approach. This different approach should do the following: - make it more transparent what the service worker is doing - gives us more control to configure the service worker and thus making it easier * Use workbox-webpack-plugin Every other plugin (`next-offline`, `next-workbox-webpack-plugin`, `next-with-workbox`, ...) added unnecessary configuration which felt contrary to how PWAs should be built. (PWAs should progressivly enhance the website in small steps, see https://web.dev/learn/pwa/getting-started/#focus-on-a-feature) These default configurations even lead to worse UX since they made invalid assumptions about stacker.news: We _do not_ want to cache our start url and we _do not_ want to cache anything unless explicitly told to. Almost every page on SN should be fresh for the best UX. To achieve this, by default, the service worker falls back to the network (as if the service worker wasn't there). Therefore, this should be the simplest configuration with a valid precache and cache busting support. In the future, we can try to use prefetching to improve performance of navigation requests. * Add support for Web Share Target API See https://developer.chrome.com/articles/web-share-target/ * Use Web Push API for push notifications I followed this (very good!) guide: https://web.dev/notifications/ * Refactor code related to Web Push * Send push notification to users on events * Merge notifications * Send notification to author of every parent recursively * Remove unused userId param in savePushSubscription As it should be, the user id is retrieved from the authenticated user in the backend. * Resubscribe user if push subscription changed * Update old subscription if oldEndpoint was given * Allow users to unsubscribe * Use LTREE operator instead of recursive query * Always show checkbox for push notifications * Justify checkbox to end * Update title of first push notification * Fix warning from uncontrolled to controlled * Add comment about Notification.requestPermission * Fix timestamp * Catch error on push subscription toggle * Wrap function bodies in try/catch * Use Promise.allSettled * Filter subscriptions by user notification settings * Fix user notification filter * Use skipWaiting --------- Co-authored-by: ekzyis <ek@stacker.news>
2023-07-04 19:36:07 +00:00
2023-08-24 00:06:26 +00:00
if (id) {
return await updateItem(parent, { id, ...item }, { me, models })
} else {
item = await createItem(parent, item, { me, models, lnd, hash, hmac })
notifyItemParents({ item, me, models })
return item
2023-08-24 00:06:26 +00:00
}
2021-08-10 22:59:06 +00:00
},
2023-12-19 18:31:24 +00:00
updateNoteId: async (parent, { id, noteId }, { me, models }) => {
if (!id) {
throw new GraphQLError('id required', { extensions: { code: 'BAD_INPUT' } })
}
await models.item.update({
2023-12-19 18:31:24 +00:00
where: { id: Number(id), userId: Number(me.id) },
data: { noteId }
})
return { id, noteId }
},
2023-09-26 20:15:09 +00:00
pollVote: async (parent, { id, hash, hmac }, { me, models, lnd }) => {
2022-07-30 13:25:46 +00:00
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
2022-07-30 13:25:46 +00:00
}
2023-09-26 20:15:09 +00:00
await serializeInvoicable(
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)),
{ me, models, lnd, hash, hmac }
)
2022-07-30 13:25:46 +00:00
return id
},
2023-12-27 16:15:18 +00:00
act: async (parent, { id, sats, act = 'TIP', idempotent, hash, hmac }, { me, models, lnd, headers }) => {
2023-12-26 22:51:47 +00:00
await ssValidate(actSchema, { sats, act })
2023-12-14 17:30:51 +00:00
await assertGofacYourself({ models, headers })
2021-04-27 21:30:58 +00:00
2023-12-26 21:55:48 +00:00
const [item] = await models.$queryRawUnsafe(`
Allow zapping, posting and commenting without funds or an account (#336) * Add anon zaps * Add anon comments and posts (link, discussion, poll) * Use payment hash instead of invoice id as proof of payment Our invoice IDs can be enumerated. So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself. Random payment hashes prevent this. Also, since we delete invoices after use, using database IDs as proof of payments are not suitable. If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs. * Allow pay per invoice for stackers The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice" * Fix onSuccess called twice For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice. This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice. * Keep invoice modal open if focus is lost * Skip anon user during trust calculation * Add error handling * Skip 'invoice not found' errors * Remove duplicate insufficient funds handling * Fix insufficient funds error detection * Fix invoice amount for comments * Allow pay per invoice for bounty and job posts * Also strike on payment after short press * Fix unexpected token 'export' * Fix eslint * Remove unused id param * Fix comment copy-paste error * Rename to useInvoiceable * Fix unexpected token 'export' * Fix onConfirmation called at every render * Add invoice HMAC This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash. * make anon posting less hidden, add anon info button explainer * Fix anon users can't zap other anon users * Always show repeat and contacts on action error * Keep track of modal stack * give anon an icon * add generic date pivot helper * make anon user's invoices expire in 5 minutes * fix forgotten find and replace * use datePivot more places * add sat amounts to invoices * reduce anon invoice expiration to 3 minutes * don't abbreviate * Fix [object Object] as error message Any errors thrown here are already objects of shape { message: string } * Fix empty invoice creation attempts I stumbled across this while checking if anons can edit their items. I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error: Variable "$amount" of required type "Int!" was not provided. I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now. * anon func mods, e.g. inv limits * anon tips should be denormalized * remove redundant meTotalSats * correct overlay zap text for anon * exclude anon from trust graph before algo runs * remove balance limit on anon * give anon a bio and remove cowboy hat/top stackers; * make anon hat appear on profile * concat hash and hmac and call it a token * Fix localStorage cleared because error were swallowed * fix qr layout shift * restyle fund error modal * Catch invoice errors in fund error modal * invoice check backoff * anon info typo * make invoice expiration times have saner defaults * add comma to anon info * use builtin copy input label --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
${SELECT}
FROM "Item"
2023-12-26 21:55:48 +00:00
WHERE id = $1`, Number(id))
// disallow self tips except anons
if (me) {
if (Number(item.userId) === Number(me.id)) {
Allow zapping, posting and commenting without funds or an account (#336) * Add anon zaps * Add anon comments and posts (link, discussion, poll) * Use payment hash instead of invoice id as proof of payment Our invoice IDs can be enumerated. So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself. Random payment hashes prevent this. Also, since we delete invoices after use, using database IDs as proof of payments are not suitable. If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs. * Allow pay per invoice for stackers The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice" * Fix onSuccess called twice For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice. This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice. * Keep invoice modal open if focus is lost * Skip anon user during trust calculation * Add error handling * Skip 'invoice not found' errors * Remove duplicate insufficient funds handling * Fix insufficient funds error detection * Fix invoice amount for comments * Allow pay per invoice for bounty and job posts * Also strike on payment after short press * Fix unexpected token 'export' * Fix eslint * Remove unused id param * Fix comment copy-paste error * Rename to useInvoiceable * Fix unexpected token 'export' * Fix onConfirmation called at every render * Add invoice HMAC This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash. * make anon posting less hidden, add anon info button explainer * Fix anon users can't zap other anon users * Always show repeat and contacts on action error * Keep track of modal stack * give anon an icon * add generic date pivot helper * make anon user's invoices expire in 5 minutes * fix forgotten find and replace * use datePivot more places * add sat amounts to invoices * reduce anon invoice expiration to 3 minutes * don't abbreviate * Fix [object Object] as error message Any errors thrown here are already objects of shape { message: string } * Fix empty invoice creation attempts I stumbled across this while checking if anons can edit their items. I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error: Variable "$amount" of required type "Int!" was not provided. I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now. * anon func mods, e.g. inv limits * anon tips should be denormalized * remove redundant meTotalSats * correct overlay zap text for anon * exclude anon from trust graph before algo runs * remove balance limit on anon * give anon a bio and remove cowboy hat/top stackers; * make anon hat appear on profile * concat hash and hmac and call it a token * Fix localStorage cleared because error were swallowed * fix qr layout shift * restyle fund error modal * Catch invoice errors in fund error modal * invoice check backoff * anon info typo * make invoice expiration times have saner defaults * add comma to anon info * use builtin copy input label --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
}
2023-09-26 20:15:09 +00:00
// Disallow tips if me is one of the forward user recipients
2023-12-26 22:51:47 +00:00
if (act === 'TIP') {
const existingForwards = await models.itemForward.findMany({ where: { itemId: Number(id) } })
if (existingForwards.some(fwd => Number(fwd.userId) === Number(me.id))) {
throw new GraphQLError('cannot zap a post for which you are forwarded zaps', { extensions: { code: 'BAD_INPUT' } })
}
2023-09-26 20:15:09 +00:00
}
Allow zapping, posting and commenting without funds or an account (#336) * Add anon zaps * Add anon comments and posts (link, discussion, poll) * Use payment hash instead of invoice id as proof of payment Our invoice IDs can be enumerated. So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself. Random payment hashes prevent this. Also, since we delete invoices after use, using database IDs as proof of payments are not suitable. If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs. * Allow pay per invoice for stackers The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice" * Fix onSuccess called twice For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice. This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice. * Keep invoice modal open if focus is lost * Skip anon user during trust calculation * Add error handling * Skip 'invoice not found' errors * Remove duplicate insufficient funds handling * Fix insufficient funds error detection * Fix invoice amount for comments * Allow pay per invoice for bounty and job posts * Also strike on payment after short press * Fix unexpected token 'export' * Fix eslint * Remove unused id param * Fix comment copy-paste error * Rename to useInvoiceable * Fix unexpected token 'export' * Fix onConfirmation called at every render * Add invoice HMAC This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash. * make anon posting less hidden, add anon info button explainer * Fix anon users can't zap other anon users * Always show repeat and contacts on action error * Keep track of modal stack * give anon an icon * add generic date pivot helper * make anon user's invoices expire in 5 minutes * fix forgotten find and replace * use datePivot more places * add sat amounts to invoices * reduce anon invoice expiration to 3 minutes * don't abbreviate * Fix [object Object] as error message Any errors thrown here are already objects of shape { message: string } * Fix empty invoice creation attempts I stumbled across this while checking if anons can edit their items. I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error: Variable "$amount" of required type "Int!" was not provided. I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now. * anon func mods, e.g. inv limits * anon tips should be denormalized * remove redundant meTotalSats * correct overlay zap text for anon * exclude anon from trust graph before algo runs * remove balance limit on anon * give anon a bio and remove cowboy hat/top stackers; * make anon hat appear on profile * concat hash and hmac and call it a token * Fix localStorage cleared because error were swallowed * fix qr layout shift * restyle fund error modal * Catch invoice errors in fund error modal * invoice check backoff * anon info typo * make invoice expiration times have saner defaults * add comma to anon info * use builtin copy input label --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
}
2023-12-27 16:15:18 +00:00
if (idempotent) {
await serialize(
models,
models.$queryRaw`
2023-09-26 20:15:09 +00:00
SELECT
2023-12-27 16:15:18 +00:00
item_act(${Number(id)}::INTEGER, ${me.id}::INTEGER, ${act}::"ItemActType",
(SELECT ${Number(sats)}::INTEGER - COALESCE(sum(msats) / 1000, 0)
FROM "ItemAct"
WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`
)
} else {
await serializeInvoicable(
models.$queryRaw`
SELECT
item_act(${Number(id)}::INTEGER,
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ me, models, lnd, hash, hmac, enforceFee: sats }
)
2023-12-27 02:27:52 +00:00
}
notifyZapped({ models, id })
2021-09-10 21:13:52 +00:00
return {
2023-12-26 21:55:48 +00:00
id,
sats,
2023-12-26 22:51:47 +00:00
act,
2023-12-26 21:55:48 +00:00
path: item.path
2021-09-10 21:13:52 +00:00
}
},
toggleOutlaw: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const item = await models.item.findUnique({
where: { id: Number(id) },
include: {
sub: true,
root: {
include: {
sub: true
}
}
}
})
const sub = item.sub || item.root?.sub
if (Number(sub.userId) !== Number(me.id)) {
throw new GraphQLError('you cant do this broh', { extensions: { code: 'FORBIDDEN' } })
}
if (item.outlawed) {
return item
}
const [result] = await models.$transaction(
[
models.item.update({
where: {
id: Number(id)
},
data: {
outlawed: true
}
}),
models.sub.update({
where: {
name: sub.name
},
data: {
moderatedCount: {
increment: 1
}
}
})
])
return result
2021-04-12 18:05:09 +00:00
}
},
Item: {
2022-11-15 20:51:55 +00:00
sats: async (item, args, { models }) => {
return msatsToSats(item.msats)
},
commentSats: async (item, args, { models }) => {
return msatsToSats(item.commentMsats)
},
2022-09-29 20:42:33 +00:00
isJob: async (item, args, { models }) => {
return item.subName === 'jobs'
},
2022-02-17 17:23:43 +00:00
sub: async (item, args, { models }) => {
if (!item.subName && !item.root) {
2022-02-17 17:23:43 +00:00
return null
}
2023-12-15 18:10:29 +00:00
if (item.sub) {
return item.sub
}
return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } })
2022-02-17 17:23:43 +00:00
},
2022-01-07 16:32:31 +00:00
position: async (item, args, { models }) => {
if (!item.pinId) {
return null
}
const pin = await models.pin.findUnique({ where: { id: item.pinId } })
if (!pin) {
return null
}
return pin.position
},
prior: async (item, args, { models }) => {
if (!item.pinId) {
return null
}
const prior = await models.item.findFirst({
where: {
pinId: item.pinId,
createdAt: {
lt: item.createdAt
}
},
orderBy: {
createdAt: 'desc'
}
})
if (!prior) {
return null
}
return prior.id
},
2022-07-30 13:25:46 +00:00
poll: async (item, args, { models, me }) => {
if (!item.pollCost) {
return null
}
const options = await models.$queryRaw`
2023-07-27 00:18:42 +00:00
SELECT "PollOption".id, option, count("PollVote"."userId")::INTEGER as count,
2022-07-30 13:25:46 +00:00
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
WHERE "PollOption"."itemId" = ${item.id}
GROUP BY "PollOption".id
ORDER BY "PollOption".id ASC
`
2023-07-27 00:18:42 +00:00
2022-07-30 13:25:46 +00:00
const poll = {}
poll.options = options
poll.meVoted = options.some(o => o.meVoted)
poll.count = options.reduce((t, o) => t + o.count, 0)
return poll
},
2023-05-06 23:17:47 +00:00
user: async (item, args, { models }) => {
if (item.user) {
return item.user
}
return await models.user.findUnique({ where: { id: item.userId } })
},
multiple forwards on a post (#403) * multiple forwards on a post first phase of the multi-forward support * update the graphql mutation for discussion posts to accept and validate multiple forwards * update the discussion form to allow multiple forwards in the UI * start working on db schema changes * uncomment db schema, add migration to create the new model, and update create_item, update_item stored procedures * Propagate updates from discussion to poll, link, and bounty forms Update the create, update poll sql functions for multi forward support * Update gql, typedefs, and resolver to return forwarded users in items responses * UI changes to show multiple forward recipients, and conditional upvote logic changes * Update notification text to reflect multiple forwards upon vote action * Disallow duplicate stacker entries * reduce duplication in populating adv-post-form initial values * Update item_act sql function to implement multi-way forwarding * Update referral functions to scale referral bonuses for forwarded users * Update notification text to reflect non-100% forwarded sats cases * Update wallet history sql queries to accommodate multi-forward use cases * Block zaps for posts you are forwarded zaps at the API layer, in addition to in the UI * Delete fwdUserId column from Item table as part of migration * Fix how we calculate stacked sats after partial forwards in wallet history * Exclude entries from wallet history that are 0 stacked sats from posts with 100% forwarded to other users * Fix wallet history query for forwarded stacked sats to be scaled by the fwd pct * Reduce duplication in adv post form, and do some style tweaks for better layout * Use MAX_FORWARDS constants * Address various PR feedback * first enhancement pass * enhancement pass too --------- Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2023-08-23 22:44:17 +00:00
forwards: async (item, args, { models }) => {
return await models.itemForward.findMany({
where: {
itemId: item.id
},
include: {
user: true
}
})
2022-04-19 18:32:39 +00:00
},
2023-07-26 00:45:35 +00:00
comments: async (item, { sort }, { me, models }) => {
if (typeof item.comments !== 'undefined') return item.comments
2023-07-26 00:45:35 +00:00
if (item.ncomments === 0) return []
2023-07-26 00:45:35 +00:00
return comments(me, models, item.id, sort || defaultCommentSort(item.pinId, item.bioId, item.createdAt))
},
2023-11-21 23:26:24 +00:00
freedFreebie: async (item) => {
return item.weightedVotes - item.weightedDownVotes > 0
2022-10-28 15:58:31 +00:00
},
2021-12-05 17:37:55 +00:00
meSats: async (item, args, { me, models }) => {
2021-09-10 21:13:52 +00:00
if (!me) return 0
2023-07-27 00:18:42 +00:00
if (typeof item.meMsats !== 'undefined') {
return msatsToSats(item.meMsats)
}
2021-09-10 21:13:52 +00:00
2023-07-26 16:01:31 +00:00
const { _sum: { msats } } = await models.itemAct.aggregate({
_sum: {
2022-11-15 20:51:55 +00:00
msats: true
2021-09-10 21:13:52 +00:00
},
where: {
2022-01-27 19:18:48 +00:00
itemId: Number(item.id),
2021-09-10 21:13:52 +00:00
userId: me.id,
2021-12-05 17:37:55 +00:00
OR: [
{
act: 'TIP'
},
{
2022-11-23 18:12:09 +00:00
act: 'FEE'
2021-12-05 17:37:55 +00:00
}
]
2021-09-10 21:13:52 +00:00
}
})
2022-11-15 20:51:55 +00:00
return (msats && msatsToSats(msats)) || 0
2021-09-10 21:13:52 +00:00
},
meDontLikeSats: async (item, args, { me, models }) => {
2022-09-21 19:57:36 +00:00
if (!me) return false
if (typeof item.meMsats !== 'undefined') {
return msatsToSats(item.meDontLikeMsats)
}
2022-09-21 19:57:36 +00:00
const { _sum: { msats } } = await models.itemAct.aggregate({
_sum: {
msats: true
},
2022-09-21 19:57:36 +00:00
where: {
itemId: Number(item.id),
userId: me.id,
act: 'DONT_LIKE_THIS'
}
})
return (msats && msatsToSats(msats)) || 0
2022-09-21 19:57:36 +00:00
},
meBookmark: async (item, args, { me, models }) => {
if (!me) return false
2023-07-29 23:27:32 +00:00
if (typeof item.meBookmark !== 'undefined') return item.meBookmark
const bookmark = await models.bookmark.findUnique({
where: {
userId_itemId: {
itemId: Number(item.id),
userId: me.id
}
}
})
return !!bookmark
},
meSubscription: async (item, args, { me, models }) => {
if (!me) return false
2023-07-29 23:27:32 +00:00
if (typeof item.meSubscription !== 'undefined') return item.meSubscription
const subscription = await models.threadSubscription.findUnique({
where: {
userId_itemId: {
itemId: Number(item.id),
userId: me.id
}
}
})
return !!subscription
},
2022-09-22 18:44:50 +00:00
outlawed: async (item, args, { me, models }) => {
if (me && Number(item.userId) === Number(me.id)) {
return false
}
return item.outlawed || item.weightedVotes - item.weightedDownVotes <= -ITEM_FILTER_THRESHOLD
2022-09-22 18:44:50 +00:00
},
2021-12-05 17:37:55 +00:00
mine: async (item, args, { me, models }) => {
return me?.id === item.userId
},
2023-12-31 01:41:16 +00:00
root: async (item, args, { models, me }) => {
2023-01-26 19:37:51 +00:00
if (!item.rootId) {
2021-07-08 00:15:27 +00:00
return null
}
2023-02-04 00:08:08 +00:00
if (item.root) {
return item.root
}
2023-12-31 01:41:16 +00:00
return await getItem(item, { id: item.rootId }, { me, models })
2021-07-08 00:15:27 +00:00
},
parent: async (item, args, { models }) => {
if (!item.parentId) {
return null
}
return await models.item.findUnique({ where: { id: item.parentId } })
2023-01-22 20:17:50 +00:00
},
parentOtsHash: async (item, args, { models }) => {
if (!item.parentId) {
return null
}
const parent = await models.item.findUnique({ where: { id: item.parentId } })
return parent.otsHash
},
deleteScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
if (meId !== item.userId) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null
}
const deleteJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}'`)
return deleteJobs[0]?.startafter ?? null
}
}
}
2021-08-18 22:20:33 +00:00
const namePattern = /\B@[\w_]+/gi
2021-09-23 17:42:00 +00:00
export const createMentions = async (item, models) => {
2021-08-18 22:20:33 +00:00
// if we miss a mention, in the rare circumstance there's some kind of
// failure, it's not a big deal so we don't do it transactionally
// ideally, we probably would
if (!item.text) {
return
}
try {
2021-08-19 19:53:11 +00:00
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) {
2021-08-18 22:20:33 +00:00
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
2021-08-18 22:20:33 +00:00
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
2023-12-28 20:44:56 +00:00
const mention = await models.mention.upsert({
2021-08-18 22:20:33 +00:00
where: {
itemId_userId: data
},
update: data,
create: data
})
2023-12-28 20:44:56 +00:00
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
sendUserNotification(user.id, {
title: 'you were mentioned',
body: item.text,
item,
tag: 'MENTION'
}).catch(console.error)
}
2021-08-18 22:20:33 +00:00
})
}
} catch (e) {
console.error('mention failure', e)
2021-08-18 22:20:33 +00:00
}
}
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
// update iff this item belongs to me
2023-12-10 23:42:30 +00:00
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
}
2023-12-11 01:26:25 +00:00
if (subName && old.subName !== subName) {
2023-12-10 23:42:30 +00:00
const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.freebie) {
if (!sub.allowFreebies) {
throw new GraphQLError(`~${subName} does not allow freebies`, { extensions: { code: 'BAD_INPUT' } })
}
} else if (sub.baseCost > old.sub.baseCost) {
throw new GraphQLError('cannot change to a more expensive sub', { extensions: { code: 'BAD_INPUT' } })
}
}
2023-09-26 20:15:09 +00:00
// in case they lied about their existing boost
await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
2023-10-13 20:27:27 +00:00
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
2022-08-18 18:15:24 +00:00
const user = await models.user.findUnique({ where: { id: me.id } })
2023-10-13 20:27:27 +00:00
if (!ITEM_ALLOW_EDITS.includes(old.id) && user.bioId !== old.id &&
!isJob(item) && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) {
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
}
if (item.url && !isJob(item)) {
2023-08-24 00:06:26 +00:00
item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url)
2022-08-26 23:31:51 +00:00
}
// only update item with the boost delta ... this is a bit of hack given the way
// boost used to work
if (item.boost > 0 && old.boost > 0) {
// only update the boost if it is higher than the old boost
if (item.boost > old.boost) {
item.boost = item.boost - old.boost
} else {
delete item.boost
}
}
2022-08-26 23:31:51 +00:00
2023-08-24 00:06:26 +00:00
item = { subName, userId: me.id, ...item }
multiple forwards on a post (#403) * multiple forwards on a post first phase of the multi-forward support * update the graphql mutation for discussion posts to accept and validate multiple forwards * update the discussion form to allow multiple forwards in the UI * start working on db schema changes * uncomment db schema, add migration to create the new model, and update create_item, update_item stored procedures * Propagate updates from discussion to poll, link, and bounty forms Update the create, update poll sql functions for multi forward support * Update gql, typedefs, and resolver to return forwarded users in items responses * UI changes to show multiple forward recipients, and conditional upvote logic changes * Update notification text to reflect multiple forwards upon vote action * Disallow duplicate stacker entries * reduce duplication in populating adv-post-form initial values * Update item_act sql function to implement multi-way forwarding * Update referral functions to scale referral bonuses for forwarded users * Update notification text to reflect non-100% forwarded sats cases * Update wallet history sql queries to accommodate multi-forward use cases * Block zaps for posts you are forwarded zaps at the API layer, in addition to in the UI * Delete fwdUserId column from Item table as part of migration * Fix how we calculate stacked sats after partial forwards in wallet history * Exclude entries from wallet history that are 0 stacked sats from posts with 100% forwarded to other users * Fix wallet history query for forwarded stacked sats to be scaled by the fwd pct * Reduce duplication in adv post form, and do some style tweaks for better layout * Use MAX_FORWARDS constants * Address various PR feedback * first enhancement pass * enhancement pass too --------- Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2023-08-23 22:44:17 +00:00
const fwdUsers = await getForwardUsers(models, forward)
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
const uploadIds = uploadIdsFromText(item.text, { models })
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
2023-09-26 20:15:09 +00:00
item = await serializeInvoicable(
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, hash, hmac, me, enforceFee: imgFees }
2023-09-26 20:15:09 +00:00
)
2021-08-18 22:20:33 +00:00
2023-09-26 20:15:09 +00:00
await createMentions(item, models)
2021-08-18 22:20:33 +00:00
if (hasDeleteCommand(old.text)) {
// delete any deletion jobs that were created from a prior version of the item
await clearDeletionJobs(item, models)
}
await enqueueDeletionJob(item, models)
2023-09-26 20:15:09 +00:00
item.comments = []
return item
2021-08-18 22:20:33 +00:00
}
export const createItem = async (parent, { forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
const spamInterval = me ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL
2023-08-24 00:06:26 +00:00
// rename to match column name
item.subName = item.sub
delete item.sub
2023-09-26 20:15:09 +00:00
item.userId = me ? Number(me.id) : ANON_USER_ID
Allow zapping, posting and commenting without funds or an account (#336) * Add anon zaps * Add anon comments and posts (link, discussion, poll) * Use payment hash instead of invoice id as proof of payment Our invoice IDs can be enumerated. So there is a - even though very rare - chance that an attacker could find a paid invoice which is not used yet and use it for himself. Random payment hashes prevent this. Also, since we delete invoices after use, using database IDs as proof of payments are not suitable. If a user tells us an invoice ID after we deleted it, we can no longer tell if the invoice was paid or not since the LN node only knows about payment hashes but nothing about the database IDs. * Allow pay per invoice for stackers The modal which pops up if the stacker does not have enough sats now has two options: "fund wallet" and "pay invoice" * Fix onSuccess called twice For some reason, when calling `showModal`, `useMemo` in modal.js and the code for the modal component (here: <Invoice>) is called twice. This leads to the `onSuccess` callback being called twice and one failing since the first one deletes the invoice. * Keep invoice modal open if focus is lost * Skip anon user during trust calculation * Add error handling * Skip 'invoice not found' errors * Remove duplicate insufficient funds handling * Fix insufficient funds error detection * Fix invoice amount for comments * Allow pay per invoice for bounty and job posts * Also strike on payment after short press * Fix unexpected token 'export' * Fix eslint * Remove unused id param * Fix comment copy-paste error * Rename to useInvoiceable * Fix unexpected token 'export' * Fix onConfirmation called at every render * Add invoice HMAC This prevents entities which know the invoice hash (like all LN nodes on the payment path) from using the invoice hash on SN. Only the user which created the invoice knows the HMAC and thus can use the invoice hash. * make anon posting less hidden, add anon info button explainer * Fix anon users can't zap other anon users * Always show repeat and contacts on action error * Keep track of modal stack * give anon an icon * add generic date pivot helper * make anon user's invoices expire in 5 minutes * fix forgotten find and replace * use datePivot more places * add sat amounts to invoices * reduce anon invoice expiration to 3 minutes * don't abbreviate * Fix [object Object] as error message Any errors thrown here are already objects of shape { message: string } * Fix empty invoice creation attempts I stumbled across this while checking if anons can edit their items. I monkey patched the code to make it possible (so they can see the 'edit' button) and tried to edit an item but I got this error: Variable "$amount" of required type "Int!" was not provided. I fixed this even though this function should never be called without an amount anyway. It will return a sane error in that case now. * anon func mods, e.g. inv limits * anon tips should be denormalized * remove redundant meTotalSats * correct overlay zap text for anon * exclude anon from trust graph before algo runs * remove balance limit on anon * give anon a bio and remove cowboy hat/top stackers; * make anon hat appear on profile * concat hash and hmac and call it a token * Fix localStorage cleared because error were swallowed * fix qr layout shift * restyle fund error modal * Catch invoice errors in fund error modal * invoice check backoff * anon info typo * make invoice expiration times have saner defaults * add comma to anon info * use builtin copy input label --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2023-08-11 23:50:57 +00:00
2023-08-24 00:06:26 +00:00
const fwdUsers = await getForwardUsers(models, forward)
if (item.url && !isJob(item)) {
2023-08-24 00:06:26 +00:00
item.url = ensureProtocol(item.url)
item.url = removeTracking(item.url)
2022-08-26 23:31:51 +00:00
}
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
const uploadIds = uploadIdsFromText(item.text, { models })
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
2023-12-10 22:56:06 +00:00
let enforceFee
if (!me) {
if (item.parentId) {
enforceFee = ANON_FEE_MULTIPLIER
} else {
const sub = await models.sub.findUnique({ where: { name: item.subName } })
enforceFee = sub.baseCost * ANON_FEE_MULTIPLIER + (item.boost || 0)
}
enforceFee += imgFees
}
2023-09-26 20:15:09 +00:00
item = await serializeInvoicable(
models.$queryRawUnsafe(
Image uploads (#576) * Add icon to add images * Open file explorer to select image * Upload images to S3 on selection * Show uploaded images below text input * Link and remove image * Fetch unsubmitted images from database * Mark S3 images as submitted in imgproxy job * Add margin-top * Mark images as submitted on client after successful mutation * Also delete objects in S3 * Allow items to have multiple uploads linked * Overwrite old avatar * Add fees for presigned URLs * Use Github style upload * removed upfront fees * removed images provider since we no longer need to keep track of unsubmitted images on the client * removed User.images resolver * removed deleteImage mutation * use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload * Add Upload.paid boolean column One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation). Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for. This makes fee calculation easier since no JOINs are required. * Add image fees during item creation/update * we calculate image fees during item creation and update now * function imageFees returns queries which deduct fees from user and mark images as paid + fees * queries need to be run inside same transaction as item creation/update * Allow anons to get presigned URLs * Add comments regarding avatar upload * Use megabytes in error message * Remove unnecessary avatar check during image fees calculation * Show image fees in frontend * Also update image fees on blur This makes sure that the images fees reflect the current state. For example, if an image was removed. We could also add debounced requests. * Show amount of unpaid images in receipt * Fix fees in sats deducted from msats * Fix algebraic order of fees Spam fees must come immediately after the base fee since it multiplies the base fee. * Fix image fees in edit receipt * Fix stale fees shown If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet. * Add 0 base fee in edit receipt * Remove 's' from 'image fees' in receipts * Remove unnecessary async * Remove 'Uploading <name>...' from text input on error * Support upload of multiple files at once * Add schedule to delete unused images * Fix image fee display in receipts * Use Drag and Drop API for image upload * Remove dragOver style on drop * Increase max upload size to 10MB to allow HQ camera pictures * Fix free upload quota * Fix stale image fees served * Fix bad image fee return statements * Fix multiplication with feesPerImage * Fix NULL returned for size24h, sizeNow * Remove unnecessary text field in query * refactor: Unify <ImageUpload> and <Upload> component * Add avatar cache busting using random query param * Calculate image fee info in postgres function * we now calculate image fee info in a postgres function which is much cleaner * we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item! * reversed changes in `serializeInvoiceable` * Fix line break in receipt * Update upload limits * Add comment about `e.target.value = null` * Use debounce instead of onBlur to update image fees info * Fix invoice amount * Refactor avatar upload control flow * Update image fees in onChange * Fix rescheduling of other jobs * also update schedule from every minute to every hour * Add image fees in calling context * keep item ids on uploads * Fix incompatible onSubmit signature * Revert "keep item ids on uploads" This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4. * many2many item uploads * pretty subdomain for images * handle upload conditions for profile images and job logos --------- Co-authored-by: ekzyis <ek@ekzyis.com> Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 20:53:33 +00:00
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
2023-09-26 20:15:09 +00:00
{ models, lnd, hash, hmac, me, enforceFee }
)
2021-08-18 22:20:33 +00:00
await createMentions(item, models)
await enqueueDeletionJob(item, models)
notifyUserSubscribers({ models, item })
2021-05-20 01:09:32 +00:00
item.comments = []
return item
}
const clearDeletionJobs = async (item, models) => {
await models.$queryRawUnsafe(`DELETE FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}';`)
}
const enqueueDeletionJob = async (item, models) => {
const deleteCommand = getDeleteCommand(item.text)
if (deleteCommand) {
await models.$queryRawUnsafe(`
INSERT INTO pgboss.job (name, data, startafter)
VALUES ('deleteItem', jsonb_build_object('id', ${item.id}), now() + interval '${deleteCommand.number} ${deleteCommand.unit}s');`)
}
}
multiple forwards on a post (#403) * multiple forwards on a post first phase of the multi-forward support * update the graphql mutation for discussion posts to accept and validate multiple forwards * update the discussion form to allow multiple forwards in the UI * start working on db schema changes * uncomment db schema, add migration to create the new model, and update create_item, update_item stored procedures * Propagate updates from discussion to poll, link, and bounty forms Update the create, update poll sql functions for multi forward support * Update gql, typedefs, and resolver to return forwarded users in items responses * UI changes to show multiple forward recipients, and conditional upvote logic changes * Update notification text to reflect multiple forwards upon vote action * Disallow duplicate stacker entries * reduce duplication in populating adv-post-form initial values * Update item_act sql function to implement multi-way forwarding * Update referral functions to scale referral bonuses for forwarded users * Update notification text to reflect non-100% forwarded sats cases * Update wallet history sql queries to accommodate multi-forward use cases * Block zaps for posts you are forwarded zaps at the API layer, in addition to in the UI * Delete fwdUserId column from Item table as part of migration * Fix how we calculate stacked sats after partial forwards in wallet history * Exclude entries from wallet history that are 0 stacked sats from posts with 100% forwarded to other users * Fix wallet history query for forwarded stacked sats to be scaled by the fwd pct * Reduce duplication in adv post form, and do some style tweaks for better layout * Use MAX_FORWARDS constants * Address various PR feedback * first enhancement pass * enhancement pass too --------- Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2023-08-23 22:44:17 +00:00
const getForwardUsers = async (models, forward) => {
const fwdUsers = []
if (forward) {
// find all users in one db query
const users = await models.user.findMany({ where: { OR: forward.map(fwd => ({ name: fwd.nym })) } })
// map users to fwdUser entries with id and pct
users.forEach(user => {
fwdUsers.push({
userId: user.id,
pct: forward.find(fwd => fwd.nym === user.name).pct
})
})
}
return fwdUsers
}
// we have to do our own query because ltree is unsupported
2021-09-23 17:42:00 +00:00
export const SELECT =
`SELECT "Item".id, "Item".created_at, "Item".created_at as "createdAt", "Item".updated_at,
"Item".updated_at as "updatedAt", "Item".title, "Item".text, "Item".url, "Item"."bounty",
"Item"."noteId", "Item"."userId", "Item"."parentId", "Item"."pinId", "Item"."maxBid",
"Item"."rootId", "Item".upvotes, "Item".company, "Item".location, "Item".remote, "Item"."deletedAt",
"Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats,
"Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes",
2023-11-19 20:16:35 +00:00
"Item"."weightedDownVotes", "Item".freebie, "Item".bio, "Item"."otsHash", "Item"."bountyPaidTo",
ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls", "Item".outlawed`
2021-04-27 21:30:58 +00:00
2023-09-14 15:46:59 +00:00
function topOrderByWeightedSats (me, models) {
return `ORDER BY ${orderByNumerator(models)} DESC NULLS LAST, "Item".id DESC`
2022-09-21 19:57:36 +00:00
}