728x90

[관리자 페이지] 암호화 유틸리티 만들기



    // https://mvnrepository.com/artifact/org.mindrot/jbcrypt
    implementation group: 'org.mindrot', name: 'jbcrypt', version: '0.4'

Bcrypt 를 사용하기 위한 dependency 추가 !

package com.hiio.adminserver.utils;

import org.mindrot.jbcrypt.BCrypt;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

@Component
public class CryptoUtil {

    public String encrypt(String value) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
        Key key = this.getKey();
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(key.toString().substring(0, 16).getBytes()));
        return new String(Base64.getEncoder().encode(cipher.doFinal(value.getBytes())));
    }

    public String decrypt(String encryptValue) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, BadPaddingException {
        Key key = this.getKey();
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(key.toString().substring(0, 16).getBytes()));

        return new String(cipher.doFinal(Base64.getDecoder().decode(encryptValue)));
    }

    public String bcrypt(String value){
        return  BCrypt.hashpw(value,BCrypt.gensalt());
    }

    public boolean validBcrypt(String value,String hashedValue){
        return BCrypt.checkpw(value,hashedValue);
    }


    public Key getKey() {
        return "12345678901234567890123456789012";

    }


}

메소드 생성

encrypt | decrypt | bcrypt | validBcrypt |
양방향암호화 | 복호화 | 단방향암호화 | 검증 |

AES/CBC/PKCS5Padding 알고리즘 사용

양방향 암호화의 경우 개인정보(이메일, 휴대폰 번호 등)이나 암호화가 필요한 정보를 DB에 저장 시 암호화해서 저장 하기 위해 사용
단방향 암호화의 경우 비밀번호 암호화에 사용

static 키워드를 사용한 객체 사용보다는 @Component 를 통해 Bean 등록 사용

// Crypto Util Test Code

package com.hiio.adminserver.utils;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class CryptoUtilTest {
    private final Logger log = LoggerFactory.getLogger(getClass());
    @Autowired
    private CryptoUtil cryptoUtil;
    @Test
    public void encrypt(){
        try{
            String value = "test@test.com";
            String encrypted = cryptoUtil.encrypt(value);
            log.info(encrypted);
        }catch(Exception e){
            log.error(e.getMessage());
        }
    }


    @Test
    public void decrypt(){
        try{
            String value = "4pzZnfHbutpNFvnWISGfzA==";
            String decrypted = cryptoUtil.decrypt(value);
            log.info(decrypted);
        }catch(Exception e){
            log.error(e.getMessage());
        }
    }

    @Test
    public void bcrypt(){
        String value="password";
        String bcrypted = cryptoUtil.bcrypt(value);
        log.info(bcrypted);
    }

    @Test
    public void validBcrypt(){
        String bcrypted = "$2a$10$jqOVKXzru5.okuYI8A13HeGvxZv3Zt9rVliaFLegTcamh6u4GTzU2";
        String plain = "password";
        boolean valided = cryptoUtil.validBcrypt(plain,bcrypted);
        Assertions.assertTrue(valided);


    }
}
728x90
728x90

DTO (Data Transfer Object)

계층간 데이터 교환에 쓰이는 객체이다.
Controller <-> Service <-> DAO <-> DB

package com.hiio.adminserver.dto.user;

import com.hiio.adminserver.entity.user.UserEntity;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class UserDTO {


    private String email;
    private String password;
    private String name;


    public static UserDTO of(UserEntity user) {
        return UserDTO
                .builder()
                .email(user.getEmail())
                .password(user.getPassword()
                .name(user.getName())
                .build();
    }

    public UserEntity toEntity(){
        return UserEntity.builder().email(email).password(password).name(name).build();
    }
}
728x90
728x90

먼저 UserEntity를 정의 합니다.

package com.hiio.adminserver.entity.user;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.*;

@Entity
@Table(name = "T_USER")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {

    @Id
    @Column(name = "USER_EMAIL", length = 512)
    private String email;

    @Column(name = "USER_PASSWORD", length = 64, nullable = true)
    private String password;

    @Column(name = "USER_NAME", length = 64, nullable = true)
    private String name;
}

Email 을 id로 하고 512의 길이를 가집니다.
password는 null이 들어 올 수 있고 길이는 64입니다.
name 은 비밀번호와 마찬가지로 null을 허용하고 길이는 64입니다.

추후에 속성들이 추가 되거나 수정할 예정입니다.

그럼 이제 Repository를 하나 만들어 줍니다.

package com.hiio.adminserver.repository.user;

import com.hiio.adminserver.entity.user.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, String> {
}

그리고 총 3개의 insert 테스트 코드를 작성해 줬습니다.

  1. 아무런 속성값을 입력하지 않으면 JpaSystemException이 발생하는 것이 예상값 테스트
  2. 이메일 속성값에 빈문자열을 입력했을 경우 TransactionSystemException이 발생하는 것이 예상값인 테스트
  3. 이메일 속성값만을 넣은 테스트 코드

package com.hiio.adminserver.User;


import com.hiio.adminserver.entity.user.UserEntity;
import com.hiio.adminserver.repository.user.UserRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
class UserRepositoryTest {
    private final Logger log = LoggerFactory.getLogger(getClass());

    @Autowired
    private UserRepository userRepo;

    @Test
    @DisplayName("Insert User Exception JpaSystemException")
    void insertUserJpaSystemException(){
        Exception exception = assertThrows(JpaSystemException.class,()->{
            UserEntity user = UserEntity.builder().build();
            userRepo.save(user);
            userRepo.flush();
        });
        log.error(exception.getClass().getName());
        exception.printStackTrace();
        assertThat(exception.getMessage(), containsString("must be manually assigned before calling 'persist()'"));
    }
    @Test
    @DisplayName("Insert User Exception TransactionSystemException")
    void insertUserTransactionSystemException(){
        Exception exception = assertThrows(TransactionSystemException.class,()->{
            UserEntity user = UserEntity.builder().email("").build();
            userRepo.save(user);
            userRepo.flush();
        });
        log.error(exception.getClass().getName());
        exception.printStackTrace();
        assertThat(exception.getMessage(), containsString("Could not commit JPA transaction"));
    }
    @Test
    @DisplayName("Insert User Fill Email")
    @Transactional
    void insertUser(){
        UserEntity user = UserEntity.builder().email("asd@asd.com").build();
        userRepo.save(user);
    }


}
728x90
728x90

728x90
728x90

요구사항? 요청사항?


진행중인 프로젝트에 투입되거나 시작되는 프로젝트에 투입되었을 때 아무래도 가장 먼저 하는게 분석이지 않을까 한다.
코드 분석이든 업무 분석이든
기능을 만들거나 테이블을 만들때 요구사항정의서를 참고하게 되는 경우가 대부분이다.
이전에 진행했던 프로젝트들의 요구사항정의서를 만들어 본적이 없이 그냥 보기만 했다.
그 요구사항 정의서들은 각 프로젝트마다 거의 제각각으로 보이기도 했다.

요구사항 또는 요청사항을 만들어 보는 연습도 해볼겸 작성해보면 어떨까 싶었다.

완벽하진 않아도 일단 해보는 것이 좋을 듯 싶다.

소프트웨어 방법론은 여러가지이다.
폭포수라던가 애자일이라던가
어쨌든 두 방법론 모두 요구사항을 어떻게 정의해 나가는지가 중요하다고 생각이 들었다.

대부분의 프로젝트는 폭포수 방법론을 따르는 경우가 많았고 요구사항을 모두 정의한 뒤에 프로젝트 개발에 들어가는 경우가 많았다.
하지만, 모든 프로젝트가 그렇듯 개발기간 중에 요구사항은 시시때떄로 변경되었다.
어떻게 보면 매번 요구사항이 변경되는 것은 고질적인 문제가 아닐까 싶기도 하다.

애자일 방법론으로 프로젝트를 진행해 본적은 없다.
그렇기 때문에 생소하기도 하고 개념적으로 이해한다고 하더라고 경험이 없는 것은 아쉬운 마음에 아는 한도 내에서
애자일 방법론(그냥 내 마음대로)에 따라 해보려고 한다.

https://www.atlassian.com/ko/agile/product-management/requirements

위 글을 참고해 보았다.

총 4단계로 나눠 져있는데

여기서는 목표를 적하고 이 목표에 대한 스토리를 작성해 본다.

1. 목표


요구사항의 목표는 사용자가 회원가입을 하고 로그인을 완료하는 것이다.

  1. 회원가입
  2. 로그인 성공

이라는 2개의 목표를 가지고 있다.

2. 스토리


  1. 사용자는 로그인을 하려고 한다.
  2. 로그인을 하기 위해서는 이메일과 비밀번호가 필요한다.
  3. 회원가입이 안되었을 경우 회원 가입을 진행한다.
  4. 회원가입이 되어있을 경우 로그인을 진행한다.
  5. 로그인이 성공하면 AccessToken과 RefreshToken / 사용자 정보를 전달 받는다.
  6. 로그인이 실패하면 실패사유를 전달 받는다.

사용자가 로그인에 성고하기위한 스토리를 작성해 보았다.

위 스토리를 토대로 세부적으로 조금 더 작성해 보자.

  1. 사용자는 로그인을 하려고 한다.
  • 사용자가 로그인 화면에 접근할 수 있는 URL이 필요하다.
  1. 로그인을 하기 위해서는 이메일과 비밀번호가 필요한다.
  • 로그인 화면에는 이메일과 비밀번호입력 받는 화면
  • 로그인 실행 버튼
  • 비밀번호 마스킹
  1. 회원가입이 안되었을 경우 회원 가입을 진행한다.
  • 회원가입 화면으로 이동하는 버튼
  • 회원가입시 필수 사항과 선택 사항
  1. 회원가입이 되어 있을 경우 로그인을 진행한다.
  2. 로그인이 성공하면 AccessToken과 RefreshToken / 사용자 정보를 전달 받는다.
  • 로그인 인증 방식은 JWT
  • 토큰 만료 시간
  • 반환할 사용자 정보 항목
  1. 로그인 실패하면 실패사유를 전달 받는다.
  • 실패시 메시지
  • 메시지 표시 화면

등을 좀더 세부적으로 작성해 보았다.

회원가입 화면으로 넘어 갔을 때 는 어떨까

  1. 회원 가입에 필요한 항목을 작성한다.
  • 필수 : 이메일 / 비밀번호 / 비밀번호 확인 / 이름
  1. 회원 가입 버튼 클릭
  • 필수 항목 체크
  • 유효성 검사
  1. 회원 가입 취소 시 이전 화면 으로 이동
  • 취소 버튼
  • 이전에 접속한 URL
  1. 회원 가입 성공시 로그인 화면으로 이동
  2. 회원가입 실패시 실패 사유 알림
  • 실패시 메시지
  • 메시지 표시 화면

등을 작성해 볼 수 있을 거 같다.

분류해보기


위에서 작성한 스토리를 바탕으로 클라이언트 / 서버 / 데이터베이스 3개의 관점으로 분류해보면 어떨까 싶다.

데이터 베이스


사용자는 이메일과 비밀번호를 로그인 시에 입력하고 또 회원가입 시 필수 정보로 입력하고 있다.그리고 이름을 필수 항목으로 받고 있다.
데이터 베이스에서는 이 항목들이 들어가야한다.

 

728x90
728x90

흐름도 / 흐름도 / 흐름도 그리기는 너무 어려워 !

그래도 해보자!

 

DFD (Data Flow Diagram)

https://en.wikipedia.org/wiki/Data-flow_diagram

 

Data-flow diagram - Wikipedia

From Wikipedia, the free encyclopedia Graphical representation of the "flow" of data through an information system Data flow diagram with data storage, data flows, function and interface A data-flow diagram is a way of representing a flow of data through a

en.wikipedia.org

 

사실 순서도나 흐름도 는 간혹 그려보기는 했지만, 전체적인 프로젝트에 대해 그려보지는 못했다 . 

API 시퀀스 다이어그램을 만들어 보기는 했지만

https://twentytwentyone.tistory.com/195

 

[관리자 페이지] Admin Page - 로그인 프로세스 생각해보기 #1

Admin Page - 로그인 프로세스 생각해보기 User가 Admin Page에 접근했을 경우 User의 Token 이 존재 하는지 확인 존재 한다면 Server에서 Token 발송 검증 요청 User 정보 return Token 이 없다면 Sign In Page로 이동

twentytwentyone.tistory.com

https://twentytwentyone.tistory.com/196

 

[관리자 페이지] Admin Page - 로그인 프로세스 생각해보기 #2

[관리자 페이지] Admin Page - 로그인 프로세스 생각해보기 #2 User는 먼저 로그인 화면으로 진입합니다. 로그인 할 수 있는 아이디나 패스워드가 없는 경우 회원가입을 진행하고 회원가입이 정상적

twentytwentyone.tistory.com

 

Draw.io를 사용해 계속해서 그려보는 연습을 해려고한다.

 

Draw.io는 이전에 웹에서만 사용했었는데 데스크탑 앱을 받을 수 있어서(이전에도 있었나?) 설치 후 사용했다.

 

처음 그려본 로그인 DFD 

 

 

 

뭔가 초라한 느낌 ㅎㅎㅎ

맞게 그린지도 모르겠지만 어쨌든 그려 봤다.

 

728x90
728x90

필요한 유틸리티 기능은 무엇이 있을까?


프로젝트를 진행하다보면 기존의 코드를 사용하던 처음 부터 구축하여 진행을 하던간에 공통 유틸리티 성의 코드를 가져다 쓰거나 만들어서 쓰는 일들이 많은 것 같다.

대부분의 요구사항에는 파일이나 엑셀, 암호화 등의 기능을 요구하는 곳이 많기도 하고 접근에 관한 이력을 DB에 저장해야하는 경우도 많았다.

  • 파일 유틸
  • 암호화 유틸
  • 엑셀 유틸
  • request 유틸
  • session 유틸

등이 기본적으로 필요했고, 많이 쓰였다.

더 많은 프로젝트를 접해 보면 더 좋겠지만, 위와 관련된 코드들을 구현해 보면 좋겠다는 생각이 들었다.

유틸리티 class


보통 이전의 유틸리티 class들을 static으로 불러와 많이 사용하였다. 요즘은 new 또는 @Component로 DI 를 통해 사용한다고 하는데 그 기준이 어떻게 되는지는 모르겠다.

아마도 객체 상태에 대한 이해와 java에서 사용되는 static에 대한 이해, 클래스 메소드가 어디서 무엇을 위해 사용되는지 이해하면 좋을 듯 싶었다.

static은 클래스 단위로 관리되어 메모리 사용량을 줄이고, 객체 생성 오버헤드 없이 접근할 수 있다. 라는 설명을 본적이 있는데, static 멤버 변수를 전역으로 공유 되기 때문에 특정 시점에서의 생성된 객체에 특정 멤버 변수의 값을 변화 시켜야 하는 작업이라면 static 보다는 instance의 유틸을 만들어 보는것이 좋지 않을까 싶다.

728x90
728x90

1. Dependency 추가

// build.gradle

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '3.2.5'
runtimeOnly group: 'com.h2database', name: 'h2', version: '2.2.224'
compileOnly group: 'org.projectlombok', name: 'lombok', version: '1.18.30'
annotationProcessor group: 'org.projectlombok', name: 'lombok', version: '1.18.30'

2.property 추가

// application.yml
spring:
  application:
    name: hiioAdminServer
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:P:\hiio420\h2DB\test.db
    username: sa
    password:
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop       
    properties:
      hibernate:
        dialect: org.hibernate.dialect.H2Dialect
        format_sql: true
        show_sql: true

3. TestEntity 생성

package com.hiio.adminserver;


import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "TEST")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TestEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Builder
    public TestEntity(Long id) {
        this.id = id;
    }
}

4. Repository 생성

package com.hiio.adminserver;

import org.springframework.data.jpa.repository.JpaRepository;

public interface TestRepository extends JpaRepository<TestEntity,Long> {
}

5. Test 코드 작성

package com.hiio.adminserver;


import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@SpringBootTest
@Slf4j
class HiioAdminServerSpringApplicationTests {

    @Autowired
    private TestRepository repo;
    @Test
    @DisplayName("test")
    @Transactional
    void contextLoads() {
        log.info(repo.toString());
        TestEntity t = TestEntity.builder().name("test1").build();
        repo.save(t);
        List<TestEntity> tList = repo.findAll();
        for (TestEntity item : tList){
            log.info(item.getId().toString());
            log.info(item.getName());
        }

    }

}

...

2024-05-07T15:48:25.291+09:00  INFO 37480 --- [hiioAdminServer] [    Test worker] .a.HiioAdminServerSpringApplicationTests : 1
2024-05-07T15:48:25.292+09:00  INFO 37480 --- [hiioAdminServer] [    Test worker] .a.HiioAdminServerSpringApplicationTests : test1
728x90
728x90

혼자 진행하는 프로젝트의 묘미는 그냥 내마음 대로 할 수 있다는게 아닐까?

FastAPI로 만들던 개인 프로젝트, 회사에서 다른 프로젝트를 FastAPI로 만들다 보니 흥미가 떨어 졌다. 

그래서 한번도 사용해 보지 않았던!

Spring Boot도 배울겸 JPA도 써볼 겸 관리자 페이지 Rest API 서버를 Spring boot로 처음부터 만들면 좋겠다 싶어서 시작해 본다.

 

IDE는 IntelliJ를 사용한다.

JDK 17 버전으로 spring boot는 3 버전을 사용한다.

 

IntelliJ의 New Project에는 Spring Initializr Generators가 있기 때문에 이를 통해서 프로젝트를 생성해보자.

기본적인 프로젝트 정보를 기입한 후에 Next!

 

 

아직은 아무런 Dependency들을 추가 하지 않고 Create를 한다.

 

 

프로젝트 파일들이 생성 됩니다.

 

Shift + F10으로 실행시켜봅니다.

프로젝트가 실행 되지만 바로 끝나버립니다.

 

Stackoverflow https://stackoverflow.com/questions/32758996/intellij-process-finished-with-exit-code-0-when-spring-boot-run

 

IntelliJ Process finished with exit code 0 when spring-boot run

I have a problem when starting spring-boot appication from IntelliJ-Idea. I don't have this problem when running application through terminal. :: Spring Boot :: (v1.2.1.RELEASE) 2015-09-24...

stackoverflow.com

에서는 boot-start-web을 추가하고, controller를 하나 만들어 보라고 합니다.

 

build.gradle에 dependency를 추가합니다.

 

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.springframework.boot:spring-boot-starter-web'
}

 

그리고 MainController.java 파일을 만들어 GetMapping으로 메소드를 하나 추가해 줍니다.

 

package com.hiio.adminserver.main;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MainController {

    @GetMapping("/")
    public String index() {
        return "Hello World";
    }
}

 

이후 다시 Run 해보면

 

 

localhost:8080 으로 서버가 열리게 됩니다.

 

위 주소로 접속하게 되면

 

Hello World 문구를 볼수 있습니다.

728x90
728x90

[관리자페이지] Admin Page - Admin - SiginIn 페이지 #7 Token 체크 리다이렉트


관리자 페이지 화면 구현은 Nextjs를 사용합니다.

Nextjs를 사용하기 위해서는 node.js를 설치해야합니다.

https://nodejs.org/en/download

설치 후에는 npx create-next-app@latest 를 이용해서 nextjs 프로젝트를 생성해 줍니다.

npx create-next-app@latest

app router를 사용합니다.
프로 젝트 생성 후에는 app 디렉토리에 admin과 signin 디렉토리를 각각 생성해 줍니다.

.
┗src
   ┗app
     ┗ admin
         ┗ page.tsx
     ┗ signin
         ┗ page.tsx

각 디렉토리에 page.tsx를 생성해 주고 간단한 텍스트 작성 후에

npm run dev

로 실행시킵니다.

// src/app/admin/page.tsx

export default function Admin(){
    return <>admin</>
}

// src/app/Signin/page.tsx
export default async function Signin() {
    return <>Sign In</>
}

 

 

Nextjs middleware를 사용해서 admin page로 접속 시 1) 토큰이 없거나, 2) 토큰이 있지만 만료 되었을 경우 SignIn 페이지로 이동하는 로직을 작성해 줍니다.

토큰은 cookie에 있고

먼저 토큰을 체크하는 로직을 작성해 줍니다.

 

 

https://nextjs.org/docs/app/building-your-application/routing/middleware

 

Routing: Middleware | Next.js

Learn how to use Middleware to run code before a request is completed.

nextjs.org

//src/middleware.ts
import {NextRequest, NextResponse} from 'next/server'

export function middleware(request: NextRequest) {

    if (request.nextUrl.pathname.startsWith('/admin')) {
        const cookie =request.cookies.get("token");
        if(!cookie){
            return NextResponse.redirect(new URL('/signin', request.url))
        }
    }


}

export const config = {
    matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)',],
}

 

728x90

+ Recent posts