├── 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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------