From 2840b2e3eaa87e1e645db8a240e9f7a8749f4d46 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 9 Aug 2026 14:46:31 +0200 Subject: [PATCH] ADD - implement Picstagram app --- frontend/src/App.vue | 44 + .../src/assets/img/app-icons/picstagram.webp | Bin 0 -> 32388 bytes frontend/src/config/apps.test.ts | 11 +- frontend/src/config/apps.ts | 15 + frontend/src/stores/phone.ts | 198 ++ frontend/src/stores/picstagram.test.ts | 216 ++ frontend/src/stores/picstagram.ts | 399 +++ frontend/src/types/apps.ts | 1 + frontend/src/types/picstagram.ts | 141 + frontend/src/utils/preferences.ts | 1 + frontend/src/views/apps/PicstagramApp.vue | 2805 +++++++++++++++++ frontend/testserver/index.cjs | 531 ++++ sky_phone/config/config.lua | 22 + sky_phone/config/locales/en.lua | 80 + sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/main.lua | 46 + sky_phone/source/server/db_migrate.lua | 293 ++ sky_phone/source/server/picstagram.lua | 1351 ++++++++ sky_phone/sql/install.sql | 191 ++ 19 files changed, 6341 insertions(+), 5 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/picstagram.webp create mode 100644 frontend/src/stores/picstagram.test.ts create mode 100644 frontend/src/stores/picstagram.ts create mode 100644 frontend/src/types/picstagram.ts create mode 100644 frontend/src/views/apps/PicstagramApp.vue create mode 100644 sky_phone/source/server/picstagram.lua diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a21e397..ec2dd24 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -33,6 +33,7 @@ import { useMessagesStore } from '@/stores/messages' import { useDarkChatStore } from '@/stores/darkchat' import { useFlareStore } from '@/stores/flare' import { useFlipTokStore } from '@/stores/fliptok' +import { usePicstagramStore } from '@/stores/picstagram' import { useMediaStore } from '@/stores/media' import { useMarketplaceStore } from '@/stores/marketplace' import { useAppStoreStore } from '@/stores/app-store' @@ -65,6 +66,8 @@ type AppMessage = { | FlareEventData | FlipTokVerificationData | FlipTokNotificationData + | PicstagramVerificationData + | PicstagramNotificationData | PhoneCall | PhoneNotificationInput | PhoneOpenPayload @@ -143,6 +146,20 @@ type FlipTokNotificationData = { title?: string videoId?: string } + +type PicstagramVerificationData = { + profileId: string + verified: boolean +} + +type PicstagramNotificationData = { + actor?: string + device?: PhoneNotificationDevicePayload + kind?: 'like' | 'comment' | 'follow' | 'follow_request' | 'verified' + postId?: string + text?: string + title?: string +} const REFERENCE_VIEWPORT_WIDTH = 1920 const REFERENCE_VIEWPORT_HEIGHT = 1080 const PHONE_BASE_SCALE = 0.69 @@ -160,6 +177,7 @@ const messages = useMessagesStore() const darkchat = useDarkChatStore() const flare = useFlareStore() const fliptok = useFlipTokStore() +const picstagram = usePicstagramStore() const media = useMediaStore() const marketplace = useMarketplaceStore() const appStore = useAppStoreStore() @@ -333,6 +351,32 @@ function onMessage(event: MessageEvent): void { } notifications.show(notification) if (phone.isOpen) void fliptok.loadActivities() + } else if ( + event.data?.type === 'picstagram:verification-changed' && + event.data.data + ) { + const data = event.data.data as PicstagramVerificationData + picstagram.applyVerification(String(data.profileId), data.verified === true) + } else if (event.data?.type === 'picstagram:new' && event.data.data) { + const data = event.data.data as PicstagramNotificationData + const notification: PhoneNotificationInput = { + appId: 'picstagram', + subtitle: data.actor, + text: data.text ?? phone.t('Apps.picstagram.notifications.default'), + title: data.title ?? phone.t('Apps.picstagram.name'), + } + if ( + data.device && + (!phone.isOpen || data.device.imei !== phone.device?.imei) + ) { + notification.device = { + imei: data.device.imei, + name: data.device.name, + preferences: parsePhonePreferences(data.device.settings ?? null), + } + } + notifications.show(notification) + if (phone.isOpen) void picstagram.loadActivities() } else if ( event.data?.type === 'marketplace:new-message' && event.data.data diff --git a/frontend/src/assets/img/app-icons/picstagram.webp b/frontend/src/assets/img/app-icons/picstagram.webp new file mode 100644 index 0000000000000000000000000000000000000000..6512bb2339c9b1082071f9ae9919efecb9dd1d46 GIT binary patch literal 32388 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 { PHONE_APPS.filter((app) => app.category === 'social').map( (app) => app.id, ), - ).toEqual([ - 'fliptok', - 'flare', - 'radio', - 'local-pages', + ).toEqual([ + 'picstagram', + 'fliptok', + 'flare', + 'radio', + 'local-pages', 'phone', 'darkchat', 'banking', diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index a029391..ecba1bf 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -57,6 +57,7 @@ import citymarktIcon from '@/assets/img/app-icons/citymarkt.webp' import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import flareIcon from '@/assets/img/app-icons/flare.svg' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' +import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -64,6 +65,20 @@ import type { } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + category: 'social', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/PicstagramApp.vue')), + ), + dockOrder: null, + gridOrder: 23, + icon: markRaw(Camera), + iconClass: 'app-icon--picstagram', + iconImage: picstagramIcon, + id: 'picstagram', + labelKey: 'Apps.picstagram.name', + route: '/apps/picstagram', + }, { category: 'social', component: markRaw( diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index e14bbc1..db125cc 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -191,6 +191,204 @@ const defaultLocales: LocaleTree = { default: 'Flare could not complete the request.', }, }, + picstagram: { + name: 'Picstagram', + loading: 'Loading Picstagram', + home: 'Home', + explore: 'Explore', + create: 'Create', + activity: 'Activity', + profile: 'Profile', + following: 'Following', + city: 'City', + posts: 'Posts', + followers: 'Followers', + verified: 'Verified profile', + editProfile: 'Edit profile', + saved: 'Saved', + photos: 'Photos', + emptyBio: 'No bio yet.', + authTitle: 'Your Picstagram profile', + login: 'Sign In', + register: 'Register', + createAccount: 'Create Profile', + logout: 'Sign Out', + signingOut: 'Signing Out...', + loginBody: + 'Sign in to continue with your photos, stories, follows, and saved posts.', + registerBody: 'Create a private Picstagram login for your photo profile.', + registrationHint: + 'An iFruit account is required once to create and own a Picstagram profile.', + displayName: 'Name', + username: 'Username', + password: 'Password', + confirmPassword: 'Confirm password', + passwordsMismatch: 'The passwords do not match.', + displayNamePlaceholder: 'Your name', + usernamePlaceholder: 'username', + passwordPlaceholder: 'At least 8 characters', + confirmPasswordPlaceholder: 'Enter password again', + signOutTitle: 'Sign out of Picstagram?', + signOutBody: + 'Your profile and posts stay online. This phone returns to the sign-in screen.', + stories: 'Stories', + yourStory: 'Your story', + noStories: 'No active stories', + storyViews: '{count} views', + storyViewers: 'Story viewers', + seenBy: 'Seen by {count}', + nextStory: 'Next story', + closeStory: 'Close story', + removeStory: 'Remove story', + storyRemoved: 'Story removed.', + emptyFeed: 'Your feed is quiet', + emptyFeedBody: 'Discover profiles or share the first photo.', + discoverPeople: 'Discover people', + searchPlaceholder: 'Search profiles, captions, places, or tags', + noResults: 'No results', + noResultsBody: 'Try another username, caption, place, or hashtag.', + newPost: 'New Post', + newStory: 'New Story', + post: 'Post', + story: 'Story', + choosePhotos: 'Choose photos', + choosePhoto: 'Choose photo', + choosePhotosHint: 'Select up to five photos from Gallery', + chooseStoryHint: 'Select one photo from Gallery', + selectedPhotos: '{count} selected', + changePhotos: 'Change selection', + caption: 'Caption', + captionPlaceholder: 'Write a caption...', + storyTextPlaceholder: 'Add a short story text...', + location: 'Location', + locationPlaceholder: 'Add a place', + allowComments: 'Allow comments', + publishing: 'Publishing...', + published: 'Your post is live.', + storyPublished: 'Your story is live.', + comments: 'Comments', + comment: 'Comment', + noComments: 'No comments yet', + addComment: 'Add a comment...', + reply: 'Reply', + removeComment: 'Remove comment', + likes: '{count} likes', + viewComments: 'View all {count} comments', + like: 'Like', + unlike: 'Unlike', + save: 'Save', + unsave: 'Remove from saved', + more: 'More', + archive: 'Archive', + restore: 'Restore', + deletePost: 'Delete post', + deletePostTitle: 'Delete this post?', + deletePostBody: 'The post and its reactions will no longer be available.', + privateProfile: 'This profile is private', + privateProfileBody: 'Follow this profile to see its posts and stories.', + follow: 'Follow', + requested: 'Requested', + unfollow: 'Following', + accept: 'Accept', + decline: 'Decline', + followRequests: 'Follow requests', + noActivity: 'No activity yet', + markRead: 'Mark as read', + publicProfile: 'Public profile', + privateProfileSetting: 'Private profile', + privacyHint: 'Only accepted followers can see your posts and stories.', + bio: 'Bio', + avatar: 'Profile photo', + chooseAvatar: 'Choose profile photo', + removeAvatar: 'Remove profile photo', + saveProfile: 'Save profile', + profileSaved: 'Profile updated.', + cancel: 'Cancel', + done: 'Done', + report: 'Report', + reportTarget: 'Report {target}', + reportReason: 'Reason', + reportDetails: 'Additional details (optional)', + submitReport: 'Submit report', + reported: 'Report submitted.', + block: 'Block profile', + unblock: 'Unblock profile', + blocked: 'Profile blocked.', + blockTitle: 'Block @{handle}?', + blockBody: + "You will no longer see or interact with each other's Picstagram content.", + moderation: 'Moderation', + reports: 'Open reports', + noReports: 'No open reports', + noDetails: 'No additional details', + hide: 'Hide', + remove: 'Remove', + restoreAction: 'Restore', + dismiss: 'Dismiss', + reportTargets: { + post: 'Post', + profile: 'Profile', + comment: 'Comment', + story: 'Story', + }, + reportReasons: { + spam: 'Spam or misleading', + harassment: 'Harassment or bullying', + dangerous: 'Dangerous activity', + illegal: 'Illegal content', + other: 'Something else', + }, + activityKinds: { + follow_request: 'requested to follow you', + follow: 'started following you', + request_accepted: 'accepted your follow request', + like: 'liked your post', + comment: 'commented on your post', + verified: 'verification changed', + }, + notifications: { + follow_request: '{actor} requested to follow you.', + follow: '{actor} started following you.', + request_accepted: '{actor} accepted your follow request.', + like: '{actor} liked your post.', + comment: '{actor} commented on your post.', + verified: 'Your Picstagram profile is now verified.', + default: 'You have new Picstagram activity.', + }, + errors: { + invalid_post: 'Check the post details.', + invalid_story: 'Check the story details.', + invalid_media: 'Choose photos owned by this phone.', + invalid_comment: 'Enter a valid comment.', + comments_disabled: 'Comments are disabled.', + invalid_profile: 'Check your profile details.', + invalid_handle: 'Use 3–24 letters, numbers, dots, or underscores.', + invalid_display_name: 'Enter a display name.', + invalid_password: 'Password must be 8–72 characters.', + invalid_credentials: 'Username or password is incorrect.', + already_registered: + 'This iFruit account already owns a Picstagram profile.', + handle_taken: 'This username is already taken.', + post_not_found: 'This post is unavailable.', + story_not_found: 'This story is unavailable.', + profile_not_found: 'This profile is unavailable.', + request_not_found: 'This follow request is no longer open.', + profile_unavailable: 'This profile is unavailable.', + blocked: 'This profile is blocked.', + already_reported: 'You already reported this content.', + invalid_report: 'Choose a valid report reason.', + invalid_search: 'Enter a valid search.', + invalid_cursor: 'The feed changed. Refresh and try again.', + invalid_status: 'This action is not available.', + not_authorized: 'You are not allowed to do that.', + report_not_found: 'This report is no longer open.', + rate_limited: 'Too many actions. Try again shortly.', + not_authenticated: 'Sign in to iFruit first.', + picstagram_not_authenticated: 'Sign in to Picstagram first.', + request_failed: 'The request failed.', + default: 'Picstagram could not complete the request.', + }, + }, fliptok: { name: 'FlipTok', loading: 'Loading FlipTok', diff --git a/frontend/src/stores/picstagram.test.ts b/frontend/src/stores/picstagram.test.ts new file mode 100644 index 0000000..d23e934 --- /dev/null +++ b/frontend/src/stores/picstagram.test.ts @@ -0,0 +1,216 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePicstagramStore } from '@/stores/picstagram' +import type { + PicstagramActivity, + PicstagramComment, + PicstagramPost, + PicstagramProfile, + PicstagramStory, +} from '@/types/picstagram' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const profile: PicstagramProfile = { + avatar_media_id: null, + avatar_url: null, + bio: 'City light collector.', + display_name: 'Nova', + follow_status: null, + followers: 12, + following: 4, + handle: 'nova', + id: 'profile-1', + is_following: false, + is_owner: true, + is_requested: false, + locked: false, + post_count: 1, + private: false, + status: 'active', + verified: false, +} + +const post: PicstagramPost = { + avatar_url: null, + caption: 'Los Santos after rain.', + comment_count: 1, + comments_enabled: true, + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'post-1', + is_liked: false, + is_owner: true, + is_saved: false, + like_count: 2, + location: 'Downtown', + media: [{ id: 1, position: 0, url: 'https://example.com/photo.webp' }], + private: false, + profile_id: profile.id, + verified: false, +} + +const comment: PicstagramComment = { + avatar_url: null, + body: 'Beautiful.', + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'comment-1', + is_owner: true, + parent_id: null, + profile_id: profile.id, + verified: false, +} + +const activity: PicstagramActivity = { + avatar_url: null, + created_at: 1, + display_name: 'Nova', + handle: 'nova', + id: 'activity-1', + kind: 'follow', + post_id: null, + post_url: null, + profile_id: profile.id, + read_at: null, + verified: false, +} + +const story: PicstagramStory = { + avatar_url: null, + body: 'Tonight.', + created_at: 1, + display_name: 'Nova', + expires_at: 2, + handle: 'nova', + id: 'story-1', + is_owner: true, + profile_id: profile.id, + seen: true, + url: 'https://example.com/story.webp', + verified: false, + view_count: 3, +} + +describe('Picstagram store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.mocked(nuiCall).mockReset() + }) + + it('updates verification on every visible surface', () => { + const store = usePicstagramStore() + store.profile = { ...profile } + store.feed = [{ ...post }] + store.explore = [{ ...post }] + store.profilePosts = [{ ...post }] + store.saved = [{ ...post }] + store.searchPosts = [{ ...post }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + + store.applyVerification(profile.id, true) + + expect(store.profile.verified).toBe(true) + expect( + store + .allPostCollections() + .flat() + .every((item) => item.verified), + ).toBe(true) + expect(store.comments[0].verified).toBe(true) + expect(store.activities[0].verified).toBe(true) + }) + + it('rolls back an optimistic like when the server rejects it', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: false }) + const store = usePicstagramStore() + store.feed = [{ ...post }] + store.explore = [{ ...post }] + + expect(await store.react(store.feed[0], 'like')).toBe(false) + expect(store.feed[0].is_liked).toBe(false) + expect(store.feed[0].like_count).toBe(2) + expect(store.explore[0].is_liked).toBe(false) + expect(store.explore[0].like_count).toBe(2) + }) + + it('removes a blocked profile from all local surfaces', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = usePicstagramStore() + store.feed = [{ ...post }] + store.explore = [{ ...post }] + store.profilePosts = [{ ...post }] + store.saved = [{ ...post }] + store.searchPosts = [{ ...post }] + store.comments = [{ ...comment }] + store.activities = [{ ...activity }] + store.stories = [{ ...story }] + store.viewedProfile = { ...profile, is_owner: false } + + expect(await store.blockProfile(profile.id)).toBe(true) + expect(store.allPostCollections().flat()).toEqual([]) + expect(store.comments).toEqual([]) + expect(store.activities).toEqual([]) + expect(store.stories).toEqual([]) + expect(store.viewedProfile).toBeNull() + }) + + it('keeps the app signed out when no Picstagram session exists', async () => { + vi.mocked(nuiCall).mockResolvedValue({ + success: true, + data: { authenticated: false, isAdmin: false }, + }) + const store = usePicstagramStore() + + expect(await store.bootstrap()).toBe(true) + expect(store.authenticated).toBe(false) + expect(store.profile).toBeNull() + expect(store.feed).toEqual([]) + }) + + it('hydrates the profile and stories after login', async () => { + vi.mocked(nuiCall) + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ + success: true, + data: { + authenticated: true, + feed: { hasMore: false, items: [{ ...post }], nextCursor: null }, + isAdmin: false, + profile: { ...profile }, + }, + }) + .mockResolvedValueOnce({ success: true, data: [{ ...story }] }) + const store = usePicstagramStore() + + expect((await store.login('nova', 'password123')).success).toBe(true) + expect(store.profile?.handle).toBe('nova') + expect(store.stories).toHaveLength(1) + expect(nuiCall).toHaveBeenNthCalledWith(1, 'picstagram:login', { + handle: 'nova', + password: 'password123', + }) + }) + + it('clears all local state after logout', async () => { + vi.mocked(nuiCall).mockResolvedValue({ success: true }) + const store = usePicstagramStore() + store.authenticated = true + store.profile = { ...profile } + store.feed = [{ ...post }] + store.stories = [{ ...story }] + + expect((await store.logout()).success).toBe(true) + expect(store.authenticated).toBe(false) + expect(store.profile).toBeNull() + expect(store.feed).toEqual([]) + expect(store.stories).toEqual([]) + }) +}) diff --git a/frontend/src/stores/picstagram.ts b/frontend/src/stores/picstagram.ts new file mode 100644 index 0000000..eee6c31 --- /dev/null +++ b/frontend/src/stores/picstagram.ts @@ -0,0 +1,399 @@ +import { defineStore } from 'pinia' + +import type { + PicstagramActivity, + PicstagramComment, + PicstagramPage, + PicstagramPost, + PicstagramProfile, + PicstagramProfilePage, + PicstagramReport, + PicstagramReportReason, + PicstagramReportTarget, + PicstagramSearchResult, + PicstagramStory, + PicstagramStoryViewer, +} from '@/types/picstagram' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +function replaceProfileState( + posts: PicstagramPost[], + profileId: string, + update: (post: PicstagramPost) => void, +): void { + posts.filter((post) => post.profile_id === profileId).forEach(update) +} + +export const usePicstagramStore = defineStore('picstagram', { + state: () => ({ + activities: [] as PicstagramActivity[], + authenticated: false, + comments: [] as PicstagramComment[], + explore: [] as PicstagramPost[], + exploreCursor: null as string | null, + feed: [] as PicstagramPost[], + feedCursor: null as string | null, + isAdmin: false, + loading: false, + profile: null as PicstagramProfile | null, + profilePosts: [] as PicstagramPost[], + reports: [] as PicstagramReport[], + saved: [] as PicstagramPost[], + searchPosts: [] as PicstagramPost[], + searchProfiles: [] as PicstagramProfile[], + stories: [] as PicstagramStory[], + storyViewers: [] as PicstagramStoryViewer[], + viewedProfile: null as PicstagramProfile | null, + }), + actions: { + allPostCollections(): PicstagramPost[][] { + return [ + this.feed, + this.explore, + this.profilePosts, + this.saved, + this.searchPosts, + ] + }, + applyVerification(profileId: string, verified: boolean): void { + if (this.profile?.id === profileId) this.profile.verified = verified + if (this.viewedProfile?.id === profileId) + this.viewedProfile.verified = verified + this.allPostCollections().forEach((posts) => + replaceProfileState(posts, profileId, (post) => { + post.verified = verified + }), + ) + this.comments + .filter((comment) => comment.profile_id === profileId) + .forEach((comment) => { + comment.verified = verified + }) + this.activities + .filter((activity) => activity.profile_id === profileId) + .forEach((activity) => { + activity.verified = verified + }) + }, + async bootstrap(): Promise { + this.loading = true + const response = await nuiCall<{ + authenticated: boolean + feed?: PicstagramPage + isAdmin: boolean + profile?: PicstagramProfile + }>('picstagram:bootstrap') + this.loading = false + if (!response.success || !response.data) return false + this.authenticated = response.data.authenticated + this.isAdmin = response.data.isAdmin + this.profile = response.data.profile ?? null + this.feed = response.data.feed?.items ?? [] + this.feedCursor = response.data.feed?.nextCursor ?? null + if (this.authenticated) await this.loadStories() + return true + }, + async login(handle: string, password: string): Promise { + const response = await nuiCall('picstagram:login', { handle, password }) + if (response.success) await this.bootstrap() + return response + }, + async register( + displayName: string, + handle: string, + password: string, + ): Promise { + const response = await nuiCall('picstagram:register', { + displayName, + handle, + password, + }) + if (response.success) await this.bootstrap() + return response + }, + async logout(): Promise { + const response = await nuiCall('picstagram:logout') + if (response.success) this.$reset() + return response + }, + async loadFeed(append = false): Promise { + const response = await nuiCall('picstagram:feed', { + cursor: append ? this.feedCursor : undefined, + }) + if (!response.success || !response.data) return false + this.feed = append + ? [...this.feed, ...response.data.items] + : response.data.items + this.feedCursor = response.data.nextCursor + return true + }, + async loadExplore(append = false): Promise { + const response = await nuiCall('picstagram:explore', { + cursor: append ? this.exploreCursor : undefined, + }) + if (!response.success || !response.data) return false + this.explore = append + ? [...this.explore, ...response.data.items] + : response.data.items + this.exploreCursor = response.data.nextCursor + return true + }, + async search(search: string): Promise { + if (!search.trim()) { + this.searchPosts = [] + this.searchProfiles = [] + return true + } + const response = await nuiCall( + 'picstagram:search', + { search }, + ) + if (!response.success || !response.data) return false + this.searchPosts = response.data.posts + this.searchProfiles = response.data.profiles + return true + }, + async loadSaved(): Promise { + const response = await nuiCall('picstagram:saved') + if (!response.success || !response.data) return false + this.saved = response.data.items + return true + }, + async loadProfile(query: { + handle?: string + profileId?: string + }): Promise { + const response = await nuiCall( + 'picstagram:profile', + query, + ) + if (!response.success || !response.data) return false + this.viewedProfile = response.data.profile + this.profilePosts = response.data.posts.items + return true + }, + showOwnProfile(): void { + this.viewedProfile = null + this.profilePosts = this.feed.filter((post) => post.is_owner) + }, + async updateProfile(payload: { + avatarMediaId: number + bio: string + displayName: string + handle: string + private: boolean + }): Promise> { + const response = await nuiCall( + 'picstagram:update-profile', + payload, + ) + if (response.success && response.data) this.profile = response.data + return response + }, + async publishPost(payload: { + caption: string + commentsEnabled: boolean + location: string + mediaIds: number[] + }): Promise> { + const response = await nuiCall<{ id: string }>( + 'picstagram:publish-post', + payload, + ) + if (response.success) await this.loadFeed() + return response + }, + async setPostStatus( + post: PicstagramPost, + status: 'archived' | 'published' | 'removed', + ): Promise { + const response = await nuiCall('picstagram:set-post-status', { + id: post.id, + status, + }) + if (!response.success) return false + this.allPostCollections().forEach((posts) => { + const index = posts.findIndex((item) => item.id === post.id) + if (index >= 0) posts.splice(index, 1) + }) + return true + }, + async react(post: PicstagramPost, kind: 'like' | 'save'): Promise { + const key = kind === 'like' ? 'is_liked' : 'is_saved' + const next = !post[key] + const matching = this.allPostCollections() + .flat() + .filter((item) => item.id === post.id) + matching.forEach((item) => { + item[key] = next + if (kind === 'like') item.like_count += next ? 1 : -1 + }) + const response = await nuiCall('picstagram:react', { + active: next, + id: post.id, + kind, + }) + if (response.success) return true + matching.forEach((item) => { + item[key] = !next + if (kind === 'like') item.like_count += next ? -1 : 1 + }) + return false + }, + async followProfile(profile: PicstagramProfile): Promise { + const active = !profile.is_following && !profile.is_requested + const response = await nuiCall<{ + status: 'accepted' | 'pending' | false + }>('picstagram:follow', { active, profileId: profile.id }) + if (!response.success || !response.data) return false + const previousFollowing = profile.is_following + profile.follow_status = response.data.status || null + profile.is_following = response.data.status === 'accepted' + profile.is_requested = response.data.status === 'pending' + if (previousFollowing !== profile.is_following) + profile.followers += profile.is_following ? 1 : -1 + return true + }, + async respondFollow(profileId: string, accept: boolean): Promise { + const response = await nuiCall('picstagram:respond-follow', { + accept, + profileId, + }) + if (response.success) + this.activities = this.activities.filter( + (activity) => + activity.kind !== 'follow_request' || + activity.profile_id !== profileId, + ) + return response.success + }, + async loadComments(id: string): Promise { + const response = await nuiCall( + 'picstagram:comments', + { id }, + ) + this.comments = response.success && response.data ? response.data : [] + return response.success + }, + async comment( + id: string, + body: string, + parentId?: string, + ): Promise { + return nuiCall('picstagram:comment', { body, id, parentId }) + }, + async removeComment(id: string): Promise { + const response = await nuiCall('picstagram:remove-comment', { id }) + if (response.success) + this.comments = this.comments.filter((comment) => comment.id !== id) + return response.success + }, + async loadStories(): Promise { + const response = await nuiCall('picstagram:stories') + this.stories = response.success && response.data ? response.data : [] + return response.success + }, + async publishStory( + mediaId: number, + body: string, + ): Promise> { + const response = await nuiCall<{ id: string }>( + 'picstagram:publish-story', + { body, mediaId }, + ) + if (response.success) await this.loadStories() + return response + }, + async viewStory(story: PicstagramStory): Promise { + if (!story.seen && !story.is_owner) { + const response = await nuiCall('picstagram:view-story', { + id: story.id, + }) + if (response.success) story.seen = true + } + }, + async loadStoryViewers(id: string): Promise { + const response = await nuiCall( + 'picstagram:story-viewers', + { id }, + ) + this.storyViewers = response.success && response.data ? response.data : [] + return response.success + }, + async removeStory(id: string): Promise { + const response = await nuiCall('picstagram:remove-story', { id }) + if (response.success) + this.stories = this.stories.filter((story) => story.id !== id) + return response.success + }, + async loadActivities(): Promise { + const response = await nuiCall( + 'picstagram:activities', + ) + this.activities = response.success && response.data ? response.data : [] + return response.success + }, + async markActivities(): Promise { + const response = await nuiCall('picstagram:mark-activities') + if (response.success) + this.activities.forEach((activity) => { + activity.read_at ??= new Date().toISOString() + }) + return response.success + }, + async blockProfile(profileId: string): Promise { + const response = await nuiCall('picstagram:block', { + active: true, + profileId, + }) + if (!response.success) return false + this.allPostCollections().forEach((posts) => { + for (let index = posts.length - 1; index >= 0; index -= 1) + if (posts[index].profile_id === profileId) posts.splice(index, 1) + }) + this.comments = this.comments.filter( + (comment) => comment.profile_id !== profileId, + ) + this.activities = this.activities.filter( + (activity) => activity.profile_id !== profileId, + ) + this.stories = this.stories.filter( + (story) => story.profile_id !== profileId, + ) + if (this.viewedProfile?.id === profileId) this.viewedProfile = null + return true + }, + async report( + targetType: PicstagramReportTarget, + targetId: string, + reason: PicstagramReportReason, + details: string, + ): Promise { + return nuiCall('picstagram:report', { + details, + reason, + targetId, + targetType, + }) + }, + async loadReports(): Promise { + const response = await nuiCall( + 'picstagram:admin-reports', + ) + this.reports = response.success && response.data ? response.data : [] + return response.success + }, + async resolveReport( + id: string, + action: 'dismiss' | 'hide' | 'remove' | 'restore', + ): Promise { + const response = await nuiCall('picstagram:admin-resolve-report', { + action, + id, + }) + if (response.success) + this.reports = this.reports.filter((report) => report.id !== id) + return response.success + }, + }, +}) diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 0a0c31d..064c4c4 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -29,6 +29,7 @@ export type PhoneAppId = | 'local-pages' | 'flare' | 'fliptok' + | 'picstagram' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/picstagram.ts b/frontend/src/types/picstagram.ts new file mode 100644 index 0000000..07fc5ea --- /dev/null +++ b/frontend/src/types/picstagram.ts @@ -0,0 +1,141 @@ +export type PicstagramProfile = { + avatar_media_id: number | null + avatar_url: string | null + bio: string + display_name: string + follow_status: 'accepted' | 'pending' | null + followers: number + following: number + handle: string + id: string + is_following: boolean + is_owner: boolean + is_requested: boolean + locked: boolean + post_count: number + private: boolean + status: 'active' + verified: boolean +} + +export type PicstagramPostMedia = { + id: number + position: number + url: string +} + +export type PicstagramPost = { + avatar_url: string | null + caption: string + comment_count: number + comments_enabled: boolean + created_at: number + display_name: string + handle: string + id: string + is_liked: boolean + is_owner: boolean + is_saved: boolean + like_count: number + location: string + media: PicstagramPostMedia[] + private: boolean + profile_id: string + verified: boolean +} + +export type PicstagramPage = { + hasMore: boolean + items: PicstagramPost[] + nextCursor: string | null +} + +export type PicstagramProfilePage = { + posts: PicstagramPage + profile: PicstagramProfile +} + +export type PicstagramSearchResult = { + posts: PicstagramPost[] + profiles: PicstagramProfile[] +} + +export type PicstagramComment = { + avatar_url: string | null + body: string + created_at: number + display_name: string + handle: string + id: string + is_owner: boolean + parent_id: string | null + profile_id: string + verified: boolean +} + +export type PicstagramStory = { + avatar_url: string | null + body: string + created_at: number + display_name: string + expires_at: number + handle: string + id: string + is_owner: boolean + profile_id: string + seen: boolean + url: string + verified: boolean + view_count: number +} + +export type PicstagramStoryViewer = { + avatar_url: string | null + created_at: number + display_name: string + handle: string + id: string + verified: boolean +} + +export type PicstagramActivityKind = + | 'follow_request' + | 'follow' + | 'request_accepted' + | 'like' + | 'comment' + | 'verified' + +export type PicstagramActivity = { + avatar_url: string | null + created_at: number + display_name: string + handle: string + id: string + kind: PicstagramActivityKind + post_id: string | null + post_url: string | null + profile_id: string + read_at: string | null + verified: boolean +} + +export type PicstagramReportTarget = 'profile' | 'post' | 'story' | 'comment' + +export type PicstagramReportReason = + | 'spam' + | 'harassment' + | 'dangerous' + | 'illegal' + | 'other' + +export type PicstagramReport = { + created_at: number + details: string + id: string + reason: PicstagramReportReason + reporter_display_name: string + reporter_handle: string + target_id: string + target_type: PicstagramReportTarget +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 39ab24f..baf72b2 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -82,6 +82,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'neon-drop': { enabled: true, sounds: true }, citymarkt: { enabled: true, sounds: true }, 'local-pages': { enabled: true, sounds: true }, + picstagram: { enabled: true, sounds: true }, fliptok: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/PicstagramApp.vue b/frontend/src/views/apps/PicstagramApp.vue new file mode 100644 index 0000000..d0a2e3c --- /dev/null +++ b/frontend/src/views/apps/PicstagramApp.vue @@ -0,0 +1,2805 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index cad4cc7..5ffcff7 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -176,6 +176,183 @@ let flipTokReports = [ creator_display_name: 'Nova', }, ] +let picstagramAuthenticated = true +const picstagramProfiles = [ + { + avatar_media_id: null, + avatar_url: + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=240&q=80', + bio: 'Street light, rain, and the quiet side of Los Santos.', + display_name: 'Skyline', + follow_status: null, + followers: 1842, + following: 128, + handle: 'skyline', + id: 'pic-profile-1', + is_following: false, + is_owner: true, + is_requested: false, + locked: false, + post_count: 1, + private: false, + status: 'active', + verified: true, + }, + { + avatar_media_id: null, + avatar_url: + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80', + bio: 'Golden-hour wanderer.', + display_name: 'Nova', + follow_status: 'accepted', + followers: 9214, + following: 91, + handle: 'nova.ls', + id: 'pic-profile-2', + is_following: true, + is_owner: false, + is_requested: false, + locked: false, + post_count: 2, + private: false, + status: 'active', + verified: true, + }, + { + avatar_media_id: null, + avatar_url: null, + bio: 'Private film diary.', + display_name: 'Milo Reed', + follow_status: null, + followers: 318, + following: 44, + handle: 'milo.reed', + id: 'pic-profile-3', + is_following: false, + is_owner: false, + is_requested: false, + locked: true, + post_count: 0, + private: true, + status: 'active', + verified: false, + }, +] +let picstagramPosts = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + caption: 'The city holds its breath right before sunrise. #LosSantos', + comment_count: 2, + comments_enabled: true, + created_at: Date.now() - 32 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: 'pic-post-1', + is_liked: false, + is_owner: false, + is_saved: true, + like_count: 842, + location: 'Vinewood Hills', + media: [ + { + id: 8101, + position: 0, + url: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=900&q=85', + }, + { + id: 8102, + position: 1, + url: 'https://images.unsplash.com/photo-1444723121867-7a241cacace9?auto=format&fit=crop&w=900&q=85', + }, + ], + private: false, + profile_id: picstagramProfiles[1].id, + verified: true, + }, + { + avatar_url: picstagramProfiles[0].avatar_url, + caption: 'Neon after rain.', + comment_count: 1, + comments_enabled: true, + created_at: Date.now() - 3 * 60 * 60 * 1000, + display_name: picstagramProfiles[0].display_name, + handle: picstagramProfiles[0].handle, + id: 'pic-post-2', + is_liked: true, + is_owner: true, + is_saved: false, + like_count: 216, + location: 'Downtown Los Santos', + media: [ + { + id: 8103, + position: 0, + url: 'https://images.unsplash.com/photo-1519608487953-e999c86e7455?auto=format&fit=crop&w=900&q=85', + }, + ], + private: false, + profile_id: picstagramProfiles[0].id, + verified: true, + }, +] +let picstagramComments = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + body: 'That reflection is unreal.', + created_at: Date.now() - 18 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: 'pic-comment-1', + is_owner: false, + parent_id: null, + profile_id: picstagramProfiles[1].id, + verified: true, + }, +] +let picstagramStories = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + body: 'First light over Vinewood.', + created_at: Date.now() - 12 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + expires_at: Date.now() + 22 * 60 * 60 * 1000, + handle: picstagramProfiles[1].handle, + id: 'pic-story-1', + is_owner: false, + profile_id: picstagramProfiles[1].id, + seen: false, + url: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=900&q=85', + verified: true, + view_count: 42, + }, +] +let picstagramActivities = [ + { + avatar_url: picstagramProfiles[1].avatar_url, + created_at: Date.now() - 9 * 60 * 1000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: 'pic-activity-1', + kind: 'like', + post_id: 'pic-post-2', + post_url: picstagramPosts[1].media[0].url, + profile_id: picstagramProfiles[1].id, + read_at: null, + verified: true, + }, +] +let picstagramReports = [ + { + created_at: Date.now() - 45 * 60 * 1000, + details: 'Please review the caption and location.', + id: 'pic-report-1', + reason: 'spam', + reporter_display_name: 'Skyline', + reporter_handle: 'skyline', + target_id: 'pic-post-1', + target_type: 'post', + }, +] const mockBankTransactions = [ { id: 1, @@ -1548,6 +1725,360 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: message }) return } + if (endpoint === 'picstagram:bootstrap') { + response.json({ + success: true, + data: picstagramAuthenticated + ? { + authenticated: true, + feed: { + hasMore: false, + items: picstagramPosts, + nextCursor: null, + }, + isAdmin: true, + profile: picstagramProfiles[0], + } + : { authenticated: false, isAdmin: true }, + }) + return + } + if (endpoint === 'picstagram:login' || endpoint === 'picstagram:register') { + picstagramAuthenticated = true + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:logout') { + picstagramAuthenticated = false + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:feed') { + response.json({ + success: true, + data: { hasMore: false, items: picstagramPosts, nextCursor: null }, + }) + return + } + if (endpoint === 'picstagram:explore') { + response.json({ + success: true, + data: { + hasMore: false, + items: picstagramPosts.filter((post) => !post.is_owner), + nextCursor: null, + }, + }) + return + } + if (endpoint === 'picstagram:saved') { + response.json({ + success: true, + data: { + hasMore: false, + items: picstagramPosts.filter((post) => post.is_saved), + nextCursor: null, + }, + }) + return + } + if (endpoint === 'picstagram:search') { + const search = String(request.body.search ?? '').toLowerCase() + response.json({ + success: true, + data: { + posts: picstagramPosts.filter((post) => + `${post.handle} ${post.display_name} ${post.caption} ${post.location}` + .toLowerCase() + .includes(search), + ), + profiles: picstagramProfiles.filter((profile) => + `${profile.handle} ${profile.display_name}` + .toLowerCase() + .includes(search), + ), + }, + }) + return + } + if (endpoint === 'picstagram:profile') { + const profile = picstagramProfiles.find( + (item) => + item.id === request.body.profileId || + item.handle === String(request.body.handle ?? '').toLowerCase(), + ) + if (!profile) { + response.json({ success: false, error: 'profile_not_found' }) + return + } + response.json({ + success: true, + data: { + posts: { + hasMore: false, + items: picstagramPosts.filter( + (post) => post.profile_id === profile.id, + ), + nextCursor: null, + }, + profile, + }, + }) + return + } + if (endpoint === 'picstagram:update-profile') { + Object.assign(picstagramProfiles[0], { + bio: request.body.bio, + display_name: request.body.displayName, + handle: request.body.handle, + private: request.body.private, + }) + response.json({ success: true, data: picstagramProfiles[0] }) + return + } + if (endpoint === 'picstagram:publish-post') { + const media = request.body.mediaIds + .map((id) => mockMedia.find((item) => item.id === id)) + .filter((item) => item?.mediaType === 'photo') + if (!media.length) { + response.json({ success: false, error: 'invalid_media' }) + return + } + const post = { + avatar_url: picstagramProfiles[0].avatar_url, + caption: request.body.caption, + comment_count: 0, + comments_enabled: request.body.commentsEnabled, + created_at: Date.now(), + display_name: picstagramProfiles[0].display_name, + handle: picstagramProfiles[0].handle, + id: `pic-post-${Date.now()}`, + is_liked: false, + is_owner: true, + is_saved: false, + like_count: 0, + location: request.body.location, + media: media.map((item, position) => ({ + id: item.id, + position, + url: item.url, + })), + private: picstagramProfiles[0].private, + profile_id: picstagramProfiles[0].id, + verified: picstagramProfiles[0].verified, + } + picstagramPosts.unshift(post) + response.json({ success: true, data: { id: post.id } }) + return + } + if (endpoint === 'picstagram:update-post') { + const post = picstagramPosts.find((item) => item.id === request.body.id) + if (post) + Object.assign(post, { + caption: request.body.caption, + comments_enabled: request.body.commentsEnabled, + location: request.body.location, + }) + response.json({ + success: Boolean(post), + error: post ? undefined : 'post_not_found', + }) + return + } + if (endpoint === 'picstagram:set-post-status') { + if (request.body.status !== 'published') + picstagramPosts = picstagramPosts.filter( + (post) => post.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:react') { + const post = picstagramPosts.find((item) => item.id === request.body.id) + if (post) { + const key = request.body.kind === 'like' ? 'is_liked' : 'is_saved' + const changed = post[key] !== request.body.active + post[key] = request.body.active + if (changed && request.body.kind === 'like') + post.like_count += request.body.active ? 1 : -1 + } + response.json({ + success: Boolean(post), + error: post ? undefined : 'post_not_found', + }) + return + } + if (endpoint === 'picstagram:follow') { + const profile = picstagramProfiles.find( + (item) => item.id === request.body.profileId, + ) + if (!profile) { + response.json({ success: false, error: 'profile_not_found' }) + return + } + profile.follow_status = request.body.active + ? profile.private + ? 'pending' + : 'accepted' + : null + profile.is_following = profile.follow_status === 'accepted' + profile.is_requested = profile.follow_status === 'pending' + profile.locked = profile.private && !profile.is_following + response.json({ success: true, data: { status: profile.follow_status } }) + return + } + if (endpoint === 'picstagram:respond-follow') { + picstagramActivities = picstagramActivities.filter( + (activity) => + activity.kind !== 'follow_request' || + activity.profile_id !== request.body.profileId, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:comments') { + response.json({ success: true, data: picstagramComments }) + return + } + if (endpoint === 'picstagram:comment') { + const comment = { + avatar_url: picstagramProfiles[0].avatar_url, + body: request.body.body, + created_at: Date.now(), + display_name: picstagramProfiles[0].display_name, + handle: picstagramProfiles[0].handle, + id: `pic-comment-${Date.now()}`, + is_owner: true, + parent_id: request.body.parentId ?? null, + profile_id: picstagramProfiles[0].id, + verified: picstagramProfiles[0].verified, + } + picstagramComments.push(comment) + response.json({ success: true, data: { id: comment.id } }) + return + } + if (endpoint === 'picstagram:remove-comment') { + picstagramComments = picstagramComments.filter( + (comment) => comment.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:stories') { + response.json({ success: true, data: picstagramStories }) + return + } + if (endpoint === 'picstagram:publish-story') { + const media = mockMedia.find((item) => item.id === request.body.mediaId) + if (!media || media.mediaType !== 'photo') { + response.json({ success: false, error: 'invalid_media' }) + return + } + const story = { + avatar_url: picstagramProfiles[0].avatar_url, + body: request.body.body, + created_at: Date.now(), + display_name: picstagramProfiles[0].display_name, + expires_at: Date.now() + 24 * 60 * 60 * 1000, + handle: picstagramProfiles[0].handle, + id: `pic-story-${Date.now()}`, + is_owner: true, + profile_id: picstagramProfiles[0].id, + seen: true, + url: media.url, + verified: picstagramProfiles[0].verified, + view_count: 0, + } + picstagramStories.unshift(story) + response.json({ success: true, data: { id: story.id } }) + return + } + if (endpoint === 'picstagram:view-story') { + const story = picstagramStories.find((item) => item.id === request.body.id) + if (story) story.seen = true + response.json({ success: Boolean(story) }) + return + } + if (endpoint === 'picstagram:story-viewers') { + response.json({ + success: true, + data: [ + { + avatar_url: picstagramProfiles[1].avatar_url, + created_at: Date.now() - 60000, + display_name: picstagramProfiles[1].display_name, + handle: picstagramProfiles[1].handle, + id: picstagramProfiles[1].id, + verified: true, + }, + ], + }) + return + } + if (endpoint === 'picstagram:remove-story') { + picstagramStories = picstagramStories.filter( + (story) => story.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:activities') { + response.json({ success: true, data: picstagramActivities }) + return + } + if (endpoint === 'picstagram:mark-activities') { + picstagramActivities.forEach((activity) => { + activity.read_at = new Date().toISOString() + }) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:block') { + const profileId = request.body.profileId + picstagramPosts = picstagramPosts.filter( + (post) => post.profile_id !== profileId, + ) + picstagramComments = picstagramComments.filter( + (comment) => comment.profile_id !== profileId, + ) + picstagramStories = picstagramStories.filter( + (story) => story.profile_id !== profileId, + ) + picstagramActivities = picstagramActivities.filter( + (activity) => activity.profile_id !== profileId, + ) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:report') { + picstagramReports.unshift({ + created_at: Date.now(), + details: request.body.details ?? '', + id: `pic-report-${Date.now()}`, + reason: request.body.reason, + reporter_display_name: picstagramProfiles[0].display_name, + reporter_handle: picstagramProfiles[0].handle, + target_id: request.body.targetId, + target_type: request.body.targetType, + }) + response.json({ success: true }) + return + } + if (endpoint === 'picstagram:admin-reports') { + response.json({ success: true, data: picstagramReports }) + return + } + if (endpoint === 'picstagram:admin-resolve-report') { + picstagramReports = picstagramReports.filter( + (report) => report.id !== request.body.id, + ) + response.json({ success: true }) + return + } + if (endpoint.startsWith('picstagram:')) { + response.json({ success: true }) + return + } if (endpoint === 'fliptok:bootstrap') { if (!flipTokAuthenticated) { response.json({ diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index d9f9f43..35ad48b 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -283,6 +283,28 @@ Config.FlipTok = { ReportAdminGroups = { "admin" }, } +Config.Picstagram = { + PageSize = 12, + CommentPageSize = 100, + MaxPostMedia = 5, + CaptionMaxLength = 800, + CommentMaxLength = 300, + BioMaxLength = 160, + LocationMaxLength = 80, + StoryTextMaxLength = 160, + StoryLifetimeSeconds = 24 * 60 * 60, + PasswordMinLength = 8, + PasswordMaxLength = 72, + PasswordPepperConvar = "sky_phone_picstagram_password_pepper", + PostsPerMinute = 6, + StoriesPerMinute = 6, + CommentsPerMinute = 20, + ReportDetailsMaxLength = 500, + ReportReasons = { "spam", "harassment", "dangerous", "illegal", "other" }, + VerifyCommand = "picstagramverify", + AdminGroups = { "admin" }, +} + Config.MapMarkers = { MaximumMarkers = 50, LabelMaxLength = 40, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 244c088..5254a30 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -8,6 +8,14 @@ Locales["en"] = { verified = "verified", unverified = "unverified", }, + PicstagramCommand = { + usage = "Usage: /{command} <@handle> ", + noPermission = "You do not have permission to manage Picstagram verification.", + notFound = "Picstagram profile @{handle} was not found.", + updated = "Picstagram @{handle} is now {state}.", + verified = "verified", + unverified = "unverified", + }, DeviceErrors = { phone_slot_missing = "The used phone could not be identified. Make sure phones are not stacked.", phone_stacked = "Phones cannot be stacked.", @@ -102,6 +110,78 @@ Locales["en"] = { match_not_found = "This match is no longer available.", invalid_message = "Write a message before sending.", invalid_attachment = "This attachment is unavailable.", gif_provider_unconfigured = "GIF search is not configured.", gif_provider_unauthorized = "The GIF provider key is invalid.", gif_provider_rate_limited = "GIF search is busy. Try again shortly.", gif_provider_failed = "GIFs are temporarily unavailable.", rate_limited = "Slow down for a moment and try again.", default = "Flare could not complete the request.", }, }, + picstagram = { + name = "Picstagram", loading = "Loading Picstagram", home = "Home", explore = "Explore", + create = "Create", activity = "Activity", profile = "Profile", following = "Following", + city = "City", posts = "Posts", followers = "Followers", verified = "Verified profile", + editProfile = "Edit profile", saved = "Saved", photos = "Photos", emptyBio = "No bio yet.", + authTitle = "Your Picstagram profile", login = "Sign In", register = "Register", + createAccount = "Create Profile", logout = "Sign Out", signingOut = "Signing Out...", + loginBody = "Sign in to continue with your photos, stories, follows, and saved posts.", + registerBody = "Create a private Picstagram login for your photo profile.", + registrationHint = "An iFruit account is required once to create and own a Picstagram profile.", + displayName = "Name", username = "Username", password = "Password", + confirmPassword = "Confirm password", passwordsMismatch = "The passwords do not match.", + displayNamePlaceholder = "Your name", usernamePlaceholder = "username", + passwordPlaceholder = "At least 8 characters", confirmPasswordPlaceholder = "Enter password again", + signOutTitle = "Sign out of Picstagram?", + signOutBody = "Your profile and posts stay online. This phone returns to the sign-in screen.", + stories = "Stories", yourStory = "Your story", noStories = "No active stories", + storyViews = "{count} views", storyViewers = "Story viewers", seenBy = "Seen by {count}", + nextStory = "Next story", closeStory = "Close story", removeStory = "Remove story", storyRemoved = "Story removed.", + emptyFeed = "Your feed is quiet", emptyFeedBody = "Discover profiles or share the first photo.", + discoverPeople = "Discover people", searchPlaceholder = "Search profiles, captions, places, or tags", + noResults = "No results", noResultsBody = "Try another username, caption, place, or hashtag.", + newPost = "New Post", newStory = "New Story", post = "Post", story = "Story", + choosePhotos = "Choose photos", choosePhoto = "Choose photo", + choosePhotosHint = "Select up to five photos from Gallery", chooseStoryHint = "Select one photo from Gallery", + selectedPhotos = "{count} selected", changePhotos = "Change selection", caption = "Caption", + captionPlaceholder = "Write a caption...", storyTextPlaceholder = "Add a short story text...", + location = "Location", locationPlaceholder = "Add a place", allowComments = "Allow comments", + publishing = "Publishing...", published = "Your post is live.", storyPublished = "Your story is live.", + comments = "Comments", comment = "Comment", noComments = "No comments yet", addComment = "Add a comment...", + reply = "Reply", removeComment = "Remove comment", likes = "{count} likes", + viewComments = "View all {count} comments", like = "Like", unlike = "Unlike", save = "Save", + unsave = "Remove from saved", more = "More", archive = "Archive", restore = "Restore", + deletePost = "Delete post", deletePostTitle = "Delete this post?", + deletePostBody = "The post and its reactions will no longer be available.", + privateProfile = "This profile is private", privateProfileBody = "Follow this profile to see its posts and stories.", + follow = "Follow", requested = "Requested", unfollow = "Following", accept = "Accept", decline = "Decline", + followRequests = "Follow requests", noActivity = "No activity yet", markRead = "Mark as read", + publicProfile = "Public profile", privateProfileSetting = "Private profile", + privacyHint = "Only accepted followers can see your posts and stories.", bio = "Bio", + avatar = "Profile photo", chooseAvatar = "Choose profile photo", removeAvatar = "Remove profile photo", + saveProfile = "Save profile", profileSaved = "Profile updated.", cancel = "Cancel", done = "Done", + report = "Report", reportTarget = "Report {target}", reportReason = "Reason", + reportDetails = "Additional details (optional)", submitReport = "Submit report", reported = "Report submitted.", + block = "Block profile", unblock = "Unblock profile", blocked = "Profile blocked.", + blockTitle = "Block @{handle}?", blockBody = "You will no longer see or interact with each other's Picstagram content.", + moderation = "Moderation", reports = "Open reports", noReports = "No open reports", + noDetails = "No additional details", hide = "Hide", remove = "Remove", restoreAction = "Restore", dismiss = "Dismiss", + reportTargets = { post = "Post", profile = "Profile", comment = "Comment", story = "Story" }, + reportReasons = { spam = "Spam or misleading", harassment = "Harassment or bullying", dangerous = "Dangerous activity", illegal = "Illegal content", other = "Something else" }, + activityKinds = { follow_request = "requested to follow you", follow = "started following you", request_accepted = "accepted your follow request", like = "liked your post", comment = "commented on your post", verified = "verification changed" }, + notifications = { follow_request = "{actor} requested to follow you.", follow = "{actor} started following you.", request_accepted = "{actor} accepted your follow request.", like = "{actor} liked your post.", comment = "{actor} commented on your post.", verified = "Your Picstagram profile is now verified.", default = "You have new Picstagram activity." }, + errors = { + invalid_post = "Check the post details.", invalid_story = "Check the story details.", + invalid_media = "Choose photos owned by this phone.", invalid_comment = "Enter a valid comment.", + comments_disabled = "Comments are disabled.", invalid_profile = "Check your profile details.", + invalid_handle = "Use 3–24 letters, numbers, dots, or underscores.", + invalid_display_name = "Enter a display name.", invalid_password = "Password must be 8–72 characters.", + invalid_credentials = "Username or password is incorrect.", + already_registered = "This iFruit account already owns a Picstagram profile.", + handle_taken = "This username is already taken.", post_not_found = "This post is unavailable.", + story_not_found = "This story is unavailable.", profile_not_found = "This profile is unavailable.", + request_not_found = "This follow request is no longer open.", profile_unavailable = "This profile is unavailable.", + blocked = "This profile is blocked.", already_reported = "You already reported this content.", + invalid_report = "Choose a valid report reason.", invalid_search = "Enter a valid search.", + invalid_cursor = "The feed changed. Refresh and try again.", invalid_status = "This action is not available.", + not_authorized = "You are not allowed to do that.", report_not_found = "This report is no longer open.", + rate_limited = "Too many actions. Try again shortly.", not_authenticated = "Sign in to iFruit first.", + picstagram_not_authenticated = "Sign in to Picstagram first.", request_failed = "The request failed.", + default = "Picstagram could not complete the request." + }, + }, fliptok = { name = "FlipTok", loading = "Loading FlipTok", following = "Following", forYou = "For You", verified = "Verified account", originalSound = "original sound", save = "Save", home = "Home", discover = "Discover", create = "Create", activity = "Activity", profile = "Profile", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index f1dd2e2..64fa820 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -59,6 +59,7 @@ server_scripts { 'source/server/marketplace.lua', 'source/server/pages.lua', 'source/server/fliptok.lua', + 'source/server/picstagram.lua', 'source/server/map.lua', 'source/server/calendar.lua', 'source/server/radio.lua', diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 3aad653..586d795 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -82,6 +82,36 @@ local server_callbacks = { "fliptok:admin-resolve-report", "fliptok:block", "fliptok:delete", + "picstagram:register", + "picstagram:login", + "picstagram:logout", + "picstagram:bootstrap", + "picstagram:feed", + "picstagram:explore", + "picstagram:search", + "picstagram:saved", + "picstagram:profile", + "picstagram:update-profile", + "picstagram:publish-post", + "picstagram:update-post", + "picstagram:set-post-status", + "picstagram:react", + "picstagram:follow", + "picstagram:respond-follow", + "picstagram:comments", + "picstagram:comment", + "picstagram:remove-comment", + "picstagram:publish-story", + "picstagram:stories", + "picstagram:view-story", + "picstagram:story-viewers", + "picstagram:remove-story", + "picstagram:activities", + "picstagram:mark-activities", + "picstagram:block", + "picstagram:report", + "picstagram:admin-reports", + "picstagram:admin-resolve-report", "calendar:list", "calendar:create", "calendar:update", @@ -450,6 +480,22 @@ RegisterNetEvent("sky_phone:fliptok:new", function(data) SendNUIMessage({ type = "fliptok:new", data = data }) end) +RegisterNetEvent("sky_phone:picstagram:command-feedback", function(data) + Bridge.Framework.Notify("Picstagram", data.message, data.notificationType, 5000) +end) + +RegisterNetEvent("sky_phone:picstagram:verification-changed", function(data) + SendNUIMessage({ type = "picstagram:verification-changed", data = data }) +end) + +RegisterNetEvent("sky_phone:picstagram:new", function(data) + local picstagram_locale = get_locale().Nui.Apps.picstagram + local notification_text = picstagram_locale.notifications[data.kind] or picstagram_locale.notifications.default + data.title = picstagram_locale.name + data.text = notification_text:gsub("{actor}", tostring(data.actor or "")) + SendNUIMessage({ type = "picstagram:new", data = data }) +end) + RegisterNetEvent("sky_phone:marketplace:new-message", function(data) local marketplace_locale = get_locale().Nui.Apps.citymarkt data.title = marketplace_locale.name diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index aa61d1f..70ea649 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -908,6 +908,299 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_picstagram_profiles", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "handle", type = "VARCHAR(24) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "display_name", type = "VARCHAR(40) NOT NULL" }, + { name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" }, + { name = "private", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "verified", type = "TINYINT(1) NOT NULL DEFAULT 0" }, + { name = "status", type = "ENUM('active', 'hidden', 'removed') NOT NULL DEFAULT 'active'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_account", columns = "(`account_id`)" }, + { name = "uniq_sky_phone_picstagram_handle", columns = "(`handle`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + { column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_credentials", + columns = { + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "password_hash", type = "BINARY(32) NOT NULL" }, + { name = "password_salt", type = "CHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "profile_id", + foreignKeys = { + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_sessions", + columns = { + { name = "device_imei", type = "CHAR(15) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "device_imei", + indexes = { + { name = "idx_sky_phone_picstagram_sessions_profile", columns = "(`profile_id`, `updated_at`)" }, + }, + foreignKeys = { + { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_posts", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "caption", type = "VARCHAR(800) NOT NULL DEFAULT ''" }, + { name = "location", type = "VARCHAR(80) NOT NULL DEFAULT ''" }, + { name = "comments_enabled", type = "TINYINT(1) NOT NULL DEFAULT 1" }, + { name = "status", type = "ENUM('published', 'archived', 'hidden', 'removed') NOT NULL DEFAULT 'published'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_feed", columns = "(`status`, `created_at`, `id`)" }, + { name = "idx_sky_phone_picstagram_profile_posts", columns = "(`profile_id`, `status`, `created_at`)" }, + }, + foreignKeys = { + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_post_media", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "position", type = "TINYINT UNSIGNED NOT NULL" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_post_position", columns = "(`post_id`, `position`)" }, + { name = "uniq_sky_phone_picstagram_post_media", columns = "(`post_id`, `media_id`)" }, + }, + foreignKeys = { + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_reactions", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "kind", type = "ENUM('like', 'save') NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_reaction", columns = "(`post_id`, `profile_id`, `kind`)" }, + }, + indexes = { + { name = "idx_sky_phone_picstagram_saved", columns = "(`profile_id`, `kind`, `created_at`)" }, + }, + foreignKeys = { + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_follows", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "follower_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "following_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "status", type = "ENUM('pending', 'accepted') NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_follow", columns = "(`follower_id`, `following_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_picstagram_following", columns = "(`following_id`, `status`, `created_at`)" }, + }, + foreignKeys = { + { column = "follower_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "following_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_comments", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "parent_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "body", type = "VARCHAR(300) NOT NULL" }, + { name = "status", type = "ENUM('visible', 'removed') NOT NULL DEFAULT 'visible'" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_comments", columns = "(`post_id`, `status`, `created_at`)" }, + }, + foreignKeys = { + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "parent_id", references = "`sky_phone_picstagram_comments` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_stories", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "body", type = "VARCHAR(160) NOT NULL DEFAULT ''" }, + { name = "status", type = "ENUM('active', 'removed') NOT NULL DEFAULT 'active'" }, + { name = "expires_at", type = "DATETIME NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_stories", columns = "(`profile_id`, `status`, `expires_at`)" }, + { name = "idx_sky_phone_picstagram_story_expiry", columns = "(`status`, `expires_at`)" }, + }, + foreignKeys = { + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_story_views", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "story_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "profile_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_story_view", columns = "(`story_id`, `profile_id`)" }, + }, + foreignKeys = { + { column = "story_id", references = "`sky_phone_picstagram_stories` (`id`) ON DELETE CASCADE" }, + { column = "profile_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_activities", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "recipient_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "actor_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "post_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "kind", type = "ENUM('follow_request', 'follow', 'request_accepted', 'like', 'comment', 'verified') NOT NULL" }, + { name = "read_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_activity", columns = "(`recipient_id`, `read_at`, `created_at`)" }, + }, + foreignKeys = { + { column = "recipient_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "actor_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "post_id", references = "`sky_phone_picstagram_posts` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_reports", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reporter_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "target_type", type = "ENUM('profile', 'post', 'story', 'comment') NOT NULL" }, + { name = "target_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "reason", type = "ENUM('spam', 'harassment', 'dangerous', 'illegal', 'other') NOT NULL" }, + { name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" }, + { name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" }, + { name = "resolved_action", type = "VARCHAR(16) NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "resolved_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_report", columns = "(`reporter_id`, `target_type`, `target_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_picstagram_reports", columns = "(`status`, `created_at`)" }, + }, + foreignKeys = { + { column = "reporter_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_blocks", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "blocker_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "blocked_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_picstagram_block", columns = "(`blocker_id`, `blocked_id`)" }, + }, + foreignKeys = { + { column = "blocker_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + { column = "blocked_id", references = "`sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_picstagram_moderation_audit", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "report_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "moderator_identifier", type = "VARCHAR(80) NOT NULL" }, + { name = "action", type = "VARCHAR(16) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "target_type", type = "VARCHAR(16) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" }, + { name = "target_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_picstagram_audit_target", columns = "(`target_type`, `target_id`, `created_at`)" }, + }, + foreignKeys = { + { column = "report_id", references = "`sky_phone_picstagram_reports` (`id`) ON DELETE SET NULL" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_fliptok_profiles", columns = { diff --git a/sky_phone/source/server/picstagram.lua b/sky_phone/source/server/picstagram.lua new file mode 100644 index 0000000..683dc34 --- /dev/null +++ b/sky_phone/source/server/picstagram.lua @@ -0,0 +1,1351 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local report_reasons = {} +local password_pepper = GetConvar(Config.Picstagram.PasswordPepperConvar, "") + +if password_pepper == "" then + Bridge.Debug( + "warn", + "[sky_phone] Picstagram password pepper convar '%s' is empty; configure it before production use.", + Config.Picstagram.PasswordPepperConvar, + { always = true } + ) +end + +for index = 1, #Config.Picstagram.ReportReasons do + report_reasons[Config.Picstagram.ReportReasons[index]] = true +end + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function valid_text(value, minimum, maximum) + local length = type(value) == "string" and utf8.len(value) or nil + return length and length >= minimum and length <= maximum +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + if type(result) == "table" then + return tonumber(result.affectedRows) or tonumber(result.affected_rows) or 0 + end + return 0 +end + +local function new_id() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + if not rows[1] or type(rows[1].id) ~= "string" then + error("[sky_phone] Database did not generate a Picstagram id.") + end + return rows[1].id +end + +local function valid_id(value) + return type(value) == "string" and #value == 36 and value:match("^[0-9a-fA-F%-]+$") ~= nil +end + +local function normalize_handle(value) + local handle = trim(value) + if not handle then + return nil + end + handle = handle:lower():gsub("^@", "") + if #handle < 3 + or #handle > 24 + or not handle:match("^[a-z0-9][a-z0-9._]*[a-z0-9]$") + or handle:find("..", 1, true) + then + return nil + end + return handle +end + +local function valid_password(value) + local length = type(value) == "string" and utf8.len(value) or nil + return length + and length >= Config.Picstagram.PasswordMinLength + and length <= Config.Picstagram.PasswordMaxLength +end + +local function are_profiles_blocked(first_id, second_id) + local rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_picstagram_blocks` + WHERE (`blocker_id` = ? AND `blocked_id` = ?) + OR (`blocker_id` = ? AND `blocked_id` = ?) + LIMIT 1 + ]], { first_id, second_id, second_id, first_id }) + return rows[1] ~= nil +end + +local function profile_for_session(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local rows = Bridge.Database.Query([[ + SELECT p.* FROM `sky_phone_picstagram_sessions` s + JOIN `sky_phone_picstagram_profiles` p ON p.`id` = s.`profile_id` + WHERE s.`device_imei` = ? LIMIT 1 + ]], { session.imei }) + if not rows[1] then + return nil, { success = false, error = "picstagram_not_authenticated" } + end + if rows[1].status ~= "active" then + return nil, { success = false, error = "profile_unavailable" } + end + return rows[1] +end + +local function hydrate_profile(profile, viewer_id) + profile.private = tonumber(profile.private) == 1 + profile.verified = tonumber(profile.verified) == 1 + profile.is_owner = viewer_id == profile.id + profile.followers = tonumber(profile.followers) or 0 + profile.following = tonumber(profile.following) or 0 + profile.post_count = tonumber(profile.post_count) or 0 + profile.avatar_media_id = tonumber(profile.avatar_media_id) + profile.follow_status = profile.follow_status ~= "" and profile.follow_status or nil + profile.is_following = profile.follow_status == "accepted" + profile.is_requested = profile.follow_status == "pending" + profile.locked = profile.private and not profile.is_owner and not profile.is_following + return profile +end + +local function load_profile(profile_id, viewer_id) + local rows = Bridge.Database.Query([[ + SELECT p.`id`, p.`handle`, p.`display_name`, p.`bio`, p.`avatar_media_id`, p.`private`, p.`verified`, p.`status`, + avatar.`url` AS `avatar_url`, + COALESCE((SELECT f.`status` FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = ? AND f.`following_id` = p.`id` LIMIT 1), '') AS `follow_status`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`following_id` = p.`id` AND f.`status` = 'accepted') AS `followers`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = p.`id` AND f.`status` = 'accepted') AS `following`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_posts` post + WHERE post.`profile_id` = p.`id` AND post.`status` = 'published') AS `post_count` + FROM `sky_phone_picstagram_profiles` p + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id` + WHERE p.`id` = ? AND p.`status` = 'active' + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` b + WHERE (b.`blocker_id` = ? AND b.`blocked_id` = p.`id`) + OR (b.`blocker_id` = p.`id` AND b.`blocked_id` = ?)) + LIMIT 1 + ]], { viewer_id, profile_id, viewer_id, viewer_id }) + return rows[1] and hydrate_profile(rows[1], viewer_id) or nil +end + +local function load_post_media(posts) + if #posts == 0 then + return posts + end + local placeholders = {} + local values = {} + local by_id = {} + for index = 1, #posts do + local post = posts[index] + placeholders[index] = "?" + values[index] = post.id + post.media = {} + by_id[post.id] = post + end + local rows = Bridge.Database.Query(([[ + SELECT pm.`post_id`, pm.`position`, m.`id`, m.`url` + FROM `sky_phone_picstagram_post_media` pm + JOIN `sky_phone_media` m ON m.`id` = pm.`media_id` + WHERE pm.`post_id` IN (%s) + ORDER BY pm.`post_id`, pm.`position` + ]]):format(table.concat(placeholders, ", ")), values) + for index = 1, #rows do + local media = rows[index] + local post = by_id[media.post_id] + if post then + post.media[#post.media + 1] = { + id = tonumber(media.id), + position = tonumber(media.position), + url = media.url, + } + end + end + return posts +end + +local function hydrate_posts(rows) + for index = 1, #rows do + local post = rows[index] + post.comments_enabled = tonumber(post.comments_enabled) == 1 + post.is_owner = tonumber(post.is_owner) == 1 + post.is_liked = tonumber(post.is_liked) == 1 + post.is_saved = tonumber(post.is_saved) == 1 + post.verified = tonumber(post.verified) == 1 + post.private = tonumber(post.private) == 1 + post.like_count = tonumber(post.like_count) or 0 + post.comment_count = tonumber(post.comment_count) or 0 + post.created_at = (tonumber(post.created_at_unix) or 0) * 1000 + post.created_at_unix = nil + end + return load_post_media(rows) +end + +local function parse_cursor(value) + if value == nil or value == "" then + return nil, nil + end + if type(value) ~= "string" or #value > 64 then + return false, false + end + local timestamp, id = value:match("^(%d+):([%x%-]+)$") + if not timestamp or not valid_id(id) then + return false, false + end + return tonumber(timestamp), id +end + +local function list_posts(viewer_id, where_clause, values, cursor_value) + local cursor_time, cursor_id = parse_cursor(cursor_value) + if cursor_time == false then + return nil, "invalid_cursor" + end + local parameters = { viewer_id, viewer_id, viewer_id } + for index = 1, #values do + parameters[#parameters + 1] = values[index] + end + local cursor_clause = "" + if cursor_time then + cursor_clause = " AND (post.`created_at` < FROM_UNIXTIME(?) OR (post.`created_at` = FROM_UNIXTIME(?) AND post.`id` < ?))" + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_time + parameters[#parameters + 1] = cursor_id + end + parameters[#parameters + 1] = viewer_id + parameters[#parameters + 1] = viewer_id + parameters[#parameters + 1] = Config.Picstagram.PageSize + 1 + local rows = hydrate_posts(Bridge.Database.Query(([[ + SELECT post.`id`, post.`profile_id`, post.`caption`, post.`location`, post.`comments_enabled`, + UNIX_TIMESTAMP(post.`created_at`) AS `created_at_unix`, profile.`handle`, profile.`display_name`, + profile.`verified`, profile.`private`, avatar.`url` AS `avatar_url`, + (post.`profile_id` = ?) AS `is_owner`, + EXISTS(SELECT 1 FROM `sky_phone_picstagram_reactions` reaction + WHERE reaction.`post_id` = post.`id` AND reaction.`profile_id` = ? AND reaction.`kind` = 'like') AS `is_liked`, + EXISTS(SELECT 1 FROM `sky_phone_picstagram_reactions` reaction + WHERE reaction.`post_id` = post.`id` AND reaction.`profile_id` = ? AND reaction.`kind` = 'save') AS `is_saved`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_reactions` reaction + WHERE reaction.`post_id` = post.`id` AND reaction.`kind` = 'like') AS `like_count`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_comments` comment + WHERE comment.`post_id` = post.`id` AND comment.`status` = 'visible') AS `comment_count` + FROM `sky_phone_picstagram_posts` post + JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id` + WHERE post.`status` = 'published' AND profile.`status` = 'active' AND %s%s + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = post.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = post.`profile_id`)) + ORDER BY post.`created_at` DESC, post.`id` DESC + LIMIT ? + ]]):format(where_clause, cursor_clause), parameters)) + local has_more = #rows > Config.Picstagram.PageSize + if has_more then + rows[#rows] = nil + end + local last = rows[#rows] + local next_cursor = last and ((math.floor(last.created_at / 1000)) .. ":" .. last.id) or nil + return { items = rows, hasMore = has_more, nextCursor = next_cursor } +end + +local function find_accessible_post(post_id, viewer_id) + local rows = Bridge.Database.Query([[ + SELECT post.`id`, post.`profile_id`, post.`comments_enabled`, profile.`account_id` + FROM `sky_phone_picstagram_posts` post + JOIN `sky_phone_picstagram_profiles` profile ON profile.`id` = post.`profile_id` + WHERE post.`id` = ? AND post.`status` = 'published' AND profile.`status` = 'active' + AND (profile.`private` = 0 OR post.`profile_id` = ? OR EXISTS( + SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = post.`profile_id` + AND follow.`status` = 'accepted')) + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = post.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = post.`profile_id`)) + LIMIT 1 + ]], { post_id, viewer_id, viewer_id, viewer_id, viewer_id }) + return rows[1] +end + +local function create_activity(recipient_id, actor_id, kind, post_id) + if recipient_id == actor_id then + return + end + local id = new_id() + if post_id then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_activities` + (`id`, `recipient_id`, `actor_id`, `post_id`, `kind`) + VALUES (?, ?, ?, ?, ?) + ]], { id, recipient_id, actor_id, post_id, kind }) + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_activities` + (`id`, `recipient_id`, `actor_id`, `kind`) + VALUES (?, ?, ?, ?) + ]], { id, recipient_id, actor_id, kind }) + end + local rows = Bridge.Database.Query([[ + SELECT recipient.`account_id`, actor.`display_name` AS `actor_name` + FROM `sky_phone_picstagram_profiles` recipient + JOIN `sky_phone_picstagram_profiles` actor ON actor.`id` = ? + WHERE recipient.`id` = ? LIMIT 1 + ]], { actor_id, recipient_id }) + if not rows[1] then + error("[sky_phone] Picstagram activity references a missing profile.") + end + SkyPhone.NotifyAccountDevices(tonumber(rows[1].account_id), "sky_phone:picstagram:new", { + actor = rows[1].actor_name, + kind = kind, + postId = post_id, + }) +end + +local function is_admin(source) + return Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) +end + +Bridge.Callbacks.Register("sky_phone:picstagram:register", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:register", 5, 60) then + return { success = false, error = "rate_limited" } + end + local account, error_response = SkyPhone.RequireAccount(source) + if not account then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_request" } + end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + if not handle then + return { success = false, error = "invalid_handle" } + end + if not valid_text(display_name, 1, 40) then + return { success = false, error = "invalid_display_name" } + end + if not valid_password(data.password) then + return { success = false, error = "invalid_password" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `account_id` = ? LIMIT 1", + { account.id } + )[1] then + return { success = false, error = "already_registered" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + )[1] then + return { success = false, error = "handle_taken" } + end + local entropy = Bridge.Database.Query("SELECT UUID() AS `id`, REPLACE(UUID(), '-', '') AS `salt`", {})[1] + if not entropy or not valid_id(entropy.id) or type(entropy.salt) ~= "string" then + error("[sky_phone] Database did not generate Picstagram registration entropy.") + end + if not Bridge.Database.Transaction({ + { + query = [[INSERT INTO `sky_phone_picstagram_profiles` + (`id`, `account_id`, `handle`, `display_name`) VALUES (?, ?, ?, ?)]], + params = { entropy.id, account.id, handle, display_name }, + }, + { + query = [[INSERT INTO `sky_phone_picstagram_credentials` + (`profile_id`, `password_hash`, `password_salt`) + VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?)]], + params = { entropy.id, password_pepper, entropy.salt, data.password, entropy.salt }, + }, + { + query = [[INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP]], + params = { account.imei, entropy.id }, + }, + }) then + return { success = false, error = "request_failed" } + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:login", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:login", 10, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_credentials" } + end + local handle = normalize_handle(data.handle) + if not handle or not valid_password(data.password) then + return { success = false, error = "invalid_credentials" } + end + local profiles = Bridge.Database.Query([[ + SELECT profile.`id` FROM `sky_phone_picstagram_profiles` profile + JOIN `sky_phone_picstagram_credentials` credentials ON credentials.`profile_id` = profile.`id` + WHERE profile.`handle` = ? AND profile.`status` = 'active' + AND credentials.`password_hash` = UNHEX(SHA2(CONCAT(?, credentials.`password_salt`, ?), 256)) + LIMIT 1 + ]], { handle, password_pepper, data.password }) + if not profiles[1] then + return { success = false, error = "invalid_credentials" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_sessions` (`device_imei`, `profile_id`) + VALUES (?, ?) ON DUPLICATE KEY UPDATE `profile_id` = VALUES(`profile_id`), `updated_at` = CURRENT_TIMESTAMP + ]], { session.imei, profiles[1].id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:logout", function(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + Bridge.Database.Query("DELETE FROM `sky_phone_picstagram_sessions` WHERE `device_imei` = ?", { session.imei }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:bootstrap", function(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + local profile = profile_for_session(source) + if not profile then + return { success = true, data = { authenticated = false, isAdmin = is_admin(source) } } + end + local hydrated = load_profile(profile.id, profile.id) + if not hydrated then + return { success = false, error = "profile_unavailable" } + end + local feed, feed_error = list_posts( + profile.id, + [[post.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = post.`profile_id` + AND follow.`status` = 'accepted')]], + { profile.id, profile.id }, + nil + ) + if not feed then + return { success = false, error = feed_error } + end + return { + success = true, + data = { + authenticated = true, + feed = feed, + isAdmin = is_admin(source), + profile = hydrated, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:feed", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local result, list_error = list_posts( + profile.id, + [[post.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = post.`profile_id` + AND follow.`status` = 'accepted')]], + { profile.id, profile.id }, + data.cursor + ) + return result and { success = true, data = result } or { success = false, error = list_error } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:explore", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local result, list_error = list_posts(profile.id, "profile.`private` = 0", {}, data.cursor) + return result and { success = true, data = result } or { success = false, error = list_error } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:search", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:search", 40, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + local search = trim(type(data) == "table" and data.search or nil) + if not search or not valid_text(search, 1, 80) then + return { success = false, error = "invalid_search" } + end + local pattern = "%" .. search:lower():gsub("^[@#]", "") .. "%" + local profiles = Bridge.Database.Query([[ + SELECT candidate.`id`, candidate.`handle`, candidate.`display_name`, candidate.`bio`, candidate.`avatar_media_id`, candidate.`private`, candidate.`status`, + candidate.`verified`, avatar.`url` AS `avatar_url`, + COALESCE((SELECT f.`status` FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = ? AND f.`following_id` = candidate.`id` LIMIT 1), '') AS `follow_status`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`following_id` = candidate.`id` AND f.`status` = 'accepted') AS `followers`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_follows` f + WHERE f.`follower_id` = candidate.`id` AND f.`status` = 'accepted') AS `following`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_posts` post + WHERE post.`profile_id` = candidate.`id` AND post.`status` = 'published') AS `post_count` + FROM `sky_phone_picstagram_profiles` candidate + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = candidate.`avatar_media_id` + WHERE candidate.`status` = 'active' + AND (LOWER(candidate.`handle`) LIKE ? OR LOWER(candidate.`display_name`) LIKE ?) + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = candidate.`id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = candidate.`id`)) + ORDER BY candidate.`verified` DESC, candidate.`handle` LIMIT 20 + ]], { profile.id, pattern, pattern, profile.id, profile.id }) + for index = 1, #profiles do + hydrate_profile(profiles[index], profile.id) + end + local posts = list_posts( + profile.id, + "profile.`private` = 0 AND (LOWER(post.`caption`) LIKE ? OR LOWER(post.`location`) LIKE ?)", + { pattern, pattern }, + nil + ) + return { success = true, data = { profiles = profiles, posts = posts and posts.items or {} } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:saved", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + data = type(data) == "table" and data or {} + local result, list_error = list_posts( + profile.id, + [[EXISTS(SELECT 1 FROM `sky_phone_picstagram_reactions` saved + WHERE saved.`post_id` = post.`id` AND saved.`profile_id` = ? AND saved.`kind` = 'save')]], + { profile.id }, + data.cursor + ) + return result and { success = true, data = result } or { success = false, error = list_error } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:profile", function(source, data) + local viewer, error_response = profile_for_session(source) + if not viewer then + return error_response + end + data = type(data) == "table" and data or {} + local target_id = valid_id(data.profileId) and data.profileId or nil + if not target_id and type(data.handle) == "string" then + local handle = normalize_handle(data.handle) + local rows = handle and Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + ) or {} + target_id = rows[1] and rows[1].id or nil + end + if not target_id then + return { success = false, error = "profile_not_found" } + end + local target = load_profile(target_id, viewer.id) + if not target then + return { success = false, error = "profile_not_found" } + end + local posts = { items = {}, hasMore = false, nextCursor = nil } + if not target.locked then + local list_error + posts, list_error = list_posts(viewer.id, "post.`profile_id` = ?", { target.id }, data.cursor) + if not posts then + return { success = false, error = list_error } + end + end + return { success = true, data = { profile = target, posts = posts } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:update-profile", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or type(data.private) ~= "boolean" then + return { success = false, error = "invalid_profile" } + end + local handle = normalize_handle(data.handle) + local display_name = trim(data.displayName) + local bio = trim(data.bio) or "" + if not handle or not valid_text(display_name, 1, 40) or not valid_text(bio, 0, Config.Picstagram.BioMaxLength) then + return { success = false, error = "invalid_profile" } + end + local avatar_media_id = tonumber(data.avatarMediaId) or 0 + if avatar_media_id ~= math.floor(avatar_media_id) or avatar_media_id < 0 then + return { success = false, error = "invalid_media" } + end + if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo") then + return { success = false, error = "invalid_media" } + end + if Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? AND `id` <> ? LIMIT 1", + { handle, profile.id } + )[1] then + return { success = false, error = "handle_taken" } + end + Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_profiles` + SET `handle` = ?, `display_name` = ?, `bio` = ?, `private` = ?, `avatar_media_id` = NULLIF(?, 0) + WHERE `id` = ? + ]], { handle, display_name, bio, data.private and 1 or 0, avatar_media_id, profile.id }) + return { success = true, data = load_profile(profile.id, profile.id) } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:publish-post", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:publish", Config.Picstagram.PostsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or type(data.mediaIds) ~= "table" or type(data.commentsEnabled) ~= "boolean" then + return { success = false, error = "invalid_post" } + end + local caption = trim(data.caption) or "" + local location = trim(data.location) or "" + if not valid_text(caption, 0, Config.Picstagram.CaptionMaxLength) + or not valid_text(location, 0, Config.Picstagram.LocationMaxLength) + or #data.mediaIds < 1 + or #data.mediaIds > Config.Picstagram.MaxPostMedia + then + return { success = false, error = "invalid_post" } + end + local media_ids = {} + local seen = {} + for index = 1, #data.mediaIds do + local media_id = tonumber(data.mediaIds[index]) + if not media_id or media_id < 1 or media_id ~= math.floor(media_id) or seen[media_id] then + return { success = false, error = "invalid_media" } + end + if not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") then + return { success = false, error = "invalid_media" } + end + seen[media_id] = true + media_ids[index] = media_id + end + local id = new_id() + local statements = { + { + query = [[INSERT INTO `sky_phone_picstagram_posts` + (`id`, `profile_id`, `caption`, `location`, `comments_enabled`) + VALUES (?, ?, ?, ?, ?)]], + params = { id, profile.id, caption, location, data.commentsEnabled == false and 0 or 1 }, + }, + } + for index = 1, #media_ids do + statements[#statements + 1] = { + query = [[INSERT INTO `sky_phone_picstagram_post_media` + (`post_id`, `media_id`, `position`) VALUES (?, ?, ?)]], + params = { id, media_ids[index], index }, + } + end + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + return { success = true, data = { id = id } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:update-post", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) or type(data.commentsEnabled) ~= "boolean" then + return { success = false, error = "invalid_post" } + end + local caption = trim(data.caption) or "" + local location = trim(data.location) or "" + if not valid_text(caption, 0, Config.Picstagram.CaptionMaxLength) + or not valid_text(location, 0, Config.Picstagram.LocationMaxLength) + then + return { success = false, error = "invalid_post" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_posts` + SET `caption` = ?, `location` = ?, `comments_enabled` = ? + WHERE `id` = ? AND `profile_id` = ? AND `status` IN ('published', 'archived') + ]], { caption, location, data.commentsEnabled == false and 0 or 1, data.id, profile.id }) + return affected_rows(result) > 0 + and { success = true } + or { success = false, error = "post_not_found" } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:set-post-status", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "invalid_post" } + end + local target = data.status + local current = Bridge.Database.Query( + "SELECT `status` FROM `sky_phone_picstagram_posts` WHERE `id` = ? AND `profile_id` = ? LIMIT 1", + { data.id, profile.id } + )[1] + if not current then + return { success = false, error = "post_not_found" } + end + local allowed = (current.status == "published" and (target == "archived" or target == "removed")) + or (current.status == "archived" and (target == "published" or target == "removed")) + if not allowed then + return { success = false, error = "invalid_status" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_posts` SET `status` = ? WHERE `id` = ? AND `profile_id` = ?", + { target, data.id, profile.id } + ) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:react", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:react", 80, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" + or not valid_id(data.id) + or (data.kind ~= "like" and data.kind ~= "save") + or type(data.active) ~= "boolean" + then + return { success = false, error = "invalid_request" } + end + local post = find_accessible_post(data.id, profile.id) + if not post then + return { success = false, error = "post_not_found" } + end + if data.active then + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_reactions` (`post_id`, `profile_id`, `kind`) + VALUES (?, ?, ?) + ]], { data.id, profile.id, data.kind }) + if data.kind == "like" and affected_rows(result) > 0 then + create_activity(post.profile_id, profile.id, "like", data.id) + end + else + Bridge.Database.Query([[ + DELETE FROM `sky_phone_picstagram_reactions` + WHERE `post_id` = ? AND `profile_id` = ? AND `kind` = ? + ]], { data.id, profile.id, data.kind }) + end + return { success = true, data = { active = data.active == true } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:follow", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:follow", 40, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" + or not valid_id(data.profileId) + or data.profileId == profile.id + or type(data.active) ~= "boolean" + then + return { success = false, error = "invalid_profile" } + end + if are_profiles_blocked(profile.id, data.profileId) then + return { success = false, error = "blocked" } + end + local target = Bridge.Database.Query([[ + SELECT `id`, `private` FROM `sky_phone_picstagram_profiles` + WHERE `id` = ? AND `status` = 'active' LIMIT 1 + ]], { data.profileId })[1] + if not target then + return { success = false, error = "profile_not_found" } + end + if not data.active then + Bridge.Database.Query( + "DELETE FROM `sky_phone_picstagram_follows` WHERE `follower_id` = ? AND `following_id` = ?", + { profile.id, target.id } + ) + return { success = true, data = { status = false } } + end + local status = tonumber(target.private) == 1 and "pending" or "accepted" + local existing = Bridge.Database.Query([[ + SELECT `status` FROM `sky_phone_picstagram_follows` + WHERE `follower_id` = ? AND `following_id` = ? LIMIT 1 + ]], { profile.id, target.id })[1] + if not existing or existing.status ~= status then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_follows` (`follower_id`, `following_id`, `status`) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `status` = VALUES(`status`), `updated_at` = CURRENT_TIMESTAMP + ]], { profile.id, target.id, status }) + create_activity(target.id, profile.id, status == "pending" and "follow_request" or "follow", nil) + end + return { success = true, data = { status = status } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:respond-follow", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.profileId) or type(data.accept) ~= "boolean" then + return { success = false, error = "invalid_profile" } + end + local request = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_picstagram_follows` + WHERE `follower_id` = ? AND `following_id` = ? AND `status` = 'pending' LIMIT 1 + ]], { data.profileId, profile.id })[1] + if not request then + return { success = false, error = "request_not_found" } + end + if data.accept then + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_follows` SET `status` = 'accepted' WHERE `id` = ?", + { request.id } + ) + create_activity(data.profileId, profile.id, "request_accepted", nil) + else + Bridge.Database.Query("DELETE FROM `sky_phone_picstagram_follows` WHERE `id` = ?", { request.id }) + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:comments", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) or not find_accessible_post(data.id, profile.id) then + return { success = false, error = "post_not_found" } + end + local rows = Bridge.Database.Query([[ + SELECT comment.`id`, comment.`profile_id`, comment.`parent_id`, comment.`body`, + UNIX_TIMESTAMP(comment.`created_at`) AS `created_at_unix`, author.`handle`, + author.`display_name`, author.`verified`, avatar.`url` AS `avatar_url`, + (comment.`profile_id` = ?) AS `is_owner` + FROM `sky_phone_picstagram_comments` comment + JOIN `sky_phone_picstagram_profiles` author ON author.`id` = comment.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id` + WHERE comment.`post_id` = ? AND comment.`status` = 'visible' AND author.`status` = 'active' + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = comment.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = comment.`profile_id`)) + ORDER BY comment.`created_at`, comment.`id` LIMIT ? + ]], { profile.id, data.id, profile.id, profile.id, Config.Picstagram.CommentPageSize }) + for index = 1, #rows do + rows[index].verified = tonumber(rows[index].verified) == 1 + rows[index].is_owner = tonumber(rows[index].is_owner) == 1 + rows[index].created_at = (tonumber(rows[index].created_at_unix) or 0) * 1000 + rows[index].created_at_unix = nil + end + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:comment", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:comment", Config.Picstagram.CommentsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "invalid_comment" } + end + local body = trim(data.body) + if not valid_text(body, 1, Config.Picstagram.CommentMaxLength) then + return { success = false, error = "invalid_comment" } + end + local post = find_accessible_post(data.id, profile.id) + if not post then + return { success = false, error = "post_not_found" } + end + if tonumber(post.comments_enabled) ~= 1 then + return { success = false, error = "comments_disabled" } + end + local parent_id = valid_id(data.parentId) and data.parentId or nil + local parent + if parent_id then + parent = Bridge.Database.Query([[ + SELECT `id`, `profile_id` FROM `sky_phone_picstagram_comments` + WHERE `id` = ? AND `post_id` = ? AND `parent_id` IS NULL AND `status` = 'visible' LIMIT 1 + ]], { parent_id, data.id })[1] + if not parent then + return { success = false, error = "invalid_comment" } + end + end + local id = new_id() + if parent_id then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `parent_id`, `body`) + VALUES (?, ?, ?, ?, ?) + ]], { id, data.id, profile.id, parent_id, body }) + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_comments` (`id`, `post_id`, `profile_id`, `body`) + VALUES (?, ?, ?, ?) + ]], { id, data.id, profile.id, body }) + end + create_activity(post.profile_id, profile.id, "comment", data.id) + if parent and parent.profile_id ~= post.profile_id then + create_activity(parent.profile_id, profile.id, "comment", data.id) + end + return { success = true, data = { id = id } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:remove-comment", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "invalid_comment" } + end + local rows = Bridge.Database.Query([[ + SELECT comment.`profile_id`, post.`profile_id` AS `post_owner_id` + FROM `sky_phone_picstagram_comments` comment + JOIN `sky_phone_picstagram_posts` post ON post.`id` = comment.`post_id` + WHERE comment.`id` = ? AND comment.`status` = 'visible' LIMIT 1 + ]], { data.id }) + local comment = rows[1] + if not comment or (comment.profile_id ~= profile.id and comment.post_owner_id ~= profile.id) then + return { success = false, error = "not_authorized" } + end + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_comments` SET `status` = 'removed' WHERE `id` = ?", + { data.id } + ) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:publish-story", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:story", Config.Picstagram.StoriesPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" then + return { success = false, error = "invalid_story" } + end + local media_id = tonumber(data.mediaId) + local body = trim(data.body) or "" + if not media_id or media_id < 1 or media_id ~= math.floor(media_id) + or not valid_text(body, 0, Config.Picstagram.StoryTextMaxLength) + or not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") + then + return { success = false, error = "invalid_story" } + end + local id = new_id() + Bridge.Database.Query([[ + INSERT INTO `sky_phone_picstagram_stories` + (`id`, `profile_id`, `media_id`, `body`, `expires_at`) + VALUES (?, ?, ?, ?, FROM_UNIXTIME(?)) + ]], { id, profile.id, media_id, body, os.time() + Config.Picstagram.StoryLifetimeSeconds }) + return { success = true, data = { id = id } } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:stories", function(source) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id`, story.`body`, media.`url`, author.`handle`, + author.`display_name`, author.`verified`, avatar.`url` AS `avatar_url`, + UNIX_TIMESTAMP(story.`created_at`) AS `created_at_unix`, + UNIX_TIMESTAMP(story.`expires_at`) AS `expires_at_unix`, + EXISTS(SELECT 1 FROM `sky_phone_picstagram_story_views` view + WHERE view.`story_id` = story.`id` AND view.`profile_id` = ?) AS `seen`, + (story.`profile_id` = ?) AS `is_owner`, + (SELECT COUNT(*) FROM `sky_phone_picstagram_story_views` view + WHERE view.`story_id` = story.`id`) AS `view_count` + FROM `sky_phone_picstagram_stories` story + JOIN `sky_phone_picstagram_profiles` author ON author.`id` = story.`profile_id` + JOIN `sky_phone_media` media ON media.`id` = story.`media_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = author.`avatar_media_id` + WHERE story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP + AND author.`status` = 'active' + AND (story.`profile_id` = ? OR EXISTS(SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = story.`profile_id` + AND follow.`status` = 'accepted')) + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = story.`profile_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = story.`profile_id`)) + ORDER BY story.`profile_id` = ? DESC, story.`created_at` + ]], { profile.id, profile.id, profile.id, profile.id, profile.id, profile.id, profile.id }) + for index = 1, #rows do + local story = rows[index] + story.verified = tonumber(story.verified) == 1 + story.seen = tonumber(story.seen) == 1 + story.is_owner = tonumber(story.is_owner) == 1 + story.view_count = tonumber(story.view_count) or 0 + story.created_at = (tonumber(story.created_at_unix) or 0) * 1000 + story.expires_at = (tonumber(story.expires_at_unix) or 0) * 1000 + story.created_at_unix = nil + story.expires_at_unix = nil + end + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:view-story", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:story-view", 120, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "story_not_found" } + end + local story = Bridge.Database.Query([[ + SELECT story.`id`, story.`profile_id` FROM `sky_phone_picstagram_stories` story + JOIN `sky_phone_picstagram_profiles` author ON author.`id` = story.`profile_id` + WHERE story.`id` = ? AND story.`status` = 'active' AND story.`expires_at` > CURRENT_TIMESTAMP + AND author.`status` = 'active' + AND (author.`private` = 0 OR story.`profile_id` = ? OR EXISTS( + SELECT 1 FROM `sky_phone_picstagram_follows` follow + WHERE follow.`follower_id` = ? AND follow.`following_id` = story.`profile_id` + AND follow.`status` = 'accepted')) + LIMIT 1 + ]], { data.id, profile.id, profile.id })[1] + if not story or are_profiles_blocked(profile.id, story.profile_id) then + return { success = false, error = "story_not_found" } + end + if story.profile_id ~= profile.id then + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_story_views` (`story_id`, `profile_id`) + VALUES (?, ?) + ]], { story.id, profile.id }) + end + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:story-viewers", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "story_not_found" } + end + if not Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_stories` WHERE `id` = ? AND `profile_id` = ? LIMIT 1", + { data.id, profile.id } + )[1] then + return { success = false, error = "story_not_found" } + end + local rows = Bridge.Database.Query([[ + SELECT viewer.`id`, viewer.`handle`, viewer.`display_name`, viewer.`verified`, + avatar.`url` AS `avatar_url`, UNIX_TIMESTAMP(viewed.`created_at`) AS `created_at_unix` + FROM `sky_phone_picstagram_story_views` viewed + JOIN `sky_phone_picstagram_profiles` viewer ON viewer.`id` = viewed.`profile_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = viewer.`avatar_media_id` + WHERE viewed.`story_id` = ? ORDER BY viewed.`created_at` DESC + ]], { data.id }) + for index = 1, #rows do + rows[index].verified = tonumber(rows[index].verified) == 1 + rows[index].created_at = (tonumber(rows[index].created_at_unix) or 0) * 1000 + rows[index].created_at_unix = nil + end + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:remove-story", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.id) then + return { success = false, error = "story_not_found" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_stories` SET `status` = 'removed' + WHERE `id` = ? AND `profile_id` = ? AND `status` = 'active' + ]], { data.id, profile.id }) + return affected_rows(result) > 0 + and { success = true } + or { success = false, error = "story_not_found" } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:activities", function(source) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + local rows = Bridge.Database.Query([[ + SELECT activity.`id`, activity.`kind`, activity.`post_id`, activity.`read_at`, + UNIX_TIMESTAMP(activity.`created_at`) AS `created_at_unix`, actor.`id` AS `profile_id`, + actor.`handle`, actor.`display_name`, actor.`verified`, avatar.`url` AS `avatar_url`, + cover.`url` AS `post_url` + FROM `sky_phone_picstagram_activities` activity + JOIN `sky_phone_picstagram_profiles` actor ON actor.`id` = activity.`actor_id` + LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = actor.`avatar_media_id` + LEFT JOIN `sky_phone_picstagram_post_media` post_media + ON post_media.`post_id` = activity.`post_id` AND post_media.`position` = 1 + LEFT JOIN `sky_phone_media` cover ON cover.`id` = post_media.`media_id` + WHERE activity.`recipient_id` = ? + AND NOT EXISTS(SELECT 1 FROM `sky_phone_picstagram_blocks` block + WHERE (block.`blocker_id` = ? AND block.`blocked_id` = activity.`actor_id`) + OR (block.`blocked_id` = ? AND block.`blocker_id` = activity.`actor_id`)) + ORDER BY activity.`created_at` DESC LIMIT 100 + ]], { profile.id, profile.id, profile.id }) + for index = 1, #rows do + rows[index].verified = tonumber(rows[index].verified) == 1 + rows[index].created_at = (tonumber(rows[index].created_at_unix) or 0) * 1000 + rows[index].created_at_unix = nil + end + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:mark-activities", function(source) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + Bridge.Database.Query([[ + UPDATE `sky_phone_picstagram_activities` SET `read_at` = CURRENT_TIMESTAMP + WHERE `recipient_id` = ? AND `read_at` IS NULL + ]], { profile.id }) + return { success = true } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:block", function(source, data) + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" + or not valid_id(data.profileId) + or data.profileId == profile.id + or type(data.active) ~= "boolean" + then + return { success = false, error = "invalid_profile" } + end + if not Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `id` = ? AND `status` = 'active' LIMIT 1", + { data.profileId } + )[1] then + return { success = false, error = "profile_not_found" } + end + if data.active == false then + Bridge.Database.Query( + "DELETE FROM `sky_phone_picstagram_blocks` WHERE `blocker_id` = ? AND `blocked_id` = ?", + { profile.id, data.profileId } + ) + return { success = true } + end + if not Bridge.Database.Transaction({ + { + query = [[INSERT IGNORE INTO `sky_phone_picstagram_blocks` (`blocker_id`, `blocked_id`) + VALUES (?, ?)]], + params = { profile.id, data.profileId }, + }, + { + query = [[DELETE FROM `sky_phone_picstagram_follows` + WHERE (`follower_id` = ? AND `following_id` = ?) + OR (`follower_id` = ? AND `following_id` = ?)]], + params = { profile.id, data.profileId, data.profileId, profile.id }, + }, + }) then + return { success = false, error = "request_failed" } + end + return { success = true } +end) + +local function valid_report_target(target_type, target_id) + if target_type == "profile" then + return Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `id` = ? AND `status` = 'active' LIMIT 1", + { target_id } + )[1] ~= nil + end + local tables = { + post = "sky_phone_picstagram_posts", + story = "sky_phone_picstagram_stories", + comment = "sky_phone_picstagram_comments", + } + local table_name = tables[target_type] + if not table_name then + return false + end + return Bridge.Database.Query(("SELECT `id` FROM `%s` WHERE `id` = ? LIMIT 1"):format(table_name), { target_id })[1] ~= nil +end + +Bridge.Callbacks.Register("sky_phone:picstagram:report", function(source, data) + if not SkyPhone.AllowOperation(source, "picstagram:report", 10, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = profile_for_session(source) + if not profile then + return error_response + end + if type(data) ~= "table" or not valid_id(data.targetId) or not report_reasons[data.reason] then + return { success = false, error = "invalid_report" } + end + local details = trim(data.details) or "" + if not valid_text(details, 0, Config.Picstagram.ReportDetailsMaxLength) + or not valid_report_target(data.targetType, data.targetId) + then + return { success = false, error = "invalid_report" } + end + local result = Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_picstagram_reports` + (`id`, `reporter_id`, `target_type`, `target_id`, `reason`, `details`) + VALUES (?, ?, ?, ?, ?, ?) + ]], { new_id(), profile.id, data.targetType, data.targetId, data.reason, details }) + return affected_rows(result) > 0 + and { success = true } + or { success = false, error = "already_reported" } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:admin-reports", function(source) + if not is_admin(source) then + return { success = false, error = "not_authorized" } + end + local rows = Bridge.Database.Query([[ + SELECT report.`id`, report.`target_type`, report.`target_id`, report.`reason`, report.`details`, + UNIX_TIMESTAMP(report.`created_at`) AS `created_at_unix`, reporter.`handle` AS `reporter_handle`, + reporter.`display_name` AS `reporter_display_name` + FROM `sky_phone_picstagram_reports` report + JOIN `sky_phone_picstagram_profiles` reporter ON reporter.`id` = report.`reporter_id` + WHERE report.`status` = 'open' ORDER BY report.`created_at` LIMIT 100 + ]], {}) + for index = 1, #rows do + rows[index].created_at = (tonumber(rows[index].created_at_unix) or 0) * 1000 + rows[index].created_at_unix = nil + end + return { success = true, data = rows } +end) + +Bridge.Callbacks.Register("sky_phone:picstagram:admin-resolve-report", function(source, data) + if not is_admin(source) then + return { success = false, error = "not_authorized" } + end + local allowed_actions = { dismiss = true, hide = true, remove = true, restore = true } + if type(data) ~= "table" or not valid_id(data.id) or not allowed_actions[data.action] then + return { success = false, error = "invalid_request" } + end + local report = Bridge.Database.Query([[ + SELECT * FROM `sky_phone_picstagram_reports` WHERE `id` = ? AND `status` = 'open' LIMIT 1 + ]], { data.id })[1] + if not report then + return { success = false, error = "report_not_found" } + end + local statements = {} + if data.action ~= "dismiss" then + local targets = { + profile = { table = "sky_phone_picstagram_profiles", hidden = "hidden", removed = "removed", restored = "active" }, + post = { table = "sky_phone_picstagram_posts", hidden = "hidden", removed = "removed", restored = "published" }, + story = { table = "sky_phone_picstagram_stories", hidden = "removed", removed = "removed", restored = "active" }, + comment = { table = "sky_phone_picstagram_comments", hidden = "removed", removed = "removed", restored = "visible" }, + } + local target = targets[report.target_type] + if not target then + return { success = false, error = "invalid_request" } + end + local status = data.action == "hide" and target.hidden + or data.action == "remove" and target.removed + or target.restored + statements[#statements + 1] = { + query = ("UPDATE `%s` SET `status` = ? WHERE `id` = ?"):format(target.table), + params = { status, report.target_id }, + } + end + statements[#statements + 1] = { + query = [[UPDATE `sky_phone_picstagram_reports` + SET `status` = ?, `resolved_action` = ?, `resolved_at` = CURRENT_TIMESTAMP + WHERE `id` = ?]], + params = { data.action == "dismiss" and "dismissed" or "reviewed", data.action, report.id }, + } + statements[#statements + 1] = { + query = [[INSERT INTO `sky_phone_picstagram_moderation_audit` + (`id`, `report_id`, `moderator_identifier`, `action`, `target_type`, `target_id`) + VALUES (?, ?, ?, ?, ?, ?)]], + params = { + new_id(), + report.id, + Bridge.Framework.GetIdentifier(source) or ("source:%s"):format(source), + data.action, + report.target_type, + report.target_id, + }, + } + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end + return { success = true } +end) + +RegisterCommand(Config.Picstagram.VerifyCommand, function(source, args) + local command_locale = (Locales[Config.Bridge.Locale] or Locales["en"]).PicstagramCommand + local function command_message(template, values) + return template:gsub("{(%w+)}", function(key) + return values[key] or "" + end) + end + local function send_command_feedback(message, notification_type) + if source == 0 then + print(message) + return + end + TriggerClientEvent("sky_phone:picstagram:command-feedback", source, { + message = message, + notificationType = notification_type, + }) + end + if source ~= 0 and not Bridge.Framework.HasAdminGroup(source, Config.Picstagram.AdminGroups) then + send_command_feedback(command_locale.noPermission, "error") + Bridge.Debug( + "warn", + "[sky_phone] Source %s attempted to use the Picstagram verification command without an admin group.", + tostring(source) + ) + return + end + local handle = normalize_handle(args[1]) + local state = args[2] == "on" and 1 or args[2] == "off" and 0 or nil + if not handle or state == nil then + send_command_feedback( + command_message(command_locale.usage, { command = Config.Picstagram.VerifyCommand }), + "error" + ) + return + end + local target = Bridge.Database.Query( + "SELECT `id` FROM `sky_phone_picstagram_profiles` WHERE `handle` = ? LIMIT 1", + { handle } + )[1] + if not target then + send_command_feedback(command_message(command_locale.notFound, { handle = handle }), "error") + return + end + Bridge.Database.Query( + "UPDATE `sky_phone_picstagram_profiles` SET `verified` = ? WHERE `id` = ?", + { state, target.id } + ) + TriggerClientEvent("sky_phone:picstagram:verification-changed", -1, { + profileId = target.id, + verified = state == 1, + }) + send_command_feedback(command_message(command_locale.updated, { + handle = handle, + state = state == 1 and command_locale.verified or command_locale.unverified, + }), "success") +end, false) +end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 6a92bf3..7bafea1 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -423,3 +423,194 @@ CREATE TABLE IF NOT EXISTS `sky_phone_flare_messages` ( FOREIGN KEY (`match_id`) REFERENCES `sky_phone_flare_matches` (`id`) ON DELETE CASCADE, FOREIGN KEY (`sender_account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NOT NULL, + `handle` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `display_name` VARCHAR(40) NOT NULL, + `bio` VARCHAR(160) NOT NULL DEFAULT '', + `avatar_media_id` BIGINT UNSIGNED NULL, + `private` TINYINT(1) NOT NULL DEFAULT 0, + `verified` TINYINT(1) NOT NULL DEFAULT 0, + `status` ENUM('active','hidden','removed') NOT NULL DEFAULT 'active', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_account` (`account_id`), + UNIQUE KEY `uniq_sky_phone_picstagram_handle` (`handle`), + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_credentials` ( + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `password_hash` BINARY(32) NOT NULL, + `password_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`profile_id`), + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_sessions` ( + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_imei`), + KEY `idx_sky_phone_picstagram_sessions_profile` (`profile_id`,`updated_at`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_posts` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `caption` VARCHAR(800) NOT NULL DEFAULT '', + `location` VARCHAR(80) NOT NULL DEFAULT '', + `comments_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `status` ENUM('published','archived','hidden','removed') NOT NULL DEFAULT 'published', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_feed` (`status`,`created_at`,`id`), + KEY `idx_sky_phone_picstagram_profile_posts` (`profile_id`,`status`,`created_at`), + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_post_media` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `position` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_post_position` (`post_id`,`position`), + UNIQUE KEY `uniq_sky_phone_picstagram_post_media` (`post_id`,`media_id`), + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_reactions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `kind` ENUM('like','save') NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_reaction` (`post_id`,`profile_id`,`kind`), + KEY `idx_sky_phone_picstagram_saved` (`profile_id`,`kind`,`created_at`), + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_follows` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `follower_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `following_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `status` ENUM('pending','accepted') NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_follow` (`follower_id`,`following_id`), + KEY `idx_sky_phone_picstagram_following` (`following_id`,`status`,`created_at`), + FOREIGN KEY (`follower_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`following_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_comments` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `parent_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `body` VARCHAR(300) NOT NULL, + `status` ENUM('visible','removed') NOT NULL DEFAULT 'visible', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_comments` (`post_id`,`status`,`created_at`), + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`parent_id`) REFERENCES `sky_phone_picstagram_comments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_stories` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `body` VARCHAR(160) NOT NULL DEFAULT '', + `status` ENUM('active','removed') NOT NULL DEFAULT 'active', + `expires_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_stories` (`profile_id`,`status`,`expires_at`), + KEY `idx_sky_phone_picstagram_story_expiry` (`status`,`expires_at`), + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_story_views` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `story_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_story_view` (`story_id`,`profile_id`), + FOREIGN KEY (`story_id`) REFERENCES `sky_phone_picstagram_stories` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`profile_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_activities` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `recipient_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `actor_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `post_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `kind` ENUM('follow_request','follow','request_accepted','like','comment','verified') NOT NULL, + `read_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_activity` (`recipient_id`,`read_at`,`created_at`), + FOREIGN KEY (`recipient_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`actor_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`post_id`) REFERENCES `sky_phone_picstagram_posts` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_reports` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `reporter_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `target_type` ENUM('profile','post','story','comment') NOT NULL, + `target_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `reason` ENUM('spam','harassment','dangerous','illegal','other') NOT NULL, + `details` VARCHAR(500) NOT NULL DEFAULT '', + `status` ENUM('open','reviewed','dismissed') NOT NULL DEFAULT 'open', + `resolved_action` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NULL, + `resolved_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_report` (`reporter_id`,`target_type`,`target_id`), + KEY `idx_sky_phone_picstagram_reports` (`status`,`created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_blocks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `blocker_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `blocked_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_picstagram_block` (`blocker_id`,`blocked_id`), + FOREIGN KEY (`blocker_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`blocked_id`) REFERENCES `sky_phone_picstagram_profiles` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_moderation_audit` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `report_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `moderator_identifier` VARCHAR(80) NOT NULL, + `action` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `target_type` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `target_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_picstagram_audit_target` (`target_type`,`target_id`,`created_at`), + FOREIGN KEY (`report_id`) REFERENCES `sky_phone_picstagram_reports` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;