Bagaimana cara mengelola pengecualian yang dilemparkan ke filter di Spring?


106

Saya ingin menggunakan cara umum untuk mengelola kode kesalahan 5xx, katakanlah secara khusus kasus ketika db turun di seluruh aplikasi pegas saya. Saya ingin json error cantik, bukan pelacakan tumpukan.

Untuk pengontrol saya memiliki @ControllerAdvicekelas untuk pengecualian yang berbeda dan ini juga menangkap kasus bahwa db berhenti di tengah permintaan. Tapi ini tidak semua. Saya juga kebetulan memiliki CorsFilterperpanjangan khusus OncePerRequestFilterdan di sana ketika saya menelepon doFiltersaya mendapatkan CannotGetJdbcConnectionExceptiondan itu tidak akan dikelola oleh@ControllerAdvice . Saya membaca beberapa hal secara online yang hanya membuat saya semakin bingung.

Jadi saya punya banyak pertanyaan:

  • Apakah saya perlu menerapkan filter kustom? Saya menemukan ExceptionTranslationFiltertapi ini hanya menangani AuthenticationExceptionatauAccessDeniedException .
  • Saya berpikir untuk menerapkannya sendiri HandlerExceptionResolver, tetapi ini membuat saya ragu, saya tidak memiliki pengecualian khusus untuk dikelola, pasti ada cara yang lebih jelas dari ini. Saya juga mencoba menambahkan coba / menangkap dan memanggil implementasi HandlerExceptionResolver(harus cukup baik, pengecualian saya tidak ada yang istimewa) tetapi ini tidak mengembalikan apa pun dalam respons, saya mendapatkan status 200 dan badan kosong.

Apakah ada cara yang baik untuk mengatasi ini? Terima kasih


Kita dapat mengganti BasicErrorController Spring Boot. Saya telah membuat blog tentang itu di sini: naturalprogrammer.com/blog/1685463/…
Sanjay

Jawaban:


83

Jadi inilah yang saya lakukan:

Saya membaca dasar-dasar tentang filter di sini dan saya menemukan bahwa saya perlu membuat filter khusus yang akan menjadi yang pertama dalam rantai filter dan akan mencoba menangkap semua pengecualian runtime yang mungkin terjadi di sana. Kemudian saya perlu membuat json secara manual dan memasukkannya ke dalam respons.

Jadi, inilah filter khusus saya:

public class ExceptionHandlerFilter extends OncePerRequestFilter {

    @Override
    public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (RuntimeException e) {

            // custom error response class used across my project
            ErrorResponse errorResponse = new ErrorResponse(e);

            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.getWriter().write(convertObjectToJson(errorResponse));
    }
}

    public String convertObjectToJson(Object object) throws JsonProcessingException {
        if (object == null) {
            return null;
        }
        ObjectMapper mapper = new ObjectMapper();
        return mapper.writeValueAsString(object);
    }
}

Dan kemudian saya menambahkannya di web.xml sebelum CorsFilter. Dan berhasil!

<filter> 
    <filter-name>exceptionHandlerFilter</filter-name> 
    <filter-class>xx.xxxxxx.xxxxx.api.controllers.filters.ExceptionHandlerFilter</filter-class> 
</filter> 


<filter-mapping> 
    <filter-name>exceptionHandlerFilter</filter-name> 
    <url-pattern>/*</url-pattern> 
</filter-mapping> 

<filter> 
    <filter-name>CorsFilter</filter-name> 
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> 
</filter> 

<filter-mapping>
    <filter-name>CorsFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Bisakah Anda memposting Kelas ErrorResponse Anda?
Siwa kumar

Kelas @Shivakumar ErrorResponse mungkin adalah DTO sederhana dengan properti kode / pesan sederhana.
ratijas

20

Saya ingin memberikan solusi berdasarkan jawaban @kopelitsa . Perbedaan utamanya adalah:

  1. Menggunakan kembali penanganan pengecualian pengontrol dengan menggunakan HandlerExceptionResolver.
  2. Menggunakan konfigurasi Java melalui konfigurasi XML

Pertama, Anda perlu memastikan, bahwa Anda memiliki kelas yang menangani pengecualian yang terjadi di RestController / Controller biasa (kelas yang dianotasi dengan @RestControllerAdviceatau @ControllerAdvicedan metode yang dianotasi @ExceptionHandler). Ini menangani pengecualian Anda yang terjadi di pengontrol. Berikut adalah contoh menggunakan RestControllerAdvice:

@RestControllerAdvice
public class ExceptionTranslator {

    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorDTO processRuntimeException(RuntimeException e) {
        return createErrorDTO(HttpStatus.INTERNAL_SERVER_ERROR, "An internal server error occurred.", e);
    }

    private ErrorDTO createErrorDTO(HttpStatus status, String message, Exception e) {
        (...)
    }
}

Untuk menggunakan kembali perilaku ini di rantai filter Keamanan Musim Semi, Anda perlu menentukan Filter dan mengaitkannya ke konfigurasi keamanan Anda. Filter perlu mengalihkan pengecualian ke penanganan pengecualian yang ditentukan di atas. Berikut ini contohnya:

@Component
public class FilterChainExceptionHandler extends OncePerRequestFilter {

    private final Logger log = LoggerFactory.getLogger(getClass());

    @Autowired
    @Qualifier("handlerExceptionResolver")
    private HandlerExceptionResolver resolver;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        try {
            filterChain.doFilter(request, response);
        } catch (Exception e) {
            log.error("Spring Security Filter Chain Exception:", e);
            resolver.resolveException(request, response, null, e);
        }
    }
}

Filter yang dibuat kemudian perlu ditambahkan ke SecurityConfiguration. Anda harus mengaitkannya ke rantai lebih awal, karena semua pengecualian filter sebelumnya tidak akan tertangkap. Dalam kasus saya, masuk akal untuk menambahkannya sebelum LogoutFilter. Lihat rantai filter default dan urutannya di dokumen resmi . Berikut ini contohnya:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private FilterChainExceptionHandler filterChainExceptionHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .addFilterBefore(filterChainExceptionHandler, LogoutFilter.class)
            (...)
    }

}

17

Saya menemukan masalah ini sendiri dan saya melakukan langkah-langkah di bawah ini untuk menggunakan kembali masalah saya ExceptionControlleryang dianotasi @ControllerAdviseuntuk Exceptionsdimasukkan ke dalam Filter terdaftar.

Jelas ada banyak cara untuk menangani pengecualian tetapi, dalam kasus saya, saya ingin pengecualian ditangani oleh saya ExceptionControllerkarena saya keras kepala dan juga karena saya tidak ingin menyalin / menempel kode yang sama (yaitu saya memiliki beberapa pemrosesan / logging kode masuk ExceptionController). Saya ingin mengembalikan JSONrespons yang indah seperti pengecualian lainnya yang tidak ditampilkan dari Filter.

{
  "status": 400,
  "message": "some exception thrown when executing the request"
}

Bagaimanapun, saya berhasil memanfaatkan saya ExceptionHandlerdan saya harus melakukan sedikit tambahan seperti yang ditunjukkan di bawah ini dalam langkah-langkah:

Langkah


  1. Anda memiliki filter ubahsuaian yang mungkin atau mungkin tidak memunculkan pengecualian
  2. Anda memiliki pengontrol Spring yang menangani pengecualian menggunakan @ControllerAdviseyaitu MyExceptionController

Kode sampel

//sample Filter, to be added in web.xml
public MyFilterThatThrowException implements Filter {
   //Spring Controller annotated with @ControllerAdvise which has handlers
   //for exceptions
   private MyExceptionController myExceptionController; 

   @Override
   public void destroy() {
        // TODO Auto-generated method stub
   }

   @Override
   public void init(FilterConfig arg0) throws ServletException {
       //Manually get an instance of MyExceptionController
       ApplicationContext ctx = WebApplicationContextUtils
                  .getRequiredWebApplicationContext(arg0.getServletContext());

       //MyExceptionHanlder is now accessible because I loaded it manually
       this.myExceptionController = ctx.getBean(MyExceptionController.class); 
   }

   @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

        try {
           //code that throws exception
        } catch(Exception ex) {
          //MyObject is whatever the output of the below method
          MyObject errorDTO = myExceptionController.handleMyException(req, ex); 

          //set the response object
          res.setStatus(errorDTO .getStatus());
          res.setContentType("application/json");

          //pass down the actual obj that exception handler normally send
          ObjectMapper mapper = new ObjectMapper();
          PrintWriter out = res.getWriter(); 
          out.print(mapper.writeValueAsString(errorDTO ));
          out.flush();

          return; 
        }

        //proceed normally otherwise
        chain.doFilter(request, response); 
     }
}

Dan sekarang contoh Pengontrol Pegas yang menangani Exceptionkasus normal (yaitu pengecualian yang biasanya tidak dilemparkan di level Filter, yang ingin kita gunakan untuk pengecualian yang dilemparkan ke Filter)

//sample SpringController 
@ControllerAdvice
public class ExceptionController extends ResponseEntityExceptionHandler {

    //sample handler
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    @ExceptionHandler(SQLException.class)
    public @ResponseBody MyObject handleSQLException(HttpServletRequest request,
            Exception ex){
        ErrorDTO response = new ErrorDTO (400, "some exception thrown when "
                + "executing the request."); 
        return response;
    }
    //other handlers
}

Berbagi solusi dengan mereka yang ingin menggunakan ExceptionControlleruntuk Exceptionsdimasukkan ke dalam Filter.


10
Nah, Anda dipersilakan untuk membagikan solusi Anda sendiri yang terdengar seperti cara untuk melakukannya :)
Raf

1
Jika Anda ingin menghindari Pengontrol terhubung ke filter Anda (yang saya asumsikan oleh @ Bato-BairTsyrenov), Anda dapat dengan mudah mengekstrak logika di mana Anda membuat ErrorDTO ke @Componentkelasnya sendiri dan menggunakannya di Filter dan di Pengendali.
Rüdiger Schulz

1
Saya tidak sepenuhnya setuju dengan Anda, karena ini tidak terlalu bersih untuk menyuntikkan pengontrol tertentu ke dalam filter Anda.
psv

Seperti yang disebutkan dalam answerini adalah salah satu caranya! Saya belum mengklaim bahwa itu adalah cara terbaik. Terima kasih telah berbagi kepedulian Anda @psv Saya yakin komunitas akan menghargai solusi yang Anda pikirkan :)
Raf

12

Jadi, inilah yang saya lakukan berdasarkan penggabungan jawaban di atas ... Kami sudah memiliki GlobalExceptionHandleranotasi @ControllerAdvicedan saya juga ingin menemukan cara untuk menggunakan kembali kode tersebut untuk menangani pengecualian yang berasal dari filter.

Solusi paling sederhana yang dapat saya temukan adalah membiarkan pengendali pengecualian saja, dan menerapkan pengontrol kesalahan sebagai berikut:

@Controller
public class ErrorControllerImpl implements ErrorController {
  @RequestMapping("/error")
  public void handleError(HttpServletRequest request) throws Throwable {
    if (request.getAttribute("javax.servlet.error.exception") != null) {
      throw (Throwable) request.getAttribute("javax.servlet.error.exception");
    }
  }
}

Jadi, setiap kesalahan yang disebabkan oleh pengecualian terlebih dahulu melewati ErrorControllerdan diarahkan kembali ke pengendali pengecualian dengan mengembalikannya dari dalam @Controllerkonteks, sedangkan kesalahan lain (tidak disebabkan secara langsung oleh pengecualian) melewatiErrorController tanpa modifikasi.

Ada alasan mengapa ini sebenarnya ide yang buruk?


1
Terima kasih sekarang menguji solusi ini tetapi dalam kasus saya berfungsi dengan sempurna.
Maciej

bersih dan sederhana satu tambahan untuk sepatu bot musim semi 2.0+ Anda harus menambahkan @Override public String getErrorPath() { return null; }
Fma

Anda dapat menggunakan javax.servlet.RequestDispatcher.ERROR_EXCEPTION daripada "javax.servlet.error.exception"
Marx

9

Jika Anda menginginkan cara yang umum, Anda dapat menentukan halaman kesalahan di web.xml:

<error-page>
  <exception-type>java.lang.Throwable</exception-type>
  <location>/500</location>
</error-page>

Dan tambahkan pemetaan di Spring MVC:

@Controller
public class ErrorController {

    @RequestMapping(value="/500")
    public @ResponseBody String handleException(HttpServletRequest req) {
        // you can get the exception thrown
        Throwable t = (Throwable)req.getAttribute("javax.servlet.error.exception");

        // customize response to what you want
        return "Internal server error.";
    }
}

Tetapi dalam istirahat pengalihan api dengan lokasi bukanlah solusi yang baik.
jmattheis

@jmatis Di atas bukan pengalihan.
holmis83

Benar, saya melihat lokasi dan mengira ada hubungannya dengan lokasi http. Maka inilah yang saya butuhkan (:
jmattheis

Bisakah Anda menambahkan konfigurasi Java yang setara dengan web.xml, jika ada?
k-den

1
@ k-den Tidak ada konfigurasi Java yang setara dalam spesifikasi saat ini, tetapi Anda dapat mencampur web.xml dan konfigurasi Java.
holmis83

5

Ini adalah solusi saya dengan mengesampingkan Spring Boot / penanganan kesalahan default

package com.mypackage;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.ErrorAttributes;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

/**
 * This controller is vital in order to handle exceptions thrown in Filters.
 */
@RestController
@RequestMapping("/error")
public class ErrorController implements org.springframework.boot.autoconfigure.web.ErrorController {

    private final static Logger LOGGER = LoggerFactory.getLogger(ErrorController.class);

    private final ErrorAttributes errorAttributes;

    @Autowired
    public ErrorController(ErrorAttributes errorAttributes) {
        Assert.notNull(errorAttributes, "ErrorAttributes must not be null");
        this.errorAttributes = errorAttributes;
    }

    @Override
    public String getErrorPath() {
        return "/error";
    }

    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest aRequest, HttpServletResponse response) {
        RequestAttributes requestAttributes = new ServletRequestAttributes(aRequest);
        Map<String, Object> result =     this.errorAttributes.getErrorAttributes(requestAttributes, false);

        Throwable error = this.errorAttributes.getError(requestAttributes);

        ResponseStatus annotation =     AnnotationUtils.getAnnotation(error.getClass(), ResponseStatus.class);
        HttpStatus statusCode = annotation != null ? annotation.value() : HttpStatus.INTERNAL_SERVER_ERROR;

        result.put("status", statusCode.value());
        result.put("error", statusCode.getReasonPhrase());

        LOGGER.error(result.toString());
        return new ResponseEntity<>(result, statusCode) ;
    }

}

apakah itu mempengaruhi konfigurasi otomatis?
Samet Baskıcı

Perhatikan bahwa HandlerExceptionResolver tidak selalu menangani pengecualian. Jadi ini mungkin termasuk HTTP 200. Menggunakan response.setStatus (..) sebelum memanggil tampaknya lebih aman.
ThomasRS

5

Hanya untuk melengkapi jawaban bagus lainnya yang diberikan, karena saya juga baru-baru ini menginginkan satu komponen penanganan kesalahan / pengecualian dalam aplikasi SpringBoot sederhana yang berisi filter yang dapat memunculkan pengecualian, dengan pengecualian lain yang berpotensi dilemparkan dari metode pengontrol.

Untungnya, tampaknya tidak ada yang menghalangi Anda untuk menggabungkan saran pengontrol Anda dengan penggantian penangan kesalahan default Spring untuk memberikan muatan respons yang konsisten, memungkinkan Anda untuk berbagi logika, memeriksa pengecualian dari filter, menjebak pengecualian layanan tertentu, dll.

Misalnya


@ControllerAdvice
@RestController
public class GlobalErrorHandler implements ErrorController {

  @ResponseStatus(HttpStatus.BAD_REQUEST)
  @ExceptionHandler(ValidationException.class)
  public Error handleValidationException(
      final ValidationException validationException) {
    return new Error("400", "Incorrect params"); // whatever
  }

  @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  @ExceptionHandler(Exception.class)
  public Error handleUnknownException(final Exception exception) {
    return new Error("500", "Unexpected error processing request");
  }

  @RequestMapping("/error")
  public ResponseEntity handleError(final HttpServletRequest request,
      final HttpServletResponse response) {

    Object exception = request.getAttribute("javax.servlet.error.exception");

    // TODO: Logic to inspect exception thrown from Filters...
    return ResponseEntity.badRequest().body(new Error(/* whatever */));
  }

  @Override
  public String getErrorPath() {
    return "/error";
  }

}

3

Ketika Anda ingin menguji keadaan aplikasi dan jika terjadi masalah, kembalikan kesalahan HTTP, saya akan menyarankan filter. Filter di bawah menangani semua permintaan HTTP. Solusi terpendek di Spring Boot dengan filter javax.

Dalam implementasinya bisa bermacam-macam kondisi. Dalam kasus saya, applicationManager menguji apakah aplikasi sudah siap.

import ...ApplicationManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class SystemIsReadyFilter implements Filter {

    @Autowired
    private ApplicationManager applicationManager;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (!applicationManager.isApplicationReady()) {
            ((HttpServletResponse) response).sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "The service is booting.");
        } else {
            chain.doFilter(request, response);
        }
    }

    @Override
    public void destroy() {}
}

2

Setelah membaca metode berbeda yang disarankan dalam jawaban di atas, saya memutuskan untuk menangani pengecualian otentikasi dengan menggunakan filter khusus. Saya dapat menangani status dan kode respons menggunakan kelas respons kesalahan menggunakan metode berikut.

Saya membuat filter kustom dan memodifikasi konfigurasi keamanan saya dengan menggunakan metode addFilterAfter dan ditambahkan setelah kelas CorsFilter.

@Component
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    //Cast the servlet request and response to HttpServletRequest and HttpServletResponse
    HttpServletResponse httpServletResponse = (HttpServletResponse) response;
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;

    // Grab the exception from the request attribute
    Exception exception = (Exception) request.getAttribute("javax.servlet.error.exception");
    //Set response content type to application/json
    httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);

    //check if exception is not null and determine the instance of the exception to further manipulate the status codes and messages of your exception
    if(exception!=null && exception instanceof AuthorizationParameterNotFoundException){
        ErrorResponse errorResponse = new ErrorResponse(exception.getMessage(),"Authetication Failed!");
        httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        PrintWriter writer = httpServletResponse.getWriter();
        writer.write(convertObjectToJson(errorResponse));
        writer.flush();
        return;
    }
    // If exception instance cannot be determined, then throw a nice exception and desired response code.
    else if(exception!=null){
            ErrorResponse errorResponse = new ErrorResponse(exception.getMessage(),"Authetication Failed!");
            PrintWriter writer = httpServletResponse.getWriter();
            writer.write(convertObjectToJson(errorResponse));
            writer.flush();
            return;
        }
        else {
        // proceed with the initial request if no exception is thrown.
            chain.doFilter(httpServletRequest,httpServletResponse);
        }
    }

public String convertObjectToJson(Object object) throws JsonProcessingException {
    if (object == null) {
        return null;
    }
    ObjectMapper mapper = new ObjectMapper();
    return mapper.writeValueAsString(object);
}
}

Kelas SecurityConfig

    @Configuration
    public class JwtSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    AuthFilter authenticationFilter;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterAfter(authenticationFilter, CorsFilter.class).csrf().disable()
                .cors(); //........
        return http;
     }
   }

Kelas ErrorResponse

public class ErrorResponse  {
private final String message;
private final String description;

public ErrorResponse(String description, String message) {
    this.message = message;
    this.description = description;
}

public String getMessage() {
    return message;
}

public String getDescription() {
    return description;
}}

0

Anda dapat menggunakan metode berikut di dalam blok tangkapan:

response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid token")

Perhatikan bahwa Anda dapat menggunakan kode HttpStatus dan pesan khusus.


-1

Aneh karena @ControllerAdvice seharusnya berfungsi, apakah Anda menangkap Exception yang benar?

@ControllerAdvice
public class GlobalDefaultExceptionHandler {

    @ResponseBody
    @ExceptionHandler(value = DataAccessException.class)
    public String defaultErrorHandler(HttpServletResponse response, DataAccessException e) throws Exception {
       response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
       //Json return
    }
}

Coba juga untuk menangkap pengecualian ini di CorsFilter dan mengirim 500 kesalahan, seperti ini

@ExceptionHandler(DataAccessException.class)
@ResponseBody
public String handleDataException(DataAccessException ex, HttpServletResponse response) {
    response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
    //Json return
}

Menangani pengecualian di CorsFilter berfungsi, tetapi tidak terlalu bersih. Sebenarnya yang saya butuhkan adalah menangani pengecualian untuk semua filter
kopelitsa

35
Exception throw from Filtermungkin tidak tertangkap @ControllerAdvicekarena in mungkin tidak terjangkau DispatcherServlet.
Thanh Nguyen Van

-1

Anda tidak perlu membuat Filter khusus untuk ini. Kami menyelesaikan ini dengan membuat pengecualian khusus yang memperluas ServletException (yang dilempar dari metode doFilter, ditampilkan dalam deklarasi). Ini kemudian ditangkap dan ditangani oleh penangan kesalahan global kami.

edit: tata bahasa


Apakah Anda keberatan membagikan cuplikan kode penangan kesalahan global Anda?
Neeraj Vernekar

itu tidak berhasil untuk saya. Saya membuat pengecualian khusus, yang memperluas ServletException, menambahkan dukungan untuk pengecualian ini di ExceptionHandler, tetapi belum dicegat di sana.
Marx
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.