replace highlight

This commit is contained in:
Hongbo Wu 2023-09-03 00:09:31 +08:00
parent 381e9bc173
commit c655f78f8c
26 changed files with 794 additions and 815 deletions

View file

@ -22,7 +22,7 @@ export enum HighlightType {
@Entity({ name: 'highlight' })
export class Highlight {
@PrimaryGeneratedColumn('uuid')
id?: string
id!: string
@Column({ type: 'varchar', length: 14 })
shortId!: string
@ -36,37 +36,37 @@ export class Highlight {
libraryItem!: LibraryItem
@Column('text')
quote!: string
quote?: string | null
@Column({ type: 'varchar', length: 5000 })
prefix?: string
prefix?: string | null
@Column({ type: 'varchar', length: 5000 })
suffix?: string
suffix?: string | null
@Column('text')
patch!: string
patch?: string | null
@Column('text')
annotation?: string
annotation?: string | null
@Column('boolean')
deleted?: boolean
@CreateDateColumn()
createdAt?: Date
createdAt!: Date
@UpdateDateColumn()
updatedAt?: Date
updatedAt!: Date
@Column('timestamp')
sharedAt?: Date
@Column('real')
highlightPositionPercent!: number
highlightPositionPercent?: number | null
@Column('integer')
highlightPositionAnchorIndex!: number
highlightPositionAnchorIndex?: number | null
@Column('enum', {
enum: HighlightType,

View file

@ -85,10 +85,10 @@ export class LibraryItem {
description?: string | null
@Column('timestamptz')
savedAt?: Date
savedAt!: Date
@CreateDateColumn()
createdAt?: Date
createdAt!: Date
@Column('timestamptz', { nullable: true })
publishedAt?: Date | null
@ -103,7 +103,7 @@ export class LibraryItem {
readAt?: Date | null
@UpdateDateColumn()
updatedAt?: Date
updatedAt!: Date
@Column('text', { nullable: true })
itemLanguage?: string | null

View file

@ -0,0 +1,35 @@
import { DeepPartial } from 'typeorm'
import { entityManager } from '.'
import { Highlight } from '../entity/highlight'
import { unescapeHtml } from '../utils/helpers'
const unescapeHighlight = (highlight: DeepPartial<Highlight>) => {
// unescape HTML entities
highlight.annotation = highlight.annotation
? unescapeHtml(highlight.annotation)
: undefined
highlight.quote = highlight.quote ? unescapeHtml(highlight.quote) : undefined
return highlight
}
export const highlightRepository = entityManager
.getRepository(Highlight)
.extend({
findById(id: string) {
return this.findOneBy({ id })
},
findByLibraryItemId(libraryItemId: string) {
return this.findBy({
libraryItem: { id: libraryItemId },
})
},
createAndSave(highlight: DeepPartial<Highlight>, userId: string) {
return this.save({
...unescapeHighlight(highlight),
user: { id: userId },
})
},
})

View file

@ -6,14 +6,11 @@ import { Follower } from '../entity/follower'
import { Group } from '../entity/groups/group'
import { GroupMembership } from '../entity/groups/group_membership'
import { Invite } from '../entity/groups/invite'
import { Highlight } from '../entity/highlight'
import { Integration } from '../entity/integration'
import { LibraryItem } from '../entity/library_item'
import { NewsletterEmail } from '../entity/newsletter_email'
import { Profile } from '../entity/profile'
import { ReceivedEmail } from '../entity/received_email'
import { Recommendation } from '../entity/recommendation'
import { Reminder } from '../entity/reminder'
import { AbuseReport } from '../entity/reports/abuse_report'
import { ContentDisplayReport } from '../entity/reports/content_display_report'
import { Rule } from '../entity/rule'
@ -37,10 +34,19 @@ export const getRepository = <T>(entity: EntityTarget<T>): Repository<T> => {
return entityManager.getRepository(entity)
}
export const authTrx = async <T>(
fn: (manager: EntityManager) => Promise<T>,
uid = '00000000-0000-0000-0000-000000000000',
dbRole = 'omnivore_user'
): Promise<T> => {
return entityManager.transaction(async (tx) => {
await setClaims(tx, uid, dbRole)
return fn(tx)
})
}
export const entityManager = appDataSource.manager
export const reminderRepository = getRepository(Reminder)
export const libraryItemRepository = getRepository(LibraryItem)
export const groupMembershipRepository = getRepository(GroupMembership)
export const groupRepository = getRepository(Group)
export const inviteRepository = getRepository(Invite)
@ -50,7 +56,6 @@ export const contentDisplayReportRepository =
export const featureRepository = getRepository(Feature)
export const filterRepository = getRepository(Filter)
export const followerRepository = getRepository(Follower)
export const highlightRepository = getRepository(Highlight)
export const integrationRepository = getRepository(Integration)
export const newsletterEmailRepository = getRepository(NewsletterEmail)
export const profileRepository = getRepository(Profile)

View file

@ -1,8 +1,15 @@
import { In } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { entityManager } from '.'
import { Label } from '../entity/label'
import { generateRandomColor } from '../utils/helpers'
export interface CreateLabelInput {
name: string
color?: string | null
description?: string | null
}
const INTERNAL_LABELS_WITH_COLOR = new Map<
string,
{ name: string; color: string }
@ -21,14 +28,7 @@ const isLabelInternal = (name: string): boolean => {
return INTERNAL_LABELS_WITH_COLOR.has(name.toLowerCase())
}
const toPartialLabel = (
label: {
name: string
color?: string | null
description?: string | null
},
userId: string
) => {
const convertToLabel = (label: CreateLabelInput, userId: string) => {
return {
user: { id: userId },
name: label.name,
@ -64,25 +64,20 @@ export const labelRepository = entityManager.getRepository(Label).extend({
})
},
createLabel(
label: {
name: string
color?: string | null
description?: string | null
},
userId: string
) {
return this.save(toPartialLabel(label, userId))
createLabel(label: CreateLabelInput, userId: string) {
return this.save(convertToLabel(label, userId))
},
createLabels(
labels: {
name: string
color?: string | null
description?: string | null
}[],
userId: string
) {
return this.save(labels.map((l) => toPartialLabel(l, userId)))
createLabels(labels: CreateLabelInput[], userId: string) {
return this.save(labels.map((l) => convertToLabel(l, userId)))
},
deleteById(id: string) {
return this.delete({ id, internal: false })
},
updateLabel(id: string, label: QueryDeepPartialEntity<Label>) {
// internal labels should not be updated
return this.update({ id, internal: false }, label)
},
})

View file

@ -1,4 +1,5 @@
import { libraryItemRepository } from '.'
import { entityManager } from '.'
import { LibraryItem } from '../entity/library_item'
export const getLibraryItemById = async (id: string) => {
return libraryItemRepository.findOneBy({ id })
@ -9,3 +10,21 @@ export const getLibraryItemByUrl = async (url: string) => {
originalUrl: url,
})
}
export const libraryItemRepository = entityManager
.getRepository(LibraryItem)
.extend({
findById(id: string) {
return this.findOneBy({ id })
},
findByUrl(url: string) {
return this.findOneBy({
originalUrl: url,
})
},
countByCreatedAt(createdAt: Date) {
return this.countBy({ createdAt })
},
})

View file

@ -28,9 +28,9 @@ export const createArticleSavingRequestResolver = authorized<
CreateArticleSavingRequestSuccess,
CreateArticleSavingRequestError,
MutationCreateArticleSavingRequestArgs
>(async (_, { input: { url } }, { claims, pubsub, log }) => {
>(async (_, { input: { url } }, { uid, pubsub, log }) => {
analytics.track({
userId: claims.uid,
userId: uid,
event: 'link_saved',
properties: {
url: url,
@ -40,16 +40,16 @@ export const createArticleSavingRequestResolver = authorized<
})
try {
const request = await createPageSaveRequest({
userId: claims.uid,
const articleSavingRequest = await createPageSaveRequest({
userId: uid,
url,
pubsub,
})
return {
articleSavingRequest: request,
articleSavingRequest,
}
} catch (err) {
log.error('error saving article', err)
log.error('createArticleSavingRequestResolver error', err)
if (isErrorWithCode(err)) {
return {
errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode],

View file

@ -1,18 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-floating-promises */
import {
addHighlightToPage,
deleteHighlight,
getHighlightById,
updateHighlight,
} from '../../elastic/highlights'
import { getPageById, updatePage } from '../../elastic/pages'
import { DeepPartial } from 'typeorm'
import {
Highlight as HighlightData,
HighlightType,
Label,
} from '../../elastic/types'
} from '../../entity/highlight'
import { Label } from '../../entity/label'
import { env } from '../../env'
import {
CreateHighlightError,
@ -28,101 +22,54 @@ import {
MutationCreateHighlightArgs,
MutationDeleteHighlightArgs,
MutationMergeHighlightArgs,
MutationSetShareHighlightArgs,
MutationUpdateHighlightArgs,
SetShareHighlightError,
SetShareHighlightErrorCode,
SetShareHighlightSuccess,
UpdateHighlightError,
UpdateHighlightErrorCode,
UpdateHighlightSuccess,
User,
} from '../../generated/graphql'
import { highlightRepository } from '../../repository/highlight'
import {
deleteHighlightById,
mergeHighlights,
saveHighlight,
} from '../../services/highlights'
import { analytics } from '../../utils/analytics'
import { authorized, unescapeHtml } from '../../utils/helpers'
import { authorized } from '../../utils/helpers'
const highlightDataToHighlight = (highlight: HighlightData): Highlight => ({
...highlight,
user: highlight.userId as unknown as User,
updatedAt: highlight.updatedAt || highlight.createdAt,
replies: [],
reactions: [],
createdByMe: undefined as never,
type: highlight.highlightType,
createdByMe: true,
user: {
...highlight.user,
sharedArticles: [],
},
})
export const createHighlightResolver = authorized<
CreateHighlightSuccess,
CreateHighlightError,
MutationCreateHighlightArgs
>(async (_, { input }, { claims, log, pubsub }) => {
const { articleId: pageId } = input
const page = await getPageById(pageId)
if (!page) {
return {
errorCodes: [CreateHighlightErrorCode.NotFound],
}
}
if (page.userId !== claims.uid) {
return {
errorCodes: [CreateHighlightErrorCode.Unauthorized],
}
}
if (input.annotation && input.annotation.length > 4000) {
return {
errorCodes: [CreateHighlightErrorCode.BadData],
}
}
// unescape HTML entities
const annotation = input.annotation
? unescapeHtml(input.annotation)
: undefined
>(async (_, { input }, { log, pubsub, uid }) => {
try {
const highlight: HighlightData = {
...input,
updatedAt: new Date(),
createdAt: new Date(),
userId: claims.uid,
annotation,
type: input.type || HighlightType.Highlight,
}
if (
!(await addHighlightToPage(pageId, highlight, {
pubsub,
uid: claims.uid,
refresh: true,
}))
) {
return {
errorCodes: [CreateHighlightErrorCode.NotFound],
}
}
log.info('Creating a new highlight', {
highlight,
labels: {
source: 'resolver',
resolver: 'createHighlightResolver',
uid: claims.uid,
},
})
const newHighlight = await saveHighlight(input, uid, pubsub)
analytics.track({
userId: claims.uid,
userId: uid,
event: 'highlight_created',
properties: {
pageId,
libraryItemId: input.articleId,
env: env.server.apiEnv,
},
})
return { highlight: highlightDataToHighlight(highlight) }
return { highlight: highlightDataToHighlight(newHighlight) }
} catch (err) {
log.error('Error creating highlight', err)
return {
errorCodes: [CreateHighlightErrorCode.AlreadyExists],
errorCodes: [CreateHighlightErrorCode.Forbidden],
}
}
})
@ -131,101 +78,80 @@ export const mergeHighlightResolver = authorized<
MergeHighlightSuccess,
MergeHighlightError,
MutationMergeHighlightArgs
>(async (_, { input }, { claims, log, pubsub }) => {
const { articleId: pageId } = input
>(async (_, { input }, { authTrx, log, pubsub, uid }) => {
const { overlapHighlightIdList, ...newHighlightInput } = input
const page = await getPageById(pageId)
if (!page || !page.highlights) {
return {
errorCodes: [MergeHighlightErrorCode.NotFound],
}
}
if (page.userId !== claims.uid) {
return {
errorCodes: [MergeHighlightErrorCode.Unauthorized],
}
}
/* Compute merged annotation form the order of highlights appearing on page */
const mergedAnnotations: string[] = []
const mergedLabels: Label[] = []
const mergedColors: string[] = []
const pageHighlights = page.highlights.filter((highlight) => {
// filter out highlights that are in the overlap list
// and are of type highlight (not annotation or note)
if (
overlapHighlightIdList.includes(highlight.id) &&
highlight.type === HighlightType.Highlight
) {
if (highlight.annotation) {
mergedAnnotations.push(highlight.annotation)
}
if (highlight.labels) {
// remove duplicates from labels by checking id
highlight.labels.forEach((label) => {
if (
!mergedLabels.find((mergedLabel) => mergedLabel.id === label.id)
) {
mergedLabels.push(label)
}
})
}
try {
const existingHighlights = await authTrx(async (tx) => {
return tx
.withRepository(highlightRepository)
.findByLibraryItemId(input.articleId)
})
existingHighlights.forEach((highlight) => {
// filter out highlights that are in the overlap list
// and are of type highlight (not annotation or note)
if (
overlapHighlightIdList.includes(highlight.id) &&
highlight.highlightType === HighlightType.Highlight
) {
highlight.annotation && mergedAnnotations.push(highlight.annotation)
if (highlight.labels) {
// remove duplicates from labels by checking id
highlight.labels.forEach((label) => {
if (
!mergedLabels.find((mergedLabel) => mergedLabel.id === label.id)
) {
mergedLabels.push(label)
}
})
}
// collect colors of overlap highlights
highlight.color && mergedColors.push(highlight.color)
return false
}
return true
})
}
})
// use new color or the color of the last overlap highlight
const color = newHighlightInput.color || mergedColors[mergedColors.length - 1]
try {
const highlight: HighlightData = {
const highlight: DeepPartial<HighlightData> = {
...newHighlightInput,
updatedAt: new Date(),
createdAt: new Date(),
userId: claims.uid,
annotation:
mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null,
type: HighlightType.Highlight,
labels: mergedLabels,
color,
}
const merged = await updatePage(
pageId,
{ highlights: pageHighlights.concat(highlight) },
{ pubsub, uid: claims.uid, refresh: true }
)
if (!merged) {
throw new Error('Failed to create merged highlight')
}
log.info('Creating a merged highlight', {
const newHighlight = await mergeHighlights(
overlapHighlightIdList,
highlight,
labels: {
source: 'resolver',
resolver: 'mergeHighlightResolver',
uid: claims.uid,
pageId,
uid,
pubsub
)
analytics.track({
userId: uid,
event: 'highlight_created',
properties: {
libraryItemId: input.articleId,
env: env.server.apiEnv,
},
})
return {
highlight: highlightDataToHighlight(highlight),
highlight: highlightDataToHighlight(newHighlight),
overlapHighlightIdList: input.overlapHighlightIdList,
}
} catch (e) {
log.info('Failed to create a merged highlight', {
error: e,
labels: {
source: 'resolver',
resolver: 'mergeHighlightResolver',
uid: claims.uid,
},
})
log.error('Error merging highlight', e)
return {
errorCodes: [MergeHighlightErrorCode.AlreadyExists],
errorCodes: [MergeHighlightErrorCode.Forbidden],
}
}
})
@ -234,149 +160,89 @@ export const updateHighlightResolver = authorized<
UpdateHighlightSuccess,
UpdateHighlightError,
MutationUpdateHighlightArgs
>(async (_, { input }, { pubsub, claims, log }) => {
const highlight = await getHighlightById(input.highlightId)
>(async (_, { input }, { pubsub, uid, log }) => {
try {
const updatedHighlight = await saveHighlight(input, uid, pubsub)
if (!highlight?.id) {
return {
errorCodes: [UpdateHighlightErrorCode.NotFound],
}
}
if (highlight.userId !== claims.uid) {
return { highlight: highlightDataToHighlight(updatedHighlight) }
} catch (error) {
log.error('updateHighlightResolver error', error)
return {
errorCodes: [UpdateHighlightErrorCode.Forbidden],
}
}
// unescape HTML entities
const annotation = input.annotation
? unescapeHtml(input.annotation)
: undefined
const quote = input.quote ? unescapeHtml(input.quote) : highlight.quote
const updatedHighlight: HighlightData = {
...highlight,
annotation,
quote,
updatedAt: new Date(),
color: input.color,
}
log.info('Updating a highlight', {
updatedHighlight,
labels: {
source: 'resolver',
resolver: 'updateHighlightResolver',
uid: claims.uid,
},
})
const updated = await updateHighlight(updatedHighlight, {
pubsub,
uid: claims.uid,
refresh: true,
})
if (!updated) {
return {
errorCodes: [UpdateHighlightErrorCode.NotFound],
}
}
return { highlight: highlightDataToHighlight(updatedHighlight) }
})
export const deleteHighlightResolver = authorized<
DeleteHighlightSuccess,
DeleteHighlightError,
MutationDeleteHighlightArgs
>(async (_, { highlightId }, { claims, log, pubsub }) => {
const highlight = await getHighlightById(highlightId)
>(async (_, { highlightId }, { uid, log }) => {
try {
const deletedHighlight = await deleteHighlightById(highlightId, uid)
if (!highlight?.id) {
return {
errorCodes: [DeleteHighlightErrorCode.NotFound],
if (!deletedHighlight) {
return {
errorCodes: [DeleteHighlightErrorCode.NotFound],
}
}
}
if (highlight.userId !== claims.uid) {
return { highlight: highlightDataToHighlight(deletedHighlight) }
} catch (error) {
log.error('deleteHighlightResolver error', error)
return {
errorCodes: [DeleteHighlightErrorCode.Forbidden],
}
}
const deleted = await deleteHighlight(highlightId, {
pubsub,
uid: claims.uid,
refresh: true,
})
if (!deleted) {
return {
errorCodes: [DeleteHighlightErrorCode.NotFound],
}
}
log.info('Deleting a highlight', {
highlight,
labels: {
source: 'resolver',
resolver: 'deleteHighlightResolver',
uid: claims.uid,
},
})
return { highlight: highlightDataToHighlight(highlight) }
})
export const setShareHighlightResolver = authorized<
SetShareHighlightSuccess,
SetShareHighlightError,
MutationSetShareHighlightArgs
>(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
const highlight = await getHighlightById(id)
// export const setShareHighlightResolver = authorized<
// SetShareHighlightSuccess,
// SetShareHighlightError,
// MutationSetShareHighlightArgs
// >(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
// const highlight = await getHighlightById(id)
if (!highlight?.id) {
return {
errorCodes: [SetShareHighlightErrorCode.NotFound],
}
}
// if (!highlight?.id) {
// return {
// errorCodes: [SetShareHighlightErrorCode.NotFound],
// }
// }
if (highlight.userId !== claims.uid) {
return {
errorCodes: [SetShareHighlightErrorCode.Forbidden],
}
}
// if (highlight.userId !== claims.uid) {
// return {
// errorCodes: [SetShareHighlightErrorCode.Forbidden],
// }
// }
const sharedAt = share ? new Date() : null
// const sharedAt = share ? new Date() : null
log.info(`${share ? 'S' : 'Uns'}haring a highlight`, {
highlight,
labels: {
source: 'resolver',
resolver: 'setShareHighlightResolver',
userId: highlight.userId,
},
})
// log.info(`${share ? 'S' : 'Uns'}haring a highlight`, {
// highlight,
// labels: {
// source: 'resolver',
// resolver: 'setShareHighlightResolver',
// userId: highlight.userId,
// },
// })
const updatedHighlight: HighlightData = {
...highlight,
sharedAt,
updatedAt: new Date(),
}
// const updatedHighlight: HighlightData = {
// ...highlight,
// sharedAt,
// updatedAt: new Date(),
// }
const updated = await updateHighlight(updatedHighlight, {
pubsub,
uid: claims.uid,
refresh: true,
})
// const updated = await updateHighlight(updatedHighlight, {
// pubsub,
// uid: claims.uid,
// refresh: true,
// })
if (!updated) {
return {
errorCodes: [SetShareHighlightErrorCode.NotFound],
}
}
// if (!updated) {
// return {
// errorCodes: [SetShareHighlightErrorCode.NotFound],
// }
// }
return { highlight: highlightDataToHighlight(updatedHighlight) }
})
// return { highlight: highlightDataToHighlight(updatedHighlight) }
// })

View file

@ -1,15 +1,5 @@
import { Between } from 'typeorm'
import { appDataSource } from '../../data_source'
import { getHighlightById } from '../../elastic/highlights'
import {
deleteLabel,
setLabelsForHighlight,
updateLabel,
updateLabelsInPage,
} from '../../elastic/labels'
import { getPageById } from '../../elastic/pages'
import { Label } from '../../entity/label'
import { User } from '../../entity/user'
import { env } from '../../env'
import {
CreateLabelError,
@ -37,34 +27,25 @@ import {
UpdateLabelErrorCode,
UpdateLabelSuccess,
} from '../../generated/graphql'
import { createPubSubClient } from '../../pubsub'
import { getRepository, setClaims } from '../../repository'
import { labelRepository } from '../../repository/label'
import {
createLabel,
getLabelByName,
getLabelsAndCreateIfNotExist,
getLabelsByIds,
saveLabelsInHighlight,
saveLabelsInLibraryItem,
} from '../../services/labels'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { claims: { uid }, log }) => {
async (_obj, _params, { log, authTrx }) => {
try {
const user = await getRepository(User).findOne({
where: { id: uid },
relations: ['labels'],
order: {
labels: {
const labels = await authTrx(async (tx) => {
return tx.find(Label, {
order: {
position: 'ASC',
},
},
})
})
if (!user) {
return {
errorCodes: [LabelsErrorCode.Unauthorized],
}
}
analytics.track({
userId: uid,
@ -79,10 +60,10 @@ export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
})
return {
labels: user.labels || [],
labels,
}
} catch (error) {
log.error(error)
log.error('labelsResolver', error)
return {
errorCodes: [LabelsErrorCode.BadRequest],
}
@ -94,26 +75,11 @@ export const createLabelResolver = authorized<
CreateLabelSuccess,
CreateLabelError,
MutationCreateLabelArgs
>(async (_, { input }, { claims: { uid }, log }) => {
log.info('createLabelResolver')
>(async (_, { input }, { authTrx, log, uid }) => {
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [CreateLabelErrorCode.Unauthorized],
}
}
// Check if label already exists ignoring case of name
const existingLabel = await getLabelByName(uid, input.name)
if (existingLabel) {
return {
errorCodes: [CreateLabelErrorCode.LabelAlreadyExists],
}
}
const label = await createLabel(uid, input)
const label = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).createLabel(input, uid)
})
analytics.track({
userId: uid,
@ -128,7 +94,7 @@ export const createLabelResolver = authorized<
label,
}
} catch (error) {
log.error(error)
log.error('createLabelResolver', error)
return {
errorCodes: [CreateLabelErrorCode.BadRequest],
}
@ -139,53 +105,18 @@ export const deleteLabelResolver = authorized<
DeleteLabelSuccess,
DeleteLabelError,
MutationDeleteLabelArgs
>(async (_, { id: labelId }, { claims: { uid }, log }) => {
log.info('deleteLabelResolver')
>(async (_, { id: labelId }, { authTrx, log, uid }) => {
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [DeleteLabelErrorCode.Unauthorized],
}
}
const label = await getRepository(Label).findOne({
where: { id: labelId, user: { id: uid } },
relations: ['user'],
const deleteResult = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).deleteById(labelId)
})
if (!label) {
if (!deleteResult.affected) {
return {
errorCodes: [DeleteLabelErrorCode.NotFound],
}
}
// internal labels cannot be deleted
if (label.internal) {
log.info('internal labels cannot be deleted')
return {
errorCodes: [DeleteLabelErrorCode.Forbidden],
}
}
const result = await appDataSource.transaction(async (t) => {
await setClaims(t, uid)
return t.getRepository(Label).delete(labelId)
})
if (!result.affected) {
log.error('Failed to delete label', labelId)
return {
errorCodes: [DeleteLabelErrorCode.BadRequest],
}
}
// delete label in elastic pages and highlights
await deleteLabel(label.name, {
pubsub: createPubSubClient(),
uid,
refresh: true,
})
analytics.track({
userId: uid,
event: 'label_deleted',
@ -196,7 +127,7 @@ export const deleteLabelResolver = authorized<
})
return {
label,
label: deleteResult.raw as Label,
}
} catch (error) {
log.error('error deleting label', error)
@ -210,177 +141,110 @@ export const setLabelsResolver = authorized<
SetLabelsSuccess,
SetLabelsError,
MutationSetLabelsArgs
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
log.info('setLabelsResolver')
const { pageId, labelIds, labels } = input
if (!labelIds && !labels) {
log.info('labelIds or labels must be provided')
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
>(
async (
_,
{ input: { pageId, labelIds, labels } },
{ uid, log, authTrx, pubsub }
) => {
if (!labelIds && !labels) {
log.error('labelIds or labels must be provided')
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
const page = await getPageById(pageId)
if (!page) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
if (page.userId !== uid) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
try {
let labelsSet: Label[] = []
const ctx = {
uid,
pubsub,
refresh: true,
}
let labelsSet: Label[] = []
if (labels && labels.length > 0) {
// for new clients that send label names
// create labels if they don't exist
labelsSet = await getLabelsAndCreateIfNotExist(ctx, labels)
} else if (labelIds && labelIds.length > 0) {
// for old clients that send labelIds
labelsSet = await getLabelsByIds(uid, labelIds)
if (labelsSet.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
if (labels && labels.length > 0) {
// for new clients that send label names
// create labels if they don't exist
labelsSet = await getLabelsAndCreateIfNotExist(labels, uid)
} else if (labelIds && labelIds.length > 0) {
// for old clients that send labelIds
labelsSet = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findByIds(labelIds)
})
if (labelsSet.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
}
}
// filter out labels that are already set
const labelsToAdd = labelsSet.filter(
(label) => !page.labels?.some((pageLabel) => pageLabel.id === label.id)
)
// update labels in the page
const updated = await updateLabelsInPage(
pageId,
labelsSet,
ctx,
labelsToAdd
)
if (!updated) {
// save labels in the library item
await saveLabelsInLibraryItem(labelsSet, pageId, uid, pubsub)
analytics.track({
userId: uid,
event: 'labels_set',
properties: {
pageId,
labelIds,
env: env.server.apiEnv,
},
})
return {
errorCodes: [SetLabelsErrorCode.NotFound],
labels: labelsSet,
}
} catch (error) {
log.error('setLabelsResolver error', error)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
analytics.track({
userId: uid,
event: 'labels_set',
properties: {
pageId,
labelIds,
env: env.server.apiEnv,
},
})
return {
labels: labelsSet,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
})
)
export const updateLabelResolver = authorized<
UpdateLabelSuccess,
UpdateLabelError,
MutationUpdateLabelArgs
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
log.info('updateLabelResolver')
>(
async (
_,
{ input: { name, color, description, labelId } },
{ authTrx, log }
) => {
try {
log.info('Updating a label', {
labels: {
source: 'resolver',
resolver: 'updateLabelResolver',
},
})
try {
const { name, color, description, labelId } = input
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [UpdateLabelErrorCode.Unauthorized],
const result = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).updateLabel(labelId, {
name,
color,
description,
})
})
if (!result.affected) {
log.error('failed to update')
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
}
const label = await getRepository(Label).findOne({
where: { id: labelId, user: { id: uid } },
select: ['id', 'name', 'color', 'description', 'createdAt'],
})
if (!label) {
return {
errorCodes: [UpdateLabelErrorCode.NotFound],
}
}
// internal labels cannot be updated
if (label.internal && label.name.toLowerCase() !== name.toLowerCase()) {
log.info('internal labels cannot be updated')
return {
errorCodes: [UpdateLabelErrorCode.Forbidden],
}
}
log.info('Updating a label', {
labels: {
source: 'resolver',
resolver: 'updateLabelResolver',
},
})
const result = await appDataSource.transaction(async (t) => {
await setClaims(t, uid)
label.name = name
label.color = color
label.description = description || undefined
label.createdAt = new Date()
return t.getRepository(Label).update({ id: labelId }, label)
})
if (!result.affected) {
log.error('failed to update')
return { label: result.raw as Label }
} catch (error) {
log.error('error updating label', error)
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
await updateLabel(label, {
pubsub,
uid,
refresh: true,
})
return { label }
} catch (error) {
log.error('error updating label', error)
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
})
)
export const setLabelsForHighlightResolver = authorized<
SetLabelsSuccess,
SetLabelsError,
MutationSetLabelsForHighlightArgs
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
log.info('setLabelsForHighlightResolver')
>(async (_, { input }, { uid, log, pubsub, authTrx }) => {
const { highlightId, labelIds, labels } = input
if (!labelIds && !labels) {
@ -391,63 +255,25 @@ export const setLabelsForHighlightResolver = authorized<
}
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
const highlight = await getHighlightById(highlightId)
if (!highlight) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
if (highlight.userId !== uid) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
const ctx = {
uid,
pubsub,
refresh: true,
}
let labelsSet: Label[] = []
if (labels && labels.length > 0) {
// for new clients that send label names
// create labels if they don't exist
labelsSet = await getLabelsAndCreateIfNotExist(ctx, labels)
labelsSet = await getLabelsAndCreateIfNotExist(labels, uid)
} else if (labelIds && labelIds.length > 0) {
// for old clients that send labelIds
labelsSet = await getLabelsByIds(uid, labelIds)
labelsSet = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findByIds(labelIds)
})
if (labelsSet.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
}
// filter out labels that are already set
const labelsToAdd = labelsSet.filter(
(label) =>
!highlight.labels?.some(
(highlightLabel) => highlightLabel.id === label.id
)
)
// set labels in the highlights
const updated = await setLabelsForHighlight(
highlightId,
labelsSet,
ctx,
labelsToAdd
)
if (!updated) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
// save labels in the library item
await saveLabelsInHighlight(labelsSet, input.highlightId, uid, pubsub)
analytics.track({
userId: uid,
@ -474,33 +300,18 @@ export const moveLabelResolver = authorized<
MoveLabelSuccess,
MoveLabelError,
MutationMoveLabelArgs
>(async (_, { input }, { claims: { uid }, log, pubsub }) => {
log.info('moveLabelResolver')
>(async (_, { input }, { authTrx, log, uid }) => {
const { labelId, afterLabelId } = input
try {
const user = await getRepository(User).findOneBy({ id: uid })
if (!user) {
return {
errorCodes: [MoveLabelErrorCode.Unauthorized],
}
}
const label = await getRepository(Label).findOne({
where: { id: labelId },
relations: ['user'],
const label = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findById(labelId)
})
if (!label) {
return {
errorCodes: [MoveLabelErrorCode.NotFound],
}
}
if (label.user.id !== uid) {
return {
errorCodes: [MoveLabelErrorCode.Unauthorized],
}
}
if (label.id === afterLabelId) {
// nothing to do
@ -511,32 +322,25 @@ export const moveLabelResolver = authorized<
// if afterLabelId is not provided, move to the top
let newPosition = 1
if (afterLabelId) {
const afterLabel = await getRepository(Label).findOne({
where: { id: afterLabelId },
relations: ['user'],
const afterLabel = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findById(labelId)
})
if (!afterLabel) {
return {
errorCodes: [MoveLabelErrorCode.NotFound],
}
}
if (afterLabel.user.id !== uid) {
return {
errorCodes: [MoveLabelErrorCode.Unauthorized],
}
}
newPosition = afterLabel.position
}
const moveUp = newPosition < oldPosition
// move label to the new position
const updated = await appDataSource.transaction(async (t) => {
await setClaims(t, uid)
const updated = await authTrx(async (tx) => {
const labelRepo = tx.withRepository(labelRepository)
// update the position of the other labels
const updated = await t.getRepository(Label).update(
const updated = await labelRepo.update(
{
user: { id: uid },
position: Between(
Math.min(newPosition, oldPosition),
Math.max(newPosition, oldPosition)
@ -551,7 +355,7 @@ export const moveLabelResolver = authorized<
}
// update the position of the label
return t.getRepository(Label).save({
return labelRepo.save({
...label,
position: newPosition,
})

View file

@ -4,7 +4,6 @@ import {
AddPopularReadSuccess,
MutationAddPopularReadArgs,
} from '../../generated/graphql'
import { userRepository } from '../../repository'
import { addPopularRead } from '../../services/popular_reads'
import { authorized } from '../../utils/helpers'
export const addPopularReadResolver = authorized<
@ -12,13 +11,6 @@ export const addPopularReadResolver = authorized<
AddPopularReadError,
MutationAddPopularReadArgs
>(async (_, { name }, { uid }) => {
const user = await userRepository.findOneBy({
id: uid,
})
if (!user) {
return { errorCodes: [AddPopularReadErrorCode.Unauthorized] }
}
const pageId = await addPopularRead(uid, name)
if (!pageId) {
return { errorCodes: [AddPopularReadErrorCode.NotFound] }

View file

@ -1,4 +1,3 @@
import { User } from '../../entity/user'
import { env } from '../../env'
import {
MutationSaveFileArgs,
@ -8,7 +7,7 @@ import {
SaveErrorCode,
SaveSuccess,
} from '../../generated/graphql'
import { getRepository, userRepository } from '../../repository'
import { userRepository } from '../../repository/user'
import { saveFile } from '../../services/save_file'
import { savePage } from '../../services/save_page'
import { saveUrl } from '../../services/save_url'

View file

@ -6,7 +6,6 @@ import {
UpdatePageErrorCode,
UpdatePageSuccess,
} from '../../generated/graphql'
import { userRepository } from '../../repository'
import { Merge } from '../../util'
import { authorized } from '../../utils/helpers'
@ -21,10 +20,6 @@ export const updatePageResolver = authorized<
MutationUpdatePageArgs
>(async (_, { input }, ctx) => {
const { pubsub, uid } = ctx
const user = await userRepository.findOneBy({ id: uid })
if (!user) {
return { errorCodes: [UpdatePageErrorCode.Unauthorized] }
}
const page = await getPageById(input.pageId)
@ -32,10 +27,6 @@ export const updatePageResolver = authorized<
return { errorCodes: [UpdatePageErrorCode.NotFound] }
}
if (page.userId !== user.id) {
return { errorCodes: [UpdatePageErrorCode.Unauthorized] }
}
const pageData = {
id: input.pageId,
title: input.title ?? undefined,

View file

@ -13,7 +13,7 @@ import {
UploadFileRequestSuccess,
UploadFileStatus,
} from '../../generated/graphql'
import { uploadFileRepository } from '../../repository'
import { uploadFileRepository } from '../../repository/upload_file'
import { validateUrl } from '../../services/create_page_save_request'
import { analytics } from '../../utils/analytics'
import { authorized, generateSlug } from '../../utils/helpers'

View file

@ -38,8 +38,9 @@ import {
UsersError,
UsersSuccess,
} from '../../generated/graphql'
import { setClaims, userRepository } from '../../repository'
import { createUser, getTopUsers } from '../../services/create_user'
import { setClaims } from '../../repository'
import { userRepository } from '../../repository/user'
import { createUser } from '../../services/create_user'
import { sendVerificationEmail } from '../../services/send_emails'
import { authorized, userDataToUser } from '../../utils/helpers'
import { validateUsername } from '../../utils/usernamePolicy'
@ -295,7 +296,7 @@ export const getUserResolver: ResolverFn<
export const getAllUsersResolver = authorized<UsersSuccess, UsersError>(
async (_obj, _params) => {
const users = await getTopUsers()
const users = await userRepository.findTopUsers()
const result = { users: users.map((userData) => userDataToUser(userData)) }
return result
}

View file

@ -759,7 +759,7 @@ const schema = gql`
quote: String! @sanitize(maxLength: 12000, minLength: 1)
prefix: String @sanitize
suffix: String @sanitize
annotation: String @sanitize(maxLength: 8000)
annotation: String @sanitize(maxLength: 4000)
overlapHighlightIdList: [String!]!
highlightPositionPercent: Float
highlightPositionAnchorIndex: Int

View file

@ -1,24 +1,22 @@
import * as privateIpLib from 'private-ip'
import { v4 as uuidv4 } from 'uuid'
import {
countByCreatedAt,
createPage,
getPageByParam,
updatePage,
} from '../elastic/pages'
import { ArticleSavingRequestStatus, Label, PageType } from '../elastic/types'
import { countByCreatedAt, createPage, updatePage } from '../elastic/pages'
import { ArticleSavingRequestStatus, PageType } from '../elastic/types'
import { LibraryItemState } from '../entity/library_item'
import { User } from '../entity/user'
import {
ArticleSavingRequest,
CreateArticleSavingRequestErrorCode,
CreateLabelInput
} from '../generated/graphql'
import { createPubSubClient, PubsubClient } from '../pubsub'
import { getRepository } from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { userRepository } from '../repository/user'
import { enqueueParseRequest } from '../utils/createTask'
import {
cleanUrl,
generateSlug,
pageToArticleSavingRequest,
pageToArticleSavingRequest
} from '../utils/helpers'
import { logger } from '../utils/logger'
@ -28,7 +26,7 @@ interface PageSaveRequest {
pubsub?: PubsubClient
articleSavingRequestId?: string
archivedAt?: Date | null
labels?: Label[]
labels?: CreateLabelInput[]
priority?: 'low' | 'high'
user?: User | null
locale?: string
@ -101,9 +99,7 @@ export const createPageSaveRequest = async ({
}
// if user is not specified, get it from the database
if (!user) {
user = await getRepository(User).findOneBy({
id: userId,
})
user = await userRepository.findById(userId)
if (!user) {
logger.info('User not found', userId)
return Promise.reject({
@ -112,26 +108,14 @@ export const createPageSaveRequest = async ({
}
}
// get priority by checking rate limit if not specified
priority = priority || (await getPriorityByRateLimit(userId))
// look for existing page
url = cleanUrl(url)
const ctx = {
pubsub,
uid: userId,
refresh: true,
}
let page = await getPageByParam({
userId,
url,
})
if (!page) {
logger.info('Page not exists', url)
page = {
// look for existing library item
const existingLibraryItem = await libraryItemRepository.findByUrl(url)
if (!existingLibraryItem) {
logger.info('libraryItem does not exist', { url })
libraryItem = {
id: articleSavingRequestId,
userId,
user: { id: userId },
content: SAVING_CONTENT,
hash: '',
pageType: PageType.Unknown,
@ -140,12 +124,11 @@ export const createPageSaveRequest = async ({
slug: generateSlug(url),
title: url,
url,
state: ArticleSavingRequestStatus.Processing,
state: LibraryItemState.Processing,
createdAt: new Date(),
savedAt: savedAt || new Date(),
publishedAt,
archivedAt,
labels,
}
// create processing page
@ -158,7 +141,7 @@ export const createPageSaveRequest = async ({
}
}
// reset state to processing
if (page.state !== ArticleSavingRequestStatus.Processing) {
if (existingLibraryItem.state !== ArticleSavingRequestStatus.Processing) {
await updatePage(
page.id,
{
@ -167,24 +150,22 @@ export const createPageSaveRequest = async ({
ctx
)
}
const labelsInput = labels?.map((label) => ({
name: label.name,
color: label.color,
description: label.description,
}))
// get priority by checking rate limit if not specified
priority = priority || (await getPriorityByRateLimit(userId))
// enqueue task to parse page
await enqueueParseRequest({
url,
userId,
saveRequestId: page.id,
saveRequestId: articleSavingRequestId,
priority,
state: archivedAt ? ArticleSavingRequestStatus.Archived : undefined,
labels: labelsInput,
labels,
locale,
timezone,
// unix timestamp
savedAt: savedAt?.getTime(),
publishedAt: publishedAt?.getTime(),
savedAt,
publishedAt,
})
return pageToArticleSavingRequest(user, page)

View file

@ -1,5 +1,12 @@
import { diff_match_patch } from 'diff-match-patch'
import { DeepPartial } from 'typeorm'
import { Highlight } from '../entity/highlight'
import { homePageURL } from '../env'
import { createPubSubClient, EntityType } from '../pubsub'
import { entityManager, setClaims } from '../repository'
import { highlightRepository } from '../repository/highlight'
type HighlightEvent = Highlight & { pageId: string }
export const getHighlightLocation = (patch: string): number | undefined => {
const dmp = new diff_match_patch()
@ -9,3 +16,69 @@ export const getHighlightLocation = (patch: string): number | undefined => {
export const getHighlightUrl = (slug: string, highlightId: string): string =>
`${homePageURL()}/me/${slug}#${highlightId}`
export const saveHighlight = async (
highlight: DeepPartial<Highlight>,
userId: string,
pubsub = createPubSubClient(),
em = entityManager
) => {
const newHighlight = await em.transaction(async (tx) => {
await setClaims(tx, userId)
return tx
.withRepository(highlightRepository)
.createAndSave(highlight, userId)
})
await pubsub.entityCreated<HighlightEvent>(
EntityType.HIGHLIGHT,
{ ...newHighlight, pageId: newHighlight.libraryItem.id },
userId
)
return newHighlight
}
export const mergeHighlights = async (
highlightsToRemove: string[],
highlightToAdd: DeepPartial<Highlight>,
userId: string,
pubsub = createPubSubClient(),
em = entityManager
) => {
const newHighlight = await em.transaction(async (tx) => {
await setClaims(tx, userId)
const highlightRepo = tx.withRepository(highlightRepository)
await highlightRepo.delete(highlightsToRemove)
return highlightRepo.createAndSave(highlightToAdd, userId)
})
await pubsub.entityCreated<HighlightEvent>(
EntityType.HIGHLIGHT,
{ ...newHighlight, pageId: newHighlight.libraryItem.id },
userId
)
return newHighlight
}
export const deleteHighlightById = async (
highlightId: string,
userId: string
) => {
return entityManager.transaction(async (tx) => {
await setClaims(tx, userId)
const highlightRepo = tx.withRepository(highlightRepository)
const highlight = await highlightRepo.findById(highlightId)
if (!highlight) {
throw new Error(`Highlight ${highlightId} not found`)
}
return highlightRepo.remove(highlight)
})
}

View file

@ -1,33 +1,14 @@
import DataLoader from 'dataloader'
import { In } from 'typeorm'
import { addLabelInPage } from '../elastic/labels'
import { PageContext } from '../elastic/types'
import { Highlight } from '../entity/highlight'
import { Label } from '../entity/label'
import { LibraryItem } from '../entity/library_item'
import { Link } from '../entity/link'
import { User } from '../entity/user'
import { CreateLabelInput } from '../generated/graphql'
import { entityManager, getRepository, setClaims } from '../repository'
import { labelRepository } from '../repository/label'
import { generateRandomColor } from '../utils/helpers'
import { logger } from '../utils/logger'
const INTERNAL_LABELS_WITH_COLOR = new Map<
string,
{ name: string; color: string }
>([
['favorites', { name: 'Favorites', color: '#FFD700' }],
['library', { name: 'Library', color: '#584C42' }],
['rss', { name: 'RSS', color: '#F26522' }],
['newsletter', { name: 'Newsletter', color: '#07D2D1' }],
])
export const getInternalLabelWithColor = (name: string) => {
return INTERNAL_LABELS_WITH_COLOR.get(name.toLowerCase())
}
const isLabelInternal = (name: string): boolean => {
return INTERNAL_LABELS_WITH_COLOR.has(name.toLowerCase())
}
import { EntityType, PubsubClient } from '../pubsub'
import { authTrx, getRepository } from '../repository'
import { highlightRepository } from '../repository/highlight'
import { CreateLabelInput, labelRepository } from '../repository/label'
import { libraryItemRepository } from '../repository/library_item'
const batchGetLabelsFromLinkIds = async (
linkIds: readonly string[]
@ -44,90 +25,11 @@ const batchGetLabelsFromLinkIds = async (
export const labelsLoader = new DataLoader(batchGetLabelsFromLinkIds)
export const addLabelToPage = async (
ctx: PageContext,
pageId: string,
label: {
name: string
color: string
description?: string
}
): Promise<boolean> => {
const user = await getRepository(User).findOneBy({
id: ctx.uid,
})
if (!user) {
return false
}
let labelEntity = await getLabelByName(user.id, label.name)
if (!labelEntity) {
logger.info('creating new label', label.name)
labelEntity = await createLabel(user.id, label)
}
logger.info('adding label to page', label.name, pageId)
return addLabelInPage(
pageId,
{
id: labelEntity.id,
name: labelEntity.name,
color: labelEntity.color,
description: labelEntity.description,
createdAt: labelEntity.createdAt,
},
ctx
)
}
export const getLabelsByIds = async (
userId: string,
labelIds: string[]
): Promise<Label[]> => {
return getRepository(Label).find({
where: { id: In(labelIds), user: { id: userId } },
select: ['id', 'name', 'color', 'description', 'createdAt'],
})
}
export const getLabelByName = async (
userId: string,
name: string
): Promise<Label | null> => {
return getRepository(Label)
.createQueryBuilder()
.where({ user: { id: userId } })
.andWhere('LOWER(name) = LOWER(:name)', { name })
.getOne()
}
export const createLabel = async (
userId: string,
label: {
name: string
color?: string | null
description?: string | null
}
): Promise<Label> => {
return getRepository(Label).save({
user: { id: userId },
name: label.name,
color: label.color || generateRandomColor(), // assign a random color if not provided
description: label.description,
internal: isLabelInternal(label.name),
})
}
export const getLabelsAndCreateIfNotExist = async (
labels: CreateLabelInput[],
userId: string
): Promise<Label[]> => {
return entityManager.transaction(async (tx) => {
await setClaims(tx, userId)
return authTrx(async (tx) => {
const labelRepo = tx.withRepository(labelRepository)
// find existing labels
const labelEntities = await labelRepo.findByNames(labels.map((l) => l.name))
@ -148,3 +50,72 @@ export const getLabelsAndCreateIfNotExist = async (
return [...labelEntities, ...newLabelEntities]
})
}
export const saveLabelsInLibraryItem = async (
labels: Label[],
libraryItemId: string,
userId: string,
pubsub: PubsubClient
) => {
await authTrx(async (tx) => {
await tx
.withRepository(libraryItemRepository)
.createQueryBuilder()
.relation(LibraryItem, 'labels')
.of(libraryItemId)
.set(labels)
})
// create pubsub event
await pubsub.entityCreated<(Label & { pageId: string })[]>(
EntityType.LABEL,
labels.map((l) => ({ ...l, pageId: libraryItemId })),
userId
)
}
export const addLabelsToLibraryItem = async (
labels: Label[],
libraryItemId: string,
userId: string,
pubsub: PubsubClient
) => {
await authTrx(async (tx) => {
await tx
.withRepository(libraryItemRepository)
.createQueryBuilder()
.relation(LibraryItem, 'labels')
.of(libraryItemId)
.add(labels)
})
// create pubsub event
await pubsub.entityCreated<(Label & { pageId: string })[]>(
EntityType.LABEL,
labels.map((l) => ({ ...l, pageId: libraryItemId })),
userId
)
}
export const saveLabelsInHighlight = async (
labels: Label[],
highlightId: string,
userId: string,
pubsub: PubsubClient
) => {
await authTrx(async (tx) => {
await tx
.withRepository(highlightRepository)
.createQueryBuilder()
.relation(Highlight, 'labels')
.of(highlightId)
.set(labels)
})
// create pubsub event
await pubsub.entityCreated<(Label & { highlightId: string })[]>(
EntityType.LABEL,
labels.map((l) => ({ ...l, highlightId })),
userId
)
}

View file

@ -6,8 +6,9 @@ import {
LibraryItemState,
LibraryItemType,
} from '../entity/library_item'
import { createPubSubClient, EntityType } from '../pubsub'
import { entityManager } from '../repository'
import { wordsCount } from '../utils/helpers'
import { libraryItemRepository } from '../repository/library_item'
import { logger } from '../utils/logger'
import {
DateFilter,
@ -84,13 +85,13 @@ export interface SearchItem {
export const createLibraryItem = async (
libraryItem: DeepPartial<LibraryItem>,
em = entityManager
pubsub = createPubSubClient()
): Promise<LibraryItem> => {
if (
libraryItem.readableContent &&
libraryItem.readableContent.length > MAX_CONTENT_LENGTH
) {
logger.info('page content is too large', {
logger.warn('page content is too large', {
url: libraryItem.originalUrl,
contentLength: libraryItem.readableContent.length,
})
@ -98,12 +99,15 @@ export const createLibraryItem = async (
libraryItem.readableContent = CONTENT_LENGTH_ERROR
}
return em.getRepository(LibraryItem).save({
...libraryItem,
savedAt: libraryItem.savedAt || new Date(),
wordCount:
libraryItem.wordCount ?? wordsCount(libraryItem.readableContent ?? ''),
})
const newItem = await libraryItemRepository.save(libraryItem)
await pubsub.entityCreated<LibraryItem>(
EntityType.PAGE,
newItem,
newItem.user.id
)
return newItem
}
const buildWhereClause = (

View file

@ -241,8 +241,8 @@ export const enqueueParseRequest = async ({
labels?: CreateLabelInput[]
locale?: string
timezone?: string
savedAt?: number // unix timestamp
publishedAt?: number // unix timestamp
savedAt?: Date
publishedAt?: Date
}): Promise<string> => {
const { GOOGLE_CLOUD_PROJECT } = process.env
const payload = {

View file

@ -7,6 +7,7 @@ import slugify from 'voca/slugify'
import wordsCounter from 'word-counting'
import { updatePage } from '../elastic/pages'
import { ArticleSavingRequestStatus, Page } from '../elastic/types'
import { LibraryItem } from '../entity/library_item'
import { RegistrationType, User } from '../entity/user'
import {
ArticleSavingRequest,
@ -149,7 +150,7 @@ export const userDataToUser = (
} => ({
...user,
source: user.source as RegistrationType,
createdAt: user.createdAt || new Date(),
createdAt: user.createdAt,
friendsCount: user.friendsCount || 0,
followersCount: user.followersCount || 0,
isFullUser: true,
@ -187,12 +188,13 @@ export const pageError = async (
export const pageToArticleSavingRequest = (
user: User,
page: Page
item: LibraryItem
): ArticleSavingRequest => ({
...page,
...item,
user: userDataToUser(user),
status: page.state,
updatedAt: page.updatedAt || new Date(),
status: item.state as unknown as ArticleSavingRequestStatus,
url: item.originalUrl,
userId: user.id,
})
export const isParsingTimeout = (page: Page): boolean => {

View file

@ -1,10 +1,10 @@
import { AppDataSource } from '../src/data-source'
import { appDataSource } from '../src/data_source'
import { stopApolloServer } from './util'
export const mochaGlobalTeardown = async () => {
await stopApolloServer()
console.log('apollo server stopped')
await AppDataSource.destroy()
await appDataSource.destroy()
console.log('db connection closed')
}

View file

@ -100,4 +100,13 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER library_item_tsv_update BEFORE INSERT OR UPDATE
ON omnivore.library_item FOR EACH ROW EXECUTE PROCEDURE update_library_item_tsv();
ALTER TABLE omnivore.library_item ENABLE ROW LEVEL SECURITY;
CREATE POLICY select_library_item ON omnivore.library_item FOR SELECT USING (user_id = omnivore.get_current_user_id());
CREATE POLICY insert_library_item ON omnivore.library_item FOR INSERT WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_library_item ON omnivore.library_item FOR UPDATE USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_library_item ON omnivore.library_item FOR DELETE USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.library_item TO omnivore_user;
COMMIT;

View file

@ -17,9 +17,25 @@ ALTER TABLE omnivore.highlight
ADD COLUMN highlight_type highlight_type NOT NULL DEFAULT 'HIGHLIGHT',
ADD COLUMN color text,
ADD COLUMN html text,
ALTER COLUMN quote DROP NOT NULL,
ALTER COLUMN patch DROP NOT NULL,
ALTER COLUMN highlight_position_percent DROP NOT NULL,
ALTER COLUMN highlight_position_anchor_index DROP NOT NULL,
DROP COLUMN article_id,
DROP COLUMN elastic_page_id;
ALTER POLICY read_highlight on omnivore.highlight
USING (user_id = omnivore.get_current_user_id());
ALTER POLICY create_highlight on omnivore.highlight
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_highlight on omnivore.highlight
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT DELETE ON omnivore.highlight TO omnivore_user;
CREATE OR REPLACE FUNCTION update_library_item_highlight_annotations()
RETURNS TRIGGER AS $$
DECLARE

View file

@ -0,0 +1,209 @@
-- Type: DO
-- Name: add_rls
-- Description: Add RLS to the tables
BEGIN;
ALTER TABLE omnivore.api_key ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_api_key on omnivore.api_key
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_api_key on omnivore.api_key
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_api_key on omnivore.api_key
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_api_key on omnivore.api_key
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.api_key TO omnivore_user;
ALTER TABLE omnivore.features ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_features on omnivore.features
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_features on omnivore.features
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_features on omnivore.features
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_features on omnivore.features
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.features TO omnivore_user;
ALTER TABLE omnivore.filters ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_filters on omnivore.filters
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_filters on omnivore.filters
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_filters on omnivore.filters
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_filters on omnivore.filters
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.filters TO omnivore_user;
ALTER TABLE omnivore.integrations ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_integrations on omnivore.integrations
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_integrations on omnivore.integrations
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_integrations on omnivore.integrations
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_integrations on omnivore.integrations
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.integrations TO omnivore_user;
ALTER TABLE omnivore.newsletter_emails ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_newsletter_emails on omnivore.newsletter_emails
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_newsletter_emails on omnivore.newsletter_emails
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_newsletter_emails on omnivore.newsletter_emails
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, DELETE ON omnivore.newsletter_emails TO omnivore_user;
ALTER POLICY read_labels on omnivore.labels
USING (user_id = omnivore.get_current_user_id());
ALTER POLICY create_labels on omnivore.labels
WITH CHECK (user_id = omnivore.get_current_user_id());
ALTER TABLE omnivore.received_emails ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_received_emails on omnivore.received_emails
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_received_emails on omnivore.received_emails
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_received_emails on omnivore.received_emails
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_received_emails on omnivore.received_emails
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.received_emails TO omnivore_user;
ALTER TABLE omnivore.rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_rules on omnivore.rules
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_rules on omnivore.rules
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_rules on omnivore.rules
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_rules on omnivore.rules
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.rules TO omnivore_user;
ALTER TABLE omnivore.subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_subscriptions on omnivore.subscriptions
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_subscriptions on omnivore.subscriptions
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_subscriptions on omnivore.subscriptions
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_subscriptions on omnivore.subscriptions
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.subscriptions TO omnivore_user;
ALTER TABLE omnivore.upload_files ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_upload_files on omnivore.upload_files
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_upload_files on omnivore.upload_files
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_upload_files on omnivore.upload_files
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_upload_files on omnivore.upload_files
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.upload_files TO omnivore_user;
ALTER TABLE omnivore.webhooks ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_webhooks on omnivore.webhooks
FOR SELECT TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY create_webhooks on omnivore.webhooks
FOR INSERT TO omnivore_user
WITH CHECK (user_id = omnivore.get_current_user_id());
CREATE POLICY update_webhooks on omnivore.webhooks
FOR UPDATE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
CREATE POLICY delete_webhooks on omnivore.webhooks
FOR DELETE TO omnivore_user
USING (user_id = omnivore.get_current_user_id());
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.webhooks TO omnivore_user;
COMMIT;

View file

@ -0,0 +1,7 @@
-- Type: UNDO
-- Name: add_rls
-- Description: Add RLS to the tables
BEGIN;
COMMIT;