diff --git a/api/resolvers/image.js b/api/resolvers/image.js new file mode 100644 index 00000000..6409e5f9 --- /dev/null +++ b/api/resolvers/image.js @@ -0,0 +1,23 @@ +import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '../../lib/constants' +import { msatsToSats } from '../../lib/format' + +export default { + Query: { + imageFeesInfo: async (parent, { s3Keys }, { models, me }) => { + return imageFeesInfo(s3Keys, { models, me }) + } + } +} + +export function uploadIdsFromText (text, { models }) { + if (!text) return null + return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) +} + +export async function imageFeesInfo (s3Keys, { models, me }) { + const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys) + const imageFee = msatsToSats(info.imageFeeMsats) + const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats) + const totalFees = msatsToSats(totalFeesMsats) + return { ...info, imageFee, totalFees, totalFeesMsats } +} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index f0b311ac..6e41b23f 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -15,6 +15,7 @@ import price from './price' import { GraphQLJSONObject as JSONObject } from 'graphql-type-json' import admin from './admin' import blockHeight from './blockHeight' +import image from './image' import { GraphQLScalarType, Kind } from 'graphql' const date = new GraphQLScalarType({ @@ -45,4 +46,4 @@ const date = new GraphQLScalarType({ }) export default [user, item, message, wallet, lnurl, notifications, invite, sub, - upload, search, growth, rewards, referrals, price, admin, blockHeight, { JSONObject }, { Date: date }] + upload, search, growth, rewards, referrals, price, admin, blockHeight, image, { JSONObject }, { Date: date }] diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 596dd790..5d6f7b41 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -19,6 +19,7 @@ import { sendUserNotification } from '../webPush' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications' import { datePivot } from '../../lib/time' +import { imageFeesInfo, uploadIdsFromText } from './image' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -1090,10 +1091,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it item = { subName, userId: me.id, ...item } const fwdUsers = await getForwardUsers(models, forward) + const uploadIds = uploadIdsFromText(item.text, { models }) + const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me }) + item = await serializeInvoicable( - models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), - { models, lnd, hash, hmac, me } + 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 } ) await createMentions(item, models) @@ -1123,11 +1127,14 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo item.url = removeTracking(item.url) } - const enforceFee = me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0))) + const uploadIds = uploadIdsFromText(item.text, { models }) + const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me }) + + const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees item = await serializeInvoicable( models.$queryRawUnsafe( - `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`, - JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)), + `${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), { models, lnd, hash, hmac, me, enforceFee } ) diff --git a/api/resolvers/upload.js b/api/resolvers/upload.js index b8bd1e93..ec098cf4 100644 --- a/api/resolvers/upload.js +++ b/api/resolvers/upload.js @@ -1,62 +1,42 @@ import { GraphQLError } from 'graphql' -import AWS from 'aws-sdk' -import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants' - -const bucketRegion = 'us-east-1' - -AWS.config.update({ - region: bucketRegion -}) +import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '../../lib/constants' +import { createPresignedPost } from '../s3' export default { Mutation: { - getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => { - if (!me) { - throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } }) - } - + getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => { if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) { throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } }) } if (size > UPLOAD_SIZE_MAX) { - throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } }) + throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } }) + } + + if (avatar && size > UPLOAD_SIZE_MAX_AVATAR) { + throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } }) } if (width * height > IMAGE_PIXELS_MAX) { throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } }) } - // create upload record - const upload = await models.upload.create({ - data: { - type, - size, - width, - height, - userId: me.id - } - }) + const imgParams = { + type, + size, + width, + height, + userId: me?.id || ANON_USER_ID, + paid: false + } - // get presigned POST ur - const s3 = new AWS.S3({ apiVersion: '2006-03-01' }) - const res = await new Promise((resolve, reject) => { - s3.createPresignedPost({ - Bucket: process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET, - Fields: { - key: String(upload.id) - }, - Expires: 300, - Conditions: [ - { 'Content-Type': type }, - { 'Cache-Control': 'max-age=31536000' }, - { acl: 'public-read' }, - ['content-length-range', size, size] - ] - }, (err, preSigned) => { if (err) { reject(err) } else { resolve(preSigned) } }) - }) + if (avatar) { + if (!me) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + imgParams.paid = undefined + } - return res + const upload = await models.upload.create({ data: { ...imgParams } }) + return createPresignedPost({ key: String(upload.id), type, size }) } } } diff --git a/api/s3/index.js b/api/s3/index.js new file mode 100644 index 00000000..3513f076 --- /dev/null +++ b/api/s3/index.js @@ -0,0 +1,37 @@ +import AWS from 'aws-sdk' + +const bucketRegion = 'us-east-1' +const Bucket = process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET + +AWS.config.update({ + region: bucketRegion +}) + +export function createPresignedPost ({ key, type, size }) { + const s3 = new AWS.S3({ apiVersion: '2006-03-01' }) + return new Promise((resolve, reject) => { + s3.createPresignedPost({ + Bucket, + Fields: { key }, + Expires: 300, + Conditions: [ + { 'Content-Type': type }, + { 'Cache-Control': 'max-age=31536000' }, + { acl: 'public-read' }, + ['content-length-range', size, size] + ] + }, (err, preSigned) => { err ? reject(err) : resolve(preSigned) }) + }) +} + +export function deleteObjects (keys) { + const s3 = new AWS.S3({ apiVersion: '2006-03-01' }) + return new Promise((resolve, reject) => { + s3.deleteObjects({ + Bucket, + Delete: { + Objects: keys.map(key => ({ Key: String(key) })) + } + }, (err, data) => { err ? reject(err) : resolve(keys) }) + }) +} diff --git a/api/typeDefs/image.js b/api/typeDefs/image.js new file mode 100644 index 00000000..dc89947d --- /dev/null +++ b/api/typeDefs/image.js @@ -0,0 +1,16 @@ +import { gql } from 'graphql-tag' + +export default gql` + type ImageFeesInfo { + totalFees: Int! + totalFeesMsats: Int! + imageFee: Int! + imageFeeMsats: Int! + nUnpaid: Int! + bytesUnpaid: Int! + bytes24h: Int! + } + extend type Query { + imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo! + } +` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index fb7b39d2..665670b7 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -16,6 +16,7 @@ import referrals from './referrals' import price from './price' import admin from './admin' import blockHeight from './blockHeight' +import image from './image' const common = gql` type Query { @@ -35,4 +36,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, image] diff --git a/api/typeDefs/upload.js b/api/typeDefs/upload.js index 07a2e9db..05a90e93 100644 --- a/api/typeDefs/upload.js +++ b/api/typeDefs/upload.js @@ -2,7 +2,7 @@ import { gql } from 'graphql-tag' export default gql` extend type Mutation { - getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!): SignedPost! + getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost! } type SignedPost { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index d9184d0e..39e3ecbb 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -45,6 +45,18 @@ export default gql` email: String } + type Image { + id: ID! + createdAt: Date! + updatedAt: Date! + type: String! + size: Int! + width: Int + height: Int + itemId: Int + userId: Int! + } + type User { id: ID! createdAt: Date! diff --git a/components/avatar.js b/components/avatar.js index e9d08c32..5c18f2e1 100644 --- a/components/avatar.js +++ b/components/avatar.js @@ -2,10 +2,10 @@ import { useRef, useState } from 'react' import AvatarEditor from 'react-avatar-editor' import Button from 'react-bootstrap/Button' import BootstrapForm from 'react-bootstrap/Form' -import Upload from './upload' import EditImage from '../svgs/image-edit-fill.svg' import Moon from '../svgs/moon-fill.svg' import { useShowModal } from './modal' +import { ImageUpload } from './image' export default function Avatar ({ onSuccess }) { const [uploading, setUploading] = useState() @@ -49,27 +49,41 @@ export default function Avatar ({ onSuccess }) { } return ( - -
- {uploading - ? - : } -
} + { console.log(e) setUploading(false) }} onSelect={(file, upload) => { - showModal(onClose => ) + return new Promise((resolve, reject) => + showModal(onClose => ( + { + onClose() + resolve() + }} + file={file} + upload={async (blob) => { + await upload(blob) + resolve(blob) + }} + /> + ))) }} - onSuccess={async key => { - onSuccess && onSuccess(key) + onSuccess={({ id }) => { + onSuccess?.(id) setUploading(false) }} - onStarted={() => { + onUpload={() => { setUploading(true) }} - /> + > +
+ {uploading + ? + : } +
+
) } diff --git a/components/fee-button.js b/components/fee-button.js index 9bc7dc72..580aa2ee 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -12,7 +12,7 @@ import AnonIcon from '../svgs/spy-fill.svg' import { useShowModal } from './modal' import Link from 'next/link' -function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { +function Receipt ({ cost, repetition, imageFeesInfo, baseFee, parentId, boost }) { return ( @@ -20,16 +20,16 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { - {hasImgLink && - - - - } {repetition > 0 && } + {imageFeesInfo.totalFees > 0 && + + + + } {boost > 0 && @@ -69,7 +69,7 @@ function AnonInfo () { ) } -export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) { +export default function FeeButton ({ parentId, baseFee, ChildButton, variant, text, alwaysShow, disabled }) { const me = useMe() baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE) const query = parentId @@ -79,46 +79,43 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, const repetition = me ? data?.itemRepetition || 0 : 0 const formik = useFormikContext() const boost = Number(formik?.values?.boost) || 0 - const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) + const cost = baseFee * Math.pow(10, repetition) + Number(boost) useEffect(() => { formik?.setFieldValue('cost', cost) }, [formik?.getFieldProps('cost').value, cost]) + const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 } + const totalCost = cost + imageFeesInfo.totalFees + const show = alwaysShow || !formik?.isSubmitting return (
- - {text}{cost > 1 && show && {numWithUnits(cost, { abbreviate: false })}} + + {text}{totalCost > 1 && show && {numWithUnits(totalCost, { abbreviate: false })}} {!me && } - {cost > baseFee && show && + {totalCost > baseFee && show && - + }
) } -function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { +function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) { return (
{numWithUnits(baseFee, { abbreviate: false })} {parentId ? 'reply' : 'post'} fee
x 10image/link fee
x 10{repetition} {repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m
+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}image fee
+ {numWithUnits(boost, { abbreviate: false })}
- {addImgLink && - <> - - - - - - - - - - - - - } + + + + + {imageFeesInfo.totalFees > 0 && + + + + } {boost > 0 && @@ -135,25 +132,27 @@ function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { ) } -export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) { +export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysShow, parentId }) { const formik = useFormikContext() const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0) - const addImgLink = hasImgLink && !hadImgLink - const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) + const cost = Number(boost) useEffect(() => { formik?.setFieldValue('cost', cost) }, [formik?.getFieldProps('cost').value, cost]) + const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 } + const totalCost = cost + imageFeesInfo.totalFees + const show = alwaysShow || !formik?.isSubmitting return (
- = 0 ? cost : 0, { abbreviate: false })}> - {text}{cost > 0 && show && {numWithUnits(cost, { abbreviate: false })}} + = 0 ? totalCost : 0, { abbreviate: false })}> + {text}{totalCost > 0 && show && {numWithUnits(totalCost, { abbreviate: false })}} - {cost > 0 && show && + {totalCost > 0 && show && - + }
) diff --git a/components/fee-button.module.css b/components/fee-button.module.css index 09fdc2dc..dea6d771 100644 --- a/components/fee-button.module.css +++ b/components/fee-button.module.css @@ -1,6 +1,6 @@ .receipt { background-color: var(--theme-inputBg); - max-width: 250px; + max-width: 300px; margin: auto; table-layout: auto; width: 100%; diff --git a/components/form.js b/components/form.js index 382079c6..e4c520b6 100644 --- a/components/form.js +++ b/components/form.js @@ -9,12 +9,12 @@ import Dropdown from 'react-bootstrap/Dropdown' import Nav from 'react-bootstrap/Nav' import Row from 'react-bootstrap/Row' import Markdown from '../svgs/markdown-line.svg' +import AddImageIcon from '../svgs/image-add-line.svg' import styles from './form.module.css' import Text from '../components/text' import AddIcon from '../svgs/add-fill.svg' -import { mdHas } from '../lib/md' import CloseIcon from '../svgs/close-line.svg' -import { useLazyQuery } from '@apollo/client' +import { gql, useLazyQuery } from '@apollo/client' import { TOP_USERS, USER_SEARCH } from '../fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' @@ -24,6 +24,8 @@ import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' import { debounce } from './use-debounce-callback' +import { ImageUpload } from './image' +import { AWS_S3_URL_REGEXP } from '../lib/constants' export function SubmitButton ({ children, variant, value, onClick, disabled, cost, ...props @@ -93,12 +95,34 @@ export function InputSkeleton ({ label, hint }) { } const DEFAULT_MENTION_INDICES = { start: -1, end: -1 } -export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) { +export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) { const [tab, setTab] = useState('write') const [, meta, helpers] = useField(props) const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 }) innerRef = innerRef || useRef(null) + const imageUploadRef = useRef(null) const previousTab = useRef(tab) + const formik = useFormikContext() + const toaster = useToast() + const [updateImageFeesInfo] = useLazyQuery(gql` + query imageFeesInfo($s3Keys: [Int]!) { + imageFeesInfo(s3Keys: $s3Keys) { + totalFees + nUnpaid + imageFee + bytes24h + } + }`, { + fetchPolicy: 'no-cache', + nextFetchPolicy: 'no-cache', + onError: (err) => { + console.log(err) + toaster.danger(err.message || err.toString?.()) + }, + onCompleted: ({ imageFeesInfo }) => { + formik?.setFieldValue('imageFeesInfo', imageFeesInfo) + } + }) props.as ||= TextareaAutosize props.rows ||= props.minRows || 6 @@ -137,11 +161,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH innerRef.current.focus() }, [mentionIndices, innerRef, helpers?.setValue]) + const imageFeesUpdate = useCallback(debounce( + (text) => { + const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : [] + updateImageFeesInfo({ variables: { s3Keys } }) + }, 1000), [debounce, updateImageFeesInfo]) + const onChangeInner = useCallback((formik, e) => { if (onChange) onChange(formik, e) - if (setHasImgLink) { - setHasImgLink(mdHas(e.target.value, ['link', 'image'])) - } // check for mention editing const { value, selectionStart } = e.target let priorSpace = -1 @@ -174,7 +201,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH setMentionQuery(undefined) setMentionIndices(DEFAULT_MENTION_INDICES) } - }, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle]) + + imageFeesUpdate(value) + }, [onChange, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle, imageFeesUpdate]) const onKeyDownInner = useCallback((userSuggestOnKeyDown) => { return (e) => { @@ -209,6 +238,22 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH } }, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown]) + const onDrop = useCallback((event) => { + event.preventDefault() + setDragStyle(null) + const changeEvent = new Event('change', { bubbles: true }) + imageUploadRef.current.files = event.dataTransfer.files + imageUploadRef.current.dispatchEvent(changeEvent) + }, [imageUploadRef]) + + const [dragStyle, setDragStyle] = useState(null) + const onDragEnter = useCallback((e) => { + setDragStyle('over') + }, [setDragStyle]) + const onDragLeave = useCallback((e) => { + setDragStyle(null) + }, [setDragStyle]) + return (
@@ -219,12 +264,39 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH preview - - - + + { + let text = innerRef.current.value + if (text) text += '\n\n' + text += `![Uploading ${file.name}…]()` + helpers.setValue(text) + }} + onSuccess={({ url, name }) => { + let text = innerRef.current.value + text = text.replace(`![Uploading ${name}…]()`, `![${name}](${url})`) + helpers.setValue(text) + const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) + updateImageFeesInfo({ variables: { s3Keys } }) + }} + onError={({ name }) => { + let text = innerRef.current.value + text = text.replace(`![Uploading ${name}…]()`, '') + helpers.setValue(text) + }} + > + + + + + +
setTimeout(resetSuggestions, 100)} + onDragEnter={onDragEnter} + onDragLeave={onDragLeave} + onDrop={onDrop} + className={dragStyle === 'over' ? styles.dragOver : ''} />)}
@@ -675,6 +751,14 @@ export function Form ({ const onSubmitInner = useCallback(async (values, ...args) => { try { if (onSubmit) { + // extract cost from formik fields + // (cost may also be set in a formik field named 'amount') + let cost = values?.cost || values?.amount + // add potential image fees which are set in a different field + // to differentiate between fees (in receipts for example) + cost += (values?.imageFeesInfo?.totalFees || 0) + values.cost = cost + const options = await onSubmit(values, ...args) if (!storageKeyPrefix || options?.keepLocalStorage) return clearLocalStorage(values) diff --git a/components/form.module.css b/components/form.module.css index 6d218fbf..6402f0b3 100644 --- a/components/form.module.css +++ b/components/form.module.css @@ -19,6 +19,10 @@ height: auto; } +.dragOver { + box-shadow: 0 0 10px var(--bs-info); +} + .appendButton { border-left: 0 !important; border-top-left-radius: 0; diff --git a/components/image.js b/components/image.js index b440dc06..4430f859 100644 --- a/components/image.js +++ b/components/image.js @@ -1,9 +1,13 @@ import styles from './text.module.css' -import { useState, useEffect, useMemo, useCallback } from 'react' +import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react' import { IMGPROXY_URL_REGEXP } from '../lib/url' import { useShowModal } from './modal' import { useMe } from './me' import { Dropdown } from 'react-bootstrap' +import { UPLOAD_TYPES_ALLOW } from '../lib/constants' +import { useToast } from './toast' +import gql from 'graphql-tag' +import { useMutation } from '@apollo/client' export function decodeOriginalUrl (imgproxyUrl) { const parts = imgproxyUrl.split('/') @@ -132,3 +136,99 @@ export default function ZoomableImage ({ src, srcSet, ...props }) { return } + +export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => { + const toaster = useToast() + ref ??= useRef(null) + + const [getSignedPOST] = useMutation( + gql` + mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!, $avatar: Boolean) { + getSignedPOST(type: $type, size: $size, width: $width, height: $height, avatar: $avatar) { + url + fields + } + }`) + + const s3Upload = useCallback(file => { + const img = new window.Image() + img.src = window.URL.createObjectURL(file) + return new Promise((resolve, reject) => { + img.onload = async () => { + onUpload?.(file) + let data + const variables = { + avatar, + type: file.type, + size: file.size, + width: img.width, + height: img.height + } + try { + ({ data } = await getSignedPOST({ variables })) + } catch (e) { + toaster.danger(e.message || e.toString?.()) + onError?.({ ...variables, name: file.name, file }) + reject(e) + return + } + + const form = new FormData() + Object.keys(data.getSignedPOST.fields).forEach(key => form.append(key, data.getSignedPOST.fields[key])) + form.append('Content-Type', file.type) + form.append('Cache-Control', 'max-age=31536000') + form.append('acl', 'public-read') + form.append('file', file) + + const res = await fetch(data.getSignedPOST.url, { + method: 'POST', + body: form + }) + + if (!res.ok) { + // TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want + const err = res.statusText + toaster.danger(err) + onError?.({ ...variables, name: file.name, file }) + reject(err) + return + } + + const url = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${data.getSignedPOST.fields.key}` + // key is upload id in database + const id = data.getSignedPOST.fields.key + onSuccess?.({ ...variables, id, name: file.name, url, file }) + resolve(id) + } + }) + }, [toaster, getSignedPOST]) + + return ( + <> + { + const fileList = e.target.files + for (const file of Array.from(fileList)) { + if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) { + toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`) + continue + } + if (onSelect) await onSelect?.(file, s3Upload) + else await s3Upload(file) + // reset file input + // see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react + e.target.value = null + } + }} + /> +
ref.current?.click()} style={{ cursor: 'pointer' }}> + {children} +
+ + ) +}) diff --git a/components/invoice.js b/components/invoice.js index 4c6f4bbc..e8f7b784 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -231,10 +231,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { // this function will be called before the Form's onSubmit handler is called // and the form must include `cost` or `amount` as a value - const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => { - let { cost, amount } = formValues - cost ??= amount - + const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => { // action only allowed if logged in if (!me && options.requireSession) { throw new Error('you must be logged in') diff --git a/components/item-job.js b/components/item-job.js index b8a49b75..0e825591 100644 --- a/components/item-job.js +++ b/components/item-job.js @@ -25,7 +25,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
diff --git a/components/job-form.js b/components/job-form.js index 2ea99b1e..20f3d12d 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -106,7 +106,7 @@ export default function JobForm ({ item, sub }) {
diff --git a/components/upload.js b/components/upload.js deleted file mode 100644 index 24a784b3..00000000 --- a/components/upload.js +++ /dev/null @@ -1,90 +0,0 @@ -import { useRef } from 'react' -import { gql, useMutation } from '@apollo/client' -import { UPLOAD_TYPES_ALLOW } from '../lib/constants' - -export default function Upload ({ as: Component, onSelect, onStarted, onError, onSuccess }) { - const [getSignedPOST] = useMutation( - gql` - mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!) { - getSignedPOST(type: $type, size: $size, width: $width, height: $height) { - url - fields - } - }`) - const ref = useRef() - - const upload = file => { - onStarted && onStarted() - - const img = new window.Image() - img.src = window.URL.createObjectURL(file) - img.onload = async () => { - let data - try { - ({ data } = await getSignedPOST({ - variables: { - type: file.type, - size: file.size, - width: img.width, - height: img.height - } - })) - } catch (e) { - onError && onError(e.toString()) - return - } - - const form = new FormData() - Object.keys(data.getSignedPOST.fields).forEach(key => - form.append(key, data.getSignedPOST.fields[key])) - form.append('Content-Type', file.type) - form.append('Cache-Control', 'max-age=31536000') - form.append('acl', 'public-read') - form.append('file', file) - - const res = await fetch(data.getSignedPOST.url, { - method: 'POST', - body: form - }) - - if (!res.ok) { - onError && onError(res.statusText) - return - } - - onSuccess && onSuccess(data.getSignedPOST.fields.key) - } - } - - return ( - <> - { - if (e.target.files.length === 0) { - return - } - - const file = e.target.files[0] - - if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) { - onError && onError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`) - return - } - - if (onSelect) { - onSelect(file, upload) - } else { - upload(file) - } - - e.target.value = null - }} - /> - ref.current?.click()} /> - - ) -} diff --git a/components/user-header.js b/components/user-header.js index 92fc6ad4..b6ad5aa5 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -71,14 +71,20 @@ function HeaderPhoto ({ user, isMe }) { } } }) + }, + onCompleted ({ setPhoto: photoId }) { + const src = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` + setSrc(src) } } ) + const initialSrc = user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg' + const [src, setSrc] = useState(initialSrc) return (
{isMe && diff --git a/components/user-list.js b/components/user-list.js index a805a13a..b8d94d6e 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -66,7 +66,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
diff --git a/lib/constants.js b/lib/constants.js index 79067559..3daf53de 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -6,8 +6,10 @@ export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs') export const NOFOLLOW_LIMIT = 1000 export const BOOST_MULT = 5000 export const BOOST_MIN = BOOST_MULT * 5 -export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 +export const UPLOAD_SIZE_MAX = 25 * 1024 * 1024 +export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024 export const IMAGE_PIXELS_MAX = 35000000 +export const AWS_S3_URL_REGEXP = new RegExp(`https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/([0-9]+)`, 'g') export const UPLOAD_TYPES_ALLOW = [ 'image/gif', 'image/heic', diff --git a/prisma/migrations/20231025000727_upload_paid/migration.sql b/prisma/migrations/20231025000727_upload_paid/migration.sql new file mode 100644 index 00000000..fb8976f6 --- /dev/null +++ b/prisma/migrations/20231025000727_upload_paid/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the column `itemId` on the `Upload` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Upload" DROP CONSTRAINT "Upload_itemId_fkey"; + +-- DropIndex +DROP INDEX "Upload.itemId_index"; + +-- DropIndex +DROP INDEX "Upload.itemId_unique"; + +-- AlterTable +ALTER TABLE "Upload" DROP COLUMN "itemId", +ADD COLUMN "paid" BOOLEAN; diff --git a/prisma/migrations/20231025203103_delete_unused_images_schedule/migration.sql b/prisma/migrations/20231025203103_delete_unused_images_schedule/migration.sql new file mode 100644 index 00000000..f053c8d9 --- /dev/null +++ b/prisma/migrations/20231025203103_delete_unused_images_schedule/migration.sql @@ -0,0 +1,17 @@ +-- add 'deleteUnusedImages' job +CREATE OR REPLACE FUNCTION create_delete_unused_images_job() +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE +BEGIN + INSERT INTO pgboss.schedule (name, cron, timezone) VALUES ('deleteUnusedImages', '0 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING; + return 0; +EXCEPTION WHEN OTHERS THEN + return 0; +END; +$$; + +SELECT create_delete_unused_images_job(); + +DROP FUNCTION create_delete_unused_images_job; diff --git a/prisma/migrations/20231026154807_image_fees_info/migration.sql b/prisma/migrations/20231026154807_image_fees_info/migration.sql new file mode 100644 index 00000000..e06fcc3d --- /dev/null +++ b/prisma/migrations/20231026154807_image_fees_info/migration.sql @@ -0,0 +1,241 @@ +-- function to calculate image fees info for given user and upload ids +CREATE OR REPLACE FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[]) +RETURNS TABLE ( + "bytes24h" INTEGER, + "bytesUnpaid" INTEGER, + "nUnpaid" INTEGER, + "imageFeeMsats" BIGINT +) +LANGUAGE plpgsql +AS $$ +BEGIN + RETURN QUERY SELECT + uploadinfo.*, + CASE + -- anons always pay 100 sats per image + WHEN user_id = 27 THEN 100000::BIGINT + ELSE CASE + -- 10 MB are free per stacker and 24 hours + WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 10 * 1024 * 1024 THEN 0::BIGINT + WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 25 * 1024 * 1024 THEN 10000::BIGINT + WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 50 * 1024 * 1024 THEN 100000::BIGINT + WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 100 * 1024 * 1024 THEN 1000000::BIGINT + ELSE 10000000::BIGINT + END + END AS "imageFeeMsats" + FROM ( + SELECT + -- how much bytes did stacker upload in last 24 hours? + COALESCE(SUM(size) FILTER(WHERE paid = 't' AND created_at >= NOW() - interval '24 hours'), 0)::INTEGER AS "bytes24h", + -- how much unpaid bytes do they want to upload now? + COALESCE(SUM(size) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "bytesUnpaid", + -- how many unpaid images do they want to upload now? + COALESCE(COUNT(id) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "nUnpaid" + FROM "Upload" + WHERE "Upload"."userId" = user_id + ) uploadinfo; + RETURN; +END; +$$; + +-- add image fees +CREATE OR REPLACE FUNCTION create_item( + jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[]) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- access fields with appropriate types + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + + IF item."maxBid" IS NOT NULL THEN + cost_msats := 1000000; + ELSE + cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within)); + END IF; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + END IF; + + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0 + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost IS NULL OR item.boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE( + percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = item."userId"; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR item."userId" = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + -- there's no great way to set default column values when using json_populate_record + -- so we need to only select fields with non-null values that way when func input + -- does not include a value, the default value is used instead of null + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key); + -- insert the item + EXECUTE format($fmt$ + INSERT INTO "Item" (%s, "weightedDownVotes", freebie) + SELECT %1$s, %L, %L + FROM jsonb_populate_record(NULL::"Item", %L) RETURNING * + $fmt$, select_clause, med_votes, freebie, jitem) INTO item; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + -- Automatically subscribe to one's own posts + INSERT INTO "ThreadSubscription" ("itemId", "userId") + VALUES (item.id, item."userId"); + + -- Automatically subscribe forward recipients to the new post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + -- if this item has boost + IF item.boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- if this is a bio + IF item.bio THEN + UPDATE users SET "bioId" = item.id WHERE id = item."userId"; + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; + +-- add image fees +CREATE OR REPLACE FUNCTION update_item( + jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[]) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost_msats BIGINT; + item "Item"; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + cost_msats := 0; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + END IF; + + IF cost_msats > 0 AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + ELSE + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + IF item.boost > 0 THEN + UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id; + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + IF item.status IS NOT NULL THEN + UPDATE "Item" SET "statusUpdatedAt" = now_utc() + WHERE id = item.id AND status <> item.status; + END IF; + + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key) + WHERE key <> 'boost'; + + EXECUTE format($fmt$ + UPDATE "Item" SET (%s) = ( + SELECT %1$s + FROM jsonb_populate_record(NULL::"Item", %L) + ) WHERE id = %L RETURNING * + $fmt$, select_clause, jitem, item.id) INTO item; + + -- Delete any old thread subs if the user is no longer a fwd recipient + DELETE FROM "ThreadSubscription" + WHERE "itemId" = item.id + -- they aren't in the new forward list + AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId") + -- and they are in the old forward list + AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" ); + + -- Automatically subscribe any new forward recipients to the post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward) + EXCEPT + SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id; + + -- Delete all old forward entries, to recreate in next command + DELETE FROM "ItemForward" WHERE "itemId" = item.id; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; diff --git a/prisma/migrations/20231105221559_item_uploads/migration.sql b/prisma/migrations/20231105221559_item_uploads/migration.sql new file mode 100644 index 00000000..d778e8bc --- /dev/null +++ b/prisma/migrations/20231105221559_item_uploads/migration.sql @@ -0,0 +1,237 @@ +-- CreateTable +CREATE TABLE "ItemUpload" ( + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "itemId" INTEGER NOT NULL, + "uploadId" INTEGER NOT NULL, + + CONSTRAINT "ItemUpload_pkey" PRIMARY KEY ("itemId","uploadId") +); + +-- CreateIndex +CREATE INDEX "ItemUpload_created_at_idx" ON "ItemUpload"("created_at"); + +-- CreateIndex +CREATE INDEX "ItemUpload_itemId_idx" ON "ItemUpload"("itemId"); + +-- CreateIndex +CREATE INDEX "ItemUpload_uploadId_idx" ON "ItemUpload"("uploadId"); + +-- AddForeignKey +ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "Upload"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- add image fees +CREATE OR REPLACE FUNCTION create_item( + jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[]) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- access fields with appropriate types + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + + IF item."maxBid" IS NOT NULL THEN + cost_msats := 1000000; + ELSE + cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within)); + END IF; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + END IF; + + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0 + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost IS NULL OR item.boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE( + percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = item."userId"; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR item."userId" = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + -- there's no great way to set default column values when using json_populate_record + -- so we need to only select fields with non-null values that way when func input + -- does not include a value, the default value is used instead of null + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key); + -- insert the item + EXECUTE format($fmt$ + INSERT INTO "Item" (%s, "weightedDownVotes", freebie) + SELECT %1$s, %L, %L + FROM jsonb_populate_record(NULL::"Item", %L) RETURNING * + $fmt$, select_clause, med_votes, freebie, jitem) INTO item; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + -- Automatically subscribe to one's own posts + INSERT INTO "ThreadSubscription" ("itemId", "userId") + VALUES (item.id, item."userId"); + + -- Automatically subscribe forward recipients to the new post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + -- if this item has boost + IF item.boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- if this is a bio + IF item.bio THEN + UPDATE users SET "bioId" = item.id WHERE id = item."userId"; + END IF; + + -- record attachments + IF upload_ids IS NOT NULL THEN + INSERT INTO "ItemUpload" ("itemId", "uploadId") + SELECT item.id, * FROM UNNEST(upload_ids); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; + +-- add image fees +CREATE OR REPLACE FUNCTION update_item( + jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[]) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + cost_msats BIGINT; + item "Item"; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + cost_msats := 0; + + -- add image fees + IF upload_ids IS NOT NULL THEN + cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids)); + UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids); + -- delete any old uploads that are no longer attached + DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids); + -- insert any new uploads that are not already attached + INSERT INTO "ItemUpload" ("itemId", "uploadId") + SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING; + END IF; + + IF cost_msats > 0 AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + ELSE + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + IF item.boost > 0 THEN + UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id; + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + IF item.status IS NOT NULL THEN + UPDATE "Item" SET "statusUpdatedAt" = now_utc() + WHERE id = item.id AND status <> item.status; + END IF; + + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key) + WHERE key <> 'boost'; + + EXECUTE format($fmt$ + UPDATE "Item" SET (%s) = ( + SELECT %1$s + FROM jsonb_populate_record(NULL::"Item", %L) + ) WHERE id = %L RETURNING * + $fmt$, select_clause, jitem, item.id) INTO item; + + -- Delete any old thread subs if the user is no longer a fwd recipient + DELETE FROM "ThreadSubscription" + WHERE "itemId" = item.id + -- they aren't in the new forward list + AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId") + -- and they are in the old forward list + AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" ); + + -- Automatically subscribe any new forward recipients to the post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward) + EXCEPT + SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id; + + -- Delete all old forward entries, to recreate in next command + DELETE FROM "ItemForward" WHERE "itemId" = item.id; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; \ No newline at end of file diff --git a/prisma/migrations/20231106204318_upload_id_idx/migration.sql b/prisma/migrations/20231106204318_upload_id_idx/migration.sql new file mode 100644 index 00000000..e9cdc3c9 --- /dev/null +++ b/prisma/migrations/20231106204318_upload_id_idx/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "Item_uploadId_idx" ON "Item"("uploadId"); + +-- CreateIndex +CREATE INDEX "users_photoId_idx" ON "users"("photoId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3cf4506a..0cc13fc0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -100,6 +100,7 @@ model User { ArcOut Arc[] @relation("fromUser") ArcIn Arc[] @relation("toUser") + @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@index([inviteId], map: "users.inviteId_index") @@map("users") @@ -168,22 +169,35 @@ model Donation { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } -model Upload { - id Int @id @default(autoincrement()) +model ItemUpload { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - type String - size Int - width Int? - height Int? - itemId Int? @unique(map: "Upload.itemId_unique") - userId Int - item Item? @relation(fields: [itemId], references: [id]) - user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade) - User User[] + itemId Int + uploadId Int + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade) + + @@id([itemId, uploadId]) + @@index([createdAt]) + @@index([itemId]) + @@index([uploadId]) +} + +model Upload { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + type String + size Int + width Int? + height Int? + userId Int + paid Boolean? + user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade) + User User[] + ItemUpload ItemUpload[] @@index([createdAt], map: "Upload.created_at_index") - @@index([itemId], map: "Upload.itemId_index") @@index([userId], map: "Upload.userId_index") } @@ -267,7 +281,6 @@ model Item { company String? weightedVotes Float @default(0) boost Int @default(0) - uploadId Int? pollCost Int? paidImgLink Boolean @default(false) commentMsats BigInt @default(0) @@ -299,10 +312,12 @@ model Item { PollOption PollOption[] PollVote PollVote[] ThreadSubscription ThreadSubscription[] - upload Upload? User User[] itemForwards ItemForward[] + ItemUpload ItemUpload[] + uploadId Int? + @@index([uploadId]) @@index([bio], map: "Item.bio_index") @@index([createdAt], map: "Item.created_at_index") @@index([freebie], map: "Item.freebie_index") diff --git a/worker/deleteUnusedImages.js b/worker/deleteUnusedImages.js new file mode 100644 index 00000000..200a815d --- /dev/null +++ b/worker/deleteUnusedImages.js @@ -0,0 +1,26 @@ +import { deleteObjects } from '../api/s3' + +export function deleteUnusedImages ({ models }) { + return async function ({ name }) { + console.log('running', name) + + // delete all images in database and S3 which weren't paid in the last 24 hours + const unpaidImages = await models.$queryRaw` + SELECT id + FROM "Upload" + WHERE (paid = 'f' + OR ( + -- for non-textarea images, they are free and paid is null + paid IS NULL + -- if the image is not used by a user or item (eg jobs), delete it + AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id) + AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id) + )) + AND created_at < date_trunc('hour', now() - interval '24 hours')` + + const s3Keys = unpaidImages.map(({ id }) => id) + console.log('deleting images:', s3Keys) + await deleteObjects(s3Keys) + await models.upload.deleteMany({ where: { id: { in: s3Keys } } }) + } +} diff --git a/worker/imgproxy.js b/worker/imgproxy.js index 67f25449..ef4918e2 100644 --- a/worker/imgproxy.js +++ b/worker/imgproxy.js @@ -64,10 +64,10 @@ export function imgproxy ({ models }) { let imgproxyUrls = {} try { if (item.text) { - imgproxyUrls = await createImgproxyUrls(id, item.text, { forceFetch }) + imgproxyUrls = await createImgproxyUrls(id, item.text, { models, forceFetch }) } if (item.url && !isJob(item)) { - imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { forceFetch })) } + imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { models, forceFetch })) } } } catch (err) { console.log('[imgproxy] error:', err) @@ -81,7 +81,7 @@ export function imgproxy ({ models }) { } } -export const createImgproxyUrls = async (id, text, { forceFetch }) => { +export const createImgproxyUrls = async (id, text, { models, forceFetch }) => { const urls = extractUrls(text) console.log('[imgproxy] id:', id, '-- extracted urls:', urls) // resolutions that we target: diff --git a/worker/index.js b/worker/index.js index 8ccbf0b1..401ed46f 100644 --- a/worker/index.js +++ b/worker/index.js @@ -16,6 +16,7 @@ import { authenticatedLndGrpc } from 'ln-service' import { views, rankViews } from './views.js' import { imgproxy } from './imgproxy.js' import { deleteItem } from './ephemeralItems.js' +import { deleteUnusedImages } from './deleteUnusedImages.js' const { loadEnvConfig } = nextEnv const { ApolloClient, HttpLink, InMemoryCache } = apolloClient @@ -70,6 +71,7 @@ async function work () { await boss.work('rankViews', rankViews(args)) await boss.work('imgproxy', imgproxy(args)) await boss.work('deleteItem', deleteItem(args)) + await boss.work('deleteUnusedImages', deleteUnusedImages(args)) console.log('working jobs') }
{numWithUnits(paidSats, { abbreviate: false })}{parentId ? 'reply' : 'post'} fee
x 10image/link fee
- {numWithUnits(paidSats, { abbreviate: false })}already paid
{numWithUnits(0, { abbreviate: false })}edit fee
+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}image fee
+ {numWithUnits(boost, { abbreviate: false })}