diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 43328fe0..660d8f68 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -19,25 +19,26 @@ jobs:
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- - uses: actions/cache@v1
+ - uses: actions/cache@v3
with:
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: yarn install
- uses: borales/actions-yarn@v2.0.0
+ uses: borales/actions-yarn@v4.0.0
with:
cmd: install
- name: npm run build:ci
run: npm run build:ci
env:
REACT_APP_API_HOST: 'https://v2.velog.io/'
- REACT_APP_GRAPHQL_HOST: 'https://v2cdn.velog.io/'
+ REACT_APP_GRAPHQL_HOST: 'https://v2.velog.io/'
REACT_APP_GRAPHQL_HOST_NOCDN: 'https://v2.velog.io/'
PUBLIC_URL: 'https://static.velog.io/'
REACT_APP_REDIS_HOST: ${{ secrets.REDIS_HOST }}
REACT_APP_CLIENT_V3_HOST: 'https://velog.io/'
+ REACT_APP_WHITELIST_IPS: ${{ secrets.REACT_APP_WHITELIST_IPS }}
CI: false
- name: upload
env:
diff --git a/.gitignore b/.gitignore
index b91ca42f..0516f767 100755
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,7 @@ jspm_packages
# Serverless directories
.serverless
.webpack
+
+# ignore setting
+.idea
+.vscode
diff --git a/package.json b/package.json
index 6f371341..eb77c765 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"@types/jest": "^24.0.0",
"@types/koa": "^2.11.0",
"@types/koa-bodyparser": "^4.3.0",
+ "@types/koa-compress": "^4.0.6",
"@types/koa-static": "^4.0.1",
"@types/koa__router": "^8.0.2",
"@types/loadable__component": "^5.10.0",
@@ -96,6 +97,7 @@
"koa": "^2.11.0",
"koa-better-http-proxy": "^0.2.4",
"koa-bodyparser": "^4.2.1",
+ "koa-compress": "^5.1.1",
"koa-static": "^5.0.0",
"mini-css-extract-plugin": "0.8.0",
"node-fetch": "^2.6.0",
@@ -161,7 +163,7 @@
"workbox-webpack-plugin": "4.3.1"
},
"scripts": {
- "start": "node scripts/start.js",
+ "start": "node scripts/start.js --max-http-header-size=1024",
"build": "node scripts/build.js",
"build:ci": "node scripts/build.js && node scripts/build.server.js",
"build:server": "node scripts/build.server.js",
@@ -248,7 +250,7 @@
"@loadable/babel-plugin"
]
},
- "proxy": "https://v2cdn.velog.io/",
+ "proxy": "https://v2.velog.io/",
"devDependencies": {
"@loadable/webpack-plugin": "^5.15.1"
}
diff --git a/src/components/common/AdFeed.tsx b/src/components/common/AdFeed.tsx
index 18259136..98f3fe11 100644
--- a/src/components/common/AdFeed.tsx
+++ b/src/components/common/AdFeed.tsx
@@ -64,9 +64,9 @@ function AdFeed({ forPost, index }: { forPost?: boolean; index: number }) {
className="adsbygoogle"
style={{ display: 'block' }}
data-ad-format="fluid"
- data-ad-layout-key="-6c+ce+2v-x+66"
- data-ad-client="ca-pub-9161852896103498"
- data-ad-slot="9446928451"
+ data-ad-layout-key="-6u+e5+1a-3q+77"
+ data-ad-client="ca-pub-5574866530496701"
+ data-ad-slot="2841722540"
>
);
@@ -87,18 +87,18 @@ function AdFeed({ forPost, index }: { forPost?: boolean; index: number }) {
className="adsbygoogle"
style={{ display: 'block' }}
data-ad-format="fluid"
- data-ad-layout-key="-6c+ce+2v-x+66"
- data-ad-client="ca-pub-9161852896103498"
- data-ad-slot="2793890198"
+ data-ad-layout-key="-6u+e5+1a-3q+77"
+ data-ad-client="ca-pub-5574866530496701"
+ data-ad-slot="8480422066"
>
) : (
)}
{/* {isMobile ? (
diff --git a/src/components/policy/policyData.ts b/src/components/policy/policyData.ts
index f0ab43db..a6a2d673 100644
--- a/src/components/policy/policyData.ts
+++ b/src/components/policy/policyData.ts
@@ -117,6 +117,7 @@ const data = {
1. "회원"의 게시물이 "정보통신망법" 및 "저작권법"등 관련법에 위반되는 내용을 포함하는 경우, 권리자는 관련법이 정한 절차에 따라 해당 게시물의 게시중단 및 삭제 등을 요청할 수 있으며, "회사"는 관련법에 따라 조치를 취하여야 합니다.
2. "회사"는 전항에 따른 권리자의 요청이 없는 경우라도 권리침해가 인정될 만한 사유가 있거나 기타 회사 정책 및 관련법에 위반되는 경우에는 관련법에 따라 해당 게시물에 대해 임시조치 등을 취할 수 있습니다.
3. 본 조에 따른 세부절차는 "정보통신망법" 및 "저작권법"이 규정한 범위 내에서 회사가 정한 게시중단요청서비스에 따릅니다.
+4. "회사"는 [커뮤니티 가이드라인](https://chafgames.notion.site/137fd2be2cdc8046b0f1fb8c93ac26d1?pvs=74)에 따라 게시물의 게시중단 및 삭제처리를 할 수 있습니다.
## 제 10조 권리의 귀속
diff --git a/src/components/post/JobPositions.tsx b/src/components/post/JobPositions.tsx
new file mode 100644
index 00000000..d9e88808
--- /dev/null
+++ b/src/components/post/JobPositions.tsx
@@ -0,0 +1,174 @@
+import { useQuery } from '@apollo/react-hooks';
+import React, { useEffect, useRef, useState } from 'react';
+import { JOB_POSITIONS, JobPosition } from '../../lib/graphql/ad';
+import styled from 'styled-components';
+import VelogResponsive from '../velog/VelogResponsive';
+import Typography from '../common/Typography';
+import { themedPalette } from '../../lib/styles/themes';
+import { ellipsis } from '../../lib/styles/utils';
+import media from '../../lib/styles/media';
+import gtag from '../../lib/gtag';
+import { getJobs, Job } from '../../lib/api/jobs';
+
+type Props = {
+ category: 'frontend' | 'backend' | 'mobile' | 'python' | 'node' | 'ai' | null;
+};
+
+function JobPositions({ category }: Props) {
+ const [isObserved, setIsObserved] = useState(false);
+ const [data, setData] = useState([]);
+
+ const ref = useRef(null);
+ const initializedRef = useRef(false);
+
+ useEffect(() => {
+ getJobs(category || 'general').then((jobs) => {
+ const shuffled = jobs.sort(() => Math.random() - 0.5);
+ const sliced = shuffled.slice(0, 3);
+ setData(sliced);
+ });
+ }, [category]);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting && !initializedRef.current) {
+ setIsObserved(true);
+ }
+ });
+ },
+ {
+ rootMargin: '300px',
+ threshold: 0,
+ },
+ );
+ if (!ref.current) return;
+ observer.observe(ref.current);
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ const onClick = () => {
+ gtag('event', 'job_position_click');
+ };
+
+ useEffect(() => {
+ if (!isObserved) {
+ return;
+ }
+ gtag('event', 'job_position_view');
+ }, [isObserved]);
+
+ if (!data)
+ return (
+
+
+
+ );
+
+ return (
+
+
+
+ 관련 채용 정보
+
+ {data.map((jobPosition) => (
+
+
+
+
+
+
+
+
+
+
+ {jobPosition.name}
+
+ {jobPosition.summary}
+
+
+ ))}
+
+
+
+ );
+}
+
+const Block = styled(VelogResponsive)`
+ ${media.small} {
+ h4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+ }
+`;
+const Container = styled.div`
+ display: flex;
+ gap: 1rem;
+ a {
+ display: block;
+ color: inherit;
+ &:hover {
+ text-decoration: none;
+ color: inherit;
+ }
+ }
+ ${media.small} {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ gap: 0.5rem;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding-bottom: 1rem;
+ }
+`;
+
+const Card = styled.div`
+ width: 33.33%;
+ ${media.small} {
+ flex-shrink: 0;
+ width: 60vw;
+ }
+`;
+
+const Thumbnail = styled.img`
+ width: 100%;
+ aspect-ratio: 400 / 292;
+ object-fit: cover;
+ border-radius: 4px;
+`;
+
+const Company = styled.div`
+ display: flex;
+ gap: 0.5rem;
+ img {
+ display: block;
+ width: 16px;
+ height: 16px;
+ }
+ font-size: 10px;
+ align-items: center;
+ color: ${themedPalette.text2};
+ ${ellipsis};
+ margin-bottom: 0.5rem;
+`;
+
+const JobTitle = styled.a`
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.25;
+`;
+
+const JobDescription = styled.div`
+ margin-top: 8px;
+ color: ${themedPalette.text2};
+ font-size: 12px;
+ line-height: 1.5;
+`;
+
+export default JobPositions;
diff --git a/src/components/write/TagInput.tsx b/src/components/write/TagInput.tsx
index 7739aa77..a4b11b69 100644
--- a/src/components/write/TagInput.tsx
+++ b/src/components/write/TagInput.tsx
@@ -6,6 +6,7 @@ import transitions from '../../lib/styles/transitions';
import { mediaQuery } from '../../lib/styles/media';
import { useTransition, animated } from 'react-spring';
import OutsideClickHandler from 'react-outside-click-handler';
+import { isEmptyOrWhitespace } from '../../lib/utils';
export interface TagInputProps {
ref?: React.RefObject;
@@ -27,7 +28,6 @@ const TagInput: React.FC = ({ onChange, tags: initialTags }) => {
const ignore = useRef(false);
useEffect(() => {
- if (tags.length === 0) return;
onChange(tags);
}, [tags, onChange]);
@@ -48,9 +48,8 @@ const TagInput: React.FC = ({ onChange, tags: initialTags }) => {
(tag: string) => {
ignore.current = true;
setValue('');
- if (tag === '' || tags.includes(tag)) return;
- let processed = tag;
- processed = tag.trim();
+ if (isEmptyOrWhitespace(tag) || tags.includes(tag)) return;
+ let processed = tag.trim().slice(0, 255);
if (processed.indexOf(' #') > 0) {
const tempTags: string[] = [];
const regex = /#(\S+)/g;
diff --git a/src/containers/post/HorizontalBanner.tsx b/src/containers/post/HorizontalBanner.tsx
index 621e43a1..cc5d6736 100644
--- a/src/containers/post/HorizontalBanner.tsx
+++ b/src/containers/post/HorizontalBanner.tsx
@@ -18,25 +18,14 @@ function HorizontalBanner({ isDisplayAd = false }: Props) {
return (
- {isDisplayAd ? (
-
- ) : (
-
- )}
+
);
}
diff --git a/src/containers/post/PostViewer.tsx b/src/containers/post/PostViewer.tsx
index 7ce37736..d991cc98 100644
--- a/src/containers/post/PostViewer.tsx
+++ b/src/containers/post/PostViewer.tsx
@@ -46,6 +46,7 @@ import gtag from '../../lib/gtag';
import FollowButton from '../../components/common/FollowButton';
import { BANNER_ADS } from '../../lib/graphql/ad';
import PostBanner from '../../components/post/PostBanner';
+import JobPositions from '../../components/post/JobPositions';
const UserProfileWrapper = styled(VelogResponsive)`
margin-top: 16rem;
@@ -75,6 +76,7 @@ const PostViewer: React.FC = ({
}) => {
const setShowFooter = useSetShowFooter();
const [showRecommends, setShowRecommends] = useState(false);
+
useEffect(() => {
window.scrollTo(0, 0);
}, [username, urlSlug]);
@@ -221,7 +223,7 @@ const PostViewer: React.FC = ({
const isOwnPost = post.user.id === userId;
const isVeryOld =
Date.now() - new Date(post.released_at).getTime() >
- 1000 * 60 * 60 * 24 * 30;
+ 1000 * 60 * 60 * 24 * 10;
if (isOwnPost) return false;
if (!isVeryOld) return false;
@@ -251,6 +253,58 @@ const PostViewer: React.FC = ({
}
}, [customAd, shouldShowBanner, shouldShowFooterBanner]);
+ // const category = useMemo(() => {
+ // const frontendKeywords = [
+ // '프런트엔드',
+ // '리액트',
+ // 'vue',
+ // 'react',
+ // 'next',
+ // '프론트엔드',
+ // ];
+ // const backendKeywords = ['백엔드', '서버', '데이터베이스', 'db'];
+ // const aiKeywords = ['인공지능', '머신러닝', '딥러닝', 'nlp', 'llm'];
+ // const mobileKeywords = [
+ // '안드로이드',
+ // 'ios',
+ // 'react native',
+ // '플러터',
+ // 'flutter',
+ // 'swift',
+ // 'xcode',
+ // ];
+ // const pythonKeywords = ['파이썬', 'python'];
+ // const nodeKeywords = ['노드', 'node', 'express', 'koa', 'nest'];
+
+ // if (!data?.post) return null;
+ // const { post } = data;
+ // const merged = post.title
+ // .concat(post.tags.join(','))
+ // .concat(post.body)
+ // .toLowerCase();
+ // if (
+ // aiKeywords.some((keyword) => {
+ // const value = merged.includes(keyword);
+ // if (value) {
+ // // console.log(merged);
+ // // console.log(keyword);
+ // }
+ // return value;
+ // })
+ // )
+ // return 'ai';
+ // if (frontendKeywords.some((keyword) => merged.includes(keyword)))
+ // return 'frontend';
+ // if (mobileKeywords.some((keyword) => merged.includes(keyword)))
+ // return 'mobile';
+ // if (pythonKeywords.some((keyword) => merged.includes(keyword)))
+ // return 'python';
+ // if (backendKeywords.some((keyword) => merged.includes(keyword)))
+ // return 'backend';
+ // if (nodeKeywords.some((keyword) => merged.includes(keyword))) return 'node';
+ // return null;
+ // }, [data]);
+
const onRemove = async () => {
if (!data || !data.post) return;
setIsRemoveLoading(true);
@@ -467,7 +521,9 @@ const PostViewer: React.FC = ({
/>
}
/>
- {shouldShowBanner ? : null}
+ {shouldShowBanner ? (
+
+ ) : null}
= ({
{shouldShowBanner && isContentLongEnough ? (
-
+
) : null}
{shouldShowFooterBanner ? (
@@ -498,6 +554,10 @@ const PostViewer: React.FC = ({
postId={post.id}
ownPost={post.user.id === userId}
/>
+ {/* {(shouldShowBanner || shouldShowFooterBanner) && !customAd ? (
+
+ ) : null} */}
+
{showRecommends ? (
) : null}
diff --git a/src/containers/register/RegisterFormContainer.tsx b/src/containers/register/RegisterFormContainer.tsx
index 2512701a..d58a3f3d 100644
--- a/src/containers/register/RegisterFormContainer.tsx
+++ b/src/containers/register/RegisterFormContainer.tsx
@@ -16,6 +16,8 @@ import { withRouter, RouteComponentProps } from 'react-router-dom';
import qs from 'qs';
import { useApolloClient } from '@apollo/react-hooks';
import { GET_CURRENT_USER } from '../../lib/graphql/user';
+import { isEmpty, trim } from 'ramda';
+import { isEmptyOrWhitespace } from '../../lib/utils';
interface RegisterFormContainerProps extends RouteComponentProps<{}> {}
@@ -65,16 +67,21 @@ const RegisterFormContainer: React.FC = ({
const onSubmit = async (form: RegisterFormType) => {
setError(null);
// validate
+
const validation = {
displayName: (text: string) => {
- if (text.trim() === '') {
- return '이름을 입력해주세요.';
+ if (isEmptyOrWhitespace(text)) {
+ return '프로필 이름을 입력해주세요.';
}
if (text.trim().length > 45) {
return '이름은 최대 45자까지 입력 할 수 있습니다.';
}
},
username: (text: string) => {
+ if (isEmptyOrWhitespace(text)) {
+ return '사용자 ID를 입력해주세요.';
+ }
+
if (!/^[a-z0-9-_]{3,16}$/.test(text)) {
return '사용자 ID는 3~16자의 알파벳 소문자,숫자,혹은 - _ 으로 이루어져야 합니다.';
}
diff --git a/src/index.server.ts b/src/index.server.ts
index 37cbb20d..a6f4b617 100644
--- a/src/index.server.ts
+++ b/src/index.server.ts
@@ -4,12 +4,33 @@ import serve from 'koa-static';
import proxy from 'koa-better-http-proxy';
import Router from '@koa/router';
import ssrMiddleware from './server/ssrMiddleware';
+import rateLimitMiddleware from './server/rateLimitMiddleware';
+import compress from 'koa-compress';
+import { constants } from 'zlib';
console.log(process.env.REACT_APP_SSR);
const app = new Koa();
const router = new Router();
+app.proxy = true;
+
+app.use(
+ compress({
+ filter(contentType) {
+ return /text/i.test(contentType);
+ },
+ threshold: 2048,
+ gzip: {
+ flush: constants.Z_SYNC_FLUSH,
+ },
+ deflate: {
+ flush: constants.Z_SYNC_FLUSH,
+ },
+ br: false, // disable brotli
+ }),
+);
+
app.use(
serve(path.resolve('./build'), {
index: false,
@@ -22,10 +43,11 @@ const proxyMiddleware = proxy(
);
app.use(router.routes()).use(router.allowedMethods());
+app.use(rateLimitMiddleware);
app.use(ssrMiddleware);
app.use(proxyMiddleware);
router.get('/ads.txt', (ctx) => {
- ctx.body = `google.com, pub-9161852896103498, DIRECT, f08c47fec0942fa0`;
+ ctx.body = `google.com, pub-5574866530496701, DIRECT, f08c47fec0942fa0`;
});
// router.post('/graphql', proxyMiddleware);
diff --git a/src/lib/api/jobs.ts b/src/lib/api/jobs.ts
new file mode 100644
index 00000000..9329a863
--- /dev/null
+++ b/src/lib/api/jobs.ts
@@ -0,0 +1,21 @@
+import axios from 'axios';
+
+const secondaryApiClient = axios.create({
+ baseURL: 'https://cdn.velev.io/',
+});
+
+export async function getJobs(category: string) {
+ const response = await secondaryApiClient.get(`/jobs/${category}`);
+ return response.data;
+}
+
+export type Job = {
+ id: number;
+ name: string;
+ companyName: string;
+ companyLogo: string;
+ thumbnail: string;
+ url: string;
+ jobId: number;
+ summary: string;
+};
diff --git a/src/lib/graphql/ad.ts b/src/lib/graphql/ad.ts
index 483be812..63c39656 100644
--- a/src/lib/graphql/ad.ts
+++ b/src/lib/graphql/ad.ts
@@ -8,6 +8,15 @@ export type Ad = {
url: string;
};
+export type JobPosition = {
+ id: string;
+ name: string;
+ companyName: string;
+ companyLogo: string;
+ thumbnail: string;
+ url: string;
+};
+
export const BANNER_ADS = gql`
query BannerAds($writerUsername: String!) {
bannerAds(writer_username: $writerUsername) {
@@ -19,3 +28,16 @@ export const BANNER_ADS = gql`
}
}
`;
+
+export const JOB_POSITIONS = gql`
+ query JobPositions($category: String) {
+ jobPositions(category: $category) {
+ id
+ name
+ companyName
+ companyLogo
+ thumbnail
+ url
+ }
+ }
+`;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 4f46ab36..4f98462f 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,6 +1,7 @@
import distanceInWordsToNow from 'date-fns/formatDistanceToNow';
import format from 'date-fns/format';
import koLocale from 'date-fns/locale/ko';
+import { isEmpty, trim } from 'ramda';
export const formatDate = (date: string): string => {
const d = new Date(date);
@@ -115,3 +116,10 @@ export const createFallbackTitle = (username: string | null) => {
};
export const ssrEnabled = process.env.REACT_APP_SSR === 'enabled';
+
+export const isEmptyOrWhitespace = (str: string) => {
+ if (typeof str !== 'string') {
+ return isEmpty(str);
+ }
+ return isEmpty(trim(str.replace(/\s/g, '')));
+};
diff --git a/src/server/CacheManager.ts b/src/server/CacheManager.ts
index 145c54ff..054692e5 100644
--- a/src/server/CacheManager.ts
+++ b/src/server/CacheManager.ts
@@ -1,7 +1,7 @@
import Redis from 'ioredis';
import checkCacheRule from './checkCacheRule';
-const redis = new Redis({
+export const redis = new Redis({
host: process.env.REACT_APP_REDIS_HOST || 'localhost',
});
@@ -18,9 +18,10 @@ export default class CacheManager {
return null;
}
}
- set(url: string, html: string) {
+ async set(url: string, html: string) {
const rule = checkCacheRule(url);
if (!rule) return;
- return redis.set(createCacheKey(url), html);
+ await redis.set(createCacheKey(url), html);
+ await redis.expire(createCacheKey(url), 300);
}
}
diff --git a/src/server/Html.tsx b/src/server/Html.tsx
index 5fa9641b..52b25f69 100644
--- a/src/server/Html.tsx
+++ b/src/server/Html.tsx
@@ -74,7 +74,7 @@ function Html({
> */}