├── pillcity ├── __init__.py ├── daos │ ├── __init__.py │ ├── cache.py │ ├── exceptions.py │ ├── post_cache.py │ ├── circle_cache.py │ ├── mention.py │ ├── user_cache.py │ ├── s3.py │ ├── invitation_code.py │ ├── poll.py │ ├── link_preview.py │ ├── media.py │ └── plaintext_notification.py ├── tasks │ ├── __init__.py │ ├── celery.py │ └── generate_link_preview.py ├── tests │ ├── __init__.py │ └── daos │ │ ├── base_test_case.py │ │ ├── __init__.py │ │ └── user_test.py ├── utils │ ├── __init__.py │ ├── now.py │ ├── make_uuid.py │ ├── s3.py │ └── profiling.py ├── resources │ ├── __init__.py │ ├── cache.py │ ├── pagination.py │ ├── plugins.py │ ├── content.py │ ├── mention.py │ ├── blocks.py │ ├── followings.py │ ├── link_preview.py │ ├── poll.py │ ├── entity_state.py │ ├── invitations_codes.py │ ├── reactions.py │ └── password_reset.py ├── plugins │ ├── cloudemoticon │ │ ├── __init__.py │ │ └── main.py │ └── __init__.py ├── plugin_core │ ├── __init__.py │ ├── context.py │ └── api.py └── models │ ├── created_at_mixin.py │ ├── invitation_code.py │ ├── password_reset_claim.py │ ├── __init__.py │ ├── circle.py │ ├── media_set.py │ ├── link_preview.py │ ├── user.py │ ├── notification.py │ └── media.py ├── web ├── src │ ├── pages │ │ ├── Users │ │ │ └── Users.css │ │ ├── Notifications │ │ │ ├── Notifications.css │ │ │ └── Notifications.tsx │ │ ├── Post │ │ │ └── Post.css │ │ ├── ResetPassword │ │ │ └── ResetPassword.css │ │ ├── ForgetPassword │ │ │ ├── ForgetPassword.css │ │ │ └── ForgetPassword.tsx │ │ ├── SignIn │ │ │ └── SignIn.css │ │ ├── SignUp │ │ │ └── SignUp.css │ │ ├── Admin │ │ │ ├── Admin.css │ │ │ └── Admin.js │ │ ├── Circles │ │ │ └── Circles.css │ │ └── Settings │ │ │ └── Settings.css │ ├── components │ │ ├── Post │ │ │ ├── LinkPreviews.css │ │ │ ├── PostAttachments.css │ │ │ ├── ResharedPost.css │ │ │ ├── LinkPreviews.tsx │ │ │ ├── PostAttachments.tsx │ │ │ ├── NestedComment.css │ │ │ ├── Comment.css │ │ │ ├── ResharedPost.tsx │ │ │ ├── Reactions.css │ │ │ ├── Post.css │ │ │ └── NewComment.css │ │ ├── EditCircle │ │ │ ├── RenameCircle.css │ │ │ ├── AddUserToCircle.css │ │ │ ├── RenameCircle.tsx │ │ │ └── AddUserToCircle.tsx │ │ ├── LinkPreview │ │ │ ├── LinkPreview.css │ │ │ ├── InstantPreview.css │ │ │ ├── FetchedPreview.css │ │ │ ├── LinkPreview.tsx │ │ │ ├── FetchedPreview.tsx │ │ │ └── InstantPreview.tsx │ │ ├── PillButtons │ │ │ ├── PillButtons.css │ │ │ ├── PillButton.css │ │ │ ├── PillButtons.tsx │ │ │ └── PillButton.tsx │ │ ├── ClickableId │ │ │ ├── ClickableId.css │ │ │ └── ClickableId.tsx │ │ ├── ContentTextarea │ │ │ ├── MentionAutoCompleteUserItem.css │ │ │ ├── MentionAutoCompleteUserItem.tsx │ │ │ └── ContentTextarea.tsx │ │ ├── RoundAvatar │ │ │ ├── RoundAvatar.css │ │ │ └── RoundAvatar.tsx │ │ ├── Poll │ │ │ └── Poll.css │ │ ├── About │ │ │ ├── About.css │ │ │ └── About.js │ │ ├── PillInput │ │ │ ├── PillInput.css │ │ │ └── PillInput.tsx │ │ ├── PillForm │ │ │ ├── PillForm.css │ │ │ └── PillForm.tsx │ │ ├── Toast │ │ │ ├── ToastContainer.css │ │ │ ├── Toast.css │ │ │ ├── ToastContainer.tsx │ │ │ ├── Toast.tsx │ │ │ └── ToastProvider.tsx │ │ ├── UploadMedia │ │ │ ├── UploadMedia.css │ │ │ └── UploadMedia.tsx │ │ ├── UpdateAvatar │ │ │ └── UpdateAvatar.css │ │ ├── PillIcons │ │ │ └── CirclesIcon.tsx │ │ ├── PillTabs │ │ │ ├── PillTabs.css │ │ │ └── PillTabs.tsx │ │ ├── AddPoll │ │ │ ├── AddPoll.css │ │ │ └── AddPoll.tsx │ │ ├── PillCheckbox │ │ │ ├── PillCheckbox.css │ │ │ └── PillCheckbox.tsx │ │ ├── HomePage │ │ │ └── HomePage.js │ │ ├── MediaV2 │ │ │ ├── AvatarV2.tsx │ │ │ └── MediaV2.tsx │ │ ├── PillModal │ │ │ ├── PillModal.css │ │ │ └── PillModal.tsx │ │ ├── PillDropdownMenu │ │ │ ├── PillDropdownMenu.css │ │ │ └── PillDropdownMenu.tsx │ │ ├── MediaV2Collage │ │ │ └── MediaV2Collage.css │ │ ├── CreateNewCircle │ │ │ ├── CreateNewCircle.css │ │ │ └── CreateNewCircle.tsx │ │ ├── UpdateBanner │ │ │ └── UpdateBanner.css │ │ ├── DesktopUsers │ │ │ ├── AddNewCircleButton.css │ │ │ ├── AddNewCircleButton.tsx │ │ │ ├── DraggableUserCard.css │ │ │ ├── DesktopUsers.css │ │ │ ├── DroppableCircleBoard.css │ │ │ └── DesktopUsers.tsx │ │ ├── EditingMediaCollage │ │ │ └── EditingMediaCollage.css │ │ ├── NotificationDropdown │ │ │ ├── NotificationDropdown.tsx │ │ │ ├── NotificationList.tsx │ │ │ ├── NotificationItem.css │ │ │ └── NotificationDropdown.css │ │ ├── FormattedContent │ │ │ └── FormattedContent.tsx │ │ ├── NavBar │ │ │ └── NavBar.css │ │ └── MobileUsers │ │ │ └── MobileUsers.css │ ├── react-app-env.d.ts │ ├── img │ │ └── favicon.png │ ├── models │ │ ├── Media.ts │ │ ├── EntityState.ts │ │ ├── User.ts │ │ ├── LinkPreview.ts │ │ ├── Circle.ts │ │ ├── MediaUrlV2.ts │ │ ├── FormattedContent.ts │ │ ├── Notification.ts │ │ └── Post.ts │ ├── store │ │ ├── persistorUtils.ts │ │ ├── hooks.ts │ │ ├── meSlice.ts │ │ └── store.ts │ ├── utils │ │ ├── summary.ts │ │ ├── useQuery.tsx │ │ ├── getMediaV2Url.ts │ │ ├── SettingsStorage.ts │ │ ├── getUserBannerUrl.ts │ │ ├── getNameAndSubName.ts │ │ ├── convertHeicFileToPng.ts │ │ ├── validators.js │ │ └── timeDelta.ts │ ├── api │ │ ├── ApiError.ts │ │ └── AuthStorage.ts │ ├── index.tsx │ └── App.css ├── .example.env ├── public │ ├── favicon.ico │ ├── robots.txt │ ├── apple-icon-180.png │ ├── assets │ │ ├── avatar.webp │ │ ├── image.webp │ │ ├── pill1.webp │ │ ├── pill2.webp │ │ ├── pill3.webp │ │ ├── pill4.webp │ │ ├── pill5.webp │ │ └── pill6.webp │ ├── apple-splash-1125-2436.jpg │ ├── apple-splash-1136-640.jpg │ ├── apple-splash-1170-2532.jpg │ ├── apple-splash-1179-2556.jpg │ ├── apple-splash-1242-2208.jpg │ ├── apple-splash-1242-2688.jpg │ ├── apple-splash-1284-2778.jpg │ ├── apple-splash-1290-2796.jpg │ ├── apple-splash-1334-750.jpg │ ├── apple-splash-1536-2048.jpg │ ├── apple-splash-1620-2160.jpg │ ├── apple-splash-1668-2224.jpg │ ├── apple-splash-1668-2388.jpg │ ├── apple-splash-1792-828.jpg │ ├── apple-splash-2048-1536.jpg │ ├── apple-splash-2048-2732.jpg │ ├── apple-splash-2160-1620.jpg │ ├── apple-splash-2208-1242.jpg │ ├── apple-splash-2224-1668.jpg │ ├── apple-splash-2388-1668.jpg │ ├── apple-splash-2436-1125.jpg │ ├── apple-splash-2532-1170.jpg │ ├── apple-splash-2556-1179.jpg │ ├── apple-splash-2688-1242.jpg │ ├── apple-splash-2732-2048.jpg │ ├── apple-splash-2778-1284.jpg │ ├── apple-splash-2796-1290.jpg │ ├── apple-splash-640-1136.jpg │ ├── apple-splash-750-1334.jpg │ ├── apple-splash-828-1792.jpg │ ├── manifest-icon-192.maskable.png │ ├── manifest-icon-512.maskable.png │ └── manifest.json ├── netlify.toml ├── .gitignore ├── README.md ├── .editorconfig └── tsconfig.json ├── .dockerignore ├── requirements.prod.txt ├── entrypoint-release.sh ├── entrypoint-worker.sh ├── Procfile ├── entrypoint-beat.sh ├── scripts ├── dev-release.sh ├── web.sh ├── dump-mock-data.sh ├── worker.sh ├── beat.sh └── dev-aws-setup.sh ├── mock-data ├── mock_data_avatars │ ├── kt.jpeg │ ├── kyo.png │ ├── buki.png │ ├── duff.jpg │ ├── horo.png │ ├── kele.jpg │ ├── mawei.jpg │ ├── soybean.png │ ├── xiaolaba.png │ ├── luxiyuan.jpeg │ └── roddyzhang.png └── mock_data_media │ ├── sif1.png │ ├── sif2.png │ ├── sif3.png │ ├── sif4.png │ ├── heisi1.jpeg │ ├── heisi2.jpeg │ ├── heisi3.jpeg │ ├── heisi4.jpeg │ ├── kotori1.jpg │ ├── kotori2.jpg │ ├── kotori3.jpg │ ├── kotori4.jpg │ ├── szzex1.jpg │ ├── szzex2.jpg │ ├── gaygineer.jpg │ └── huoguomei.png ├── Makefile ├── release.py ├── .devcontainer ├── install-deps.sh ├── devcontainer.json ├── docker-compose.yml └── Dockerfile ├── .example.env ├── requirements.txt ├── uwsgi.ini ├── LICENSE ├── terraform ├── .gitignore └── .terraform.lock.hcl ├── compose.production.yaml ├── Dockerfile └── README.md /pillcity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pillcity/daos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pillcity/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pillcity/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pillcity/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pillcity/resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/pages/Users/Users.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | data 2 | *venv 3 | *.env 4 | -------------------------------------------------------------------------------- /web/src/components/Post/LinkPreviews.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/components/EditCircle/RenameCircle.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/components/LinkPreview/LinkPreview.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.prod.txt: -------------------------------------------------------------------------------- 1 | uwsgi 2 | dnspython==2.1.0 3 | -------------------------------------------------------------------------------- /entrypoint-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python release.py -------------------------------------------------------------------------------- /web/.example.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_ENDPOINT=http://localhost:5000/api 2 | -------------------------------------------------------------------------------- /web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pillcity/plugins/cloudemoticon/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import CloudEmoticon 2 | -------------------------------------------------------------------------------- /entrypoint-worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | celery -A pillcity.tasks worker --loglevel=INFO -------------------------------------------------------------------------------- /web/src/components/PillButtons/PillButtons.css: -------------------------------------------------------------------------------- 1 | .pill-buttons { 2 | display: flex; 3 | } 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./scripts/web.sh 2 | worker: ./scripts/worker.sh 3 | beat: ./scripts/beat.sh 4 | -------------------------------------------------------------------------------- /entrypoint-beat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | celery -A pillcity.tasks beat --loglevel=DEBUG --max-interval 30 -------------------------------------------------------------------------------- /scripts/dev-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o allexport 3 | source .env 4 | python release.py -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/src/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/src/img/favicon.png -------------------------------------------------------------------------------- /pillcity/plugin_core/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import PillCityPlugin 2 | from .context import PillCityPluginContext 3 | -------------------------------------------------------------------------------- /scripts/web.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o allexport 3 | source .env 4 | FLASK_ENVIRONMENT=development python app.py -------------------------------------------------------------------------------- /web/public/apple-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-icon-180.png -------------------------------------------------------------------------------- /web/public/assets/avatar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/avatar.webp -------------------------------------------------------------------------------- /web/public/assets/image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/image.webp -------------------------------------------------------------------------------- /web/public/assets/pill1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/pill1.webp -------------------------------------------------------------------------------- /web/public/assets/pill2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/pill2.webp -------------------------------------------------------------------------------- /web/public/assets/pill3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/pill3.webp -------------------------------------------------------------------------------- /web/public/assets/pill4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/pill4.webp -------------------------------------------------------------------------------- /web/public/assets/pill5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/pill5.webp -------------------------------------------------------------------------------- /web/public/assets/pill6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/assets/pill6.webp -------------------------------------------------------------------------------- /web/src/models/Media.ts: -------------------------------------------------------------------------------- 1 | export default interface Media { 2 | object_name: string 3 | media_url: string 4 | } 5 | -------------------------------------------------------------------------------- /scripts/dump-mock-data.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o allexport 3 | source .env 4 | python ./mock-data/dump_mock_data.py -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/kt.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/kt.jpeg -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/kyo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/kyo.png -------------------------------------------------------------------------------- /mock-data/mock_data_media/sif1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/sif1.png -------------------------------------------------------------------------------- /mock-data/mock_data_media/sif2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/sif2.png -------------------------------------------------------------------------------- /mock-data/mock_data_media/sif3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/sif3.png -------------------------------------------------------------------------------- /mock-data/mock_data_media/sif4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/sif4.png -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/buki.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/buki.png -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/duff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/duff.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/horo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/horo.png -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/kele.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/kele.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/mawei.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/mawei.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/heisi1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/heisi1.jpeg -------------------------------------------------------------------------------- /mock-data/mock_data_media/heisi2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/heisi2.jpeg -------------------------------------------------------------------------------- /mock-data/mock_data_media/heisi3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/heisi3.jpeg -------------------------------------------------------------------------------- /mock-data/mock_data_media/heisi4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/heisi4.jpeg -------------------------------------------------------------------------------- /mock-data/mock_data_media/kotori1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/kotori1.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/kotori2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/kotori2.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/kotori3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/kotori3.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/kotori4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/kotori4.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/szzex1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/szzex1.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/szzex2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/szzex2.jpg -------------------------------------------------------------------------------- /pillcity/daos/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import redis 3 | 4 | r = redis.from_url(os.environ['REDIS_URL']) 5 | RMediaUrl = "mediaUrl" 6 | -------------------------------------------------------------------------------- /scripts/worker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o allexport 3 | source .env 4 | celery -A pillcity.tasks worker --loglevel=INFO 5 | -------------------------------------------------------------------------------- /web/public/apple-splash-1125-2436.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1125-2436.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1136-640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1136-640.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1170-2532.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1170-2532.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1179-2556.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1179-2556.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1242-2208.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1242-2208.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1242-2688.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1242-2688.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1284-2778.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1284-2778.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1290-2796.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1290-2796.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1334-750.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1334-750.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1536-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1536-2048.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1620-2160.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1620-2160.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1668-2224.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1668-2224.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1668-2388.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1668-2388.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-1792-828.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-1792-828.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2048-1536.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2048-1536.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2048-2732.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2048-2732.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2160-1620.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2160-1620.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2208-1242.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2208-1242.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2224-1668.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2224-1668.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2388-1668.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2388-1668.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2436-1125.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2436-1125.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2532-1170.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2532-1170.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2556-1179.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2556-1179.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2688-1242.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2688-1242.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2732-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2732-2048.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2778-1284.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2778-1284.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-2796-1290.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-2796-1290.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-640-1136.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-640-1136.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-750-1334.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-750-1334.jpg -------------------------------------------------------------------------------- /web/public/apple-splash-828-1792.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/apple-splash-828-1792.jpg -------------------------------------------------------------------------------- /web/src/components/ClickableId/ClickableId.css: -------------------------------------------------------------------------------- 1 | .clickable-id-subtext { 2 | color: #a6a6a6; 3 | font-size: small; 4 | } 5 | -------------------------------------------------------------------------------- /web/src/components/ContentTextarea/MentionAutoCompleteUserItem.css: -------------------------------------------------------------------------------- 1 | .mention-auto-complete-user-item { 2 | padding: 5px; 3 | } 4 | -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/soybean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/soybean.png -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/xiaolaba.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/xiaolaba.png -------------------------------------------------------------------------------- /mock-data/mock_data_media/gaygineer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/gaygineer.jpg -------------------------------------------------------------------------------- /mock-data/mock_data_media/huoguomei.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_media/huoguomei.png -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/luxiyuan.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/luxiyuan.jpeg -------------------------------------------------------------------------------- /mock-data/mock_data_avatars/roddyzhang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/mock-data/mock_data_avatars/roddyzhang.png -------------------------------------------------------------------------------- /web/public/manifest-icon-192.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/manifest-icon-192.maskable.png -------------------------------------------------------------------------------- /web/public/manifest-icon-512.maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/pill-city/master/web/public/manifest-icon-512.maskable.png -------------------------------------------------------------------------------- /web/src/models/EntityState.ts: -------------------------------------------------------------------------------- 1 | type EntityState = 'visible' | 'invisible' | 'author_blocked' | 'deleted' 2 | 3 | export default EntityState 4 | -------------------------------------------------------------------------------- /pillcity/resources/cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import redis 3 | 4 | r = redis.Redis.from_url(os.environ['REDIS_URL']) 5 | RMediaUrl = "mediaUrl" 6 | -------------------------------------------------------------------------------- /scripts/beat.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o allexport 3 | source .env 4 | celery -A pillcity.tasks beat --loglevel=DEBUG --max-interval 30 5 | -------------------------------------------------------------------------------- /web/src/components/RoundAvatar/RoundAvatar.css: -------------------------------------------------------------------------------- 1 | .round-avatar-img { 2 | width: 100%; 3 | height: 100%; 4 | border-radius: 50%; 5 | } 6 | -------------------------------------------------------------------------------- /pillcity/models/created_at_mixin.py: -------------------------------------------------------------------------------- 1 | class CreatedAtMixin(object): 2 | @property 3 | def created_at(self): 4 | return self.id.generation_time.timestamp() 5 | -------------------------------------------------------------------------------- /pillcity/utils/now.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def now_ms(): 5 | return time.time_ns() // 1_000_000 6 | 7 | 8 | def now_seconds(): 9 | return time.time_ns() // 1_000_000_000 10 | -------------------------------------------------------------------------------- /web/src/components/Poll/Poll.css: -------------------------------------------------------------------------------- 1 | .post-poll-choice { 2 | padding-top: 0.5em; 3 | padding-left: 0.5em; 4 | height: 2.5em; 5 | font-size: small; 6 | border-radius: 5px; 7 | } 8 | -------------------------------------------------------------------------------- /pillcity/utils/make_uuid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | def make_uuid(): 5 | return str(uuid.uuid4()) 6 | 7 | 8 | def make_dashless_uuid(): 9 | return make_uuid().replace("-", "") 10 | -------------------------------------------------------------------------------- /web/src/components/About/About.css: -------------------------------------------------------------------------------- 1 | .about { 2 | color: lightgray; 3 | padding-top: 5px; 4 | font-size: smaller; 5 | } 6 | 7 | .about-commit-link { 8 | color: #9dd0ff; 9 | } 10 | -------------------------------------------------------------------------------- /web/src/store/persistorUtils.ts: -------------------------------------------------------------------------------- 1 | import {persistKey} from "./store"; 2 | 3 | export const purgeCache = () => { 4 | // todo: hacky 5 | window.localStorage.removeItem(`persist:${persistKey}`) 6 | } 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev-aws-setup: 2 | ./scripts/dev-aws-setup.sh 3 | 4 | dev-release: 5 | ./scripts/dev-release.sh 6 | 7 | dev-dump: 8 | ./scripts/dump-mock-data.sh 9 | 10 | test: 11 | nosetests 12 | -------------------------------------------------------------------------------- /web/src/components/PillInput/PillInput.css: -------------------------------------------------------------------------------- 1 | .pill-input { 2 | width: 240px; 3 | height: 3em; 4 | padding: 1em; 5 | border-width: 1px; 6 | border-radius: 4px; 7 | border-color: #86989B; 8 | } 9 | -------------------------------------------------------------------------------- /pillcity/models/invitation_code.py: -------------------------------------------------------------------------------- 1 | from mongoengine import Document, StringField, BooleanField 2 | 3 | 4 | class InvitationCode(Document): 5 | code = StringField(required=True, unique=True) 6 | claimed = BooleanField(required=True) 7 | -------------------------------------------------------------------------------- /web/netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/api" 3 | to = "https://api.pill.city/docs" 4 | status = 301 5 | 6 | # This a rule for Single Page Applications 7 | [[redirects]] 8 | from = "/*" 9 | to = "/index.html" 10 | status = 200 11 | -------------------------------------------------------------------------------- /web/src/components/PillForm/PillForm.css: -------------------------------------------------------------------------------- 1 | .pill-form { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | flex-direction: column; 6 | } 7 | 8 | .pill-form > * { 9 | margin-bottom: 15px; 10 | } 11 | -------------------------------------------------------------------------------- /web/src/models/User.ts: -------------------------------------------------------------------------------- 1 | import MediaUrlV2 from "./MediaUrlV2"; 2 | 3 | export default interface User { 4 | id: string 5 | created_at_seconds: number 6 | avatar_url_v2?: MediaUrlV2 7 | profile_pic: string, 8 | display_name?: string 9 | } 10 | -------------------------------------------------------------------------------- /web/src/components/PillButtons/PillButton.css: -------------------------------------------------------------------------------- 1 | .pill-button { 2 | padding: 8px 10px; 3 | border-radius: 4px; 4 | font-weight: bold; 5 | font-size: medium; 6 | color: white; 7 | cursor: pointer; 8 | margin-right: 10px; 9 | } 10 | -------------------------------------------------------------------------------- /web/src/utils/summary.ts: -------------------------------------------------------------------------------- 1 | const summary = (summary: string , summaryLength: number): string => { 2 | if (summary.length > summaryLength) { 3 | return `${summary.slice(0,summaryLength)}...` 4 | } 5 | return summary 6 | } 7 | 8 | export default summary 9 | -------------------------------------------------------------------------------- /pillcity/resources/pagination.py: -------------------------------------------------------------------------------- 1 | from flask_restful import reqparse 2 | 3 | pagination_parser = reqparse.RequestParser() 4 | pagination_parser.add_argument('from_id', type=str, required=False, location='args') 5 | pagination_parser.add_argument('to_id', type=str, required=False, location='args') 6 | -------------------------------------------------------------------------------- /pillcity/resources/plugins.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource 2 | from flask_jwt_extended import jwt_required 3 | from pillcity.plugins import get_plugins 4 | 5 | 6 | class Plugins(Resource): 7 | @jwt_required() 8 | def get(self): 9 | return list(get_plugins().keys()) 10 | -------------------------------------------------------------------------------- /pillcity/models/password_reset_claim.py: -------------------------------------------------------------------------------- 1 | from mongoengine import Document, StringField, EmailField, LongField 2 | 3 | 4 | class PasswordResetClaim(Document): 5 | code = StringField(required=True) 6 | email = EmailField(required=True, unique=True) 7 | expire_at = LongField(required=True) 8 | -------------------------------------------------------------------------------- /web/src/components/Toast/ToastContainer.css: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | position: fixed; 3 | right: 0; 4 | top: 50px; 5 | } 6 | 7 | /* Mobile */ 8 | @media only screen and (max-width: 750px) { 9 | .toast-container { 10 | top: 5px; 11 | width: 100%; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/src/utils/useQuery.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {useLocation} from "react-router-dom"; 3 | 4 | const useQuery = () => { 5 | const { search } = useLocation(); 6 | 7 | return React.useMemo(() => new URLSearchParams(search), [search]); 8 | } 9 | 10 | export default useQuery; 11 | -------------------------------------------------------------------------------- /web/src/models/LinkPreview.ts: -------------------------------------------------------------------------------- 1 | type LinkPreviewState = 'fetching' | 'fetched' | 'errored' 2 | 3 | export default interface LinkPreview { 4 | url: string 5 | title: string 6 | subtitle: string 7 | image_urls: string[] 8 | state: LinkPreviewState 9 | errored_next_refetch_seconds: number, 10 | } 11 | -------------------------------------------------------------------------------- /web/src/utils/getMediaV2Url.ts: -------------------------------------------------------------------------------- 1 | import MediaUrlV2 from "../models/MediaUrlV2"; 2 | 3 | const getMediaV2Url = (mediaUrlV2: MediaUrlV2): string => { 4 | if (!mediaUrlV2.processed) { 5 | return mediaUrlV2.original_url 6 | } 7 | return mediaUrlV2.processed_url 8 | } 9 | 10 | export default getMediaV2Url 11 | -------------------------------------------------------------------------------- /pillcity/daos/exceptions.py: -------------------------------------------------------------------------------- 1 | from werkzeug.exceptions import HTTPException 2 | 3 | 4 | class UnauthorizedAccess(HTTPException): 5 | pass 6 | 7 | 8 | class BadRequest(HTTPException): 9 | pass 10 | 11 | 12 | class NotFound(HTTPException): 13 | pass 14 | 15 | 16 | class Conflict(HTTPException): 17 | pass 18 | -------------------------------------------------------------------------------- /release.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from pymongo.uri_parser import parse_uri 4 | from mongoengine import connect 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | uri = os.environ['MONGODB_URI'] 9 | connect( 10 | host=uri, 11 | db=parse_uri(uri)['database'] 12 | ) 13 | 14 | logging.info("Running release") 15 | -------------------------------------------------------------------------------- /web/src/pages/Notifications/Notifications.css: -------------------------------------------------------------------------------- 1 | .mobile-all-read-button { 2 | position: fixed; 3 | bottom: calc(60px + env(safe-area-inset-bottom)); 4 | right: 10px; 5 | width: 60px; 6 | height: 60px; 7 | background-color: #E05140; 8 | border-radius: 50%; 9 | padding: 15px; 10 | color: white; 11 | } 12 | -------------------------------------------------------------------------------- /web/src/components/UploadMedia/UploadMedia.css: -------------------------------------------------------------------------------- 1 | .upload-media-drop-zone { 2 | display: block; 3 | background-color: #f0f0f0; 4 | width: 100%; 5 | padding: 40px; 6 | text-align: center; 7 | color: #838383; 8 | font-size: x-large; 9 | line-height: 1.5em; 10 | border-radius: 4px; 11 | cursor: pointer; 12 | } 13 | -------------------------------------------------------------------------------- /web/src/components/PillForm/PillForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import './PillForm.css' 3 | 4 | interface Props { 5 | children: JSX.Element[] 6 | } 7 | 8 | const PillForm = (props: Props) => { 9 | return ( 10 |
11 | {props.children} 12 |
13 | ) 14 | } 15 | 16 | export default PillForm 17 | -------------------------------------------------------------------------------- /web/src/components/UpdateAvatar/UpdateAvatar.css: -------------------------------------------------------------------------------- 1 | .settings-avatar-drop-zone { 2 | display: block; 3 | background-color: #f0f0f0; 4 | width: 100%; 5 | padding: 40px; 6 | text-align: center; 7 | color: #838383; 8 | font-size: x-large; 9 | line-height: 1.5em; 10 | border-radius: 4px; 11 | cursor: pointer; 12 | } 13 | -------------------------------------------------------------------------------- /web/src/components/PillButtons/PillButtons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './PillButtons.css' 3 | 4 | interface Props { 5 | children: any 6 | } 7 | 8 | const PillButtons = (props: Props) => { 9 | return ( 10 |
11 | {props.children} 12 |
13 | ) 14 | } 15 | 16 | export default PillButtons 17 | -------------------------------------------------------------------------------- /web/src/utils/SettingsStorage.ts: -------------------------------------------------------------------------------- 1 | const UseMultiColumnKey = "use_multi_column" 2 | 3 | export const getUseMultiColumn = (): boolean => { 4 | return window.localStorage.getItem(UseMultiColumnKey) === "true" 5 | } 6 | 7 | export const setUseMultiColumn = (use: boolean) => { 8 | window.localStorage.setItem(UseMultiColumnKey, use ? 'true' : 'false') 9 | } 10 | -------------------------------------------------------------------------------- /web/src/models/Circle.ts: -------------------------------------------------------------------------------- 1 | import User from "./User"; 2 | 3 | interface MyCircle { 4 | id: string 5 | name: string 6 | } 7 | 8 | interface OthersCircle { 9 | id: string 10 | } 11 | 12 | export type AnonymizedCircle = MyCircle | OthersCircle 13 | 14 | export default interface Circle { 15 | id: string 16 | name: string 17 | members: User[] 18 | } 19 | -------------------------------------------------------------------------------- /web/src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' 2 | import type { RootState, AppDispatch } from './store' 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = () => useDispatch() 6 | export const useAppSelector: TypedUseSelectorHook = useSelector 7 | -------------------------------------------------------------------------------- /web/src/utils/getUserBannerUrl.ts: -------------------------------------------------------------------------------- 1 | import User from "../models/User"; 2 | 3 | const getUserBanner = (user: User | null) => { 4 | if (!user || !user.profile_pic) { 5 | return `${process.env.PUBLIC_URL}/assets/pill1.webp` 6 | } 7 | 8 | return `${process.env.PUBLIC_URL}/assets/${user.profile_pic.replace('.png', '.webp')}` 9 | } 10 | 11 | export default getUserBanner 12 | -------------------------------------------------------------------------------- /pillcity/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .circle import Circle 3 | from .notification import Notification, NotifyingAction 4 | from .post import Post, Reaction, Comment, Poll, PollChoice 5 | from .media import Media 6 | from .invitation_code import InvitationCode 7 | from .link_preview import LinkPreview, LinkPreviewState 8 | from .password_reset_claim import PasswordResetClaim 9 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | .env 24 | .idea 25 | -------------------------------------------------------------------------------- /web/src/models/MediaUrlV2.ts: -------------------------------------------------------------------------------- 1 | type MediaUrlV2 = UnprocessedMedia | ProcessedMedia 2 | 3 | interface UnprocessedMedia { 4 | original_url: string 5 | processed: false 6 | } 7 | 8 | export interface ProcessedMedia { 9 | original_url: string 10 | processed: true 11 | processed_url: string 12 | width: number 13 | height: number 14 | dominant_color_hex: string 15 | } 16 | 17 | export default MediaUrlV2 18 | -------------------------------------------------------------------------------- /.devcontainer/install-deps.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | mkdir -p ~/.aws 5 | if [ ! -f ~/.aws/credentials ]; then 6 | cat <> ~/.aws/credentials 7 | [PillCityDevTerraform] 8 | aws_access_key_id= 9 | aws_secret_access_key= 10 | region=us-west-2 11 | EOT 12 | fi 13 | 14 | pip install -U pip==24.0 15 | pip install -r requirements.txt 16 | pushd web 17 | yarn install 18 | popd 19 | -------------------------------------------------------------------------------- /web/src/components/PillIcons/CirclesIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | const CirclesIcon = () => { 4 | return ( 5 | 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default CirclesIcon 13 | -------------------------------------------------------------------------------- /web/src/models/FormattedContent.ts: -------------------------------------------------------------------------------- 1 | type FormattedContentSegmentType = "strikethrough" | "bold" | "italic" | "url" | "mention" 2 | 3 | export interface FormattedContentSegment { 4 | content: string; 5 | types: FormattedContentSegmentType[]; 6 | reference?: number 7 | } 8 | 9 | interface FormattedContent { 10 | segments: FormattedContentSegment[]; 11 | references: string[]; 12 | } 13 | 14 | export default FormattedContent; 15 | -------------------------------------------------------------------------------- /web/src/components/PillTabs/PillTabs.css: -------------------------------------------------------------------------------- 1 | .pill-tabs { 2 | display: flex; 3 | flex-direction: row; 4 | margin-bottom: 12px; 5 | } 6 | 7 | .pill-tab { 8 | margin-right: 12px; 9 | padding: 6px; 10 | color: #838383; 11 | font-weight: bold; 12 | } 13 | 14 | .pill-tab:hover { 15 | cursor: pointer; 16 | } 17 | 18 | .pill-tab-selected { 19 | color: #0d71bb; 20 | border-bottom: 2px solid #0d71bb; 21 | } 22 | -------------------------------------------------------------------------------- /pillcity/utils/s3.py: -------------------------------------------------------------------------------- 1 | import os 2 | import boto3 3 | from typing import Tuple 4 | 5 | 6 | def get_s3_client() -> Tuple[any, str]: 7 | s3_client = boto3.client( 8 | 's3', 9 | region_name=os.environ['AWS_REGION'], 10 | aws_access_key_id=os.environ['AWS_ACCESS_KEY'], 11 | aws_secret_access_key=os.environ['AWS_SECRET_KEY'] 12 | ) 13 | s3_bucket_name = os.environ['S3_BUCKET_NAME'] 14 | return s3_client, s3_bucket_name 15 | -------------------------------------------------------------------------------- /web/src/components/AddPoll/AddPoll.css: -------------------------------------------------------------------------------- 1 | .add-poll-choices { 2 | margin-bottom: 15px; 3 | } 4 | 5 | .add-poll-choice { 6 | display: flex; 7 | } 8 | 9 | .add-poll-choice-text { 10 | width: 240px; 11 | height: 2em; 12 | padding: 0.5em; 13 | } 14 | 15 | .add-poll-delete-choice-icon { 16 | width: 15px; 17 | margin-left: 5px; 18 | cursor: pointer; 19 | color: #727272; 20 | } 21 | 22 | .add-poll-new-choice { 23 | margin-bottom: 15px; 24 | } 25 | -------------------------------------------------------------------------------- /web/src/pages/Post/Post.css: -------------------------------------------------------------------------------- 1 | .post-wrapper-page { 2 | display: flex; 3 | padding: 5em 50px 0 50px; 4 | } 5 | 6 | .post-status { 7 | padding: 13px; 8 | background-color: #ffffff; 9 | border-radius: 5px; 10 | box-shadow: 2px 2px 1px 1px #e0e0e0; 11 | margin-bottom: 20px; 12 | color: gray; 13 | width: 100%; 14 | } 15 | 16 | @media only screen and (max-width: 750px) { 17 | .post-wrapper-page { 18 | padding: 0 0 50px 0; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /web/src/components/Toast/Toast.css: -------------------------------------------------------------------------------- 1 | .toast { 2 | margin-right: 16px; 3 | margin-top: 16px; 4 | width: 200px; 5 | position: relative; 6 | padding: 16px; 7 | border-radius: 5px; 8 | background: #333333; 9 | box-shadow: 2px 2px 1px 1px rgba(42, 42, 42, 1); 10 | color: white; 11 | } 12 | 13 | @media only screen and (max-width: 750px) { 14 | .toast { 15 | width: 90%; 16 | margin-left: auto; 17 | margin-right: auto; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/src/components/Post/PostAttachments.css: -------------------------------------------------------------------------------- 1 | .post-attachments-modal-content-wrapper { 2 | padding: 10px; 3 | overflow-y: scroll; 4 | } 5 | 6 | /* Hide scrollbar for Chrome, Safari and Opera */ 7 | .post-attachments-modal-content-wrapper::-webkit-scrollbar { 8 | display: none; 9 | } 10 | 11 | /* Hide scrollbar for IE, Edge and Firefox */ 12 | .post-attachments-modal-content-wrapper { 13 | -ms-overflow-style: none; /* IE and Edge */ 14 | scrollbar-width: none; /* Firefox */ 15 | } 16 | -------------------------------------------------------------------------------- /web/src/utils/getNameAndSubName.ts: -------------------------------------------------------------------------------- 1 | import User from "../models/User"; 2 | 3 | const getNameAndSubName = (user: User | null): { name: string, subName?: string } => { 4 | let name 5 | let subName 6 | if (user !== null) { 7 | if (user.display_name) { 8 | name = user.display_name 9 | subName = user.id 10 | } else { 11 | name = user.id 12 | } 13 | } else { 14 | name = '...' 15 | } 16 | return { name, subName } 17 | } 18 | 19 | export default getNameAndSubName 20 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | FLASK_ENVIRONMENT=development 2 | MONGODB_URI=mongodb://mongo:27017/minigplus 3 | JWT_SECRET_KEY=supersecret 4 | AWS_REGION=us-west-2 5 | REDIS_URL=redis://redis:6379 6 | SENTRY_DSN= 7 | OPEN_REGISTRATION=true 8 | ADMINS=kt 9 | OFFICIAL=official 10 | GHOST=ghost 11 | NITTER_HOST=nitter.net 12 | NITTER_HTTPS=true 13 | SMTP_ENABLED=false 14 | SMTP_FROM=admin@ktachibana.party 15 | WEB_DOMAIN=localhost:3000 16 | API_DOMAIN=localhost:5000 17 | JWT_ACCESS_TOKEN_EXPIRES=1209600 18 | LINK_PREVIEW_RETRY_PROXIES= 19 | -------------------------------------------------------------------------------- /web/src/components/ContentTextarea/MentionAutoCompleteUserItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import User from "../../models/User"; 3 | import './MentionAutoCompleteUserItem.css' 4 | 5 | interface Props { 6 | selected: boolean 7 | entity: User 8 | } 9 | 10 | const MentionAutoCompleteUserItem = (props: Props) => { 11 | return ( 12 |
{props.entity.display_name} @{props.entity.id}
13 | ) 14 | } 15 | 16 | export default MentionAutoCompleteUserItem 17 | -------------------------------------------------------------------------------- /web/src/components/LinkPreview/InstantPreview.css: -------------------------------------------------------------------------------- 1 | /* https://github.com/tjallingt/react-youtube/issues/242#issuecomment-623885531 */ 2 | .link-preview-youtube-container { 3 | position: relative; 4 | width: 100%; 5 | height: 0; 6 | padding-bottom: 56.25%; 7 | overflow: hidden; 8 | margin-bottom: 15px; 9 | border-radius: 5px; 10 | } 11 | 12 | .link-preview-youtube-container iframe { 13 | width: 100%; 14 | height: 100%; 15 | position: absolute; 16 | top: 0; 17 | left: 0; 18 | } 19 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # web 2 | Web frontend for pill.city written in React/TypeScript 3 | 4 | ## Video demo 5 | Here is an early version video demo for some of its features such as circle management, emoji reactions and post formatting 6 | 7 | https://user-images.githubusercontent.com/3515852/132497391-32cb728b-ff70-478a-a0cd-e97aa57106f1.mp4 8 | 9 | ## Dependencies 10 | * Node.js v16 and Yarn 11 | 12 | ## Prerequisites 13 | 1. `cp .example.env .env` 14 | 2. `yarn install` 15 | 16 | ## Run 17 | ```shell 18 | yarn start 19 | ``` 20 | -------------------------------------------------------------------------------- /pillcity/tests/daos/base_test_case.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mongoengine import connect, disconnect 3 | from pillcity.daos.user_cache import populate_user_cache 4 | from . import r 5 | 6 | 7 | class BaseTestCase(TestCase): 8 | def setUp(self): 9 | self.connection = connect('mongoenginetest', host='mongomock://localhost') 10 | populate_user_cache() 11 | 12 | def tearDown(self): 13 | r.flushall() 14 | self.connection.drop_database("mongoenginetest") 15 | disconnect() 16 | -------------------------------------------------------------------------------- /web/src/models/Notification.ts: -------------------------------------------------------------------------------- 1 | import User from "./User"; 2 | 3 | export type NotifyingAction = 'comment' | 'mention' | 'reaction' | 'reshare' | 'follow' 4 | 5 | export default interface Notification { 6 | notified_summary: string; 7 | created_at_seconds: number; 8 | id: string; 9 | notified_href: string; 10 | notified_deleted: boolean; 11 | notifier: User; 12 | notifier_blocked: boolean; 13 | notifying_deleted: boolean; 14 | notifying_summary: string; 15 | unread: boolean; 16 | notifying_action: NotifyingAction; 17 | } 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pill.City", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "devcontainer", 5 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 6 | "postCreateCommand": { 7 | "install-deps": "./.devcontainer/install-deps.sh" 8 | }, 9 | "forwardPorts": [3000, 5000], 10 | "customizations": { 11 | "vscode": { 12 | "extensions": [ 13 | "ms-python.vscode-pylance", 14 | "streetsidesoftware.code-spell-checker", 15 | "hashicorp.terraform" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pillcity/resources/content.py: -------------------------------------------------------------------------------- 1 | from flask_restful import fields 2 | from pillcity.daos.content import format_content 3 | 4 | class FormattedContent(fields.Raw): 5 | def format(self, value): 6 | fc = format_content(value) 7 | return { 8 | 'references': fc.references, 9 | 'segments': list(map(lambda s: { 10 | 'content': s.content, 11 | 'types': list(sorted(s.types)), 12 | 'reference': s.reference if s.reference != -1 else None, 13 | }, fc.segments)) 14 | } 15 | -------------------------------------------------------------------------------- /web/src/utils/convertHeicFileToPng.ts: -------------------------------------------------------------------------------- 1 | import heic2any from "heic2any"; 2 | 3 | const convertHeicFileToPng = async (f: File): Promise => { 4 | // https://stackoverflow.com/questions/57127365/make-html5-filereader-working-with-heic-files 5 | const heicUrl = URL.createObjectURL(f); 6 | const heicFetched = await fetch(heicUrl) 7 | const heicBlob = await heicFetched.blob() 8 | const pngBlob = await heic2any({ blob: heicBlob }) as Blob 9 | return new File([pngBlob], f.name.toLowerCase().replace(".heic", ".png")) 10 | } 11 | 12 | export default convertHeicFileToPng 13 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [{*.js,*.jsx,*.ts,*.tsx}] 14 | charset = utf-8 15 | indent_style = space 16 | indent_size = 2 17 | 18 | # Matches the exact files either package.json or .travis.yml 19 | [{package.json,.travis.yml}] 20 | indent_style = space 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /web/src/components/PillCheckbox/PillCheckbox.css: -------------------------------------------------------------------------------- 1 | .pill-checkbox-container { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | } 6 | 7 | .pill-checkbox.react-toggle--checked .react-toggle-track { 8 | background-color: #E05140; 9 | } 10 | 11 | .pill-checkbox.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { 12 | background-color: #E05140; 13 | } 14 | 15 | .react-toggle--checked .react-toggle-thumb { 16 | left: 27px; 17 | border-color: #E05140; 18 | } 19 | 20 | .pill-checkbox-label { 21 | margin-left: 10px; 22 | } 23 | -------------------------------------------------------------------------------- /web/src/utils/validators.js: -------------------------------------------------------------------------------- 1 | const idRegex = /^[A-Za-z0-9_-]+$/i; 2 | 3 | export const validateId = (id) => { 4 | return id && id.trim() && id.trim().length <= 15 && id.trim().match(idRegex) 5 | } 6 | 7 | const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 8 | 9 | export const validateEmail = (email) => { 10 | return String(email).toLowerCase().match(emailRegex) 11 | } 12 | 13 | export const validatePassword = (password) => { 14 | return password && password.trim() 15 | } 16 | -------------------------------------------------------------------------------- /pillcity/tests/daos/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest.mock 3 | import fakeredis 4 | 5 | patch_env = unittest.mock.patch.dict(os.environ, { 6 | "REDIS_URL": "redis://localhost.fake", 7 | "OFFICIAL": "official", 8 | "GHOST": "ghost" 9 | }) 10 | patch_env.start() 11 | 12 | r = fakeredis.FakeRedis() 13 | import pillcity.daos.cache 14 | patch_redis = unittest.mock.patch.object(pillcity.daos.cache, 'r', r) 15 | patch_redis.start() 16 | 17 | c = None 18 | import pillcity.tasks.celery 19 | patch_celery = unittest.mock.patch.object(pillcity.tasks, 'celery', c) 20 | patch_celery.start() 21 | -------------------------------------------------------------------------------- /pillcity/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict 3 | from redis import Redis 4 | from pillcity.plugin_core import PillCityPlugin, PillCityPluginContext 5 | from .cloudemoticon import CloudEmoticon 6 | 7 | PLUGINS = { 8 | "cloudemoticon": CloudEmoticon 9 | } 10 | 11 | 12 | def get_plugins() -> Dict[str, PillCityPlugin]: 13 | r = Redis.from_url(os.environ['REDIS_URL']) 14 | res = {} 15 | for name, clazz in PLUGINS.items(): 16 | context = PillCityPluginContext(name, r) 17 | plugin = clazz(context) 18 | res[name] = plugin 19 | 20 | return res 21 | -------------------------------------------------------------------------------- /web/src/components/HomePage/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./HomePage.css" 3 | 4 | const HomePage = (props) => { 5 | return ( 6 |
7 |
8 |
9 | Welcome back to 10 |
11 |
12 | Pill City 13 |
14 |
15 | 16 |
17 | {props.formElement} 18 |
19 |
20 | ) 21 | } 22 | 23 | export default HomePage 24 | -------------------------------------------------------------------------------- /web/src/components/PillInput/PillInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import './PillInput.css' 3 | 4 | interface Props { 5 | placeholder: string 6 | value: string 7 | onChange: (newValue: string) => void 8 | } 9 | 10 | const PillInput = (props: Props) => { 11 | return ( 12 | { 18 | e.preventDefault() 19 | props.onChange(e.target.value) 20 | }} 21 | /> 22 | ) 23 | } 24 | 25 | export default PillInput 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.1.4 2 | mongoengine==0.23.1 3 | mongomock==3.23.0 4 | nose==1.3.7 5 | flask-restful==0.3.9 6 | flask-cors==3.0.10 7 | flask-jwt-extended==4.2.3 8 | requests==2.26.0 9 | requests_toolbelt==0.9.1 10 | emoji==1.4.2 11 | bleach<4.0 12 | boto3==1.18.35 13 | Pillow==9.4.0 14 | sentry-sdk[flask]==1.5.1 15 | redis==3.5.3 16 | celery[redis]==5.1.2 17 | git+https://github.com/sekai-soft/linkpreview 18 | feedgen==0.9.0 19 | markupsafe==2.0.1 20 | flask-swagger-ui==3.36.0 21 | colorthief==0.2.1 22 | urlextract==1.6.0 23 | cryptography==38.0.4 24 | 25 | # testing 26 | freezegun==1.1.0 27 | fakeredis==1.6.1 28 | -------------------------------------------------------------------------------- /pillcity/resources/mention.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pillcity.daos.user import find_user 3 | from pillcity.models import User 4 | from pillcity.daos.content import format_content 5 | 6 | def get_mentioned_user_ids(content: str) -> List[User]: 7 | fc = format_content(content) 8 | mentioned_users = [] 9 | for s in fc.segments: 10 | if 'mention' in s.types and s.reference < len(fc.references): 11 | mentioned_user_id = fc.references[s.reference] 12 | user = find_user(mentioned_user_id) 13 | if user: 14 | mentioned_users.append(user) 15 | return mentioned_users 16 | -------------------------------------------------------------------------------- /pillcity/utils/profiling.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import functools 4 | from .now import now_ms 5 | 6 | 7 | def timer(func): 8 | """Print the runtime of the decorated function""" 9 | @functools.wraps(func) 10 | def wrapper_timer(*args, **kwargs): 11 | if not os.getenv('PROFILE'): 12 | return func(*args, **kwargs) 13 | start_time = now_ms() 14 | value = func(*args, **kwargs) 15 | end_time = now_ms() 16 | run_time = end_time - start_time 17 | logging.info(f"Finished {func.__name__!r} in {run_time} milliseconds") 18 | return value 19 | return wrapper_timer 20 | -------------------------------------------------------------------------------- /web/src/api/ApiError.ts: -------------------------------------------------------------------------------- 1 | import {AxiosResponse} from "axios"; 2 | 3 | class ApiError extends Error { 4 | statusCode: number; 5 | 6 | constructor(response: AxiosResponse) { 7 | super() 8 | this.name = 'ApiError' 9 | if (response.data && response.data.msg) { 10 | this.message = `${response.data.msg} (${response.status})` 11 | } else if (response.data) { 12 | this.message = `${JSON.stringify(response.data)} (${response.status})` 13 | } else { 14 | this.message = `${response.statusText} (${response.status})` 15 | } 16 | this.statusCode = response.status 17 | } 18 | } 19 | 20 | export default ApiError; 21 | -------------------------------------------------------------------------------- /pillcity/plugin_core/context.py: -------------------------------------------------------------------------------- 1 | from redis import Redis 2 | 3 | 4 | class PillCityPluginContext(object): 5 | def __init__(self, plugin_name: str, _redis: Redis): 6 | self.plugin_name = plugin_name # type: str 7 | self._redis = _redis # type: Redis 8 | 9 | def redis_get(self, key: str) -> str: 10 | return self._redis.hget(f'plugin.{self.plugin_name}', key) 11 | 12 | def redis_set(self, key: str, value: str): 13 | self._redis.hset(f'plugin.{self.plugin_name}', key, value) 14 | 15 | def mongo_get(self, user_id: str): 16 | pass 17 | 18 | def mongo_set(self, user_id: str, value): 19 | pass 20 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /pillcity/daos/post_cache.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from pillcity.models import Post 3 | from pillcity.utils.profiling import timer 4 | from .cache import r 5 | 6 | RPost = "post" 7 | 8 | 9 | def set_in_post_cache(post: Post): 10 | r.hset(RPost, str(post.id), post.to_json()) 11 | 12 | 13 | @timer 14 | def get_in_post_cache(oid: ObjectId): 15 | r_post = r.hget(RPost, str(oid)) 16 | if not r_post: 17 | post = Post.objects.get(id=oid) 18 | set_in_post_cache(post) 19 | return post 20 | return Post.from_json(r_post.decode('utf-8')) 21 | 22 | 23 | def exists_in_post_cache(oid: ObjectId): 24 | return r.hexists(RPost, str(oid)) 25 | -------------------------------------------------------------------------------- /web/src/components/Post/ResharedPost.css: -------------------------------------------------------------------------------- 1 | .post-reshared-wrapper { 2 | border: #e0e0e0 solid 1px; 3 | margin-left: 15px; 4 | padding: 10px; 5 | border-radius: 4px; 6 | cursor: pointer; 7 | margin-bottom: 4px; 8 | } 9 | 10 | .post-reshared-info { 11 | display: flex; 12 | align-items: center; 13 | margin-bottom: 5px; 14 | } 15 | 16 | .post-reshared-avatar { 17 | width: 30px; 18 | height: 30px; 19 | flex-shrink: 0; 20 | } 21 | 22 | .post-reshared-author { 23 | margin-left: 5px; 24 | font-weight: bold; 25 | font-size: larger; 26 | } 27 | 28 | .post-reshared-attachments-wrapper { 29 | margin-left: 15px; 30 | margin-bottom: 10px; 31 | } 32 | -------------------------------------------------------------------------------- /pillcity/models/circle.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from mongoengine import Document, ListField, LazyReferenceField, StringField, PULL, CASCADE 3 | from .created_at_mixin import CreatedAtMixin 4 | from .user import User 5 | 6 | 7 | class Circle(Document, CreatedAtMixin): 8 | eid = StringField(required=True) 9 | owner = LazyReferenceField(User, required=True, reverse_delete_rule=CASCADE) # type: User 10 | name = StringField(required=True) 11 | members = ListField(LazyReferenceField(User, reverse_delete_rule=PULL), default=[]) # type: List[User] 12 | meta = { 13 | 'indexes': [ 14 | {'fields': ('owner', 'name'), 'unique': True} 15 | ] 16 | } 17 | 18 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | http = 0.0.0.0:5000 3 | module = app 4 | callable = app 5 | strict = true ; fail to start if any parameter in the configuration file isn’t explicitly understood by uWSGI 6 | ;master = true ; necessary to gracefully re-spawn and pre-fork workers, consolidate logs, and manage many other features 7 | enable-threads = true 8 | vacuum = true ; Delete sockets during shutdown 9 | single-interpreter = true 10 | die-on-term = true ; Shutdown when receiving SIGTERM (default is respawn) 11 | need-app = true ; prevents uWSGI from starting if it is unable to find or load your application module 12 | -------------------------------------------------------------------------------- /pillcity/daos/circle_cache.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from pillcity.models import Circle 3 | from pillcity.utils.profiling import timer 4 | from .cache import r 5 | 6 | RCircle = "circle" 7 | 8 | 9 | def set_in_circle_cache(circle: Circle): 10 | r.hset(RCircle, str(circle.id), circle.to_json()) 11 | 12 | 13 | @timer 14 | def get_in_circle_cache(oid: ObjectId) -> Circle: 15 | r_circle = r.hget(RCircle, str(oid)) 16 | if not r_circle: 17 | circle = Circle.objects.get(id=oid) 18 | set_in_circle_cache(circle) 19 | return circle 20 | return Circle.from_json(r_circle.decode('utf-8')) 21 | 22 | 23 | def delete_from_circle_cache(oid: ObjectId): 24 | r.hdel(RCircle, str(oid)) 25 | -------------------------------------------------------------------------------- /web/src/components/MediaV2/AvatarV2.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import User from "../../models/User"; 3 | import MediaV2 from "./MediaV2"; 4 | 5 | interface Props extends React.ImgHTMLAttributes { 6 | user: User | null 7 | } 8 | 9 | const AvatarV2 = (props: Props) => { 10 | if (!props.user || !props.user.avatar_url_v2) { 11 | return ( 12 | 18 | ) 19 | } 20 | return ( 21 | 25 | ) 26 | } 27 | 28 | export default AvatarV2 29 | -------------------------------------------------------------------------------- /pillcity/models/media_set.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from mongoengine import Document, LazyReferenceField, ListField, StringField, BooleanField 3 | from .user import User 4 | from .media import Media 5 | from .created_at_mixin import CreatedAtMixin 6 | 7 | 8 | class MediaSet(Document, CreatedAtMixin): 9 | eid = StringField(required=True) 10 | owner = LazyReferenceField(User, required=True) # type: User 11 | name = StringField(required=True) 12 | media_list = ListField(LazyReferenceField(Media), default=[]) # type: List[Media] 13 | is_public = BooleanField(required=False, default=False) 14 | meta = { 15 | 'indexes': [ 16 | {'fields': ('owner', 'name'), 'unique': True} 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /web/src/components/PillModal/PillModal.css: -------------------------------------------------------------------------------- 1 | .pill-modal-header { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | height: 50px; 6 | color: #727272; 7 | } 8 | 9 | .pill-modal-dummy { 10 | width: 25px; 11 | height: 25px; 12 | } 13 | 14 | .pill-modal-title { 15 | font-weight: bolder; 16 | font-size: large; 17 | } 18 | 19 | .pill-modal-close-button { 20 | width: 25px; 21 | height: 25px; 22 | margin-right: 10px; 23 | cursor: pointer; 24 | } 25 | 26 | .pill-modal-content-wrapper { 27 | padding: 0 20px 20px; 28 | } 29 | 30 | @media only screen and (max-width: 750px) { 31 | .pill-modal-content-wrapper { 32 | padding: 0 10px 10px; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/src/components/Toast/ToastContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {createPortal} from "react-dom"; 3 | import ToastComponent from './Toast' 4 | import './ToastContainer.css' 5 | 6 | export interface Toast { 7 | id: number 8 | content: string 9 | dismissible: boolean 10 | } 11 | 12 | interface Props { 13 | toasts: Toast[] 14 | } 15 | 16 | const ToastContainer = ({ toasts }: Props) => { 17 | return createPortal( 18 |
19 | {toasts.map(item => { 20 | return {item.content} 21 | })} 22 |
, 23 | document.body 24 | ); 25 | } 26 | 27 | export default ToastContainer 28 | -------------------------------------------------------------------------------- /pillcity/models/link_preview.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from mongoengine import Document, StringField, EnumField, URLField, ListField, IntField 3 | 4 | 5 | class LinkPreviewState(Enum): 6 | Fetching = "fetching" 7 | Fetched = "fetched" 8 | Errored = "errored" 9 | 10 | 11 | class LinkPreview(Document): 12 | url = URLField(required=True, unique=True) 13 | title = StringField(required=False, default='') 14 | subtitle = StringField(required=False, default='') 15 | image_urls = ListField(URLField(), required=False, default=list) 16 | state = EnumField(LinkPreviewState, required=True) 17 | errored_retries = IntField(required=False, default=0) 18 | errored_next_refetch_seconds = IntField(required=False, default=0) 19 | -------------------------------------------------------------------------------- /pillcity/plugin_core/api.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | from flask import Blueprint 4 | from .context import PillCityPluginContext 5 | 6 | 7 | class PillCityPlugin(ABC): 8 | def __init__(self, context: PillCityPluginContext): 9 | self._context = context # type: PillCityPluginContext 10 | 11 | def get_context(self) -> PillCityPluginContext: 12 | return self._context 13 | 14 | @abstractmethod 15 | def init(self): 16 | pass 17 | 18 | @abstractmethod 19 | def job(self): 20 | pass 21 | 22 | @abstractmethod 23 | def job_interval_seconds(self) -> int: 24 | pass 25 | 26 | @abstractmethod 27 | def flask_blueprint(self) -> Optional[Blueprint]: 28 | pass 29 | -------------------------------------------------------------------------------- /web/src/components/PillDropdownMenu/PillDropdownMenu.css: -------------------------------------------------------------------------------- 1 | .pill-dropdown-menu-container { 2 | position: relative; 3 | } 4 | 5 | .pill-dropdown-children { 6 | display: inline-block; 7 | } 8 | 9 | .pill-dropdown-menu { 10 | border-radius: 4px; 11 | position: absolute; 12 | border: 1px solid #e0e0e0; 13 | right: 0; 14 | width: 150px; 15 | opacity: 0; 16 | visibility: hidden; 17 | } 18 | 19 | .pill-dropdown-menu-item { 20 | padding: 6px; 21 | background-color: white; 22 | cursor: pointer; 23 | font-weight: bold; 24 | border-radius: 4px; 25 | color: #c97070; 26 | } 27 | 28 | .pill-dropdown-menu-item:hover { 29 | background-color: #f5f4f4; 30 | } 31 | 32 | .pill-dropdown-menu-active { 33 | opacity: 1; 34 | visibility: visible; 35 | } 36 | -------------------------------------------------------------------------------- /web/src/store/meSlice.ts: -------------------------------------------------------------------------------- 1 | import User from "../models/User"; 2 | import {AnyAction, createSlice, ThunkAction} from "@reduxjs/toolkit"; 3 | import {RootState} from "./store"; 4 | import api from "../api/Api"; 5 | 6 | interface MeState { 7 | me: User | null 8 | } 9 | 10 | const initialState: MeState = { 11 | me: null, 12 | } 13 | 14 | const meSlice = createSlice({ 15 | name: 'me', 16 | initialState, 17 | reducers: { 18 | setMe: (state, action) => { 19 | state.me = action.payload 20 | }, 21 | } 22 | }) 23 | 24 | const { 25 | setMe 26 | } = meSlice.actions 27 | 28 | export const loadMe = (): ThunkAction => { 29 | return async (dispatch) => { 30 | dispatch(setMe(await api.getMe())) 31 | } 32 | } 33 | 34 | export default meSlice.reducer 35 | -------------------------------------------------------------------------------- /web/src/utils/timeDelta.ts: -------------------------------------------------------------------------------- 1 | const minute = 60, hour = 3600, day = 3600 * 24, week = 3600 * 24 * 7; 2 | 3 | const deltaToString = (d: number): (string | false) => { 4 | if (d < minute) { 5 | return `${Math.floor(d)}s`; 6 | } else if (d < hour) { 7 | return `${Math.floor(d / minute)}m`; 8 | } else if (d < day) { 9 | return `${Math.floor(d / hour)}h`; 10 | } else if (d < week) { 11 | return `${Math.floor(d / day)}d`; 12 | } else { 13 | return false 14 | } 15 | } 16 | 17 | const nowSeconds = (): number => { 18 | return new Date().getTime() / 1000; 19 | } 20 | 21 | export const pastTime = (time: number): string => { 22 | const d = deltaToString(nowSeconds() - time) 23 | if (!d) { 24 | return new Date(time * 1000).toISOString().split('T')[0]; 25 | } 26 | return d 27 | } 28 | -------------------------------------------------------------------------------- /web/src/components/MediaV2Collage/MediaV2Collage.css: -------------------------------------------------------------------------------- 1 | .media-v2-collage-item { 2 | cursor: pointer; 3 | object-fit: cover; 4 | width: 100%; 5 | border-radius: 4px; 6 | } 7 | 8 | .media-v2-collage-pswp-content { 9 | position: absolute; 10 | top: 50%; 11 | left: 50%; 12 | -webkit-transform: translate(-50%, -50%); 13 | transform: translate(-50%, -50%); 14 | } 15 | 16 | .media-v2-collage-pswp-image { 17 | width: auto; 18 | } 19 | 20 | @media only screen and (max-width: 750px) { 21 | .media-v2-collage-pswp-content { 22 | position: absolute; 23 | top: 50%; 24 | left: auto; 25 | -webkit-transform: translateY(-50%); 26 | transform: translateY(-50%); 27 | } 28 | 29 | .media-v2-collage-pswp-image { 30 | width: 100%; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | devcontainer: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - ../..:/workspaces:cached 9 | command: sleep infinity 10 | mongo: 11 | image: mongo:4.4 12 | volumes: 13 | - mongodb_data:/data/db 14 | healthcheck: 15 | test: echo 'db.runCommand("ping").ok' | mongo localhost:27017/test --quiet 16 | interval: 10s 17 | timeout: 10s 18 | retries: 10 19 | redis: 20 | image: redis:7-alpine 21 | restart: unless-stopped 22 | volumes: 23 | - redis_data:/data 24 | healthcheck: 25 | test: 26 | - CMD 27 | - redis-cli 28 | - ping 29 | interval: 1s 30 | timeout: 3s 31 | retries: 30 32 | 33 | volumes: 34 | mongodb_data: 35 | redis_data: 36 | 37 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {Provider as ReduxProvider} from 'react-redux' 4 | import 'semantic-ui-css/semantic.min.css'; 5 | import 'react-image-lightbox/style.css'; 6 | import App from './App'; 7 | import * as serviceWorkerRegistration from './serviceWorkerRegistration'; 8 | import store from "./store/store"; 9 | import {PersistGate} from "redux-persist/integration/react"; 10 | import {persistStore} from "redux-persist"; 11 | 12 | const persistor = persistStore(store); 13 | 14 | ReactDOM.render( 15 | 16 | 17 | 18 | 19 | 20 | 21 | , 22 | document.getElementById('root') 23 | ); 24 | 25 | serviceWorkerRegistration.register(); 26 | -------------------------------------------------------------------------------- /pillcity/resources/blocks.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource 2 | from flask_jwt_extended import jwt_required, get_jwt_identity 3 | from pillcity.daos.user import find_user_or_raise, block, unblock 4 | 5 | 6 | class Blocks(Resource): 7 | @jwt_required() 8 | def post(self, blocking_user_id): 9 | """ 10 | Block a user 11 | """ 12 | user_id = get_jwt_identity() 13 | user = find_user_or_raise(user_id) 14 | target_user = find_user_or_raise(blocking_user_id) 15 | block(user, target_user) 16 | 17 | @jwt_required() 18 | def delete(self, blocking_user_id): 19 | """ 20 | Unblock a user 21 | """ 22 | user_id = get_jwt_identity() 23 | user = find_user_or_raise(user_id) 24 | target_user = find_user_or_raise(blocking_user_id) 25 | unblock(user, target_user) 26 | -------------------------------------------------------------------------------- /web/src/components/CreateNewCircle/CreateNewCircle.css: -------------------------------------------------------------------------------- 1 | .add-new-circle-name-input { 2 | width: 240px; 3 | height: 3em; 4 | padding: 1em; 5 | border-width: 1px; 6 | border-radius: 4px; 7 | border-color: #86989B; 8 | display: block; 9 | margin-left: auto; 10 | margin-right: auto; 11 | } 12 | 13 | .modal-content-button-wrapper { 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | margin-top: 20px; 18 | } 19 | 20 | .modal-content-button { 21 | padding: 8px 10px; 22 | font-weight: bold; 23 | font-size: medium; 24 | color: white; 25 | border-radius: 5px; 26 | width: auto; 27 | cursor: pointer; 28 | margin-right: 20px; 29 | } 30 | 31 | .confirm { 32 | background-color: #E05140; 33 | } 34 | 35 | .cancel { 36 | background-color: #767676; 37 | margin-right: 30px; 38 | } 39 | -------------------------------------------------------------------------------- /pillcity/resources/followings.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource 2 | from flask_jwt_extended import jwt_required, get_jwt_identity 3 | from pillcity.daos.user import find_user_or_raise, follow, unfollow 4 | 5 | 6 | class Following(Resource): 7 | @jwt_required() 8 | def post(self, following_user_id): 9 | """ 10 | Follow a user 11 | """ 12 | user_id = get_jwt_identity() 13 | user = find_user_or_raise(user_id) 14 | target_user = find_user_or_raise(following_user_id) 15 | follow(user, target_user) 16 | 17 | @jwt_required() 18 | def delete(self, following_user_id): 19 | """ 20 | Unfollow a user 21 | """ 22 | user_id = get_jwt_identity() 23 | user = find_user_or_raise(user_id) 24 | target_user = find_user_or_raise(following_user_id) 25 | unfollow(user, target_user) 26 | -------------------------------------------------------------------------------- /pillcity/models/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from mongoengine import Document, ListField, LazyReferenceField, StringField, PULL, NULLIFY, EmailField 3 | from .created_at_mixin import CreatedAtMixin 4 | from .media import Media 5 | 6 | 7 | class User(Document, CreatedAtMixin): 8 | user_id = StringField(required=True, unique=True) 9 | password = StringField(required=True) 10 | followings = ListField(LazyReferenceField('User', reverse_delete_rule=PULL), default=[]) # type: List[User] 11 | blocking = ListField(LazyReferenceField('User', reverse_delete_rule=PULL), default=[]) # type: List[User] 12 | avatar = LazyReferenceField(Media, reverse_delete_rule=NULLIFY) # type: Media 13 | profile_pic = StringField(required=False, default="pill1.png") 14 | display_name = StringField(required=False) 15 | email = EmailField(required=False) 16 | rss_token = StringField(required=False) 17 | -------------------------------------------------------------------------------- /web/src/components/PillTabs/PillTabs.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import './PillTabs.css' 3 | 4 | interface Tab { 5 | title: string 6 | el: JSX.Element 7 | } 8 | 9 | interface Props { 10 | tabs: Tab[] 11 | } 12 | 13 | const PillTabs = (props: Props) => { 14 | const [showingTab, updateShowingTab] = useState(0) 15 | 16 | return ( 17 | <> 18 |
19 | { 20 | props.tabs.map((tab, index) => { 21 | return ( 22 |
{updateShowingTab(index)}} 25 | >{tab.title}
26 | ) 27 | }) 28 | } 29 |
30 |
31 | {props.tabs[showingTab].el} 32 |
33 | 34 | ) 35 | } 36 | 37 | export default PillTabs 38 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | /* Hide scrollbar for Chrome, Safari and Opera */ 2 | body::-webkit-scrollbar { 3 | display: none; 4 | } 5 | 6 | /* Hide scrollbar for IE, Edge and Firefox */ 7 | body { 8 | -ms-overflow-style: none; /* IE and Edge */ 9 | scrollbar-width: none; /* Firefox */ 10 | } 11 | 12 | #root { 13 | height: 100%; 14 | } 15 | 16 | input[type="file"] { 17 | display: none; 18 | } 19 | 20 | .app-container { 21 | margin-left: auto; 22 | margin-right: auto; 23 | } 24 | 25 | @media only screen and (max-width: 750px) { 26 | .app-container { 27 | width: 100%; 28 | padding-bottom: calc(50px + env(safe-area-inset-bottom));; 29 | } 30 | } 31 | 32 | .link-button { 33 | background-color: transparent; 34 | border: none; 35 | cursor: pointer; 36 | text-decoration: underline; 37 | display: inline; 38 | margin: 0; 39 | padding: 0; 40 | color: #0d71bb; 41 | } 42 | -------------------------------------------------------------------------------- /pillcity/daos/mention.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pillcity.models import NotifyingAction, User 3 | from .notification import create_notification 4 | 5 | 6 | def mention(self: User, notified_href: str, notified_summary: str, mentioned_users: List[User]): 7 | """ 8 | Create the notifications for mentioning a list of user 9 | 10 | :param self: The acting user 11 | :param notified_href: The notifying href 12 | :param notified_summary: Text summary for notifying href 13 | :param mentioned_users: The mentioned users 14 | """ 15 | for mentioned_user in mentioned_users: 16 | create_notification( 17 | self=self, 18 | notifying_href='', 19 | notifying_summary='', 20 | notifying_action=NotifyingAction.Mention, 21 | notified_href=notified_href, 22 | notified_summary=notified_summary, 23 | owner=mentioned_user 24 | ) 25 | -------------------------------------------------------------------------------- /web/src/components/PillCheckbox/PillCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Toggle from 'react-toggle' 3 | import 'react-toggle/style.css' 4 | import './PillCheckbox.css' 5 | 6 | interface Props { 7 | checked: boolean 8 | onChange: (c: boolean) => void 9 | label: string 10 | disabled?: boolean 11 | } 12 | 13 | const PillCheckbox = (props: Props) => { 14 | return ( 15 |
16 | { 19 | e.preventDefault() 20 | if (props.disabled) { 21 | return 22 | } 23 | props.onChange(!props.checked) 24 | }} 25 | disabled={props.disabled} 26 | icons={false} 27 | className='pill-checkbox' 28 | /> 29 | {props.label} 30 |
31 | ) 32 | } 33 | 34 | export default PillCheckbox 35 | -------------------------------------------------------------------------------- /web/src/components/UpdateBanner/UpdateBanner.css: -------------------------------------------------------------------------------- 1 | .settings-profile-pic-preview { 2 | width: 100%; 3 | height: 250px; 4 | background-size: 100px; 5 | position: relative; 6 | } 7 | 8 | .settings-profile-pic-selections { 9 | display: flex; 10 | flex-wrap: wrap; 11 | justify-content: center; 12 | } 13 | 14 | .settings-profile-selection-option { 15 | margin-right: 20px; 16 | margin-top: 15px; 17 | width: 50px; 18 | height: 50px; 19 | border: 2px solid #e0e0e0; 20 | background-color: #e0e0e0; 21 | border-radius: 3px; 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | padding: 3px; 26 | cursor: pointer; 27 | } 28 | 29 | .settings-profile-selection-option-selected { 30 | border: 2px solid #9dd0ff; 31 | background-color: #e0e0e0; 32 | } 33 | 34 | .settings-profile-selection-option-img { 35 | max-width: 100%; 36 | max-height: 100%; 37 | } 38 | -------------------------------------------------------------------------------- /web/src/components/Toast/Toast.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react' 2 | import {useToast} from "./ToastProvider"; 3 | import './Toast.css' 4 | 5 | interface Props { 6 | children: string 7 | id: number 8 | dismissible: boolean 9 | } 10 | 11 | const ToastComponent = ({ children, id, dismissible }: Props) => { 12 | const { removeToast } = useToast(); 13 | 14 | useEffect(() => { 15 | if (!dismissible) { 16 | return () => {} 17 | } 18 | 19 | const timer = setTimeout(() => { 20 | removeToast(id); 21 | }, 3000); // delay 22 | 23 | return () => { 24 | clearTimeout(timer); 25 | }; 26 | }, [id, removeToast, dismissible]); 27 | 28 | const onCLick = () => { 29 | if (dismissible) { 30 | removeToast(id) 31 | } 32 | } 33 | 34 | return
{children}
35 | } 36 | 37 | export default ToastComponent 38 | -------------------------------------------------------------------------------- /pillcity/resources/link_preview.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource, fields, marshal_with, reqparse 2 | from flask_jwt_extended import jwt_required 3 | from pillcity.daos.link_preview import get_link_preview 4 | 5 | 6 | link_preview_parser = reqparse.RequestParser() 7 | link_preview_parser.add_argument('url', type=str, required=True) 8 | 9 | 10 | class LinkPreviewState(fields.Raw): 11 | def format(self, state): 12 | return state.value 13 | 14 | 15 | link_preview_fields = { 16 | 'url': fields.String, 17 | 'title': fields.String, 18 | 'subtitle': fields.String, 19 | 'image_urls': fields.List(fields.String), 20 | 'state': LinkPreviewState, 21 | 'errored_next_refetch_seconds': fields.Integer 22 | } 23 | 24 | 25 | class LinkPreview(Resource): 26 | @jwt_required() 27 | @marshal_with(link_preview_fields) 28 | def post(self): 29 | args = link_preview_parser.parse_args() 30 | return get_link_preview(args['url']) 31 | -------------------------------------------------------------------------------- /web/src/api/AuthStorage.ts: -------------------------------------------------------------------------------- 1 | const AccessTokenKey = 'access_token' 2 | const AccessTokenExpiresKey = 'access_token_expires' 3 | 4 | export const accessTokenExists = (): boolean => { 5 | if (window.localStorage.getItem(AccessTokenExpiresKey) === null) { 6 | return false 7 | } 8 | const expires = parseInt(window.localStorage.getItem(AccessTokenExpiresKey) as string) 9 | const now = new Date().getTime() / 1000 10 | if (expires <= now) { 11 | return false 12 | } 13 | return window.localStorage.getItem(AccessTokenKey) !== null 14 | } 15 | 16 | export const getAccessToken = (): string => { 17 | return window.localStorage.getItem(AccessTokenKey) as string 18 | } 19 | 20 | export const setAccessToken = (accessToken: string, expires: number) => { 21 | window.localStorage.setItem(AccessTokenKey, accessToken) 22 | window.localStorage.setItem(AccessTokenExpiresKey, `${expires}`) 23 | } 24 | 25 | export const removeAccessToken = () => { 26 | window.localStorage.removeItem(AccessTokenKey) 27 | } 28 | -------------------------------------------------------------------------------- /pillcity/resources/poll.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource, fields 2 | from flask_jwt_extended import jwt_required, get_jwt_identity 3 | from pillcity.daos.user import find_user_or_raise 4 | from pillcity.daos.post import dangerously_get_post 5 | from pillcity.daos.poll import vote 6 | from .users import user_fields 7 | from .media import MediaUrlV2 8 | 9 | poll_fields = { 10 | 'choices': fields.List(fields.Nested({ 11 | 'id': fields.String(attribute='eid'), 12 | 'content': fields.String, 13 | "media_url_v2": MediaUrlV2(attribute='media'), 14 | 'voters': fields.List(fields.Nested(user_fields)) 15 | })), 16 | 'close_by_seconds': fields.Integer(attribute='close_by') 17 | } 18 | 19 | 20 | class Vote(Resource): 21 | @jwt_required() 22 | def post(self, post_id: str, choice_id: str): 23 | user_id = get_jwt_identity() 24 | user = find_user_or_raise(user_id) 25 | post = dangerously_get_post(post_id) 26 | 27 | vote(user, post, choice_id) 28 | -------------------------------------------------------------------------------- /web/src/components/ClickableId/ClickableId.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import User from "../../models/User"; 3 | import {useHistory} from "react-router-dom"; 4 | import './ClickableId.css' 5 | import getNameAndSubName from "../../utils/getNameAndSubName"; 6 | 7 | interface Props { 8 | user: User | null 9 | } 10 | 11 | const ClickableId = (props: Props) => { 12 | const history = useHistory() 13 | const { user } = props 14 | 15 | const { name, subName } = getNameAndSubName(props.user) 16 | 17 | return ( 18 | { 21 | // This component is sometimes nested in other clickable places so need this 22 | e.stopPropagation() 23 | if (!user) { 24 | return 25 | } 26 | history.push(`/profile/${user.id}`) 27 | }} 28 | > 29 | {name} 30 | {' '} 31 | {subName && {`@${subName}`}} 32 | 33 | ) 34 | } 35 | 36 | export default ClickableId 37 | -------------------------------------------------------------------------------- /web/src/components/DesktopUsers/AddNewCircleButton.css: -------------------------------------------------------------------------------- 1 | .add-new-circle-button-wrapper { 2 | flex-shrink: 0; 3 | margin: 20px 20px; 4 | } 5 | 6 | .add-new-circle-button { 7 | box-shadow: 2px 2px 1px 1px #e0e0e0; 8 | color: white; 9 | background-color: #000000; 10 | border-radius: 50%; 11 | width: 250px; 12 | height: 250px; 13 | font-size: large; 14 | font-weight: bold; 15 | cursor: pointer; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | } 20 | 21 | .add-new-circle-button:hover { 22 | background-color: #e0e0e0; 23 | -webkit-animation: color-change-2x 0.5s ease-in both; 24 | animation: color-change-2x 0.5s ease-in both; 25 | } 26 | 27 | /* Animation */ 28 | @-webkit-keyframes color-change-2x { 29 | 0% { 30 | background: #000000; 31 | } 32 | 100% { 33 | background: #E05140; 34 | } 35 | } 36 | @keyframes color-change-2x { 37 | 0% { 38 | background: #000000; 39 | } 40 | 100% { 41 | background: #E05140; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /web/src/components/RoundAvatar/RoundAvatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {useHistory} from "react-router-dom"; 3 | import User from '../../models/User' 4 | import './RoundAvatar.css' 5 | import AvatarV2 from "../MediaV2/AvatarV2"; 6 | 7 | interface Props { 8 | user: User | null 9 | disableNavigateToProfile?: boolean 10 | } 11 | 12 | const RoundAvatar = (props: Props) => { 13 | const history = useHistory() 14 | 15 | return ( 16 | { 23 | if (props.disableNavigateToProfile) { 24 | return 25 | } 26 | // This component is sometimes nested in other clickable places so need this 27 | e.stopPropagation() 28 | if (props.user === null) { 29 | return 30 | } 31 | history.push(`/profile/${props.user.id}`) 32 | }} 33 | /> 34 | ) 35 | } 36 | 37 | export default RoundAvatar 38 | -------------------------------------------------------------------------------- /web/src/pages/Notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import NotificationList from "../../components/NotificationDropdown/NotificationList"; 3 | import {useAppDispatch, useAppSelector} from "../../store/hooks"; 4 | import {markAllNotificationsAsRead} from "../../store/notificationsSlice"; 5 | import "./Notifications.css" 6 | import {CheckIcon} from "@heroicons/react/solid"; 7 | 8 | interface Props {} 9 | 10 | const Notifications = (_: Props) => { 11 | const dispatch = useAppDispatch() 12 | const unreadNotificationsCount = useAppSelector(state => state.notifications.notifications.filter(n => n.unread).length) 13 | 14 | return ( 15 |
{ 17 | e.preventDefault() 18 | await dispatch(markAllNotificationsAsRead()) 19 | }} 20 | > 21 | {unreadNotificationsCount !== 0 && 22 |
23 | 24 |
25 | } 26 | 27 |
28 | ) 29 | } 30 | 31 | export default Notifications 32 | -------------------------------------------------------------------------------- /web/src/components/About/About.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import api from '../../api/Api' 3 | import './About.css' 4 | 5 | const About = () => { 6 | const webGitCommit = process.env.REACT_APP_GIT_SHA 7 | const [apiGitCommit, updateApiGitCommit] = useState(undefined) 8 | useEffect(() => { 9 | const _fetch = async () => { 10 | updateApiGitCommit(await api.getApiGitCommit()) 11 | } 12 | _fetch() 13 | }, []) 14 | 15 | const githubLink = (commit) => { 16 | return {commit} 17 | } 18 | 19 | return ( 20 |

21 | Web {webGitCommit ? githubLink(webGitCommit) : '?'}{', '} 22 | API {apiGitCommit ? githubLink(apiGitCommit) : '?'}{', '} 23 | GitHub 29 |

30 | ) 31 | } 32 | 33 | export default About; 34 | -------------------------------------------------------------------------------- /web/src/components/PillButtons/PillButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import './PillButton.css' 3 | 4 | export enum PillButtonVariant { 5 | Neutral = 0, 6 | Positive, 7 | } 8 | 9 | const variantToBackgroundColor = (variant: PillButtonVariant) => { 10 | if (variant === PillButtonVariant.Positive) { 11 | return '#E05140' 12 | } else { 13 | return '#727272' 14 | } 15 | } 16 | 17 | interface Props { 18 | text: string 19 | variant: PillButtonVariant 20 | onClick: () => void 21 | disabled?: boolean 22 | } 23 | 24 | const PillButton = (props: Props) => { 25 | return ( 26 |
{ 33 | e.preventDefault() 34 | if (props.disabled) { 35 | return 36 | } 37 | props.onClick() 38 | }} 39 | >{props.text}
40 | ) 41 | } 42 | 43 | export default PillButton 44 | -------------------------------------------------------------------------------- /web/src/pages/ResetPassword/ResetPassword.css: -------------------------------------------------------------------------------- 1 | .reset-password { 2 | padding: 40px 40px 40px 40px; 3 | border-radius: 20px; 4 | background-color: rgba(255, 255, 255, .15); 5 | backdrop-filter: blur(9px); 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | .reset-password-title { 12 | margin-bottom: 24px; 13 | } 14 | 15 | .reset-password-input { 16 | margin-bottom: 12px; 17 | width: 240px; 18 | height: 3em; 19 | padding: 1em; 20 | border-width: 1px; 21 | border-radius: 4px; 22 | border-color: #86989B; 23 | } 24 | 25 | .reset-password-button { 26 | margin-bottom: 12px; 27 | background-color: #E05140; 28 | width: 240px; 29 | color: white; 30 | text-align: center; 31 | padding: 12px; 32 | border-radius: 4px; 33 | font-size: medium; 34 | } 35 | 36 | .reset-password-button-disabled { 37 | background-color: #727272; 38 | } 39 | 40 | .reset-password-button:hover { 41 | cursor: pointer; 42 | } 43 | 44 | .reset-password-button-disabled:hover { 45 | cursor: default; 46 | } 47 | -------------------------------------------------------------------------------- /web/src/pages/ForgetPassword/ForgetPassword.css: -------------------------------------------------------------------------------- 1 | .forget-password { 2 | padding: 40px 40px 40px 40px; 3 | border-radius: 20px; 4 | background-color: rgba(255, 255, 255, .15); 5 | backdrop-filter: blur(9px); 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | .forget-password-title { 12 | margin-bottom: 24px; 13 | } 14 | 15 | .forget-password-input { 16 | margin-bottom: 12px; 17 | width: 240px; 18 | height: 3em; 19 | padding: 1em; 20 | border-width: 1px; 21 | border-radius: 4px; 22 | border-color: #86989B; 23 | } 24 | 25 | .forget-password-button { 26 | margin-bottom: 12px; 27 | background-color: #E05140; 28 | width: 240px; 29 | color: white; 30 | text-align: center; 31 | padding: 12px; 32 | border-radius: 4px; 33 | font-size: medium; 34 | } 35 | 36 | .forget-password-button-disabled { 37 | background-color: #727272; 38 | } 39 | 40 | .forget-password-button:hover { 41 | cursor: pointer; 42 | } 43 | 44 | .forget-password-button-disabled:hover { 45 | cursor: default; 46 | } 47 | -------------------------------------------------------------------------------- /pillcity/tasks/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | import celery 3 | from celery.utils.log import get_task_logger 4 | from pillcity.plugins import get_plugins 5 | 6 | app = celery.Celery( 7 | 'tasks', 8 | broker=os.environ['REDIS_URL'], 9 | include=[ 10 | 'pillcity.tasks.generate_link_preview', 11 | 'pillcity.tasks.process_image' 12 | ] 13 | ) 14 | logger = get_task_logger(__name__) 15 | 16 | # Set up plugin jobs 17 | for plugin_name, plugin in get_plugins().items(): 18 | celery_task_name = f"pillcity.plugins.{plugin_name}.job" 19 | 20 | class _Task(celery.Task): 21 | name = celery_task_name 22 | 23 | def run(self, *args, **kwargs): 24 | plugin.job() 25 | app.register_task(_Task) 26 | 27 | if plugin.job_interval_seconds() >= 60: 28 | # TODO: app.on_after_configure.connect just doesn't seem to work? 29 | app.conf.beat_schedule[plugin_name] = { 30 | 'task': celery_task_name, 31 | 'schedule': float(plugin.job_interval_seconds()) # int will mean minutes instead of seconds 32 | } 33 | 34 | app.conf.timezone = 'UTC' 35 | -------------------------------------------------------------------------------- /web/src/components/LinkPreview/FetchedPreview.css: -------------------------------------------------------------------------------- 1 | .fetched-preview { 2 | margin-bottom: 10px; 3 | border: 1px solid #e0e0e0; 4 | border-radius: 4px; 5 | padding: 10px; 6 | color: #3f3f3f; 7 | cursor: pointer; 8 | } 9 | 10 | .fetched-preview:hover { 11 | background-color: #f0f0f0; 12 | } 13 | 14 | .fetched-preview-with-image { 15 | border-top: 0; 16 | border-radius: 0 0 4px 4px; 17 | } 18 | 19 | .fetched-preview-image-container { 20 | border: 1px solid #e0e0e0; 21 | border-radius: 4px 4px 0 0; 22 | height: 200px; 23 | cursor: pointer; 24 | } 25 | 26 | .fetched-preview-image { 27 | object-fit: cover; 28 | width: 100%; 29 | height: 100%; 30 | border-radius: 4px 4px 0 0; 31 | } 32 | 33 | .fetched-preview-title { 34 | font-size: large; 35 | overflow-wrap: break-word; 36 | white-space: pre-line; 37 | } 38 | 39 | .fetched-preview-subtitle { 40 | margin-top: 10px; 41 | font-size: small; 42 | overflow-wrap: break-word; 43 | white-space: pre-line; 44 | } 45 | 46 | .fetched-preview-link { 47 | color: #56a5ff; 48 | text-decoration: none; 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 KTachibanaM 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#000000", 3 | "background_color": "#A8CFFB", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "Pill City", 8 | "short_name": "Pill City", 9 | "description": "A social network reminiscent of Google+ with enhancements", 10 | "icons": [ 11 | { 12 | "src": "/manifest-icon-192.maskable.png", 13 | "sizes": "192x192", 14 | "type": "image/png", 15 | "purpose": "any" 16 | }, 17 | { 18 | "src": "/manifest-icon-192.maskable.png", 19 | "sizes": "192x192", 20 | "type": "image/png", 21 | "purpose": "maskable" 22 | }, 23 | { 24 | "src": "/manifest-icon-512.maskable.png", 25 | "sizes": "512x512", 26 | "type": "image/png", 27 | "purpose": "any" 28 | }, 29 | { 30 | "src": "/manifest-icon-512.maskable.png", 31 | "sizes": "512x512", 32 | "type": "image/png", 33 | "purpose": "maskable" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /web/src/components/LinkPreview/LinkPreview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import InstantPreview, {getInstantPreview} from "./InstantPreview"; 3 | import FetchedPreview from "./FetchedPreview"; 4 | import './LinkPreview.css' 5 | import {useState} from "react"; 6 | import LinkPreview from "../../models/LinkPreview"; 7 | 8 | interface Props { 9 | preview: LinkPreview, 10 | } 11 | 12 | const LinkPreviewComponent = (props: Props) => { 13 | const { preview } = props 14 | const [clicked, updateClicked] = useState(false) 15 | 16 | const instantPreview = getInstantPreview(preview.url) 17 | const fetchedPreview = ( 18 | { 21 | updateClicked(true) 22 | if (!instantPreview) { 23 | window.open(preview.url, '_blank') 24 | } 25 | }} 26 | /> 27 | ) 28 | 29 | if (instantPreview) { 30 | if (!clicked) { 31 | return fetchedPreview 32 | } else { 33 | return 34 | } 35 | } 36 | 37 | return fetchedPreview 38 | } 39 | 40 | export default LinkPreviewComponent 41 | -------------------------------------------------------------------------------- /web/src/components/EditingMediaCollage/EditingMediaCollage.css: -------------------------------------------------------------------------------- 1 | .editing-media-collage { 2 | margin-top: 10px; 3 | margin-bottom: 10px; 4 | display: flex; 5 | } 6 | 7 | .editing-media-collage-img-container { 8 | position: relative; 9 | z-index: 1; 10 | } 11 | 12 | .editing-media-collage-img { 13 | object-fit: cover; 14 | width: 100%; 15 | border-radius: 4px; 16 | position: absolute; 17 | z-index: 2; 18 | cursor: pointer; 19 | } 20 | 21 | .editing-media-collage-img-index { 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | z-index: 3; 26 | background-color: white; 27 | width:20px; 28 | height: 20px; 29 | border-radius: 50%; 30 | color: #727272; 31 | text-align: center; 32 | } 33 | 34 | .editing-media-collage-img-ops { 35 | position: absolute; 36 | bottom: 0; 37 | right: 0; 38 | z-index: 3; 39 | background-color: white; 40 | display: flex; 41 | flex-direction: row; 42 | border-radius: 4px; 43 | } 44 | 45 | .editing-media-collage-img-op { 46 | width: 20px; 47 | height: 100%; 48 | color: #727272; 49 | cursor: pointer; 50 | } 51 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:3.11-bullseye 2 | 3 | WORKDIR /root 4 | RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.4.0/overmind-v2.4.0-linux-amd64.gz -o overmind.gz 5 | RUN gunzip -d overmind.gz 6 | RUN chmod +x overmind 7 | RUN sudo mv overmind /usr/local/bin 8 | 9 | RUN wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg 10 | RUN echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list 11 | RUN sudo apt update && sudo apt install terraform 12 | 13 | RUN curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - 14 | RUN curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/yarnkey.gpg >/dev/null 15 | RUN echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" | sudo tee /etc/apt/sources.list.d/yarn.list 16 | RUN sudo apt update 17 | RUN sudo apt install -y nodejs gcc g++ make yarn ntp tmux 18 | RUN sudo service ntp restart 19 | -------------------------------------------------------------------------------- /web/src/pages/SignIn/SignIn.css: -------------------------------------------------------------------------------- 1 | .sign-in { 2 | padding: 40px 40px 40px 40px; 3 | border-radius: 20px; 4 | background-color: rgba(255, 255, 255, .15); 5 | backdrop-filter: blur(9px); 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | .sign-in-title { 12 | margin-bottom: 24px; 13 | } 14 | 15 | .sign-in-input { 16 | margin-bottom: 12px; 17 | width: 240px; 18 | height: 3em; 19 | padding: 1em; 20 | border-width: 1px; 21 | border-radius: 4px; 22 | border-color: #86989B; 23 | } 24 | 25 | .sign-in-button { 26 | margin-bottom: 12px; 27 | background-color: #E05140; 28 | width: 240px; 29 | color: white; 30 | text-align: center; 31 | padding: 12px; 32 | border-radius: 4px; 33 | font-size: medium; 34 | } 35 | 36 | .sign-in-button-disabled { 37 | background-color: #727272; 38 | } 39 | 40 | .sign-in-button:hover { 41 | cursor: pointer; 42 | } 43 | 44 | .sign-in-button-disabled:hover { 45 | cursor: default; 46 | } 47 | 48 | .sign-up-message { 49 | cursor: default; 50 | } 51 | 52 | .sign-up-link { 53 | color: #E05140; 54 | } 55 | -------------------------------------------------------------------------------- /web/src/pages/SignUp/SignUp.css: -------------------------------------------------------------------------------- 1 | .sign-up { 2 | padding: 40px 40px 40px 40px; 3 | border-radius: 20px; 4 | background-color: rgba(255, 255, 255, .15); 5 | backdrop-filter: blur(9px); 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | .sign-up-title { 12 | margin-bottom: 24px; 13 | } 14 | 15 | .sign-up-input { 16 | margin-bottom: 12px; 17 | width: 240px; 18 | height: 3em; 19 | padding: 1em; 20 | border-width: 1px; 21 | border-radius: 4px; 22 | border-color: #86989B; 23 | } 24 | 25 | .sign-up-button { 26 | margin-bottom: 12px; 27 | background-color: #E05140; 28 | width: 240px; 29 | color: white; 30 | text-align: center; 31 | padding: 12px; 32 | border-radius: 4px; 33 | font-size: medium; 34 | } 35 | 36 | .sign-up-button-disabled { 37 | background-color: #727272; 38 | } 39 | 40 | .sign-up-button:hover { 41 | cursor: pointer; 42 | } 43 | 44 | .sign-up-button-disabled:hover { 45 | cursor: default; 46 | } 47 | 48 | .sign-in-message { 49 | cursor: default; 50 | } 51 | 52 | .sign-in-link { 53 | color: #E05140; 54 | } 55 | -------------------------------------------------------------------------------- /web/src/components/UploadMedia/UploadMedia.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './UploadMedia.css' 3 | 4 | interface Props { 5 | onChangeMedias: (fl: FileList) => void 6 | onClose: () => void 7 | } 8 | 9 | const UploadMedia = (props: Props) => { 10 | const onDrop = (e: React.DragEvent) => { 11 | e.preventDefault() 12 | props.onChangeMedias(e.dataTransfer.files) 13 | props.onClose() 14 | } 15 | 16 | const onChange = (e: any) => { 17 | e.preventDefault() 18 | props.onChangeMedias(e.target.files) 19 | props.onClose() 20 | } 21 | 22 | return ( 23 | <> 24 | 34 | 41 | 42 | ) 43 | } 44 | 45 | export default UploadMedia 46 | -------------------------------------------------------------------------------- /pillcity/resources/entity_state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from flask_restful import fields 3 | from flask_jwt_extended import get_jwt_identity 4 | from pillcity.daos.user_cache import get_in_user_cache_by_user_id 5 | 6 | class EntityStates(Enum): 7 | Visible = 'visible' 8 | Invisible = 'invisible' 9 | AuthorBlocked = "author_blocked" 10 | Deleted = 'deleted' 11 | 12 | 13 | class EntityState(fields.Raw): 14 | def output(self, key, entity): 15 | author = entity.author 16 | user_id = get_jwt_identity() 17 | user = get_in_user_cache_by_user_id(user_id) 18 | author_blocked = user and author and author in user.blocking 19 | deleted = entity.deleted 20 | if not author_blocked and not deleted: 21 | return EntityStates.Visible.value 22 | if (hasattr(entity, 'reactions2') and not entity.reactions2) \ 23 | and (hasattr(entity, 'comments2') and not entity.comments2) \ 24 | and (hasattr(entity, 'poll') and not entity.poll): 25 | return EntityStates.Invisible.value 26 | if author_blocked: 27 | return EntityStates.AuthorBlocked.value 28 | return EntityStates.Deleted.value 29 | -------------------------------------------------------------------------------- /pillcity/plugins/cloudemoticon/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | import json 4 | from typing import Optional 5 | from flask import Blueprint, jsonify 6 | from pillcity.plugin_core import PillCityPlugin 7 | 8 | 9 | class CloudEmoticon(PillCityPlugin): 10 | def _poll_emoticons(self): 11 | resp = requests.get("https://raw.githubusercontent.com/cloud-emoticon/store-repos/master/kt-favorites.json") 12 | resp = resp.json() 13 | logging.info(f"Polled {len(resp['categories'])} categories") 14 | resp = json.dumps(resp) 15 | self.get_context().redis_set("emoticons", resp) 16 | 17 | def init(self): 18 | self._poll_emoticons() 19 | 20 | def job(self): 21 | logging.info("Polling latest emoticons") 22 | self._poll_emoticons() 23 | 24 | def job_interval_seconds(self) -> int: 25 | return 3600 26 | 27 | def flask_blueprint(self) -> Optional[Blueprint]: 28 | api = Blueprint(__name__, __name__) 29 | 30 | @api.route('/emoticons', methods=['GET']) 31 | def _get_emoticons(): 32 | s = self.get_context().redis_get("emoticons") 33 | return jsonify(json.loads(s)) 34 | 35 | return api 36 | -------------------------------------------------------------------------------- /web/src/components/CreateNewCircle/CreateNewCircle.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react" 2 | import './CreateNewCircle.css' 3 | import PillForm from "../PillForm/PillForm"; 4 | import PillInput from "../PillInput/PillInput"; 5 | import PillButtons from "../PillButtons/PillButtons"; 6 | import PillButton, {PillButtonVariant} from "../PillButtons/PillButton"; 7 | 8 | interface Props { 9 | onCreate: (name: string) => void 10 | onCancel: () => void 11 | } 12 | 13 | const CreateNewCircle = (props: Props) => { 14 | const [name, updateName] = useState('') 15 | 16 | return ( 17 | 18 | 23 | 24 | 29 | { 33 | props.onCreate(name) 34 | }} 35 | /> 36 | 37 | 38 | ) 39 | } 40 | 41 | export default CreateNewCircle 42 | -------------------------------------------------------------------------------- /web/src/components/DesktopUsers/AddNewCircleButton.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import "./AddNewCircleButton.css" 3 | import PillModal from "../PillModal/PillModal"; 4 | import CreateNewCircle from "../CreateNewCircle/CreateNewCircle"; 5 | 6 | interface Props { 7 | onCreate: (name: string) => void 8 | } 9 | 10 | const AddNewCircleButton = (props: Props) => { 11 | const [modalOpened, updateModalOpened] = useState(false) 12 | 13 | return ( 14 | <> 15 |
16 |
{updateModalOpened(true)}} 19 | > 20 | Create new circle 21 |
22 |
23 | {updateModalOpened(false)}} 26 | title="Create new circle" 27 | > 28 | { 30 | updateModalOpened(false) 31 | props.onCreate(name) 32 | }} 33 | onCancel={() => {updateModalOpened(false)}} 34 | /> 35 | 36 | 37 | ) 38 | } 39 | 40 | export default AddNewCircleButton 41 | -------------------------------------------------------------------------------- /web/src/components/EditCircle/AddUserToCircle.css: -------------------------------------------------------------------------------- 1 | .add-user-to-circle-grid-container { 2 | display: grid; 3 | width: 100%; 4 | padding: 15px; 5 | top: 0; 6 | grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); 7 | grid-column-gap: 5px; 8 | grid-row-gap: 5px; 9 | } 10 | 11 | .add-user-to-circle-user-card-wrapper { 12 | background-color: #ffffff; 13 | width: 100%; 14 | border-radius: 5px; 15 | box-shadow: 2px 2px 1px 1px #e0e0e0; 16 | display: flex; 17 | align-items: center; 18 | cursor: pointer; 19 | height: 60px; 20 | } 21 | 22 | .add-user-to-circle-user-card-right { 23 | display: flex; 24 | flex: 1; 25 | align-items: center; 26 | cursor: pointer; 27 | justify-content: space-between; 28 | } 29 | 30 | .add-user-to-circle-user-card-avatar { 31 | flex-shrink: 0; 32 | height: 60px; 33 | width: 60px; 34 | } 35 | 36 | .add-user-to-circle-user-card-avatar-img { 37 | width: 100%; 38 | height: 100%; 39 | } 40 | 41 | .add-user-to-circle-user-card-name { 42 | font-weight: bold; 43 | font-size: large; 44 | margin-left: 20px; 45 | overflow: hidden; 46 | white-space: nowrap; 47 | text-overflow: ellipsis; 48 | } 49 | -------------------------------------------------------------------------------- /web/src/components/Post/LinkPreviews.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import LinkPreview from "../LinkPreview/LinkPreview"; 3 | import Post from "../../models/Post"; 4 | import LinkPreviewModel from "../../models/LinkPreview"; 5 | import {useInterval} from "react-interval-hook"; 6 | import api from "../../api/Api"; 7 | import './LinkPreviews.css' 8 | 9 | interface Props { 10 | post: Post 11 | } 12 | 13 | const LinkPreviews = (props: Props) => { 14 | const [previews, updatePreviews] = useState(props.post.link_previews) 15 | 16 | useInterval(async () => { 17 | for (let preview of previews) { 18 | if (preview.state === 'fetching') { 19 | const newPreview = await api.getLinkPreview(preview.url) 20 | updatePreviews(previews.map(p => { 21 | if (p.url !== preview.url) { 22 | return p 23 | } 24 | return newPreview 25 | })) 26 | } 27 | } 28 | }, 5000, { immediate: true }) 29 | 30 | if (props.post.link_previews.length === 0) { 31 | return null 32 | } 33 | 34 | return ( 35 |
36 | {previews.map(_ => )} 37 |
38 | ) 39 | } 40 | 41 | export default LinkPreviews 42 | -------------------------------------------------------------------------------- /pillcity/models/notification.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from mongoengine import Document, LazyReferenceField, StringField, EnumField, BooleanField, CASCADE 3 | from .created_at_mixin import CreatedAtMixin 4 | from .user import User 5 | 6 | 7 | class NotifyingAction(Enum): 8 | Comment = "comment" 9 | Mention = "mention" 10 | Reaction = "reaction" 11 | Reshare = "reshare" 12 | Follow = "follow" 13 | 14 | 15 | class Notification(Document, CreatedAtMixin): 16 | eid = StringField(required=True) 17 | notifying_action = EnumField(NotifyingAction, required=True) # type: NotifyingAction 18 | unread = BooleanField(required=False, default=True) 19 | 20 | notifier = LazyReferenceField(User, required=True, reverse_delete_rule=CASCADE) # type: User 21 | notifying_href = StringField(required=True) 22 | notifying_summary = StringField(required=False, default='') 23 | notifying_deleted = BooleanField(required=False, default=False) 24 | 25 | owner = LazyReferenceField(User, required=True, reverse_delete_rule=CASCADE) # type: User 26 | notified_href = StringField(required=True) 27 | notified_summary = StringField(required=False, default='') 28 | notified_deleted = BooleanField(required=False, default=False) 29 | -------------------------------------------------------------------------------- /web/src/components/DesktopUsers/DraggableUserCard.css: -------------------------------------------------------------------------------- 1 | .draggable-user-card-wrapper { 2 | background-color: #ffffff; 3 | width: 100%; 4 | height: 100px; 5 | border-radius: 5px; 6 | box-shadow: 2px 2px 1px 1px #e0e0e0; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | cursor: pointer; 11 | } 12 | 13 | .draggable-user-card-left { 14 | display: flex; 15 | flex-direction: row; 16 | align-items: center; 17 | } 18 | 19 | .draggable-user-card-avatar { 20 | height: 100px; 21 | width: 100px; 22 | flex-shrink: 0; 23 | } 24 | 25 | .draggable-user-card-avatar-img { 26 | width: 100%; 27 | height: 100%; 28 | } 29 | 30 | .draggable-user-card-name { 31 | font-weight: bold; 32 | font-size: large; 33 | margin-left: 20px; 34 | overflow: hidden; 35 | white-space: nowrap; 36 | text-overflow: ellipsis; 37 | max-width: 100px; 38 | } 39 | 40 | .draggable-user-card-buttons { 41 | display: flex; 42 | flex-direction: row; 43 | } 44 | 45 | .draggable-user-card-button { 46 | width: 25px; 47 | height: 25px; 48 | margin-right: 15px; 49 | } 50 | 51 | .draggable-user-card-button-disabled { 52 | color: lightgray; 53 | cursor: not-allowed; 54 | } 55 | -------------------------------------------------------------------------------- /web/src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import storage from 'redux-persist/lib/storage'; 3 | import { combineReducers } from 'redux'; 4 | import { 5 | persistReducer, 6 | FLUSH, 7 | REHYDRATE, 8 | PAUSE, 9 | PERSIST, 10 | PURGE, 11 | REGISTER, 12 | } from 'redux-persist'; 13 | import homeReducer from "./homeSlice"; 14 | import meReducer from "./meSlice"; 15 | import notificationsReducer from "./notificationsSlice"; 16 | 17 | const reducers = combineReducers({ 18 | home: homeReducer, 19 | me: meReducer, 20 | notifications: notificationsReducer 21 | }) 22 | 23 | export const persistKey = 'persist' 24 | 25 | const persistConfig = { 26 | key: persistKey, 27 | storage, 28 | whitelist: ['me'], 29 | } 30 | 31 | const persistedReducer = persistReducer(persistConfig, reducers); 32 | 33 | const store = configureStore({ 34 | reducer: persistedReducer, 35 | middleware: (getDefaultMiddleware) => 36 | getDefaultMiddleware({ 37 | serializableCheck: { 38 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], 39 | }, 40 | }), 41 | }) 42 | 43 | export type RootState = ReturnType 44 | export type AppDispatch = typeof store.dispatch 45 | 46 | export default store 47 | -------------------------------------------------------------------------------- /web/src/components/EditCircle/RenameCircle.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import './RenameCircle.css' 3 | import PillInput from "../PillInput/PillInput"; 4 | import PillButtons from "../PillButtons/PillButtons"; 5 | import PillButton, {PillButtonVariant} from "../PillButtons/PillButton"; 6 | import Circle from "../../models/Circle"; 7 | import PillForm from "../PillForm/PillForm"; 8 | 9 | interface Props { 10 | circle: Circle 11 | onUpdate: (name: string) => void 12 | onClose: () => void 13 | } 14 | 15 | const RenameCircle = (props: Props) => { 16 | const {circle, onUpdate, onClose} = props 17 | const [name, updateName] = useState(circle.name) 18 | 19 | return ( 20 | 21 | 26 | 27 | 32 | {onUpdate(name)}} 36 | disabled={!name} 37 | /> 38 | 39 | 40 | ) 41 | } 42 | 43 | export default RenameCircle 44 | -------------------------------------------------------------------------------- /pillcity/resources/invitations_codes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask_restful import Resource, marshal_with, fields 3 | from flask_jwt_extended import jwt_required, get_jwt_identity 4 | from pillcity.daos.invitation_code import create_invitation_code, get_invitation_codes 5 | from .cache import r, RMediaUrl 6 | 7 | admins = list(map(lambda s: s.strip(), os.getenv('ADMINS', '').split(','))) 8 | 9 | invitation_code_fields = { 10 | 'code': fields.String, 11 | 'claimed': fields.Boolean 12 | } 13 | 14 | 15 | class InvitationCodes(Resource): 16 | @jwt_required() 17 | @marshal_with(invitation_code_fields) 18 | def get(self): 19 | user_id = get_jwt_identity() 20 | if user_id not in admins: 21 | return {'msg': 'Not an admin'}, 403 22 | return get_invitation_codes() 23 | 24 | 25 | class InvitationCode(Resource): 26 | @jwt_required() 27 | def post(self): 28 | user_id = get_jwt_identity() 29 | if user_id not in admins: 30 | return {'msg': 'Not an admin'}, 403 31 | return create_invitation_code() 32 | 33 | 34 | class ClearMediaUrlCache(Resource): 35 | @jwt_required() 36 | def post(self): 37 | user_id = get_jwt_identity() 38 | if user_id not in admins: 39 | return {'msg': 'Not an admin'}, 403 40 | r.delete(RMediaUrl) 41 | -------------------------------------------------------------------------------- /terraform/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/terraform 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=terraform 3 | 4 | ### Terraform ### 5 | # Local .terraform directories 6 | **/.terraform/* 7 | 8 | # .tfstate files 9 | *.tfstate 10 | *.tfstate.* 11 | 12 | # Crash log files 13 | crash.log 14 | crash.*.log 15 | 16 | # Exclude all .tfvars files, which are likely to contain sensitive data, such as 17 | # password, private keys, and other secrets. These should not be part of version 18 | # control as they are data points which are potentially sensitive and subject 19 | # to change depending on the environment. 20 | *.tfvars 21 | *.tfvars.json 22 | 23 | # Ignore override files as they are usually used to override resources locally and so 24 | # are not checked in 25 | override.tf 26 | override.tf.json 27 | *_override.tf 28 | *_override.tf.json 29 | 30 | # Include override files you do wish to add to version control using negated pattern 31 | # !example_override.tf 32 | 33 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 34 | # example: *tfplan* 35 | 36 | # Ignore CLI configuration files 37 | .terraformrc 38 | terraform.rc 39 | 40 | # End of https://www.toptal.com/developers/gitignore/api/terraform 41 | 42 | public_key.pem 43 | private_key.pem 44 | -------------------------------------------------------------------------------- /web/src/pages/Admin/Admin.css: -------------------------------------------------------------------------------- 1 | .admin-page { 2 | margin-top: 12px; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | .admin-page-button { 9 | cursor: pointer; 10 | margin-bottom: 20px; 11 | color: #f0f0f0; 12 | background-color: #0c0c0c; 13 | padding: 4px; 14 | border-radius: 2px; 15 | } 16 | 17 | .admin-page-code { 18 | cursor: pointer; 19 | } 20 | 21 | /* Animation */ 22 | 23 | .shake-horizontal { 24 | -webkit-animation: shake-horizontal 0.8s cubic-bezier(0.455, 0.030, 0.515, 0.955) both; 25 | animation: shake-horizontal 0.8s cubic-bezier(0.455, 0.030, 0.515, 0.955) both; 26 | } 27 | 28 | @-webkit-keyframes shake-horizontal { 29 | 0%, 30 | 100% { 31 | -webkit-transform: translateX(0); 32 | transform: translateX(0); 33 | } 34 | 10%, 35 | 30%, 36 | 50%, 37 | 70% { 38 | -webkit-transform: translateX(-10px); 39 | transform: translateX(-10px); 40 | } 41 | 20%, 42 | 40%, 43 | 60% { 44 | -webkit-transform: translateX(10px); 45 | transform: translateX(10px); 46 | } 47 | 80% { 48 | -webkit-transform: translateX(8px); 49 | transform: translateX(8px); 50 | } 51 | 90% { 52 | -webkit-transform: translateX(-8px); 53 | transform: translateX(-8px); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pillcity/models/media.py: -------------------------------------------------------------------------------- 1 | from mongoengine import Document, StringField, LazyReferenceField, IntField, LongField, DO_NOTHING, BooleanField 2 | 3 | 4 | class Media(Document): 5 | # this is the object name (obviously..) 6 | id = StringField(primary_key=True) 7 | # todo: change to required 8 | # DO_NOTHING instead of NULLIFY here because of circular ref to User model 9 | # We should instead manually NULLIFY. 10 | # See https://github.com/MongoEngine/mongoengine/issues/1697 11 | # Missing type because don't want circular ref 12 | owner = LazyReferenceField('User', required=False, default=None, reverse_delete_rule=DO_NOTHING) 13 | # todo: change to required 14 | refs = IntField(required=False, default=-1) 15 | # todo: change to required 16 | created_at = LongField(required=False, default=0) 17 | # todo: change to required 18 | used_at = LongField(required=False, default=0) 19 | processing = BooleanField(required=False, default=False) 20 | processed = BooleanField(required=False, default=False) 21 | width = IntField(required=False, default=None) 22 | height = IntField(required=False, default=None) 23 | dominant_color_hex = StringField(required=False, default=None) 24 | 25 | def get_processed_object_name(self): 26 | return self.id.rsplit('.', 1)[0] + '.processed.webp' 27 | 28 | def should_process(self): 29 | return not self.processed and not self.processing 30 | -------------------------------------------------------------------------------- /web/src/components/DesktopUsers/DesktopUsers.css: -------------------------------------------------------------------------------- 1 | .desktop-users-wrapper { 2 | display: flex; 3 | padding: 5em 2em 0 2em; 4 | } 5 | 6 | .desktop-users-status { 7 | padding: 13px; 8 | background-color: #ffffff; 9 | border-radius: 5px; 10 | box-shadow: 2px 2px 1px 1px #e0e0e0; 11 | margin-bottom: 20px; 12 | color: gray; 13 | width: 100%; 14 | max-width: 100%; 15 | } 16 | 17 | .desktop-users-user-cards-container { 18 | padding: 0 50px; 19 | height: 550px; 20 | overflow-y: scroll; 21 | overflow-x: hidden; 22 | display: grid; 23 | position: absolute; 24 | top: 5em; 25 | width: 100%; 26 | left: 0; 27 | grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); 28 | grid-column-gap: 30px; 29 | grid-row-gap: 30px; 30 | } 31 | 32 | /* Hide scrollbar for Chrome, Safari and Opera */ 33 | .desktop-users-user-cards-container::-webkit-scrollbar { 34 | display: none; 35 | } 36 | 37 | /* Hide scrollbar for IE, Edge and Firefox */ 38 | .desktop-users-user-cards-container { 39 | -ms-overflow-style: none; /* IE and Edge */ 40 | scrollbar-width: none; /* Firefox */ 41 | } 42 | 43 | .desktop-users-boards-wrapper { 44 | overflow-x: scroll; 45 | overflow-y: hidden; 46 | direction:ltr; 47 | position: fixed; 48 | bottom: 0; 49 | left: 0; 50 | width: 100%; 51 | } 52 | 53 | .desktop-users-boards { 54 | height: 100%; 55 | display: flex; 56 | } 57 | -------------------------------------------------------------------------------- /web/src/components/DesktopUsers/DroppableCircleBoard.css: -------------------------------------------------------------------------------- 1 | .droppable-circle-board { 2 | flex-shrink: 0; 3 | background-color: #ffffff; 4 | border-radius: 50%; 5 | width: 250px; 6 | height: 250px; 7 | margin: 20px 20px; 8 | box-shadow: 2px 2px 1px 1px #e0e0e0; 9 | display: flex; 10 | cursor: pointer; 11 | } 12 | 13 | .droppable-circle-board-member-cards-wrapper { 14 | position: relative; 15 | z-index: 0; 16 | height: 0; 17 | width: 0; 18 | } 19 | 20 | .droppable-circle-board-member-card-wrapper { 21 | position: absolute; 22 | } 23 | 24 | .droppable-circle-board-member-card-avatar-img { 25 | width: 100%; 26 | height: 100%; 27 | border-radius: 50%; 28 | } 29 | 30 | .droppable-circle-board-inner-circle { 31 | z-index: 1; 32 | flex-shrink: 0; 33 | border-radius: 50%; 34 | width: 250px; 35 | height: 250px; 36 | display: flex; 37 | flex-direction: column; 38 | align-items: center; 39 | justify-content: center; 40 | } 41 | 42 | .droppable-circle-board-inner-circle-name { 43 | font-weight: bold; 44 | font-size: large; 45 | color: #ffffff; 46 | margin-top: 30px; 47 | padding: 0 10px; 48 | overflow: hidden; 49 | white-space: nowrap; 50 | text-overflow: ellipsis; 51 | max-width: 100%; 52 | text-align: center; 53 | } 54 | 55 | .droppable-circle-board-inner-circle-follow-number { 56 | font-size: large; 57 | color: #ffffff; 58 | margin-top: 20px; 59 | } 60 | -------------------------------------------------------------------------------- /pillcity/daos/user_cache.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from typing import List, Union 3 | from pillcity.models import User 4 | from pillcity.utils.profiling import timer 5 | from .cache import r 6 | 7 | 8 | RUserByUserId = "userByUserId" 9 | RUserByOid = "userByOid" 10 | 11 | # Cache structure within Redis 12 | # "userByUserId" -> user_id -> serialized user 13 | # "userByOid" -> str(oid) -> serialized user 14 | 15 | 16 | def set_in_user_cache(user: User): 17 | r.hset(RUserByUserId, user.user_id, user.to_json()) 18 | r.hset(RUserByOid, str(user.id), user.to_json()) 19 | 20 | 21 | @timer 22 | def get_in_user_cache_by_user_id(user_id: str) -> Union[User, bool]: 23 | r_user = r.hget(RUserByUserId, user_id) 24 | if not r_user: 25 | return False 26 | r_user = r_user.decode('utf-8') 27 | return User.from_json(r_user) 28 | 29 | 30 | @timer 31 | def get_in_user_cache_by_oid(oid: ObjectId) -> Union[User, bool]: 32 | r_user = r.hget(RUserByOid, str(oid)) 33 | if not r_user: 34 | return False 35 | r_user = r_user.decode('utf-8') 36 | return User.from_json(r_user) 37 | 38 | 39 | @timer 40 | def populate_user_cache(): 41 | for user in User.objects(): 42 | set_in_user_cache(user) 43 | 44 | 45 | def get_users_in_user_cache() -> List[User]: 46 | res = [] 47 | for oid, r_user in r.hgetall(RUserByOid).items(): 48 | r_user = r_user.decode('utf-8') 49 | res.append(User.from_json(r_user)) 50 | return list(sorted(res, key=lambda u: u.id)) 51 | -------------------------------------------------------------------------------- /web/src/components/NotificationDropdown/NotificationDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import "./NotificationDropdown.css" 3 | import NotificationList from "./NotificationList"; 4 | import {useAppDispatch, useAppSelector} from "../../store/hooks"; 5 | import {markAllNotificationsAsRead} from "../../store/notificationsSlice"; 6 | import {CheckIcon} from "@heroicons/react/solid"; 7 | 8 | interface Props {} 9 | 10 | const NotificationDropdown = (_: Props) => { 11 | const dispatch = useAppDispatch() 12 | const unreadNotificationsCount = useAppSelector(state => state.notifications.notifications.filter(n => n.unread).length) 13 | 14 | return ( 15 |
16 |
17 |
18 | Notifications {unreadNotificationsCount} 20 | { 21 | unreadNotificationsCount !== 0 &&
{ 22 | e.preventDefault() 23 | await dispatch(markAllNotificationsAsRead()) 24 | }}> 25 | 26 |
27 | } 28 |
29 |
30 | 31 |
32 | ) 33 | } 34 | 35 | export default NotificationDropdown 36 | -------------------------------------------------------------------------------- /pillcity/daos/s3.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import uuid 4 | from typing import Optional 5 | from PIL import Image, UnidentifiedImageError 6 | from pillcity.utils.s3 import get_s3_client 7 | from .cache import r, RMediaUrl 8 | 9 | AllowedImageTypes = ['gif', 'jpeg', 'bmp', 'png', 'webp'] 10 | 11 | 12 | def upload_to_s3(file, object_name_stem: str) -> Optional[str]: 13 | s3_client, s3_bucket_name = get_s3_client() 14 | 15 | # check file size 16 | # flask will limit upload size for us :) 17 | # would return 413 if file is too large 18 | 19 | # save the file 20 | temp_fp = os.path.join(tempfile.gettempdir(), str(uuid.uuid4())) 21 | file.save(temp_fp) 22 | 23 | # check upload format 24 | try: 25 | img_type = Image.open(temp_fp).format.lower() 26 | except UnidentifiedImageError: 27 | return None 28 | if img_type not in AllowedImageTypes: 29 | return None 30 | 31 | # upload the file 32 | object_name = f"{object_name_stem}.{img_type}" 33 | s3_client.upload_file( 34 | Filename=temp_fp, 35 | Bucket=s3_bucket_name, 36 | Key=object_name, 37 | ExtraArgs={ 38 | 'ContentType': f"image/{img_type}", 39 | } 40 | ) 41 | 42 | # update user model 43 | os.remove(temp_fp) 44 | 45 | return object_name 46 | 47 | 48 | def delete_from_s3(object_name: str): 49 | s3_client, s3_bucket_name = get_s3_client() 50 | 51 | s3_client.delete_object(Bucket=s3_bucket_name, Key=object_name) 52 | r.hdel(RMediaUrl, object_name) 53 | -------------------------------------------------------------------------------- /web/src/components/LinkPreview/FetchedPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LinkPreview from "../../models/LinkPreview"; 3 | import summary from "../../utils/summary"; 4 | import './FetchedPreview.css' 5 | 6 | interface Props { 7 | preview: LinkPreview 8 | onClick: () => void 9 | } 10 | 11 | const FetchedPreview = (props: Props) => { 12 | const {preview} = props 13 | if (preview.state !== 'fetched') { 14 | return null 15 | } 16 | 17 | const title = preview.title.trim() 18 | const subtitle = preview.subtitle.trim() 19 | 20 | return ( 21 |
{ 22 | e.preventDefault() 23 | props.onClick() 24 | }}> 25 | { 26 | (preview.image_urls || []).length !== 0 && 27 |
28 | {''} 34 |
35 | 36 | } 37 | { 38 | (title || subtitle) && 39 |
42 |
{summary(title, 100)}
43 |
{summary(subtitle, 150)}
44 |
45 | } 46 |
47 | ) 48 | } 49 | 50 | export default FetchedPreview 51 | -------------------------------------------------------------------------------- /terraform/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "4.48.0" 6 | constraints = "~> 4.0" 7 | hashes = [ 8 | "h1:Fz26mWZmM9syrY91aPeTdd3hXG4DvMR81ylWC9xE2uA=", 9 | "h1:t4+ZVZIg8DbyFTMy4sZcvb7FULMG3mpg9Woh/2IaQ+o=", 10 | "zh:08f5e3c5256a4fbd5c988863d10e5279172b2470fec6d4fb13c372663e7f7cac", 11 | "zh:2a04376b7fa84681bd2938973c7d0822c8c0f0656a4e7661a2f50ac4d852d4a3", 12 | "zh:30d6cdf321aaba874934cbde505333d89d172d8d5ffcf40b6e66626c57bc6ab2", 13 | "zh:364639ee19cf4cfaa65de84a2a71d32725d5b728b71dd88d01ccb639c006c1cf", 14 | "zh:4e02252cd88b6f59f556f49c5ce46a358046c98f069230358ac15f4030ae1e76", 15 | "zh:611717320f20b3512ceb90abddd5198a85e1093965ce59e3ef8183188c84f8c3", 16 | "zh:630be3b9ba5b3a95ecb2ce2f3523714ab37cd8bcd7479c879a769e6a446ab5ed", 17 | "zh:6701f9d3ae1ffadb3ebefbe75c9d82668cc5495b8f826e498adb8530e202b652", 18 | "zh:6dc6fdfa7469c9de7b405c68b2f6a09a3438db1ef09d348e49c7ceff4300b01a", 19 | "zh:84c8140d8af6965fa9cd80e52eb2ee3d273e3ab7762719a8d1af665c08fab748", 20 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 21 | "zh:9b6b4f7d4cea37ba7a42a47d506115498858bcd6440ad97dfb214c13a688ba90", 22 | "zh:a7f876af20f5c5dae8e333ec0dfc901e26aa801137e7df65fb365565637bbfe2", 23 | "zh:ad107b8e11dd0609b856584ce70ae6621aa4f1f946da51f7c792f1259e3f9c27", 24 | "zh:d5dc1683693a5fe2652952f50dbbeccd02716799c26c6d1a1378b226cf845e9b", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /pillcity/daos/invitation_code.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pillcity.models import InvitationCode 3 | from pillcity.utils.make_uuid import make_dashless_uuid 4 | 5 | 6 | def create_invitation_code() -> str: 7 | """ 8 | Create a new invitation code 9 | 10 | :return: The new invitation code 11 | """ 12 | code = make_dashless_uuid() 13 | new_code = InvitationCode() 14 | new_code.code = code 15 | new_code.claimed = False 16 | new_code.save() 17 | return code 18 | 19 | 20 | def check_invitation_code(code: str) -> bool: 21 | """ 22 | Check if an invitation code exists and is unclaimed 23 | 24 | :param code: The checked invitation code 25 | :return: Whether the invitation code exists and is unclaimed 26 | """ 27 | codes = InvitationCode.objects(code=code, claimed=False) 28 | if not codes: 29 | return False 30 | return True 31 | 32 | 33 | def claim_invitation_code(code: str) -> bool: 34 | """ 35 | Claim an invitation code 36 | 37 | :param code: The claimed invitation code 38 | :return: Whether the claim was successful 39 | """ 40 | if not check_invitation_code(code): 41 | return False 42 | code = InvitationCode.objects.get(code=code) 43 | code.claimed = True 44 | code.save() 45 | return True 46 | 47 | 48 | def get_invitation_codes() -> List[InvitationCode]: 49 | """ 50 | Get all invitation codes, reverse chronologically ordered 51 | 52 | :return: All invitation codes 53 | """ 54 | return list(InvitationCode.objects().order_by('-id')) 55 | -------------------------------------------------------------------------------- /pillcity/daos/poll.py: -------------------------------------------------------------------------------- 1 | from pillcity.models import User, Post 2 | from .post import sees_post 3 | from .exceptions import UnauthorizedAccess, BadRequest 4 | from .post_cache import set_in_post_cache, exists_in_post_cache 5 | 6 | 7 | def vote(self: User, parent_post: Post, choice_id: str): 8 | """ 9 | Cast a vote on a post poll 10 | It also removes the user from an existing choice if there is one 11 | 12 | :param self: The acting user 13 | :param parent_post: The post that contains the poll 14 | :param choice_id: The ID for the poll choice 15 | """ 16 | if parent_post.deleted: 17 | raise UnauthorizedAccess() 18 | if not sees_post(self, parent_post, context_home_or_profile=False): 19 | raise UnauthorizedAccess() 20 | if not parent_post.poll: 21 | raise BadRequest() 22 | 23 | for c in parent_post.poll.choices: 24 | if c.eid == choice_id: 25 | # this is the choice that the user casts 26 | if self not in c.voters: 27 | # the user hasn't picked this choice before 28 | c.voters.append(self) 29 | else: 30 | # the user has picked this choice before, remove instead 31 | c.voters.remove(self) 32 | elif self in c.voters: 33 | # this is not hte choice that the use casts but it's the choice before 34 | c.voters.remove(self) 35 | 36 | if exists_in_post_cache(parent_post.id): 37 | # only set in post cache if it already exists 38 | set_in_post_cache(parent_post) 39 | 40 | parent_post.save() 41 | -------------------------------------------------------------------------------- /web/src/components/Toast/ToastProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useCallback } from "react"; 2 | import ToastContainer from "./ToastContainer"; 3 | import {Toast} from './ToastContainer' 4 | 5 | interface Context { 6 | addToast: (content: string, dismissible?: boolean) => number 7 | removeToast: (id: number) => void 8 | } 9 | 10 | const ToastContext = React.createContext(null); 11 | 12 | let id = 1; 13 | 14 | interface Props { 15 | children: JSX.Element 16 | } 17 | 18 | const ToastProvider = ({ children }: Props) => { 19 | const [toasts, updateToasts] = useState([]); 20 | 21 | const addToast = useCallback((content: string, dismissible?: boolean) => { 22 | const newId = id++ 23 | updateToasts(toasts => [ 24 | ...toasts, 25 | { 26 | id: newId, 27 | content, 28 | dismissible: dismissible !== undefined ? dismissible : true 29 | } 30 | ]) 31 | return newId 32 | }, [updateToasts]); 33 | 34 | const removeToast = useCallback((id: number) => { 35 | updateToasts(toasts => toasts.filter(t => t.id !== id)); 36 | }, [updateToasts]); 37 | 38 | return ( 39 | 45 | 46 | {children} 47 | 48 | ); 49 | }; 50 | 51 | // todo: hacky 52 | const useToast = (): Context => { 53 | return useContext(ToastContext) as Context; 54 | }; 55 | 56 | export { ToastContext, useToast }; 57 | export default ToastProvider; 58 | -------------------------------------------------------------------------------- /web/src/components/Post/PostAttachments.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react" 2 | import './PostAttachments.css' 3 | import PillModal from "../PillModal/PillModal"; 4 | import PillTabs from "../PillTabs/PillTabs"; 5 | 6 | export interface PostAttachment { 7 | title: string, 8 | el: JSX.Element 9 | } 10 | 11 | interface Props { 12 | attachments: PostAttachment[] 13 | } 14 | 15 | const PostAttachments = (props: Props) => { 16 | const {attachments} = props 17 | const [showMoreModalOpened, updateShowMoreModalOpened] = useState(false) 18 | 19 | if (attachments.length === 0) { 20 | return null 21 | } 22 | 23 | if (attachments.length === 1) { 24 | return attachments[0].el 25 | } 26 | 27 | return ( 28 | <> 29 | {attachments[0].el} 30 | 34 | { 37 | updateShowMoreModalOpened(false); 38 | }} 39 | title='More post attachments' 40 | > 41 |
42 | { 44 | return { 45 | title: attachment.title, 46 | el: attachment.el 47 | } 48 | })} 49 | /> 50 |
51 |
52 | 53 | ) 54 | } 55 | 56 | export default PostAttachments 57 | -------------------------------------------------------------------------------- /compose.production.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | image: ghcr.io/sekai-soft/pill-city:latest 4 | restart: unless-stopped 5 | container_name: pill-city 6 | env_file: 7 | - env 8 | depends_on: 9 | - mongodb 10 | - redis 11 | worker: 12 | image: ghcr.io/sekai-soft/pill-city:latest 13 | restart: unless-stopped 14 | entrypoint: /home/app/entrypoint-worker.sh 15 | env_file: 16 | - env 17 | depends_on: 18 | - mongodb 19 | - redis 20 | beat: 21 | image: ghcr.io/sekai-soft/pill-city:latest 22 | restart: unless-stopped 23 | entrypoint: /home/app/entrypoint-beat.sh 24 | env_file: 25 | - env 26 | depends_on: 27 | - mongodb 28 | - redis 29 | mongodb: 30 | image: mongo:4.4 31 | restart: unless-stopped 32 | volumes: 33 | - mongodb_data:/data/db 34 | - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro 35 | healthcheck: 36 | test: echo 'db.runCommand("ping").ok' | mongo localhost:27017/test --quiet 37 | interval: 10s 38 | timeout: 10s 39 | retries: 10 40 | env_file: 41 | - env 42 | redis: 43 | image: redis 44 | restart: unless-stopped 45 | volumes: 46 | - redis_data:/data 47 | healthcheck: 48 | test: 49 | - CMD 50 | - redis-cli 51 | - ping 52 | interval: 1s 53 | timeout: 3s 54 | retries: 30 55 | cloudflared: 56 | image: cloudflare/cloudflared 57 | restart: unless-stopped 58 | command: tunnel run pill-city 59 | env_file: 60 | - env 61 | 62 | volumes: 63 | mongodb_data: 64 | redis_data: 65 | -------------------------------------------------------------------------------- /web/src/components/EditCircle/AddUserToCircle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import './AddUserToCircle.css' 3 | import User from "../../models/User"; 4 | import getNameAndSubName from "../../utils/getNameAndSubName"; 5 | import AvatarV2 from "../MediaV2/AvatarV2"; 6 | 7 | interface Props { 8 | users: User[] 9 | onAddUser: (user: User) => void 10 | } 11 | 12 | interface UserCardProps { 13 | user: User 14 | onAddUser: (user: User) => void 15 | } 16 | 17 | const UserCard = (props: UserCardProps) => { 18 | const {user, onAddUser} = props 19 | const { name } = getNameAndSubName(user) 20 | 21 | return ( 22 |
{ 23 | e.preventDefault() 24 | onAddUser(user) 25 | }}> 26 |
27 | 28 |
29 |
30 |
31 | {name} 32 |
33 |
34 |
35 | ) 36 | } 37 | 38 | const AddUserToCircle = (props: Props) => { 39 | const {users, onAddUser} = props 40 | 41 | let userCardElements = [] 42 | for (let user of users) { 43 | userCardElements.push( 44 | 49 | ) 50 | } 51 | 52 | return ( 53 |
54 | {userCardElements} 55 |
56 | ) 57 | } 58 | 59 | export default AddUserToCircle 60 | -------------------------------------------------------------------------------- /web/src/components/LinkPreview/InstantPreview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import YouTube from "react-youtube"; 3 | import './InstantPreview.css' 4 | 5 | const youtubeDomains = [ 6 | "youtube.com", 7 | "www.youtube.com", 8 | "m.youtube.com", 9 | ] 10 | 11 | const youtubeShortDomains = [ 12 | "youtu.be" 13 | ] 14 | 15 | interface YouTubeVideoInstantPreview { 16 | youtubeVideoId: string 17 | } 18 | 19 | type InstantPreviews = YouTubeVideoInstantPreview 20 | 21 | export const getInstantPreview = (url: string): InstantPreviews | undefined => { 22 | let parsedUrl 23 | try { 24 | parsedUrl = new URL(url) 25 | } catch (e) { 26 | return 27 | } 28 | 29 | if (!parsedUrl) { 30 | return 31 | } 32 | 33 | if (youtubeDomains.indexOf(parsedUrl.hostname) !== -1) { 34 | if (parsedUrl.pathname === '/watch') { 35 | const vid = parsedUrl.searchParams.get('v') 36 | if (vid !== null) { 37 | return { 38 | youtubeVideoId: vid 39 | } 40 | } 41 | } 42 | } else if (youtubeShortDomains.indexOf(parsedUrl.hostname) !== -1) { 43 | return { 44 | youtubeVideoId: parsedUrl.pathname.split('/')[1] 45 | } 46 | } 47 | } 48 | 49 | interface Props { 50 | instantPreview: InstantPreviews 51 | } 52 | 53 | const InstantPreview = (props: Props) => { 54 | const { instantPreview } = props 55 | 56 | if ("youtubeVideoId" in instantPreview) { 57 | return ( 58 | 62 | ) 63 | } 64 | return null 65 | } 66 | 67 | export default InstantPreview 68 | -------------------------------------------------------------------------------- /pillcity/resources/reactions.py: -------------------------------------------------------------------------------- 1 | from flask_restful import reqparse, Resource 2 | from flask_jwt_extended import jwt_required, get_jwt_identity 3 | from pillcity.daos.user import find_user 4 | from pillcity.daos.post import dangerously_get_post 5 | from pillcity.daos.reaction import get_reaction, delete_reaction, create_reaction 6 | 7 | reaction_parser = reqparse.RequestParser() 8 | reaction_parser.add_argument('emoji', type=str, required=True) 9 | 10 | 11 | class Reactions(Resource): 12 | @jwt_required() 13 | def post(self, post_id: str): 14 | """ 15 | Creates a new reaction to a post 16 | """ 17 | user_id = get_jwt_identity() 18 | user = find_user(user_id) 19 | post = dangerously_get_post(post_id) 20 | if not post: 21 | return {"msg": "Post is not found"}, 404 22 | reaction_args = reaction_parser.parse_args() 23 | reaction_id = create_reaction(user, reaction_args['emoji'], post) 24 | return {'id': reaction_id}, 201 25 | 26 | 27 | class Reaction(Resource): 28 | @jwt_required() 29 | def delete(self, post_id: str, reaction_id: str): 30 | """ 31 | Remove a reaction from a post 32 | """ 33 | user_id = get_jwt_identity() 34 | user = find_user(user_id) 35 | post = dangerously_get_post(post_id) 36 | if not post: 37 | return {"msg": "Post is not found"}, 404 38 | reaction_to_delete = get_reaction(reaction_id, post) 39 | if not reaction_to_delete: 40 | return {'msg': f'Reaction {reaction_to_delete} is already not in post {post_id}'}, 409 41 | delete_reaction(user, reaction_to_delete, post) 42 | -------------------------------------------------------------------------------- /web/src/components/FormattedContent/FormattedContent.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactNode} from 'react'; 2 | import FormattedContent, {FormattedContentSegment} from "../../models/FormattedContent"; 3 | 4 | interface Props { 5 | fc: FormattedContent 6 | className?: string 7 | } 8 | 9 | const convertSegment = (s: FormattedContentSegment, references: string[]): ReactNode => { 10 | let node: ReactNode = s.content; 11 | if (s.types.includes("strikethrough")) { 12 | node = {node} 13 | } 14 | if (s.types.includes("bold")) { 15 | node = {node} 16 | } 17 | if (s.types.includes("italic")) { 18 | node = {node} 19 | } 20 | if (s.types.includes("url") && s.reference !== undefined && s.reference < references.length) { 21 | node = 25 | {node} 26 | 27 | } 28 | if (s.types.includes("mention") && s.reference !== undefined && s.reference < references.length) { 29 | node = 32 | {node} 33 | 34 | } 35 | return node 36 | } 37 | 38 | const FormattedContentComponent = (props: Props) => { 39 | const { fc, className } = props; 40 | 41 | return ( 42 |
43 | {fc.segments.map((segment, index) => { 44 | return ( 45 | {convertSegment(segment, fc.references)} 46 | ) 47 | })} 48 |
49 | ) 50 | } 51 | 52 | export default FormattedContentComponent 53 | -------------------------------------------------------------------------------- /web/src/components/Post/NestedComment.css: -------------------------------------------------------------------------------- 1 | .post-nested-comment { 2 | display: flex; 3 | margin-top: 3px; 4 | } 5 | 6 | .highlight-comment { 7 | background: #f0f0f0; 8 | border: 2px solid #f0f0f0; 9 | border-radius: 2px; 10 | } 11 | 12 | .post-nested-comment-avatar { 13 | height: 20px; 14 | width: 20px; 15 | flex-shrink: 0; 16 | } 17 | 18 | .post-nested-comment-name { 19 | font-size: small; 20 | font-weight: bold; 21 | margin-left: 3px; 22 | } 23 | 24 | .post-nested-comment-reply-to { 25 | height: 15px; 26 | width: 15px; 27 | margin-top: 2px; 28 | color: #777777; 29 | cursor: pointer; 30 | } 31 | 32 | .post-nested-comment-content { 33 | overflow-wrap: break-word; 34 | flex: 1; 35 | min-width: 0; 36 | margin-left: 3px; 37 | white-space: pre-line; 38 | } 39 | 40 | .post-nested-comment-actions { 41 | display: flex; 42 | align-items: center; 43 | } 44 | 45 | .post-nested-comment-time { 46 | font-size: smaller; 47 | color: #767676; 48 | } 49 | 50 | .post-nested-comment-reply-btn { 51 | margin-left: 5px; 52 | font-size: x-small; 53 | background-color: #f0f0f0; 54 | padding: 1px 2px; 55 | border-radius: 2px; 56 | font-weight: bold; 57 | cursor: pointer; 58 | } 59 | 60 | .post-nested-comment-reply-btn:hover { 61 | background-color: #e7e7e7; 62 | } 63 | 64 | .post-nested-comment-more-actions-trigger { 65 | color: #bdbdbd; 66 | width: 17px; 67 | height: 17px; 68 | border-radius: 2px; 69 | padding: 2px; 70 | cursor: pointer; 71 | margin-left: 5px; 72 | } 73 | 74 | .post-nested-comment-more-actions-trigger:hover { 75 | background-color: #f0f0f0; 76 | } 77 | 78 | -------------------------------------------------------------------------------- /web/src/components/NavBar/NavBar.css: -------------------------------------------------------------------------------- 1 | .nav-bar-container { 2 | position: fixed; 3 | background-color: #0c0c0c; 4 | width: 100%; 5 | display: flex; 6 | z-index: 3; 7 | cursor: pointer; 8 | } 9 | 10 | .nav-bar-top { 11 | top: 0; 12 | } 13 | 14 | .nav-bar-bottom { 15 | bottom: 0; 16 | padding-bottom: env(safe-area-inset-bottom); 17 | } 18 | 19 | .nav-bar-button-container { 20 | margin-top: 5px; 21 | margin-bottom: 5px; 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | .nav-bar-button-container-spaced { 27 | width: 25%; 28 | justify-content: center; 29 | } 30 | 31 | .nav-bar-button-container-aligned { 32 | padding-left: 20px; 33 | padding-right: 20px; 34 | } 35 | 36 | .nav-bar-button-active { 37 | background-color: #262626; 38 | border-radius: 5px; 39 | } 40 | 41 | .nav-bar-button-container>svg { 42 | width: 22px; 43 | height: 40px; 44 | color: white; 45 | } 46 | 47 | .nav-bar-button-text { 48 | margin-left: 6px; 49 | color: #ffffff; 50 | } 51 | 52 | .nav-bar-name-and-avatar { 53 | margin-left: auto; 54 | margin-right: 10px; 55 | } 56 | 57 | .nav-bar-avatar { 58 | width: 30px; 59 | height: 30px; 60 | margin-left: 10px; 61 | } 62 | 63 | .mobile-nav-bar-avatar { 64 | width: 30px; 65 | height: 30px; 66 | } 67 | 68 | .nav-bar-notification-indicator-wrapper { 69 | position: relative; 70 | width: 0; 71 | height: 0; 72 | bottom: 14px; 73 | left: 24px; 74 | } 75 | 76 | .nav-bar-notification-indicator { 77 | position: absolute; 78 | width: 10px; 79 | height: 10px; 80 | border-radius: 50%; 81 | background-color: #ff6969; 82 | right: 0; 83 | top: 0; 84 | } 85 | -------------------------------------------------------------------------------- /web/src/components/Post/Comment.css: -------------------------------------------------------------------------------- 1 | .post-comment { 2 | display: flex; 3 | margin-top: 10px; 4 | } 5 | 6 | .highlight-comment { 7 | background: #f0f0f0; 8 | border: 2px solid #f0f0f0; 9 | border-radius: 2px; 10 | } 11 | 12 | .post-comment-avatar { 13 | width: 35px; 14 | height: 35px; 15 | flex-shrink: 0; 16 | } 17 | 18 | .post-comment-main-content { 19 | flex: 1; 20 | min-width: 0; 21 | margin-left: 10px; 22 | } 23 | 24 | .post-comment-name { 25 | font-weight: bold; 26 | font-size: medium; 27 | } 28 | 29 | .post-comment-content { 30 | max-width: 100%; 31 | overflow-wrap: break-word; 32 | white-space: pre-line; 33 | } 34 | 35 | .post-comment-content-summary { 36 | text-overflow: ellipsis; 37 | display: -webkit-box; 38 | -webkit-line-clamp: 10; 39 | -webkit-box-orient: vertical; 40 | overflow: hidden; 41 | } 42 | 43 | .post-comment-actions { 44 | display: flex; 45 | margin-top: 5px; 46 | align-items: center; 47 | } 48 | 49 | .post-time { 50 | font-size: smaller; 51 | color: #767676; 52 | } 53 | 54 | .post-comment-reply-btn { 55 | font-size: smaller; 56 | background-color: #f0f0f0; 57 | padding: 2px 5px; 58 | border-radius: 2px; 59 | font-weight: bold; 60 | cursor: pointer; 61 | margin-left: 5px; 62 | } 63 | 64 | .post-comment-reply-btn:hover { 65 | background-color: #e7e7e7; 66 | } 67 | 68 | .post-comment-more-actions-trigger { 69 | color: #bdbdbd; 70 | width: 25px; 71 | height: 25px; 72 | border-radius: 2px; 73 | padding: 5px; 74 | cursor: pointer; 75 | margin-left: 5px; 76 | } 77 | 78 | .post-comment-more-actions-trigger:hover { 79 | background-color: #f0f0f0; 80 | } 81 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ref https://ep2020.europython.eu/media/conference/slides/CeKGczx-best-practices-for-production-ready-docker-packaging.pdf 2 | # ref https://pythonspeed.com/articles/multi-stage-docker-python/ 3 | 4 | # builder outputs a virtualenv with installed dependencies 5 | FROM python:3.11-slim-buster AS builder 6 | 7 | # makes sure system is up-to-date 8 | RUN apt-get update 9 | RUN apt-get install -y --no-install-recommends build-essential git 10 | 11 | # use regular user 12 | RUN useradd --create-home app 13 | USER app 14 | WORKDIR /home/app 15 | 16 | # creates a venv and install dependencies 17 | RUN python -m venv venv 18 | ENV PATH="./venv/bin:$PATH" 19 | COPY requirements.txt . 20 | COPY requirements.prod.txt . 21 | RUN python -m pip install -U pip==24.0 setuptools wheel 22 | RUN pip install -r requirements.txt 23 | RUN pip install -r requirements.prod.txt 24 | 25 | # runner intakes the builder's virtualenv, does various things and define an entrypoint 26 | FROM python:3.11-slim-buster AS runner 27 | ARG GIT_COMMIT 28 | RUN test -n "$GIT_COMMIT" 29 | ENV GIT_COMMIT=$GIT_COMMIT 30 | 31 | # use regular user 32 | RUN useradd --create-home app 33 | USER app 34 | WORKDIR /home/app 35 | 36 | # intakes the virtualenv from builder 37 | COPY --from=builder /home/app/venv ./venv 38 | 39 | # copy in only neccessary files 40 | COPY pillcity/ /home/app/pillcity 41 | COPY app.py . 42 | COPY release.py . 43 | COPY entrypoint-worker.sh . 44 | COPY entrypoint-release.sh . 45 | COPY entrypoint-beat.sh . 46 | COPY swagger.yaml . 47 | COPY uwsgi.ini . 48 | 49 | # pre-compile bytecode and enable PYTHONFAULTHANDLER (catches error in c) 50 | ENV PATH="./venv/bin:$PATH" 51 | ENV PYTHONFAULTHANDLER=1 52 | EXPOSE 5000 53 | ENTRYPOINT ["/home/app/venv/bin/uwsgi", "--ini", "uwsgi.ini"] 54 | -------------------------------------------------------------------------------- /pillcity/tests/daos/user_test.py: -------------------------------------------------------------------------------- 1 | from .base_test_case import BaseTestCase 2 | from pillcity.daos.user import sign_up, sign_in, find_user, follow, unfollow 3 | from pillcity.daos.exceptions import BadRequest 4 | 5 | 6 | class TestUserDao(BaseTestCase): 7 | def test_user_successful_check_after_create(self): 8 | self.assertTrue(sign_up('official', '1234')) 9 | self.assertTrue(sign_up('user1', '1234')) 10 | self.assertEqual('user1', sign_in('user1', '1234').user_id) 11 | self.assertIn(find_user('official'), find_user('user1').followings) 12 | 13 | def test_user_failure_check_wrong_password(self): 14 | self.assertTrue(sign_up('user1', '1234')) 15 | self.assertFalse(sign_in('user1', '2345')) 16 | 17 | def test_user_failure_create_duplicate(self): 18 | self.assertTrue(sign_up('user1', '1234')) 19 | self.assertFalse(sign_up('user1', '1234')) 20 | 21 | def test_user_successful_find(self): 22 | self.assertTrue(sign_up('user1', '1234')) 23 | self.assertEqual('user1', find_user('user1').user_id) 24 | 25 | def test_user_failure_find_not_found(self): 26 | self.assertTrue(sign_up('user1', '1234')) 27 | self.assertFalse(find_user('user2')) 28 | 29 | def test_following(self): 30 | self.assertTrue(sign_up('user1', '1234')) 31 | self.assertTrue(sign_up('user2', '2345')) 32 | user1 = find_user('user1') 33 | user2 = find_user('user2') 34 | 35 | follow(user1, user2) 36 | 37 | def op(): 38 | follow(user1, user2) 39 | self.assertRaises(BadRequest, op) 40 | 41 | unfollow(user1, user2) 42 | 43 | def op2(): 44 | unfollow(user1, user2) 45 | self.assertRaises(BadRequest, op2) 46 | -------------------------------------------------------------------------------- /web/src/components/PillDropdownMenu/PillDropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef, useState, useEffect} from "react"; 2 | import './PillDropdownMenu.css' 3 | 4 | export interface DropdownMenuItem { 5 | text: string 6 | onClick: () => void 7 | } 8 | 9 | interface Props { 10 | items: DropdownMenuItem[] 11 | children: JSX.Element, 12 | } 13 | 14 | const PillDropdownMenu = (props: Props) => { 15 | const dropdownRef = useRef(null); 16 | const [isActive, updateIsActive] = useState(false); 17 | 18 | useEffect(() => { 19 | const pageClickEvent = (e: any) => { 20 | if (dropdownRef.current !== null && !(dropdownRef.current as any).contains(e.target)) { 21 | updateIsActive(!isActive); 22 | } 23 | }; 24 | if (isActive) { 25 | window.addEventListener('click', pageClickEvent); 26 | } 27 | return () => { 28 | window.removeEventListener('click', pageClickEvent); 29 | } 30 | }, [isActive]); 31 | 32 | return ( 33 |
34 |
updateIsActive(!isActive)} 37 | > 38 | {props.children} 39 |
40 |
43 | {props.items.map((item, i) => { 44 | return ( 45 |
{ 49 | e.preventDefault() 50 | item.onClick() 51 | }} 52 | >{item.text}
53 | ) 54 | })} 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default PillDropdownMenu 61 | -------------------------------------------------------------------------------- /web/src/models/Post.ts: -------------------------------------------------------------------------------- 1 | import User from "./User"; 2 | import {AnonymizedCircle} from "./Circle"; 3 | import MediaUrlV2 from "./MediaUrlV2"; 4 | import LinkPreview from "./LinkPreview"; 5 | import EntityState from "./EntityState"; 6 | import FormattedContent from "./FormattedContent"; 7 | 8 | interface BaseComment { 9 | id: string 10 | created_at_seconds: number 11 | author: User 12 | content: string 13 | formatted_content?: FormattedContent 14 | media_urls_v2: MediaUrlV2[] 15 | reply_to_comment_id: string 16 | state: EntityState 17 | } 18 | 19 | export type NestedComment = BaseComment 20 | 21 | export interface Comment extends BaseComment { 22 | comments: BaseComment[] 23 | } 24 | 25 | export interface Reaction { 26 | id: string 27 | emoji: string 28 | author: User 29 | } 30 | 31 | export interface ResharedPost { 32 | id: string 33 | created_at_seconds: number 34 | author: User 35 | content: string, 36 | formatted_content?: FormattedContent 37 | media_urls_v2: MediaUrlV2[] 38 | poll: Poll | null, 39 | state: EntityState 40 | } 41 | 42 | export interface PollChoice { 43 | id: string, 44 | content: string, 45 | media_url_v2?: MediaUrlV2 46 | voters: User[] 47 | } 48 | 49 | export interface Poll { 50 | choices: PollChoice[] 51 | close_by_seconds: number 52 | } 53 | 54 | export default interface Post { 55 | id: string 56 | created_at_seconds: number 57 | author: User 58 | is_public: boolean 59 | reshareable: boolean 60 | reshared_from: ResharedPost | null, 61 | reactions: Reaction[], 62 | comments: Comment[], 63 | circles: AnonymizedCircle[], 64 | media_urls_v2: MediaUrlV2[] 65 | content: string, 66 | formatted_content?: FormattedContent, 67 | is_update_avatar: boolean 68 | poll: Poll | null, 69 | link_previews: LinkPreview[] 70 | state: EntityState 71 | } 72 | -------------------------------------------------------------------------------- /web/src/components/MobileUsers/MobileUsers.css: -------------------------------------------------------------------------------- 1 | .mobile-users-wrapper { 2 | display: flex; 3 | padding: 0; 4 | } 5 | 6 | .mobile-users-status { 7 | padding: 13px; 8 | background-color: #ffffff; 9 | border-radius: 5px; 10 | box-shadow: 2px 2px 1px 1px #e0e0e0; 11 | margin-bottom: 20px; 12 | color: gray; 13 | width: 100%; 14 | max-width: 100%; 15 | } 16 | 17 | .mobile-users-grid-container { 18 | display: grid; 19 | width: 100%; 20 | padding: 15px; 21 | top: 0; 22 | grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); 23 | grid-column-gap: 5px; 24 | grid-row-gap: 5px; 25 | } 26 | 27 | .mobile-users-user-card-wrapper { 28 | background-color: #ffffff; 29 | width: 100%; 30 | border-radius: 5px; 31 | box-shadow: 2px 2px 1px 1px #e0e0e0; 32 | display: flex; 33 | align-items: center; 34 | cursor: pointer; 35 | height: 60px; 36 | } 37 | 38 | .mobile-users-user-card-right { 39 | display: flex; 40 | flex: 1; 41 | align-items: center; 42 | cursor: pointer; 43 | justify-content: space-between; 44 | } 45 | 46 | .mobile-users-user-card-avatar { 47 | flex-shrink: 0; 48 | height: 60px; 49 | width: 60px; 50 | } 51 | 52 | .mobile-users-user-card-avatar-img { 53 | width: 100%; 54 | height: 100%; 55 | } 56 | 57 | .mobile-users-user-card-name { 58 | font-weight: bold; 59 | font-size: large; 60 | margin-left: 20px; 61 | overflow: hidden; 62 | white-space: nowrap; 63 | text-overflow: ellipsis; 64 | max-width: 10em; 65 | } 66 | 67 | .mobile-users-user-card-buttons { 68 | display: flex; 69 | flex-direction: row; 70 | } 71 | 72 | .mobile-users-user-card-button { 73 | width: 25px; 74 | height: 25px; 75 | margin-right: 15px; 76 | } 77 | 78 | .mobile-users-user-card-button-disabled { 79 | color: lightgray; 80 | cursor: not-allowed; 81 | } 82 | -------------------------------------------------------------------------------- /web/src/components/NotificationDropdown/NotificationList.tsx: -------------------------------------------------------------------------------- 1 | import NotificationItem from "./NotificaitonItem"; 2 | import React from "react"; 3 | import {useAppDispatch, useAppSelector} from "../../store/hooks"; 4 | import {loadMoreNotifications} from "../../store/notificationsSlice"; 5 | 6 | interface Props {} 7 | 8 | const NotificationList = (_: Props) => { 9 | const dispatch = useAppDispatch() 10 | 11 | const notifications = useAppSelector(state => state.notifications.notifications) 12 | const loading = useAppSelector(state => state.notifications.loading) 13 | const loadingMore = useAppSelector(state => state.notifications.loadingMore) 14 | 15 | if (loading) { 16 | return ( 17 |
Loading...
21 | ) 22 | } 23 | if (notifications.length === 0) { 24 | return ( 25 |
No notification
29 | ) 30 | } 31 | const res = [] 32 | for (let i = 0; i < notifications.length; i++) { 33 | const notification = notifications[i] 34 | res.push() 38 | 39 | } 40 | if (!loadingMore) { 41 | res.push( 42 |
{ 46 | await dispatch(loadMoreNotifications()) 47 | }} 48 | >Load more
49 | ) 50 | } else { 51 | res.push( 52 |
Loading...
56 | ) 57 | } 58 | return ( 59 |
60 | {res} 61 |
62 | ) 63 | } 64 | 65 | export default NotificationList 66 | -------------------------------------------------------------------------------- /web/src/components/MediaV2/MediaV2.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MediaUrlV2, {ProcessedMedia} from "../../models/MediaUrlV2"; 3 | import { LazyImage } from 'react-lazy-images'; 4 | 5 | interface Props extends React.ImgHTMLAttributes { 6 | mediaUrlV2: MediaUrlV2, 7 | } 8 | 9 | const MediaV2 = (props: Props) => { 10 | const {mediaUrlV2, ...imageProps} = props 11 | 12 | if (!props.mediaUrlV2.processed) { 13 | return ( 14 | ( 18 | 27 | )} 28 | // @ts-ignore 29 | actual={({ imageProps: actualImageProps }) => ( 30 | 35 | )} 36 | /> 37 | ) 38 | } 39 | const processed = props.mediaUrlV2 as ProcessedMedia 40 | return ( 41 | ( 45 | 54 | )} 55 | // @ts-ignore 56 | actual={({ imageProps: actualImageProps }) => ( 57 | 62 | )} 63 | /> 64 | ) 65 | } 66 | 67 | export default MediaV2 68 | -------------------------------------------------------------------------------- /web/src/components/NotificationDropdown/NotificationItem.css: -------------------------------------------------------------------------------- 1 | .notification-wrapper { 2 | padding: 5px; 3 | border-bottom: 2px solid #f1f1f1; 4 | } 5 | 6 | .notification-first-row { 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | cursor: pointer; 11 | } 12 | 13 | .notification-info { 14 | display: flex; 15 | align-items: center; 16 | } 17 | 18 | .notification-second-row { 19 | margin-left: 35px; 20 | color: #767676; 21 | cursor: pointer; 22 | margin-top: 5px; 23 | overflow-wrap: break-word; 24 | white-space: pre-line; 25 | } 26 | 27 | .notification-avatar { 28 | width: 30px; 29 | height: 30px; 30 | align-self: flex-start; 31 | flex-shrink: 0; 32 | } 33 | 34 | .notification-notifier { 35 | max-width: 90%; 36 | padding-left: 3px; 37 | overflow-wrap: break-word; 38 | } 39 | 40 | .notification-notifier-wrapper { 41 | width: 100%; 42 | word-break: break-word; 43 | } 44 | 45 | .notification-notifier-id { 46 | color: #0d71bb; 47 | } 48 | 49 | .notification-time { 50 | color: #767676; 51 | align-self: flex-start; 52 | } 53 | 54 | .notification-summary { 55 | display: inline; 56 | inline-size: 100%; 57 | color: #767676; 58 | overflow-wrap: break-word; 59 | white-space: pre-line; 60 | } 61 | 62 | .notification-status { 63 | padding: 6px; 64 | width: 100%; 65 | text-align: center; 66 | color: lightgray; 67 | } 68 | 69 | /* Mobile */ 70 | @media only screen and (max-width: 750px) { 71 | .notification-wrapper { 72 | padding: 15px; 73 | } 74 | 75 | .notification-avatar { 76 | width: 40px; 77 | height: 40px; 78 | } 79 | 80 | .notification-first-row>*{ 81 | font-size: larger; 82 | } 83 | 84 | .notification-notifier{ 85 | padding-left: 10px; 86 | } 87 | 88 | .notification-second-row { 89 | margin-left: 50px; 90 | } 91 | } 92 | 93 | -------------------------------------------------------------------------------- /web/src/components/ContentTextarea/ContentTextarea.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; 3 | import MentionAutoCompleteUserItem from "./MentionAutoCompleteUserItem"; 4 | import User from "../../models/User"; 5 | import api from '../../api/Api' 6 | import "@webscopeio/react-textarea-autocomplete/style.css"; 7 | 8 | interface Props { 9 | content: string 10 | onChange: (newContent: string) => void 11 | onAddMedia: (fl: FileList) => void 12 | disabled: boolean 13 | textAreaClassName?: string 14 | placeholder?: string 15 | } 16 | 17 | const ContentTextarea = (props: Props) => { 18 | return ( 19 | 20 | className={props.textAreaClassName} 21 | value={props.content} 22 | onChange={(e: any) => { 23 | props.onChange(e.target.value) 24 | }} 25 | disabled={props.disabled} 26 | placeholder={props.placeholder} 27 | loadingComponent={() => {return null}} 28 | trigger={{ 29 | "@": { 30 | dataProvider: (keyword) => api.searchUsers(keyword), 31 | component: MentionAutoCompleteUserItem, 32 | output: (item, trigger) => trigger+item.id, 33 | allowWhitespace: true 34 | } 35 | }} 36 | style={{ 37 | fontSize: '14px' 38 | }} 39 | itemStyle={{ 40 | fontSize: '14px' 41 | }} 42 | dropdownStyle={{ 43 | zIndex: 999 44 | }} 45 | onPaste={e => { 46 | const files = e.clipboardData.files 47 | if (files.length === 0) { 48 | return 49 | } 50 | for (let i = 0; i < files.length; ++i) { 51 | const f = files[i] 52 | if (!f.type.startsWith("image/")) { 53 | return 54 | } 55 | } 56 | e.preventDefault() 57 | props.onAddMedia(files) 58 | }} 59 | /> 60 | ) 61 | } 62 | 63 | export default ContentTextarea 64 | -------------------------------------------------------------------------------- /web/src/pages/ForgetPassword/ForgetPassword.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from "../../components/HomePage/HomePage"; 2 | import React, {useEffect, useState} from "react"; 3 | import './ForgetPassword.css' 4 | import {validateEmail} from "../../utils/validators"; 5 | import api from "../../api/Api"; 6 | 7 | const ForgetPasswordForm = () => { 8 | const [email, updateEmail] = useState('') 9 | const [forgetPasswordLoading, updateForgetPasswordLoading] = useState(false) 10 | const [formValidated, updateFormValidated] = useState(false) 11 | useEffect(() => { 12 | if (!validateEmail(email)) { 13 | updateFormValidated(false) 14 | return 15 | } 16 | updateFormValidated(true) 17 | }, [email]) 18 | 19 | const forgetPassword = async () => { 20 | updateForgetPasswordLoading(true) 21 | try { 22 | await api.forgetPassword(email) 23 | alert('Your password reset email has been sent. Please follow instructions on the email to reset your password') 24 | } catch (e: any) { 25 | if (e.message) { 26 | alert(e.message) 27 | } else { 28 | console.error(e) 29 | } 30 | } finally { 31 | updateForgetPasswordLoading(false) 32 | } 33 | } 34 | 35 | return ( 36 |
37 |

Forget password?

38 | updateEmail(e.target.value)} 44 | /> 45 |
Send password reset email
49 |
50 | ) 51 | } 52 | 53 | const ForgetPassword = () => { 54 | return ( 55 | }/> 56 | ) 57 | } 58 | 59 | export default ForgetPassword 60 | -------------------------------------------------------------------------------- /web/src/components/PillModal/PillModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "react-modal"; 2 | import React, {CSSProperties} from "react"; 3 | import {useMediaQuery} from "react-responsive"; 4 | import {XIcon} from "@heroicons/react/solid"; 5 | import './PillModal.css' 6 | 7 | interface Props { 8 | children: any 9 | isOpen: boolean 10 | onClose: () => void 11 | title: string 12 | } 13 | 14 | const PillModal = (props: Props) => { 15 | const isMobile = useMediaQuery({query: '(max-width: 750px)'}) 16 | let styles: CSSProperties 17 | if (isMobile) { 18 | styles = { 19 | backgroundColor: '#ffffff', 20 | borderRadius: '0', 21 | resize: 'none', 22 | top: '0', 23 | left: '0', 24 | right: '0', 25 | paddingLeft: '0', 26 | paddingRight: '0', 27 | paddingTop: '0', 28 | paddingBottom: '25px' 29 | } 30 | } else { 31 | styles = { 32 | backgroundColor: '#ffffff', 33 | borderRadius: '5px', 34 | boxShadow: '2px 2px 1px 1px #e0e0e0', 35 | resize: 'none', 36 | top: '50%', 37 | left: '50%', 38 | right: 'auto', 39 | bottom: 'auto', 40 | marginRight: '-50%', 41 | transform: 'translate(-50%, -50%)', 42 | width: '800px', 43 | padding: '0' 44 | } 45 | } 46 | 47 | return ( 48 | 57 |
58 |
59 |
{props.title}
60 |
61 | 62 |
63 |
64 |
65 | {props.children} 66 |
67 |
68 | ) 69 | } 70 | 71 | export default PillModal 72 | -------------------------------------------------------------------------------- /web/src/pages/Circles/Circles.css: -------------------------------------------------------------------------------- 1 | .circles-wrapper { 2 | display: flex; 3 | padding: 0; 4 | } 5 | 6 | .circles-status { 7 | padding: 13px; 8 | background-color: #ffffff; 9 | border-radius: 5px; 10 | box-shadow: 2px 2px 1px 1px #e0e0e0; 11 | margin-bottom: 20px; 12 | color: gray; 13 | width: 100%; 14 | max-width: 100%; 15 | } 16 | 17 | .circles-grid-container { 18 | display: grid; 19 | width: 100%; 20 | padding: 15px; 21 | top: 0; 22 | grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); 23 | grid-column-gap: 5px; 24 | grid-row-gap: 5px; 25 | } 26 | 27 | .circles-circle-card-wrapper { 28 | background-color: #ffffff; 29 | width: 100%; 30 | border-radius: 5px; 31 | box-shadow: 2px 2px 1px 1px #e0e0e0; 32 | display: flex; 33 | align-items: center; 34 | cursor: pointer; 35 | height: 60px; 36 | justify-content: space-between; 37 | } 38 | 39 | .circles-circle-card-name { 40 | font-weight: bold; 41 | font-size: large; 42 | margin-left: 20px; 43 | overflow: hidden; 44 | white-space: nowrap; 45 | text-overflow: ellipsis; 46 | } 47 | 48 | .circles-circle-card-right { 49 | display: flex; 50 | flex-direction: row; 51 | align-items: center; 52 | } 53 | 54 | .circles-circle-card-count-icon { 55 | width: 15px; 56 | height: 15px; 57 | margin-right: 5px; 58 | color: #727272; 59 | } 60 | 61 | .circles-circle-card-count { 62 | margin-right: 15px; 63 | color: #727272; 64 | } 65 | 66 | .circles-circle-card-button { 67 | width: 25px; 68 | height: 25px; 69 | margin-right: 15px; 70 | color: #565656; 71 | } 72 | 73 | .circles-create-circle-button { 74 | position: fixed; 75 | bottom: calc(60px + env(safe-area-inset-bottom)); 76 | right: 10px; 77 | width: 60px; 78 | height: 60px; 79 | background-color: #E05140; 80 | border-radius: 50%; 81 | padding: 15px; 82 | color: white; 83 | } 84 | 85 | .circles-create-circle-button:hover { 86 | cursor: pointer; 87 | } 88 | -------------------------------------------------------------------------------- /pillcity/daos/link_preview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib.parse 3 | import ipaddress 4 | from typing import Optional 5 | from mongoengine.errors import ValidationError 6 | from pillcity.models import LinkPreview, LinkPreviewState 7 | from pillcity.tasks.generate_link_preview import generate_link_preview 8 | from pillcity.utils.now import now_seconds 9 | 10 | 11 | MaxRetries = 15 12 | 13 | 14 | def is_url_private(url: str): 15 | parsed_url = urllib.parse.urlparse(url) 16 | host = parsed_url.hostname 17 | if host == 'localhost': 18 | return True 19 | try: 20 | ip = ipaddress.ip_address(host) 21 | return ip.is_private 22 | except ValueError: 23 | return False 24 | 25 | 26 | def get_link_preview(url: str) -> Optional[LinkPreview]: 27 | if is_url_private(url): 28 | return None 29 | try: 30 | link_previews = LinkPreview.objects(url=url) 31 | if not link_previews: 32 | new_link_preview = LinkPreview( 33 | url=url, 34 | state=LinkPreviewState.Fetching 35 | ) 36 | new_link_preview.save() 37 | generate_link_preview.delay(url) 38 | return new_link_preview 39 | 40 | link_preview = link_previews[0] # type: LinkPreview 41 | now = now_seconds() 42 | if link_preview.state == LinkPreviewState.Errored: 43 | if link_preview.errored_retries < MaxRetries: 44 | if now >= link_preview.errored_next_refetch_seconds: 45 | link_preview.errored_next_refetch_seconds = now + (2 ** link_preview.errored_retries) 46 | link_preview.errored_retries += 1 47 | link_preview.save() 48 | generate_link_preview.delay(url) 49 | else: 50 | logging.info(f"Haven't got to the next link preview time for url {url}") 51 | else: 52 | logging.info(f"Exhausted link preview retries for url {url}") 53 | return link_preview 54 | except (ValueError, ValidationError): 55 | return None 56 | -------------------------------------------------------------------------------- /pillcity/tasks/generate_link_preview.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib.parse 3 | import linkpreview 4 | from mongoengine import connect, disconnect 5 | from pillcity.models import LinkPreview, LinkPreviewState 6 | from .celery import app, logger 7 | 8 | twitter_domains = [ 9 | "twitter.com", 10 | "www.twitter.com", 11 | "mobile.twitter.com", 12 | "x.com", 13 | "www.x.com", 14 | "mobile.x.com" 15 | ] 16 | 17 | 18 | def _is_twitter(url: str) -> bool: 19 | parsed_url = urllib.parse.urlparse(url) 20 | if parsed_url.netloc in twitter_domains: 21 | return True 22 | return False 23 | 24 | 25 | def _get_nitter_url(url: str) -> str: 26 | parsed_url = urllib.parse.urlparse(url) 27 | parsed_url = parsed_url._replace(netloc=os.environ['NITTER_HOST']) 28 | nitter_https = os.environ['NITTER_HTTPS'] == 'true' 29 | parsed_url = parsed_url._replace(scheme='https' if nitter_https else 'http') 30 | return parsed_url.geturl() 31 | 32 | 33 | @app.task() 34 | def generate_link_preview(url: str): 35 | connect(host=os.environ['MONGODB_URI']) 36 | logger.info(f'Generating link preview for url {url}') 37 | link_preview = LinkPreview.objects.get(url=url) # type: LinkPreview 38 | try: 39 | processed_url = url 40 | if _is_twitter(url): 41 | processed_url = _get_nitter_url(url) 42 | 43 | proxies = {} 44 | if link_preview.errored_retries > 0 and 'LINK_PREVIEW_RETRY_PROXIES' in os.environ: 45 | proxies = {"http": os.environ['LINK_PREVIEW_RETRY_PROXIES'], "https": os.environ['LINK_PREVIEW_RETRY_PROXIES']} 46 | preview = linkpreview.link_preview(processed_url, proxies=proxies) 47 | 48 | link_preview.title = preview.title 49 | link_preview.subtitle = preview.description 50 | if preview.absolute_image: 51 | link_preview.image_urls = [preview.absolute_image] 52 | link_preview.state = LinkPreviewState.Fetched 53 | except Exception as e: 54 | logger.warn(str(e)) 55 | link_preview.state = LinkPreviewState.Errored 56 | link_preview.save() 57 | disconnect() 58 | -------------------------------------------------------------------------------- /web/src/pages/Admin/Admin.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react' 2 | import api from "../../api/Api"; 3 | import './Admin.css' 4 | 5 | const Admin = () => { 6 | const [invitationCodes, updateInvitationCodes] = useState([]) 7 | 8 | useEffect(() => { 9 | const _fetch = async () => { 10 | const invitationCodes = await api.getInvitationCodes() 11 | updateInvitationCodes(invitationCodes) 12 | } 13 | _fetch() 14 | }, []) 15 | 16 | const codeElem = (i, ic) => { 17 | return ( 18 |

{ 22 | await navigator.clipboard.writeText(ic.code) 23 | updateInvitationCodes(invitationCodes.map(iic => { 24 | if (iic.code === ic.code) { 25 | return { 26 | ...iic, clicked: true 27 | } 28 | } else { 29 | return iic 30 | } 31 | })) 32 | }} 33 | style={{ 34 | textDecoration: ic.claimed ? 'line-through' : null, 35 | color: ic.isNewCode ? "#3bc75b" : "#333" 36 | }} 37 | >{ic.code}

38 | ) 39 | } 40 | return ( 41 |
42 |

Invitation Codes

43 |
{ 44 | e.preventDefault() 45 | const newCode = await api.createInvitationCode() 46 | updateInvitationCodes([ 47 | {code: newCode, claimed: false, isNewCode: true, clicked: false}, 48 | ...invitationCodes.map(ic => {return {...ic, clicked: false}}) 49 | ]) 50 | }}>Make a new invitation code
51 | {invitationCodes.map((ic, i) => { 52 | return ( 53 | codeElem(i, ic) 54 | ) 55 | })} 56 |

Cache

57 |
{ 58 | e.preventDefault() 59 | await api.clearMediaUrlCache() 60 | }}>Clear media URL cache
61 |
62 | ) 63 | } 64 | 65 | export default Admin 66 | -------------------------------------------------------------------------------- /scripts/dev-aws-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pushd terraform || exit 4 | 5 | if [ ! -f private_key.pem ]; then 6 | echo "Generating CloudFront signing private key" 7 | openssl genrsa -out private_key.pem 2048 8 | fi 9 | if [ ! -f public_key.pem ]; then 10 | echo "Generating CloudFront signing public key" 11 | openssl rsa -pubout -in private_key.pem -out public_key.pem 12 | fi 13 | CF_SIGNER_PRIVATE_KEY_ENCODED=$(base64 -w 0 -i private_key.pem) 14 | 15 | echo "Applying Terraform" 16 | export AWS_PROFILE=PillCityDevTerraform 17 | terraform init 18 | terraform apply -auto-approve 19 | S3_BUCKET_NAME=$(cat terraform.tfstate | jq -r '.resources[] | select(.name=="pill-city-bucket") .instances[0].attributes.bucket') 20 | AWS_ACCESS_KEY=$(cat terraform.tfstate | jq -r '.resources[] | select(.name=="pill-city-admin-user-secret") .instances[0].attributes.id') 21 | AWS_SECRET_KEY=$(cat terraform.tfstate | jq -r '.resources[] | select(.name=="pill-city-admin-user-secret") .instances[0].attributes.secret') 22 | CF_SIGNER_KEY_ID=$(cat terraform.tfstate | jq -r '.resources[] | select(.name=="pill-city-cf-public-key") .instances[0].attributes.id') 23 | CF_DISTRIBUTION_DOMAIN_NAME=$(cat terraform.tfstate | jq -r '.resources[] | select(.name=="pill-city-cf-distribution") .instances[0].attributes.domain_name') 24 | 25 | popd || exit 26 | 27 | if ! grep -q S3_BUCKET_NAME= ".env"; then 28 | echo "S3_BUCKET_NAME=${S3_BUCKET_NAME}" >> .env 29 | fi 30 | if ! grep -q AWS_ACCESS_KEY= ".env"; then 31 | echo "AWS_ACCESS_KEY=${AWS_ACCESS_KEY}" >> .env 32 | fi 33 | if ! grep -q AWS_SECRET_KEY= ".env"; then 34 | echo "AWS_SECRET_KEY=${AWS_SECRET_KEY}" >> .env 35 | fi 36 | if ! grep -q CF_SIGNER_PRIVATE_KEY_ENCODED= ".env"; then 37 | echo "CF_SIGNER_PRIVATE_KEY_ENCODED=${CF_SIGNER_PRIVATE_KEY_ENCODED}" >> .env 38 | fi 39 | if ! grep -q CF_SIGNER_KEY_ID= ".env"; then 40 | echo "CF_SIGNER_KEY_ID=${CF_SIGNER_KEY_ID}" >> .env 41 | fi 42 | if ! grep -q CF_DISTRIBUTION_DOMAIN_NAME= ".env"; then 43 | echo "CF_DISTRIBUTION_DOMAIN_NAME=${CF_DISTRIBUTION_DOMAIN_NAME}" >> .env 44 | fi 45 | 46 | rm pill-city-dev-env.zip 47 | zip pill-city-dev-env.zip terraform/private_key.pem terraform/public_key.pem terraform/terraform.tfstate .env 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pill-city 2 | A social network reminiscent of Google+ with enhancements 3 | 4 | 5 | ## Prerequisites 6 | 7 | 0. [Open the project in VSCode using devcontainer](https://code.visualstudio.com/docs/devcontainers/containers#:~:text=Start%20VS%20Code%2C%20run%20the,set%20up%20the%20container%20for.) 8 | 9 | 1. Prepare environment files 10 | 11 | ```bash 12 | cp .example.env .env 13 | cp ./web/.env.development ./web/.env.development.local 14 | ``` 15 | 16 | 2. Setup AWS for development 17 | 18 | * Obtain an AWS account and setup admin credentials locally 19 | * Go to [Add user UI on IAM Dashboard](https://us-east-1.console.aws.amazon.com/iam/home#/users$new?step=details) 20 | * Enter `PillCityDevTerraform` for `User name` 21 | * Select `Programmatic access` in `Access type` 22 | * Click `Next: Permissions` 23 | * Select `Attach existing policies directly` in `Set permissions` 24 | * Search for `AdministratorAccess` and select it 25 | * Click `Next: Tags` 26 | * Click `Next: Review` 27 | * Click `Create user` 28 | * Copy the `Access key ID` and `Secret access key` to your clipboard 29 | * Edit the file `~/.aws/credentials` and fill in the `` and `` 30 | * Run `make dev-aws-setup` to provision AWS resources and add related environment variables 31 | * **Save the resulting `pill-city-dev-env.zip` (right click the file in VSCode and click "Download") to your host machine because there is currently no way to persist the generated files from above steps** 32 | 33 | 34 | ## Run 35 | ``` shell 36 | overmind s 37 | ``` 38 | The API will be running at `localhost:5000` 39 | 40 | 41 | ## Dump dummy data into server 42 | Make sure you have the server running 43 | ``` shell 44 | make dev-dump 45 | ``` 46 | Use ID `kele` and password `1234` to log in 47 | 48 | 49 | ## Run the web UI 50 | See [README for web](./web/README.md) 51 | 52 | 53 | ## Run unit tests 54 | ``` shell 55 | make test 56 | ``` 57 | 58 | 59 | ## Run API database schema migration 60 | Make sure you have the API running 61 | ``` shell 62 | make dev-release 63 | ``` 64 | 65 | 66 | ## Security 67 | Please send security findings to [`admin@ktachibana.party`](mailto:admin@ktachibana.party). 68 | -------------------------------------------------------------------------------- /web/src/components/Post/ResharedPost.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {useHistory} from "react-router-dom"; 3 | import RoundAvatar from "../RoundAvatar/RoundAvatar"; 4 | import {ResharedPost} from "../../models/Post"; 5 | import './ResharedPost.css' 6 | import ClickableId from "../ClickableId/ClickableId"; 7 | import MediaV2Collage from "../MediaV2Collage/MediaV2Collage"; 8 | import Poll from "../Poll/Poll"; 9 | import User from "../../models/User"; 10 | import FormattedContent from "../FormattedContent/FormattedContent"; 11 | 12 | interface Props { 13 | resharedFrom: ResharedPost, 14 | showDetail: boolean, 15 | me: User 16 | } 17 | 18 | const ResharedPostComponent = (props: Props) => { 19 | const { resharedFrom } = props 20 | 21 | const history = useHistory() 22 | 23 | return ( 24 | <> 25 |
{ 26 | e.preventDefault() 27 | history.push(`/post/${resharedFrom.id}`) 28 | }}> 29 |
30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 |
38 | { 39 | resharedFrom.state !== 'deleted' ? 40 | resharedFrom.formatted_content && : 41 |
This post has been deleted
42 | } 43 |
44 |
45 |
46 | { 47 | resharedFrom.state !== 'deleted' && 48 | 49 | } 50 | { 51 | resharedFrom.state !== 'deleted' && resharedFrom.poll !== null && 52 | 53 | } 54 |
55 | 56 | ) 57 | } 58 | 59 | export default ResharedPostComponent 60 | -------------------------------------------------------------------------------- /web/src/components/Post/Reactions.css: -------------------------------------------------------------------------------- 1 | .post-reactions-wrapper { 2 | display: flex; 3 | max-width: 80%; 4 | flex-wrap: wrap; 5 | } 6 | 7 | .post-reaction { 8 | padding: 2px 8px; 9 | margin-right: 5px; 10 | margin-top: 3px; 11 | border-radius: 4px; 12 | max-height: 25px; 13 | background-color: #f0f0f0; 14 | color: #767676; 15 | 16 | font-weight: bold; 17 | font-size: small; 18 | display: flex; 19 | align-items: center; 20 | cursor: pointer; 21 | } 22 | 23 | .post-reaction-icon { 24 | width: 25px; 25 | height: 25px; 26 | padding: 5px 27 | } 28 | 29 | .post-reaction:hover { 30 | background-color: #e7e7e7; 31 | } 32 | 33 | .post-reaction-loading { 34 | cursor: default; 35 | background-color: #fafafa; 36 | } 37 | 38 | .post-reaction-loading:hover { 39 | background-color: #fafafa; 40 | } 41 | 42 | .post-reaction-active { 43 | background-color: #e0f4ff; 44 | border: #9dd0ff 1px solid; 45 | } 46 | 47 | .post-reaction-active:hover { 48 | background-color: #e0f4ff; 49 | } 50 | 51 | .post-emoji { 52 | font-size: large; 53 | font-weight: 400; 54 | } 55 | 56 | #post-reaction-emoji-picker-wrapper { 57 | position: relative; 58 | width: 0; 59 | height: 0; 60 | z-index: 3; 61 | } 62 | 63 | .post-reaction-emoji-picker { 64 | position: absolute; 65 | top: 10px; 66 | } 67 | 68 | .post-reaction-emoji-picker-mobile { 69 | width: 100% 70 | } 71 | 72 | .post-reactions-detail-emoji { 73 | font-size: 1.2em; 74 | margin-bottom: 8px; 75 | padding: 2px; 76 | border-bottom: 2px #f0f0f0 solid; 77 | } 78 | 79 | .post-reactions-detail-author-wrapper { 80 | display: flex; 81 | align-items: center; 82 | margin-bottom: 12px; 83 | } 84 | 85 | .post-reactions-detail-author-avatar { 86 | width: 30px; 87 | height: 30px; 88 | align-self: flex-start; 89 | flex-shrink: 0; 90 | } 91 | 92 | .post-reactions-detail-author { 93 | padding-left: 3px; 94 | width: 100%; 95 | overflow-wrap: break-word; 96 | word-break: break-word; 97 | color: #0d71bb; 98 | font-weight: bold; 99 | } 100 | -------------------------------------------------------------------------------- /pillcity/daos/media.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from pillcity.models import Media, User 3 | from pillcity.utils.now import now_seconds 4 | from pillcity.tasks.process_image import process_image 5 | from .s3 import upload_to_s3, delete_from_s3 6 | 7 | 8 | def get_media(object_name: str) -> Media: 9 | media = Media.objects.get(id=object_name) # type: Media 10 | 11 | if media.should_process(): 12 | process_image.delay(object_name) 13 | 14 | return media 15 | 16 | 17 | def get_media_page(owner: User, page_number: int, page_count: int) -> List[Media]: 18 | """ 19 | Get a page of media items owned by a user, reverse chronologically ordered 20 | """ 21 | return list( 22 | Media.objects(owner=owner).order_by('-created_at', '+id').skip(page_number * page_count).limit(page_count) 23 | ) 24 | 25 | 26 | def create_media(file, object_name_stem: str, owner: User) -> Optional[Media]: 27 | object_name = upload_to_s3(file, object_name_stem) 28 | if not object_name: 29 | return None 30 | 31 | media = Media() 32 | media.id = object_name 33 | media.owner = owner 34 | media.refs = 0 35 | 36 | now = now_seconds() 37 | media.created_at = now 38 | media.used_at = now 39 | 40 | # have to force save for some reason... 41 | # https://github.com/MongoEngine/mongoengine/issues/1246 42 | media.save(force_insert=True) 43 | 44 | # trigger async job to process the image 45 | process_image.delay(object_name) 46 | 47 | return media 48 | 49 | 50 | def use_media(media: Media): 51 | media.refs += 1 52 | media.used_at = now_seconds() 53 | media.save() 54 | 55 | 56 | def use_media_list(media_list: List[Media]): 57 | for media in media_list: 58 | use_media(media) 59 | 60 | 61 | def delete_media(media: Media): 62 | # we can always fetch because all reference to Media is Lazy... 63 | media = media.fetch() 64 | media.refs -= 1 65 | if media.refs <= 0: 66 | object_name = media.id 67 | media.delete() 68 | delete_from_s3(object_name) 69 | else: 70 | media.save() 71 | 72 | 73 | def delete_media_list(media_list: List[Media]): 74 | for media in media_list: 75 | delete_media(media) 76 | -------------------------------------------------------------------------------- /pillcity/daos/plaintext_notification.py: -------------------------------------------------------------------------------- 1 | from pillcity.models import Notification, NotifyingAction, User 2 | from .user_cache import get_in_user_cache_by_oid 3 | 4 | # TODO: this is all duplicate with frontend lol 5 | action_to_word = { 6 | NotifyingAction.Mention: 'mentioned', 7 | NotifyingAction.Reshare: 'reshared', 8 | NotifyingAction.Comment: 'commented', 9 | NotifyingAction.Reaction: 'reacted', 10 | NotifyingAction.Follow: 'followed', 11 | } 12 | 13 | 14 | def plaintext_user(user: User) -> str: 15 | if not user: 16 | return 'Someone' 17 | if user.display_name: 18 | return f'{user.display_name} (@{user.user_id})' 19 | return user.user_id 20 | 21 | 22 | def plaintext_summary(s: str, length: int) -> str: 23 | if len(s) > length: 24 | return f"{s[: length]}..." 25 | return s 26 | 27 | 28 | def plaintext_notification(notification: Notification) -> str: 29 | res = '' 30 | 31 | if notification.notifying_action != NotifyingAction.Mention: 32 | notifier = notification.notifier if not notification.notifying_deleted else None 33 | else: 34 | notifier = notification.notifier if not notification.notified_deleted else None 35 | if notifier: 36 | notifier = get_in_user_cache_by_oid(notifier.id) 37 | res += plaintext_user(notifier) 38 | res += ' ' 39 | 40 | res += action_to_word.get(notification.notifying_action, '') 41 | res += ' ' 42 | 43 | if notification.notifying_action in {NotifyingAction.Mention, NotifyingAction.Follow}: 44 | res += "you" 45 | else: 46 | res += '"' 47 | res += plaintext_summary(notification.notifying_summary, 150) 48 | res += '"' 49 | res += ' ' 50 | 51 | if notification.notifying_action != NotifyingAction.Follow: 52 | if notification.notifying_action == NotifyingAction.Mention: 53 | pronoun = "their" 54 | else: 55 | pronoun = "your" 56 | location_type = '' 57 | if '#comment-' in notification.notified_href: 58 | location_type = 'comment' 59 | elif '/post/' in notification.notified_href: 60 | location_type = 'post' 61 | res += f'on {pronoun} {location_type}' 62 | 63 | return res.strip() 64 | -------------------------------------------------------------------------------- /web/src/components/AddPoll/AddPoll.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import PillButton, {PillButtonVariant} from "../PillButtons/PillButton"; 3 | import PillButtons from "../PillButtons/PillButtons"; 4 | import PillInput from "../PillInput/PillInput"; 5 | import {XCircleIcon} from "@heroicons/react/solid"; 6 | import './AddPoll.css' 7 | import {AddPollChoice} from "../NewPost/NewPost"; 8 | import PillForm from "../PillForm/PillForm"; 9 | 10 | 11 | interface Props { 12 | choices: AddPollChoice[] 13 | onChangeChoices: (choices: AddPollChoice[]) => void 14 | onDone: () => void 15 | } 16 | 17 | const AddPoll = (props: Props) => { 18 | const {choices, onChangeChoices} = props 19 | const [newChoiceText, updateNewChoiceText] = useState('') 20 | 21 | return ( 22 | 23 | {choices.length > 0 ? 24 |
25 | {choices.map((c, i) => { 26 | return ( 27 |
28 |
{c.text}
29 | { 30 | e.preventDefault() 31 | onChangeChoices(choices.filter((_, ii) => i !== ii)) 32 | }}/> 33 |
34 | ) 35 | })} 36 |
: <> 37 | } 38 |
39 | 44 |
45 | 46 | { 51 | onChangeChoices([...choices, { 52 | text: newChoiceText 53 | }]) 54 | updateNewChoiceText('') 55 | }} 56 | /> 57 | 62 | 63 |
64 | ) 65 | } 66 | 67 | export default AddPoll 68 | -------------------------------------------------------------------------------- /web/src/components/NotificationDropdown/NotificationDropdown.css: -------------------------------------------------------------------------------- 1 | .notification-container { 2 | width: 100%; 3 | border-radius: 5px; 4 | background-color: #ffffff; 5 | box-shadow: 2px 2px 1px 1px #e0e0e0; 6 | max-height: 40vh; 7 | flex-grow: 1; 8 | overflow-y: scroll; 9 | } 10 | 11 | .notification-header-wrapper { 12 | position: -webkit-sticky; 13 | position: sticky; 14 | background-color: white; 15 | top: 0; 16 | } 17 | 18 | .notification-header { 19 | padding: 10px 5px; 20 | border-bottom: 3px solid #f1f1f1; 21 | display: flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | } 25 | 26 | .notification-title { 27 | font-size: large; 28 | } 29 | 30 | .notification-count { 31 | padding: 1px 5px; 32 | border-radius: 4px; 33 | font-size: medium; 34 | } 35 | 36 | .notification-count-grey { 37 | background-color: #c7c7c7; 38 | color: #505050; 39 | } 40 | 41 | .notification-count-red { 42 | background-color: #E05140; 43 | color: white; 44 | } 45 | 46 | .notification-mark-all-button { 47 | width: 20px; 48 | height: 20px; 49 | cursor: pointer; 50 | } 51 | 52 | .notification-mark-all-button:hover { 53 | color: #0d71bb; 54 | } 55 | 56 | .notification-load-more { 57 | padding: 6px; 58 | width: 100%; 59 | cursor: pointer; 60 | text-align: center; 61 | color: cornflowerblue; 62 | } 63 | 64 | .notification-load-more-disabled { 65 | color: lightgray; 66 | cursor: not-allowed; 67 | } 68 | 69 | .notification-load-more:hover { 70 | color: #0d71bb; 71 | } 72 | 73 | .notification-load-more-disabled:hover { 74 | color: lightgray; 75 | } 76 | 77 | /* Hide scrollbar for Chrome, Safari and Opera */ 78 | .notification-container::-webkit-scrollbar { 79 | display: none; 80 | } 81 | 82 | /* Hide scrollbar for IE, Edge and Firefox */ 83 | .notification-container { 84 | -ms-overflow-style: none; /* IE and Edge */ 85 | scrollbar-width: none; /* Firefox */ 86 | } 87 | 88 | /* Mobile */ 89 | /* Notifications will be shown as a page on mobile devices */ 90 | @media only screen and (max-width: 750px) { 91 | .notification-container { 92 | max-height: calc(100vh - 50px); 93 | overflow-y: scroll; 94 | } 95 | } 96 | 97 | -------------------------------------------------------------------------------- /web/src/components/DesktopUsers/DesktopUsers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DraggableUserCard from "./DraggableUserCard"; 3 | import AddNewCircleButton from "./AddNewCircleButton"; 4 | import DroppableCircleBoard from "./DroppableCircleBoard"; 5 | import "./DesktopUsers.css" 6 | import User from "../../models/User"; 7 | import Circle from "../../models/Circle"; 8 | 9 | interface Props { 10 | loading: boolean 11 | users: User[], 12 | followings: User[] 13 | updateFollowings: (v: User[]) => void 14 | circles: Circle[] 15 | createCircle: (name: string) => void 16 | updateCircle: (circle: Circle) => void 17 | deleteCircle: (circle: Circle) => void 18 | } 19 | 20 | const DesktopUsers = (props: Props) => { 21 | const {loading, users, followings, updateFollowings, circles, updateCircle, deleteCircle} = props 22 | 23 | if (loading) { 24 | return ( 25 |
26 |
Loading...
27 |
28 | ) 29 | } 30 | 31 | const circleElements = [ 32 | 36 | ] 37 | 38 | for (let c of circles) { 39 | circleElements.push( 40 | { 45 | deleteCircle(c) 46 | }} 47 | users={users} 48 | /> 49 | ) 50 | } 51 | 52 | return ( 53 |
54 |
55 | {users.map((user, i) => { 56 | return ( 57 | _.id).indexOf(user.id) !== -1} 61 | updateFollowing={f => { 62 | if (f) { 63 | updateFollowings([...followings, user]) 64 | } else { 65 | updateFollowings(followings.filter(_ => _.id !== user.id)) 66 | } 67 | }} 68 | /> 69 | ) 70 | })} 71 |
72 |
73 |
74 | {circleElements} 75 |
76 |
77 |
78 | ) 79 | } 80 | 81 | export default DesktopUsers 82 | -------------------------------------------------------------------------------- /web/src/components/Post/Post.css: -------------------------------------------------------------------------------- 1 | .post-wrapper { 2 | padding: 13px; 3 | background-color: #ffffff; 4 | border-radius: 5px; 5 | box-shadow: 2px 2px 1px 1px #e0e0e0; 6 | margin-bottom: 10px; 7 | width: 100%; 8 | } 9 | 10 | .post-avatar { 11 | width: 40px; 12 | height: 40px; 13 | flex-shrink: 0; 14 | } 15 | 16 | .post-op-info-wrapper { 17 | display: flex; 18 | justify-content: space-between; 19 | } 20 | 21 | .post-op-info-wrapper > * { 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | .post-more-actions-trigger { 27 | color: #bdbdbd; 28 | width: 25px; 29 | height: 25px; 30 | border-radius: 7px; 31 | padding: 5px; 32 | cursor: pointer; 33 | } 34 | 35 | .post-more-actions-trigger:hover { 36 | background-color: #e7e7e7; 37 | } 38 | 39 | .post-op-info-time { 40 | color: #767676; 41 | cursor: pointer; 42 | margin-right: 4px; 43 | } 44 | 45 | .post-name { 46 | margin: 0 10px; 47 | font-size: large; 48 | font-weight: bold; 49 | } 50 | 51 | .post-visibility { 52 | font-size: larger; 53 | font-weight: bold; 54 | color: cornflowerblue; 55 | } 56 | 57 | .post-time { 58 | font-size: smaller; 59 | color: #767676; 60 | } 61 | 62 | /*Post Content*/ 63 | .post-content { 64 | overflow-wrap: break-word; 65 | white-space: pre-line; 66 | margin: 15px 0; 67 | } 68 | 69 | .post-attachments-wrapper { 70 | margin-bottom: 15px; 71 | } 72 | 73 | /*Post Interactions*/ 74 | .post-interactions-wrapper { 75 | width: 100%; 76 | display: flex; 77 | justify-content: space-between; 78 | align-items: flex-start; 79 | } 80 | 81 | .post-interactions { 82 | display: flex; 83 | } 84 | 85 | .post-circle-button { 86 | margin-left: 12px; 87 | background-color: #f0f0f0; 88 | color: #767676; 89 | border-radius: 4px; 90 | width: 35px; 91 | height: 35px; 92 | padding: 7px; 93 | cursor: pointer; 94 | } 95 | 96 | .post-circle-button:hover { 97 | background-color: #e7e7e7; 98 | } 99 | 100 | .post-delete-button { 101 | color: white; 102 | background-color: pink; 103 | } 104 | 105 | .post-delete-button:hover { 106 | background-color: lightpink; 107 | } 108 | 109 | @media only screen and (max-width: 750px) { 110 | .post-wrapper { 111 | border-radius: 0; 112 | margin-bottom: 2px; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /web/src/pages/Settings/Settings.css: -------------------------------------------------------------------------------- 1 | .settings-wrapper { 2 | display: flex; 3 | padding: 5em 2em 0 2em; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | .settings-status { 9 | padding: 13px; 10 | background-color: #ffffff; 11 | border-radius: 5px; 12 | box-shadow: 2px 2px 1px 1px #e0e0e0; 13 | margin-bottom: 20px; 14 | color: gray; 15 | width: 100%; 16 | max-width: 100%; 17 | } 18 | 19 | .settings-row { 20 | width: 60%; 21 | padding: 12px; 22 | border-radius: 2px; 23 | } 24 | 25 | .settings-row:hover { 26 | background-color: #e8e8e8; 27 | cursor: pointer; 28 | } 29 | 30 | .settings-row-header { 31 | font-size: 16px; 32 | font-weight: bold; 33 | } 34 | 35 | .settings-row-content { 36 | margin-top: 4px; 37 | color: #555555; 38 | } 39 | 40 | .settings-display-name { 41 | width: 240px; 42 | height: 3em; 43 | padding: 1em; 44 | border-width: 1px; 45 | border-radius: 4px; 46 | border-color: #86989B; 47 | display: block; 48 | margin-left: auto; 49 | margin-right: auto; 50 | } 51 | 52 | .settings-email { 53 | width: 240px; 54 | height: 3em; 55 | padding: 1em; 56 | border-width: 1px; 57 | border-radius: 4px; 58 | border-color: #86989B; 59 | display: block; 60 | margin-left: auto; 61 | margin-right: auto; 62 | } 63 | 64 | .settings-display-name-button-cancel { 65 | background-color: #727272; 66 | } 67 | 68 | .settings-display-name-button-confirm { 69 | background-color: #E05140; 70 | } 71 | 72 | .settings-email-button-cancel { 73 | background-color: #727272; 74 | } 75 | 76 | .settings-email-button-confirm { 77 | background-color: #E05140; 78 | } 79 | 80 | .settings-email-button-confirm-disabled { 81 | background-color: #727272; 82 | cursor: default; 83 | } 84 | 85 | .settings-rss-url { 86 | padding: 12px; 87 | background: #eeeded; 88 | font-family: monospace; 89 | } 90 | 91 | .settings-rss-code-checkboxes { 92 | display: flex; 93 | flex-direction: row; 94 | align-items: baseline; 95 | } 96 | 97 | .settings-rss-code-checkboxes > * { 98 | margin-right: 10px; 99 | } 100 | 101 | /* Mobile */ 102 | @media only screen and (max-width: 750px) { 103 | .settings-wrapper { 104 | padding: 0; 105 | } 106 | 107 | .settings-row { 108 | min-width: 0; 109 | width: 100%; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /web/src/components/Post/NewComment.css: -------------------------------------------------------------------------------- 1 | /* TODO: all classnames not starting with post-new-comment are duplicated */ 2 | .post-new-comment-wrapper { 3 | margin-top: 20px; 4 | padding-top: 5px; 5 | } 6 | 7 | .post-new-comment-input-area { 8 | display: flex; 9 | } 10 | 11 | .post-new-comment-avatar { 12 | width: 35px; 13 | height: 35px; 14 | flex-shrink: 0; 15 | } 16 | 17 | .post-new-comment-input { 18 | border: none; 19 | flex: 1; 20 | resize: none; 21 | margin-left: 10px; 22 | margin-top: 5px; 23 | height: auto; 24 | } 25 | 26 | .post-new-comment-input:focus { 27 | outline: none !important; 28 | } 29 | 30 | .post-new-comment-attachment-button { 31 | top: 15px; 32 | right: 5px; 33 | width: 20px; 34 | cursor: pointer; 35 | z-index: 999; 36 | } 37 | 38 | .post-new-comment-buttons { 39 | display: flex; 40 | justify-content: flex-end; 41 | align-items: center; 42 | } 43 | 44 | .post-new-comment-attachment-icon { 45 | width: 25px; 46 | height: 25px; 47 | margin-right: 10px; 48 | color: #727272; 49 | cursor: pointer; 50 | } 51 | 52 | .post-new-comment-post-button { 53 | padding: 5px 8px; 54 | font-weight: bold; 55 | font-size: small; 56 | background-color: #E05140; 57 | color: white; 58 | border-radius: 4px; 59 | cursor: pointer; 60 | } 61 | 62 | .post-new-comment-post-button-invalid { 63 | background-color: #767676; 64 | cursor: none; 65 | pointer-events: none; 66 | user-select: none; 67 | } 68 | 69 | .fade-in { 70 | -webkit-animation: fade-in 1.2s cubic-bezier(0.390, 0.575, 0.565, 1.000) both; 71 | animation: fade-in 1.2s cubic-bezier(0.390, 0.575, 0.565, 1.000) both; 72 | } 73 | 74 | /* ---------------------------------------------- 75 | * Generated by Animista on 2021-7-25 20:27:38 76 | * Licensed under FreeBSD License. 77 | * See http://animista.net/license for more info. 78 | * w: http://animista.net, t: @cssanimista 79 | * ---------------------------------------------- */ 80 | 81 | /** 82 | * ---------------------------------------- 83 | * animation fade-in 84 | * ---------------------------------------- 85 | */ 86 | @-webkit-keyframes fade-in { 87 | 0% { 88 | opacity: 0; 89 | } 90 | 100% { 91 | opacity: 1; 92 | } 93 | } 94 | 95 | @keyframes fade-in { 96 | 0% { 97 | opacity: 0; 98 | } 99 | 100% { 100 | opacity: 1; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pillcity/resources/password_reset.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | import os 3 | import logging 4 | from email.mime.text import MIMEText 5 | from flask_restful import Resource, reqparse 6 | from pillcity.daos.password_reset import forget_password, reset_password 7 | 8 | 9 | CLAIM_EXPIRATION_SECONDS = 60 10 | 11 | 12 | def _send_password_reset_email(email: str, code: str): 13 | smtp_enabled = os.getenv("SMTP_ENABLED", "false") 14 | web_domain = os.environ['WEB_DOMAIN'] 15 | smtp_from = os.environ['SMTP_FROM'] 16 | 17 | msg = MIMEText(f"""Hello, 18 | 19 | Go to https://{web_domain}/reset?code={code} to reset your password 20 | """) 21 | msg['Subject'] = f'Reset your {web_domain} password' 22 | msg['From'] = smtp_from 23 | msg['To'] = email 24 | logging.info(f"Going to send email {msg}") 25 | if smtp_enabled != "true": 26 | return 27 | 28 | logging.info(f"Sending email {msg}") 29 | smtp_server = os.environ['SMTP_HOST'] 30 | smtp_port = int(os.environ['SMTP_PORT']) 31 | smtp_username = os.environ['SMTP_USERNAME'] 32 | smtp_password = os.environ['SMTP_PASSWORD'] 33 | 34 | s = smtplib.SMTP(smtp_server, smtp_port) 35 | s.login(smtp_username, smtp_password) 36 | s.sendmail(msg['From'], msg['To'], msg.as_string()) 37 | s.quit() 38 | 39 | 40 | forget_password_args = reqparse.RequestParser() 41 | forget_password_args.add_argument('email', type=str, required=True) 42 | 43 | 44 | class ForgetPassword(Resource): 45 | def post(self): 46 | args = forget_password_args.parse_args() 47 | email = args.get('email') 48 | code = forget_password(email) 49 | if code: 50 | _send_password_reset_email(email, code) 51 | return {'msg': 'Password reset email sent'}, 200 52 | else: 53 | return {'msg': 'Email address is invalid or an associated password reset claim' 54 | ' has not been expired yet'}, 401 55 | 56 | 57 | reset_password_args = reqparse.RequestParser() 58 | reset_password_args.add_argument('code', type=str, required=True) 59 | reset_password_args.add_argument('password', type=str, required=True) 60 | 61 | 62 | class ResetPassword(Resource): 63 | def post(self): 64 | args = reset_password_args.parse_args() 65 | code = args.get('code') 66 | password = args.get('password') 67 | if reset_password(code, password): 68 | return {'msg': 'Password has been reset'}, 200 69 | else: 70 | return {'msg': 'Invalid code or email is not associated with any user'}, 401 71 | --------------------------------------------------------------------------------