노드JS [Express]

[쿠키 와 인증] 노드JS Cookie 인증 정리글

훈츠 2020. 6. 29. 18:22
반응형

 

안녕하세요. 훈츠입니다. 노드 JS에서 쿠키가 기본적으로 동작하는 원리에 대해 정리해 봅니다. 

 


목 차

  • 쿠키 설명 및 특징

  • 쿠키 만들기

  • 쿠키 읽기

  • 쿠키의 활용

  • Session 쿠키 (휘발성) VS Permanent 쿠키 (비휘발성)

  • 쿠키 옵션

  • 앱 만들기 적용  (쿠키를 이용한 인증 기능 구현)

    • 로그인 쿠키 생성

    • 로그인 상태 체크

    • 로그인 상태를 UI에 반영

    • 로그 아웃

    • 접근 제어

 


쿠키 설명 및 특징

https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies

 

HTTP 쿠키

HTTP 쿠키(웹 쿠키, 브라우저 쿠키)는 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각입니다. 브라우저는 그 데이터 조각들을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데��

developer.mozilla.org

쿠키에 대한 설명이 나와있습니다. 

"쿠키는 (웹쿠키, 브라우저 쿠키)는 서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각 입니다."

브라우저는 그 데이터 조각을 저장해 놓았다가, 동일한 서버에 재 요청 시 저장된 데이터를 함께 전송 합니다. HTTP 프로토콜은 상태가 없기 때문에 상태정보를 쿠키를 이용하여, 로그인 상태를 유지 할수 있습니다. 

 

쿠키는 다음에 사용 됩니다. 

세션 관리(Session management)서버에 저장해야 할 로그인, 장바구니, 게임 스코어 등의 정보 관리

개인화(Personalization)사용자 선호, 테마 등의 세팅

트래킹(Tracking)사용자 행동을 기록하고 분석하는 용도

 

데이터를 클라이언트에 저장 하려면, localStorage 와 sessionStorage 와 indexedDB를 사용합니다. 

참고로 쿠키는 4KB의 용량 제한 또한 있습니다. 

 

쿠키의 흐름 

웹서버(쿠키생성) -> 웹브라우저(쿠키저장) -> 웹서버(전송된 쿠키 읽기) 

 


쿠키 만들기(웹서버 -> 웹브라우저)

 

쿠키를 사용하려면 Set-Cookie 코드를 사용 합니다. 

HTTP 요청을 수신할 때, 서버는 응답과 함께 Set-Cookie 헤더를 전송할 수 있습니다. 쿠키는 보통 브라우저에 의해 저장되며, 그 후 쿠키는 같은 서버에 의해 만들어진 요청(Request)들의 Cookie HTTP 헤더안에 포함되어 전송됩니다. 만료일 혹은 지속시간(duration)도 명시될 수 있고, 만료된 쿠키는 더이상 보내지지 않습니다. 추가적으로, 특정 도메인 혹은 경로 제한을 설정할 수 있으며 이는 쿠키가 보내지는 것을 제한할 수 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
var http = require('http');
http.createServer(function(request, response){
    response.writeHead(200, {
        'Set-Cookie':['yummy_cookie=chocos''tasty_cookie=strawberrys']
    });
    response.end('Cookie!!');
}).listen(3000);
 
 
cs

크롬개발자 -> 네크워크 -> DOC -> 로컬호스트 -> Res Header and Req Header 체크

 

위와 같이 쿠키값을 확인 가능합니다. 


쿠키 읽기 (웹브라우저 -> 웹서버)

웹브라우저가 전송한 쿠키를 웹서버 측에서 읽는 방법 입니다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
var http = require('http');
var cookie = require('cookie');
http.createServer(function(request, response){
      response.writeHead(200, {
          'Set-Cookie':['yummy_cookie=chocos''tasty_cookie=strawberrys']
      });
 
      //console.log(request.headers.cookie)
      var cookies ={};
      if(cookies !== undefined){
        cookies = cookie.parse(request.headers.cookie);
        console.log(cookies.yummy_cookie);
        console.log(cookies.tasty_cookie);
      }
 
    response.end('Cookie!!');
}).listen(3000);
 
 
 
cs

설명 

1. 전송 받은 쿠키는 다음으로 들어옵니다. 

  request.headers.cookie 

2. 전송 받은 쿠키는 쿠키 파서를 이용하여, 객체화 합니다. 그러기 위해서 쿠키 파서를 설치 합니다. 

  npm install -s cookie

 require('cookie');

 cookie.parser(req.headers.cookie);

3. 쿠키를 클라이언트가 삭제 하였을 경우를 대비하여, undefined 체크 코드를 추가 합니다. 

 


쿠키의 활용

웹브라우저에 어플리케이션 탭에 쿠키 탭이 있습니다. 이곳에서 자바스크립트를 이용하면, 리로드 없이 쿠키의 값을 바꿀수 있습니다. 언어 세팅 같은 경우가 좋은 예시 입니다.

 


Session 쿠키 (휘발성) VS Permanent 쿠키 (비휘발성)

Session 쿠키(휘발성) 메모리에 저장되어 웹브라우저가 종료 되면 데이터가 리셋 됩니다. 

Permanent 쿠키(비휘발성) 메모리에 저장되어 웹브라우저가 종료 되어도 값을 유지 합니다. 

이러한 차이점을 인지하고 사용하면 됩니다. 


쿠키 옵션

Secure 는 웹브라우저와 웹서버가 https 로 통신하는 경우만 웹브라우저가 쿠키를 서버로 전송하는 옵션 입니다. 

HttpOnly 는 자바스크립트의 document.cookie를 이용해서 쿠키에 접속하는것을 막는 옵션 입니다. 쿠키를 훔쳐가는 행위를 막기위한 보안 옵션 입니다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
var http = require('http');
var cookie = require('cookie');
http.createServer(function(request, response){
      response.writeHead(200, {
          'Set-Cookie':['yummy_cookie=choco',
          'tasty_cookie=strawberry',
          `Permanent=cookies; Max-Age=${60*60*24*30}`, //세션의 타임값을 지정할수 있습니다. 
          'Secure=Secure; Secure',      //https 로 통신할때만 나옵니다. 
          'HttpOnly=HttpOnly; HttpOnly'//자바스크립트 콘솔창에서 Document.cookie 처도 안나옵니다. 
          'Path=Path; Path=/cookie',
          'Doamin=Domain; Domain=test.o2.org'
 
        ]
      });
 
      //console.log(request.headers.cookie)
      var cookies ={};
      if(cookies !== undefined){
        cookies = cookie.parse(request.headers.cookie);
        console.log(cookies.yummy_cookie);
        console.log(cookies.tasty_cookie);
        
      }
 
    response.end('Cookie!!');
}).listen(3000);
 
 
 
cs


path & domain 옵션 

path 특정 디렉토리에서만 쿠키가 작동하도록 만드는 방법 입니다. 

domain 아래 도메인 이하에서는 쿠키 세션이 동작 합니다. 

코드는 쿠키 옵션 현 페이지 코드를 참조하세요. 

 


앱만들기 적용 (쿠키를 이용한 인증 기능 구현 )

앱을 적용하여 만들어 단계별로 설명 드리겠습니다. 참고로 생활 코딩의 강좌를 그래도 참조 하여 만들어 보았습니다. 

 

  • 앱 만들기 적용  

    • 로그인 UI 만들기 
    • 로그인 쿠키 생성

    • 로그인 상태 체크
    • 로그인 상태를 UI에 반영
    • 로그 아웃
    • 접근 제어

       


로그인 UI 만들기 

쿠키를 만들기 이전에 login 으로 접근 했을때, UI를 생성 하겠습니다. 먼저 login 링크 창이 main에 나와야 하기 때문에 템플릿 파일을 수정 합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
module.exports = {
  HTML:function(title, list, body, control){
    return `
    <!doctype html>
    <html>
    <head>
      <title>WEB1 - ${title}</title>
      <meta charset="utf-8">
    </head>
    <body>
      <a href=/login>login</a>
 
 
cs

 

main.js 에 login으로 이동 했을때, 나올 UI를 구현 합니다. create 와 비슷하므로 복사해서 항목의 이름을 수정하여 아래와 같이 수정 합니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
else if(pathname === '/login'){
      if(queryData.id === undefined){
        fs.readdir('./data', function(error, filelist){
          var title = 'Login';
          var list = template.list(filelist);
          var html = template.HTML(title, list,
            `
            <form action="login_process" method="post">
              <p><input type="text" name="email" placeholder="email"></p>
              <p><input type="text" name="password" placeholder="password"></p>
              <p><input type="submit" ></p>
            </form>`,
            `<a href ="/create">create</a>`
          );
          response.writeHead(200);
          response.end(html);
        });
      }
   }
    else {
      response.writeHead(404);
      response.end('Not found');
    }
 
cs

로그인 쿠키 생성

create_process 와 비슷하므로, 복사하여 아래와 같이 코드를 수정 합니다. 확인해 보면 id, password, nickname을 확인해 볼수 있습니다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
else if(pathname === '/login_process'){
    var body = '';
    request.on('data', function(data){
        body = body + data;
    });
    request.on('end', function(){
        var post = qs.parse(body);
        if(post.email === 'lkh' && post.password === '111'){
          response.writeHead(302, {
            'Set-Cookie':[
             `email=${post.email}`,
             `password=${post.password}`,
             `nickname=lkh`
          ],Location:`/`
          
        });
          response.end();
        } else{
          //로그인 실패시 
          response.end('Who ?');
        }  
 
cs

로그인 상태 체크

로그인 상태를 체크하여, UI에 표기 합니다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
function authIsOwner(request,response){
   var isOwner = false;
    var cookies = {};
    //쿠키값 확인 
    
    if(request.headers.cookie !== undefined){
      cookies = cookie.parse(request.headers.cookie)
      console.log(cookies);
    }
 
    //로그인 확인 
    if(cookies.email === 'lkh' && cookies.password === '111'){
      isOwner = true;
    }
  return isOwner;
 
}
 
var app = http.createServer(function(request,response){
    var _url = request.url;
    var queryData = url.parse(_url, true).query;
    var pathname = url.parse(_url, true).pathname;
    var isOwner = authIsOwner(request,response);
    console.log(isOwner);
 
cs

로그인 상태를 UI에 반영

lib 에 template 에 로그인 상태에 따라 login 혹은 logout 를 받는 코드를 작성합니다. 아래는 만약 값이 안들어오면 디폴트로 작동 합니다. 

전체 코드 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
module.exports = {
  HTML:function(title, list, body, control, authStatusUI=`<a href=/login>login</a>`){  //값이 없으면 다음값이 기본값이 된다. 
    return `
    <!doctype html>
    <html>
    <head>
      <title>WEB1 - ${title}</title>
      <meta charset="utf-8">
    </head>
    <body>
      ${authStatusUI}
      <h1><a href="/">WEB</a></h1>
      ${list}
      ${control}
      ${body}
    </body>
    </html>
    `;
  },list:function(filelist){
    var list = '<ul>';
    var i = 0;
    while(i < filelist.length){
      list = list + `<li><a href="/?id=${filelist[i]}">${filelist[i]}</a></li>`;
      i = i + 1;
    }
    list = list+'</ul>';
    return list;
  }
}
 
cs

authIsOwner 로 로그인 상태를 판단하는 함수와 그 함수로 인해 UI를 셋하는 함수를 만들어 CRUD 기능이 있는곳에 셀렉터로 적용해 줍니다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
function authIsOwner(request,response){
   var isOwner = false;
    var cookies = {};
    //쿠키값 확인 
    if(request.headers.cookie !== undefined){
      cookies = cookie.parse(request.headers.cookie)
      console.log(cookies);
    }
    //로그인 확인 
    if(cookies.email === 'lkh' && cookies.password === '111'){
      isOwner = true;
    }
  return isOwner;
}
 
function authStatusUI(request,response){
  var authStatusUI = `<a href=/login>login</a>`
  if( authIsOwner(request,response)){    //위쪽 함수 호출 
    authStatusUI = `<a href=/logout_process>logout</a>`
  }
  return authStatusUI;
}
 
cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
else if(pathname === '/login'){
      if(queryData.id === undefined){
        fs.readdir('./data', function(error, filelist){
          var title = 'Login';
          var list = template.list(filelist);
          var html = template.HTML(title, list,
            `
            <form action="login_process" method="post">
              <p><input type="text" name="email" placeholder="email"></p>
              <p><input type="text" name="password" placeholder="password"></p>
              <p><input type="submit" ></p>
            </form>`,
            `<a href ="/create">create</a>`
          );
          response.writeHead(200);
          response.end(html);
        });
      }
   }
    else {
      response.writeHead(404);
      response.end('Not found');
    }
cs

 

 

 

로그 아웃

로그인 프로세서 복사후, 로그아웃 프로세서로 변경 합니다. 옵션중 Max-Age=0 으로 추가하면 삭제가 됩니다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
else if(pathname === '/logout_process'){
    var body = '';
    request.on('data', function(data){
        body = body + data;
    });
    request.on('end', function(){
        var post = qs.parse(body);
          response.writeHead(302, {
            'Set-Cookie':[
             `email=; Max-Age=0;`,
             `password=; Max-Age=0;`,
             `nickname=; Max-Age=0;`
          ],Location:`/`
          
        });
          response.end();
    
    });
  
  }
cs

접근 제어

각 CRUD 구현부에서 로그인 체크 함수를 호출하여, 로그인 경우에만 가능 하도록 합니다. 

전체 코드로 코드 대체 합니다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
//Hoons Blog---https://rain2002kr.tistory.com------------------------------------------------------------------코드///
 
var http = require('http');
var fs = require('fs');
var url = require('url');
var qs = require('querystring');
var template = require('./lib/template.js');
var path = require('path');
var sanitizeHtml = require('sanitize-html');
 
var cookie = require('cookie');
 
function authIsOwner(request,response){
   var isOwner = false;
    var cookies = {};
    //쿠키값 확인 
    if(request.headers.cookie !== undefined){
      cookies = cookie.parse(request.headers.cookie)
      console.log(cookies);
    }
    //로그인 확인 
    if(cookies.email === 'lkh' && cookies.password === '111'){
      isOwner = true;
    }
  return isOwner;
}
 
function authStatusUI(request,response){
  var authStatusUI = `<a href=/login>login</a>`
  if( authIsOwner(request,response)){    //위쪽 함수 호출 
    authStatusUI = `<a href=/logout_process>logout</a>`
  }
  return authStatusUI;
}
 
var app = http.createServer(function(request,response){
    var _url = request.url;
    var queryData = url.parse(_url, true).query;
    var pathname = url.parse(_url, true).pathname;
    
 
    if(pathname === '/'){
      if(queryData.id === undefined){
        fs.readdir('./data', function(error, filelist){
          var title = 'Welcome';
          var description = 'Hello, Node.js';
          var list = template.list(filelist);
          var html = template.HTML(title, list,
            `<h2>${title}</h2>${description}`,
            `<a href="/create">create</a>`,
            authStatusUI(request,response)
          );
          response.writeHead(200);
          response.end(html);
        });
      } else {
        fs.readdir('./data', function(error, filelist){
          //로그인 유무 판단.
          if(authIsOwner(request,response)===false){
            response.end('Login required!!!')
            return 
          }
          var filteredId = path.parse(queryData.id).base;
          fs.readFile(`data/${filteredId}`, 'utf8', function(err, description){
            var title = queryData.id;
            var sanitizedTitle = sanitizeHtml(title);
            var sanitizedDescription = sanitizeHtml(description, {
              allowedTags:['h1']
            });
            var list = template.list(filelist);
            var html = template.HTML(sanitizedTitle, list,
              `<h2>${sanitizedTitle}</h2>${sanitizedDescription}`,
              ` <a href="/create">create</a>
                <a href="/update?id=${sanitizedTitle}">update</a>
                <form action="delete_process" method="post">
                  <input type="hidden" name="id" value="${sanitizedTitle}">
                  <input type="submit" value="delete">
                </form>`,
                authStatusUI(request,response)
            );
            response.writeHead(200);
            response.end(html);
          });
        });
      }
    } else if(pathname === '/create'){
      
      fs.readdir('./data', function(error, filelist){
        //로그인 유무 판단.
        if(authIsOwner(request,response)===false){
          response.end('Login required!!!')
          return 
        }
        var title = 'WEB - create';
        var list = template.list(filelist);
        var html = template.HTML(title, list, `
          <form action="/create_process" method="post">
            <p><input type="text" name="title" placeholder="title"></p>
            <p>
              <textarea name="description" placeholder="description"></textarea>
            </p>
            <p>
              <input type="submit">
            </p>
          </form>
        `, '',authStatusUI(request,response));
        response.writeHead(200);
        response.end(html);
      });
    } else if(pathname === '/create_process'){
      //로그인 유무 판단.
      if(authIsOwner(request,response)===false){
          response.end('Login required!!!')
          return 
      }
      var body = '';
      request.on('data', function(data){
          body = body + data;
      });
      request.on('end', function(){
          var post = qs.parse(body);
          var title = post.title;
          var description = post.description;
          fs.writeFile(`data/${title}`, description, 'utf8', function(err){
            response.writeHead(302, {Location: `/?id=${title}`});
            response.end();
          })
      });
    } else if(pathname === '/update'){
      
      fs.readdir('./data', function(error, filelist){
        //로그인 유무 판단.
      if(authIsOwner(request,response)===false){
        response.end('Login required!!!')
        return 
    }
        var filteredId = path.parse(queryData.id).base;
        fs.readFile(`data/${filteredId}`, 'utf8', function(err, description){
          var title = queryData.id;
          var list = template.list(filelist);
          var html = template.HTML(title, list,
            `
            <form action="/update_process" method="post">
              <input type="hidden" name="id" value="${title}">
              <p><input type="text" name="title" placeholder="title" value="${title}"></p>
              <p>
                <textarea name="description" placeholder="description">${description}</textarea>
              </p>
              <p>
                <input type="submit">
              </p>
            </form>
            `,
            `<a href="/create">create</a> <a href="/update?id=${title}">update</a>`,
            authStatusUI(request,response)
          );
          response.writeHead(200);
          response.end(html);
        });
      });
    } else if(pathname === '/update_process'){
      //로그인 유무 판단.
      if(authIsOwner(request,response)===false){
        response.end('Login required!!!')
        return 
    }
      var body = '';
      request.on('data', function(data){
          body = body + data;
      });
      request.on('end', function(){
          var post = qs.parse(body);
          var id = post.id;
          var title = post.title;
          var description = post.description;
          fs.rename(`data/${id}`, `data/${title}`, function(error){
            fs.writeFile(`data/${title}`, description, 'utf8', function(err){
              response.writeHead(302, {Location: `/?id=${title}`});
              response.end();
            })
          });
      });
    } else if(pathname === '/delete_process'){
      //로그인 유무 판단.
      if(authIsOwner(request,response)===false){
        response.end('Login required!!!')
        return 
    }
      var body = '';
      request.on('data', function(data){
          body = body + data;
      });
      request.on('end', function(){
          var post = qs.parse(body);
          var id = post.id;
          var filteredId = path.parse(id).base;
          fs.unlink(`data/${filteredId}`, function(error){
            response.writeHead(302, {Location: `/`});
            response.end();
          })
      });
    } else if(pathname === '/login'){
      if(queryData.id === undefined){
        fs.readdir('./data', function(error, filelist){
          var title = 'Login';
          var list = template.list(filelist);
          var html = template.HTML(title, list,
            `
            <form action="login_process" method="post">
              <p><input type="text" name="email" placeholder="email"></p>
              <p><input type="text" name="password" placeholder="password"></p>
              <p><input type="submit" ></p>
            </form>`,
            `<a href ="/create">create</a>`
          );
          response.writeHead(200);
          response.end(html);
        });
      }
   }else if(pathname === '/login_process'){
    var body = '';
    request.on('data', function(data){
        body = body + data;
    });
    request.on('end', function(){
        var post = qs.parse(body);
        if(post.email === 'lkh' && post.password === '111'){
          response.writeHead(302, {
            'Set-Cookie':[
             `email=${post.email}`,
             `password=${post.password}`,
             `nickname=lkh`
          ],Location:`/`
          
        });
          response.end();
        } else{
          //로그인 실패시 
          response.end('Who ?');
        }  
    });
  
  }
  else if(pathname === '/logout_process'){
    var body = '';
    request.on('data', function(data){
        body = body + data;
    });
    request.on('end', function(){
        var post = qs.parse(body);
          response.writeHead(302, {
            'Set-Cookie':[
             `email=; Max-Age=0;`,
             `password=; Max-Age=0;`,
             `nickname=; Max-Age=0;`
          ],Location:`/`
          
        });
          response.end();
    
    });
  
  }
    else {
      response.writeHead(404);
      response.end('Not found');
    }
});
app.listen(3000);
 
cs

 


코드 공유

 

 

rain2002kr/cookieExample

생활코딩 쿠키 예제코드. Contribute to rain2002kr/cookieExample development by creating an account on GitHub.

github.com

 

 

반응형