Servlet untuk menyajikan konten statis


145

Saya menggunakan webapp di dua wadah yang berbeda (Tomcat dan Jetty), tetapi servlet default mereka untuk menyajikan konten statis memiliki cara berbeda dalam menangani struktur URL yang ingin saya gunakan ( detail ).

Karena itu saya ingin memasukkan servlet kecil di webapp untuk menyajikan konten statisnya sendiri (gambar, CSS, dll.). Servlet harus memiliki properti berikut:

  • Tidak ada ketergantungan eksternal
  • Sederhana dan andal
  • Dukungan untuk If-Modified-Sincetajuk (yaitu getLastModifiedmetode khusus )
  • (Opsional) dukungan untuk penyandian gzip, etag, ...

Apakah servlet semacam itu tersedia di suatu tempat? Yang paling dekat yang bisa saya temukan adalah contoh 4-10 dari buku servlet.

Pembaruan: Struktur URL yang ingin saya gunakan - jika Anda bertanya-tanya - cukup:

    <servlet-mapping>
            <servlet-name>main</servlet-name>
            <url-pattern>/*</url-pattern>
    </servlet-mapping>
    <servlet-mapping>
            <servlet-name>default</servlet-name>
            <url-pattern>/static/*</url-pattern>
    </servlet-mapping>

Jadi semua permintaan harus diteruskan ke servlet utama, kecuali jika itu untuk staticpath. Masalahnya adalah servlet default Tomcat tidak memperhitungkan ServletPath (sehingga mencari file statis di folder utama), sementara Jetty melakukannya (sehingga terlihat di staticfolder).


Bisakah Anda menguraikan "struktur URL" yang ingin Anda gunakan? Memutar sendiri, berdasarkan contoh 4-10 yang ditautkan, tampaknya seperti upaya sepele. Saya sudah sering melakukannya sendiri ...
Stu Thompson

Saya mengedit pertanyaan saya untuk menguraikan struktur URL. Dan ya, saya akhirnya menggulung servlet saya sendiri. Lihat jawaban saya di bawah ini.
Bruno De Fraine

1
Mengapa Anda tidak menggunakan server web untuk konten statis?
Stephen

4
@Stephen: karena tidak selalu ada Apache di depan Tomcat / Jetty. Dan untuk menghindari kerumitan konfigurasi yang terpisah. Tapi Anda benar, saya bisa mempertimbangkan opsi itu.
Bruno De Fraine

Saya tidak mengerti, mengapa Anda tidak menggunakan pemetaan seperti ini <servlet-mapping> <servlet-name> default </servlet-name> <url-pattern> / </url-pattern> </ servlet-mapping > untuk menyajikan konten statis
Maciek Kreft

Jawaban:


53

Saya datang dengan solusi yang sedikit berbeda. Agak hack-ish, tapi ini pemetaannya:

<servlet-mapping>   
    <servlet-name>default</servlet-name>
    <url-pattern>*.html</url-pattern>
</servlet-mapping>
<servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>*.jpg</url-pattern>
</servlet-mapping>
<servlet-mapping>
 <servlet-name>default</servlet-name>
    <url-pattern>*.png</url-pattern>
</servlet-mapping>
<servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>*.css</url-pattern>
</servlet-mapping>
<servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>*.js</url-pattern>
</servlet-mapping>

<servlet-mapping>
    <servlet-name>myAppServlet</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

Ini pada dasarnya hanya memetakan semua file konten dengan ekstensi ke servlet default, dan yang lainnya ke "myAppServlet".

Ini bekerja di Jetty dan Tomcat.


13
sebenarnya Anda dapat menambahkan lebih dari satu tag url-pattern di dalam servelet-mapping;)
Fareed Alnamrouti

5
Servlet 2.5 dan yang lebih baru mendukung banyak tag pola-url di dalam pemetaan servlet
vivid_voidgroup

Berhati-hatilah dengan file indeks (index.html) karena mereka dapat diutamakan di atas servlet Anda.
Andres

Saya pikir itu ide buruk *.sth. Jika seseorang akan mendapatkan url, example.com/index.jsp?g=.sthia akan mendapatkan sumber file jsp. Atau saya salah? (Saya baru di Java EE) Saya biasanya menggunakan pola url /css/*dan lain
SemprePeritus

46

Tidak perlu untuk implementasi kustom sepenuhnya dari servlet default dalam hal ini, Anda dapat menggunakan servlet sederhana ini untuk membungkus permintaan dengan implementasi kontainer:


package com.example;

import java.io.*;

import javax.servlet.*;
import javax.servlet.http.*;

public class DefaultWrapperServlet extends HttpServlet
{   
    public void doGet(HttpServletRequest req, HttpServletResponse resp)
        throws ServletException, IOException
    {
        RequestDispatcher rd = getServletContext().getNamedDispatcher("default");

        HttpServletRequest wrapped = new HttpServletRequestWrapper(req) {
            public String getServletPath() { return ""; }
        };

        rd.forward(wrapped, resp);
    }
}

Pertanyaan ini memiliki cara pemetaan / pengontrol yang rapi dan / statis ke konten statis menggunakan filter. Periksa jawaban yang dipilih setelah jawaban yang diterima: stackoverflow.com/questions/870150/…
David Carboni


30

Saya mendapatkan hasil yang baik dengan FileServlet , karena mendukung hampir semua HTTP (etags, chunking, dll.).


Terima kasih! berjam-jam upaya gagal dan jawaban buruk, dan ini memecahkan masalah saya
Yossi Shasho

4
Meskipun untuk menyajikan konten dari folder di luar aplikasi (saya menggunakannya untuk server folder dari disk, katakan C: \ sumber daya) Saya memodifikasi baris ini: this.basePath = getServletContext (). GetRealPath (getInitParameter ("basePath ")); Dan menggantinya dengan: this.basePath = getInitParameter ("basePath");
Yossi Shasho


26

Template abstrak untuk servlet sumber daya statis

Sebagian didasarkan pada blog ini dari tahun 2007, inilah templat abstrak yang dimodernisasi dan dapat digunakan kembali untuk servlet yang menangani caching dengan benar ETag,, If-None-Matchdan If-Modified-Since(tetapi tidak ada dukungan Gzip dan Range; hanya untuk membuatnya tetap sederhana; Gzip dapat dilakukan dengan filter atau melalui konfigurasi wadah).

public abstract class StaticResourceServlet extends HttpServlet {

    private static final long serialVersionUID = 1L;
    private static final long ONE_SECOND_IN_MILLIS = TimeUnit.SECONDS.toMillis(1);
    private static final String ETAG_HEADER = "W/\"%s-%s\"";
    private static final String CONTENT_DISPOSITION_HEADER = "inline;filename=\"%1$s\"; filename*=UTF-8''%1$s";

    public static final long DEFAULT_EXPIRE_TIME_IN_MILLIS = TimeUnit.DAYS.toMillis(30);
    public static final int DEFAULT_STREAM_BUFFER_SIZE = 102400;

    @Override
    protected void doHead(HttpServletRequest request, HttpServletResponse response) throws ServletException ,IOException {
        doRequest(request, response, true);
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        doRequest(request, response, false);
    }

    private void doRequest(HttpServletRequest request, HttpServletResponse response, boolean head) throws IOException {
        response.reset();
        StaticResource resource;

        try {
            resource = getStaticResource(request);
        }
        catch (IllegalArgumentException e) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        if (resource == null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        String fileName = URLEncoder.encode(resource.getFileName(), StandardCharsets.UTF_8.name());
        boolean notModified = setCacheHeaders(request, response, fileName, resource.getLastModified());

        if (notModified) {
            response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
            return;
        }

        setContentHeaders(response, fileName, resource.getContentLength());

        if (head) {
            return;
        }

        writeContent(response, resource);
    }

    /**
     * Returns the static resource associated with the given HTTP servlet request. This returns <code>null</code> when
     * the resource does actually not exist. The servlet will then return a HTTP 404 error.
     * @param request The involved HTTP servlet request.
     * @return The static resource associated with the given HTTP servlet request.
     * @throws IllegalArgumentException When the request is mangled in such way that it's not recognizable as a valid
     * static resource request. The servlet will then return a HTTP 400 error.
     */
    protected abstract StaticResource getStaticResource(HttpServletRequest request) throws IllegalArgumentException;

    private boolean setCacheHeaders(HttpServletRequest request, HttpServletResponse response, String fileName, long lastModified) {
        String eTag = String.format(ETAG_HEADER, fileName, lastModified);
        response.setHeader("ETag", eTag);
        response.setDateHeader("Last-Modified", lastModified);
        response.setDateHeader("Expires", System.currentTimeMillis() + DEFAULT_EXPIRE_TIME_IN_MILLIS);
        return notModified(request, eTag, lastModified);
    }

    private boolean notModified(HttpServletRequest request, String eTag, long lastModified) {
        String ifNoneMatch = request.getHeader("If-None-Match");

        if (ifNoneMatch != null) {
            String[] matches = ifNoneMatch.split("\\s*,\\s*");
            Arrays.sort(matches);
            return (Arrays.binarySearch(matches, eTag) > -1 || Arrays.binarySearch(matches, "*") > -1);
        }
        else {
            long ifModifiedSince = request.getDateHeader("If-Modified-Since");
            return (ifModifiedSince + ONE_SECOND_IN_MILLIS > lastModified); // That second is because the header is in seconds, not millis.
        }
    }

    private void setContentHeaders(HttpServletResponse response, String fileName, long contentLength) {
        response.setHeader("Content-Type", getServletContext().getMimeType(fileName));
        response.setHeader("Content-Disposition", String.format(CONTENT_DISPOSITION_HEADER, fileName));

        if (contentLength != -1) {
            response.setHeader("Content-Length", String.valueOf(contentLength));
        }
    }

    private void writeContent(HttpServletResponse response, StaticResource resource) throws IOException {
        try (
            ReadableByteChannel inputChannel = Channels.newChannel(resource.getInputStream());
            WritableByteChannel outputChannel = Channels.newChannel(response.getOutputStream());
        ) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(DEFAULT_STREAM_BUFFER_SIZE);
            long size = 0;

            while (inputChannel.read(buffer) != -1) {
                buffer.flip();
                size += outputChannel.write(buffer);
                buffer.clear();
            }

            if (resource.getContentLength() == -1 && !response.isCommitted()) {
                response.setHeader("Content-Length", String.valueOf(size));
            }
        }
    }

}

Gunakan bersama-sama dengan antarmuka di bawah ini yang mewakili sumber daya statis.

interface StaticResource {

    /**
     * Returns the file name of the resource. This must be unique across all static resources. If any, the file
     * extension will be used to determine the content type being set. If the container doesn't recognize the
     * extension, then you can always register it as <code>&lt;mime-type&gt;</code> in <code>web.xml</code>.
     * @return The file name of the resource.
     */
    public String getFileName();

    /**
     * Returns the last modified timestamp of the resource in milliseconds.
     * @return The last modified timestamp of the resource in milliseconds.
     */
    public long getLastModified();

    /**
     * Returns the content length of the resource. This returns <code>-1</code> if the content length is unknown.
     * In that case, the container will automatically switch to chunked encoding if the response is already
     * committed after streaming. The file download progress may be unknown.
     * @return The content length of the resource.
     */
    public long getContentLength();

    /**
     * Returns the input stream with the content of the resource. This method will be called only once by the
     * servlet, and only when the resource actually needs to be streamed, so lazy loading is not necessary.
     * @return The input stream with the content of the resource.
     * @throws IOException When something fails at I/O level.
     */
    public InputStream getInputStream() throws IOException;

}

Yang Anda butuhkan hanyalah memperluas dari servlet abstrak yang diberikan dan mengimplementasikan getStaticResource()metode sesuai javadoc.

Contoh nyata melayani dari sistem file:

Berikut ini adalah contoh nyata yang menyajikannya melalui URL seperti /files/foo.extdari sistem file disk lokal:

@WebServlet("/files/*")
public class FileSystemResourceServlet extends StaticResourceServlet {

    private File folder;

    @Override
    public void init() throws ServletException {
        folder = new File("/path/to/the/folder");
    }

    @Override
    protected StaticResource getStaticResource(HttpServletRequest request) throws IllegalArgumentException {
        String pathInfo = request.getPathInfo();

        if (pathInfo == null || pathInfo.isEmpty() || "/".equals(pathInfo)) {
            throw new IllegalArgumentException();
        }

        String name = URLDecoder.decode(pathInfo.substring(1), StandardCharsets.UTF_8.name());
        final File file = new File(folder, Paths.get(name).getFileName().toString());

        return !file.exists() ? null : new StaticResource() {
            @Override
            public long getLastModified() {
                return file.lastModified();
            }
            @Override
            public InputStream getInputStream() throws IOException {
                return new FileInputStream(file);
            }
            @Override
            public String getFileName() {
                return file.getName();
            }
            @Override
            public long getContentLength() {
                return file.length();
            }
        };
    }

}

Contoh nyata melayani dari basis data:

Berikut ini adalah contoh nyata yang menyajikannya melalui URL seperti /files/foo.extdari database melalui panggilan layanan EJB yang mengembalikan entitas Anda yang memiliki byte[] contentproperti:

@WebServlet("/files/*")
public class YourEntityResourceServlet extends StaticResourceServlet {

    @EJB
    private YourEntityService yourEntityService;

    @Override
    protected StaticResource getStaticResource(HttpServletRequest request) throws IllegalArgumentException {
        String pathInfo = request.getPathInfo();

        if (pathInfo == null || pathInfo.isEmpty() || "/".equals(pathInfo)) {
            throw new IllegalArgumentException();
        }

        String name = URLDecoder.decode(pathInfo.substring(1), StandardCharsets.UTF_8.name());
        final YourEntity yourEntity = yourEntityService.getByName(name);

        return (yourEntity == null) ? null : new StaticResource() {
            @Override
            public long getLastModified() {
                return yourEntity.getLastModified();
            }
            @Override
            public InputStream getInputStream() throws IOException {
                return new ByteArrayInputStream(yourEntityService.getContentById(yourEntity.getId()));
            }
            @Override
            public String getFileName() {
                return yourEntity.getName();
            }
            @Override
            public long getContentLength() {
                return yourEntity.getContentLength();
            }
        };
    }

}

1
Sayang @BalusC Saya pikir pendekatan Anda rentan terhadap hacker yang mengirimkan permintaan berikut bisa menavigasi melalui sistem file: files/%2e%2e/mysecretfile.txt. Permintaan ini menghasilkan files/../mysecretfile.txt. Saya mengujinya di Tomcat 7.0.55. Mereka menyebutnya pendakian direktori: owasp.org/index.php/Path_Traversal
Cristian Arteaga

1
@ Kristen: Ya, mungkin. Saya memperbarui contoh untuk menunjukkan bagaimana mencegahnya.
BalusC

Ini seharusnya tidak mendapatkan upvotes. Melayani file statis untuk halaman web dengan Servlet seperti ini adalah resep untuk keamanan bencana. Semua masalah seperti itu telah dipecahkan, dan tidak ada alasan untuk menerapkan cara Kustom baru dengan kemungkinan bom waktu keamanan yang belum ditemukan meledak. Jalur yang benar adalah mengkonfigurasi Tomcat / GlassFish / Jetty dll untuk menyajikan konten, atau bahkan lebih baik menggunakan server file khusus seperti NGinX.
Leonhard Printz

@LeonhardPrintz: Saya akan menghapus jawaban dan melaporkan kembali ke teman-teman saya di Tomcat setelah Anda menunjukkan masalah keamanan. Tidak masalah.
BalusC

19

Saya akhirnya menggulung sendiri StaticServlet. Ini mendukung If-Modified-Since, encoding gzip dan seharusnya dapat melayani file statis dari file perang juga. Ini bukan kode yang sangat sulit, tetapi juga tidak sepenuhnya sepele.

Kode tersedia: StaticServlet.java . Jangan ragu untuk berkomentar.

Pembaruan: Khurram bertanya tentang ServletUtilskelas yang dirujuk di StaticServlet. Ini hanyalah sebuah kelas dengan metode tambahan yang saya gunakan untuk proyek saya. Satu-satunya metode yang Anda butuhkan adalah coalesce(yang identik dengan fungsi SQL COALESCE). Ini kodenya:

public static <T> T coalesce(T...ts) {
    for(T t: ts)
        if(t != null)
            return t;
    return null;
}

2
Jangan beri nama Galat kelas batin Anda. Itu mungkin menyebabkan kebingungan karena Anda dapat salah mengartikannya untuk java.lang.Error. Juga, apakah web.xml Anda sama?
Leonel

Terima kasih atas peringatan Kesalahan. web.xml sama, dengan "default" diganti dengan nama StaticServlet.
Bruno De Fraine

1
Adapun metode coalesce, itu dapat diganti (di dalam kelas Servlet) oleh commons-lang StringUtils.defaultString (String, String)
Mike Minicki

Metode transferStreams () juga dapat diganti dengan Files.copy (is, os);
Gerrit Brink

Mengapa pendekatan ini begitu populer? Mengapa orang menerapkan kembali server file statis seperti ini? Ada begitu banyak celah keamanan yang menunggu untuk ditemukan, dan begitu banyak fitur server file statis nyata yang tidak diimplementasikan.
Leonhard Printz

12

Menilai dari contoh informasi di atas, saya pikir seluruh artikel ini didasarkan pada perilaku yang disadap di Tomcat 6.0.29 dan sebelumnya. Lihat https://issues.apache.org/bugzilla/show_bug.cgi?id=50026 . Tingkatkan ke Tomcat 6.0.30 dan perilaku di antara (Tomcat | Jetty) harus bergabung.


1
Itu juga pemahaman saya svn diff -c1056763 http://svn.apache.org/repos/asf/tomcat/tc6.0.x/trunk/. Akhirnya, setelah menandai WONTFIX ini +3 tahun yang lalu!
Bruno De Fraine

12

coba ini

<servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>*.js</url-pattern>
    <url-pattern>*.css</url-pattern>
    <url-pattern>*.ico</url-pattern>
    <url-pattern>*.png</url-pattern>
    <url-pattern>*.jpg</url-pattern>
    <url-pattern>*.htc</url-pattern>
    <url-pattern>*.gif</url-pattern>
</servlet-mapping>    

Sunting: Ini hanya valid untuk spesifikasi servlet 2.5 dan lebih tinggi.


Sepertinya ini bukan konfigurasi yang valid.
Gedrox

10

Saya memiliki masalah yang sama dan saya menyelesaikannya dengan menggunakan kode 'default servlet' dari basis kode Tomcat.

https://github.com/apache/tomcat/blob/master/java/org/apache/catalina/servlets/DefaultServlet.java

The DefaultServlet adalah servlet yang melayani sumber daya statis (jpg, html, css, gif dll) di Tomcat.

Servlet ini sangat efisien dan memiliki beberapa properti yang Anda tetapkan di atas.

Saya pikir ini kode sumber, adalah cara yang baik untuk memulai dan menghapus fungsionalitas atau depedensi yang tidak Anda butuhkan.

  • Referensi ke paket org.apache.naming.resources dapat dihapus atau diganti dengan kode java.io.File.
  • Referensi ke paket org.apache.catalina.util mungkin hanya metode / kelas utilitas yang dapat diduplikasi dalam kode sumber Anda.
  • Referensi ke kelas org.apache.catalina.Globals dapat digarisbawahi atau dihapus.

Tampaknya tergantung pada banyak hal dari org.apache.*. Bagaimana Anda bisa menggunakannya dengan Jetty?
Bruno De Fraine

Anda benar, versi ini memiliki terlalu banyak ketergantungan pada Tomcat (caand ini juga mendukung banyak hal yang mungkin tidak Anda inginkan. Saya akan mengedit jawaban saya.
Panagiotis Korros


4

Saya melakukan ini dengan memperluas tomcat DefaultServlet ( src ) dan mengganti metode getRelativePath ().

package com.example;

import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import org.apache.catalina.servlets.DefaultServlet;

public class StaticServlet extends DefaultServlet
{
   protected String pathPrefix = "/static";

   public void init(ServletConfig config) throws ServletException
   {
      super.init(config);

      if (config.getInitParameter("pathPrefix") != null)
      {
         pathPrefix = config.getInitParameter("pathPrefix");
      }
   }

   protected String getRelativePath(HttpServletRequest req)
   {
      return pathPrefix + super.getRelativePath(req);
   }
}

... Dan inilah pemetaan servlet saya

<servlet>
    <servlet-name>StaticServlet</servlet-name>
    <servlet-class>com.example.StaticServlet</servlet-class>
    <init-param>
        <param-name>pathPrefix</param-name>
        <param-value>/static</param-value>
    </init-param>       
</servlet>

<servlet-mapping>
    <servlet-name>StaticServlet</servlet-name>
    <url-pattern>/static/*</url-pattern>
</servlet-mapping>  

1

Untuk melayani semua permintaan dari aplikasi Spring serta /favicon.ico dan file JSP dari / WEB-INF / jsp / * yang akan diminta oleh AbstrakUrlBasedView Spring Anda dapat memetakan ulang servlet jsp dan servlet default:

  <servlet>
    <servlet-name>springapp</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <servlet-mapping>
    <servlet-name>jsp</servlet-name>
    <url-pattern>/WEB-INF/jsp/*</url-pattern>
  </servlet-mapping>

  <servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>/favicon.ico</url-pattern>
  </servlet-mapping>

  <servlet-mapping>
    <servlet-name>springapp</servlet-name>
    <url-pattern>/*</url-pattern>
  </servlet-mapping>

Kami tidak dapat mengandalkan pola-* .jsp url pada pemetaan standar untuk servlet jsp karena pola jalur '/ *' cocok sebelum pemetaan ekstensi dicentang. Memetakan servlet jsp ke folder yang lebih dalam berarti cocok terlebih dahulu. Pencocokan '/favicon.ico' persis terjadi sebelum pencocokan pola jalur. Pencocokan jalur yang lebih dalam akan berfungsi, atau pencocokan yang tepat, tetapi tidak ada pencocokan ekstensi yang dapat melewati pencocokan jalur '/ *'. Memetakan '/' ke servlet default tampaknya tidak berfungsi. Anda akan berpikir persis '/' akan mengalahkan pola path '/ *' pada springapp.

Solusi filter di atas tidak berfungsi untuk permintaan JSP diteruskan / disertakan dari aplikasi. Untuk membuatnya berfungsi, saya harus menerapkan filter ke springapp secara langsung, pada saat itu pencocokan pola-url tidak berguna karena semua permintaan yang masuk ke aplikasi juga masuk ke filternya. Jadi saya menambahkan pencocokan pola ke filter dan kemudian belajar tentang servlet 'jsp' dan melihat bahwa itu tidak menghapus awalan path seperti servlet default tidak. Itu memecahkan masalah saya, yang tidak persis sama tetapi cukup umum.


1

Diperiksa untuk Tomcat 8.x: sumber daya statis berfungsi OK jika root servlet memetakan ke "". Untuk servlet 3.x itu bisa dilakukan oleh@WebServlet("")


0

Gunakan org.mortbay.jetty.handler.ContextHandler. Anda tidak memerlukan komponen tambahan seperti StaticServlet.

Di rumah dermaga,

konteks $ cd

$ cp javadoc.xml static.xml

$ vi static.xml

...

<Configure class="org.mortbay.jetty.handler.ContextHandler">
<Set name="contextPath">/static</Set>
<Set name="resourceBase"><SystemProperty name="jetty.home" default="."/>/static/</Set>
<Set name="handler">
  <New class="org.mortbay.jetty.handler.ResourceHandler">
    <Set name="cacheControl">max-age=3600,public</Set>
  </New>
 </Set>
</Configure>

Tetapkan nilai contextPath dengan awalan URL Anda, dan tetapkan nilai resourceBase sebagai jalur file konten statis.

Ini berhasil untuk saya.


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.