2018-08-12 07:27:15 +02:00
package org.dynmap.storage.sqllte ;
import java.io.File ;
import java.nio.charset.Charset ;
import java.sql.Connection ;
import java.sql.DriverManager ;
import java.sql.PreparedStatement ;
import java.sql.ResultSet ;
import java.sql.SQLException ;
import java.sql.Statement ;
import java.util.ArrayList ;
import java.util.Collections ;
import java.util.HashMap ;
import java.util.List ;
import org.dynmap.DynmapCore ;
import org.dynmap.DynmapWorld ;
import org.dynmap.Log ;
import org.dynmap.MapType ;
import org.dynmap.WebAuthManager ;
import org.dynmap.MapType.ImageVariant ;
import org.dynmap.PlayerFaces.FaceType ;
import org.dynmap.storage.MapStorage ;
import org.dynmap.storage.MapStorageTile ;
import org.dynmap.storage.MapStorageTileEnumCB ;
2019-10-21 21:32:49 +02:00
import org.dynmap.storage.MapStorageBaseTileEnumCB ;
import org.dynmap.storage.MapStorageTileSearchEndCB ;
2018-08-12 07:27:15 +02:00
import org.dynmap.utils.BufferInputStream ;
import org.dynmap.utils.BufferOutputStream ;
public class SQLiteMapStorage extends MapStorage {
private String connectionString ;
private String databaseFile ;
2022-02-05 09:05:54 +01:00
private static final int POOLSIZE = 1 ; // SQLite is really not thread safe... 1 at a time works best
2018-08-12 07:27:15 +02:00
private Connection [ ] cpool = new Connection [ POOLSIZE ] ;
2022-08-08 06:48:47 +02:00
private long [ ] cpoolLastUseTS = new long [ POOLSIZE ] ; // Time when last returned to pool
private static final long IDLE_TIMEOUT = 60000 ; // Use 60 second timeout
2018-08-12 07:27:15 +02:00
private int cpoolCount = 0 ;
private static final Charset UTF8 = Charset . forName ( " UTF-8 " ) ;
public class StorageTile extends MapStorageTile {
private Integer mapkey ;
private String uri ;
protected StorageTile ( DynmapWorld world , MapType map , int x , int y ,
int zoom , ImageVariant var ) {
super ( world , map , x , y , zoom , var ) ;
mapkey = getMapKey ( world , map , var ) ;
if ( zoom > 0 ) {
2022-03-10 09:24:27 +01:00
uri = map . getPrefix ( ) + var . variantSuffix + " / " + ( x > > 5 ) + " _ " + ( y > > 5 ) + " / " + " zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz " . substring ( 0 , zoom ) + " _ " + x + " _ " + y + " . " + map . getImageFormat ( ) . getFileExt ( ) ;
2018-08-12 07:27:15 +02:00
}
else {
uri = map . getPrefix ( ) + var . variantSuffix + " / " + ( x > > 5 ) + " _ " + ( y > > 5 ) + " / " + x + " _ " + y + " . " + map . getImageFormat ( ) . getFileExt ( ) ;
}
}
@Override
public boolean exists ( ) {
if ( mapkey = = null ) return false ;
boolean rslt = false ;
Connection c = null ;
boolean err = false ;
try {
c = getConnection ( ) ;
Statement stmt = c . createStatement ( ) ;
//ResultSet rs = stmt.executeQuery("SELECT HashCode FROM Tiles WHERE MapID=" + mapkey + " AND x=" + x + " AND y=" + y + " AND zoom=" + zoom + ";");
ResultSet rs = doExecuteQuery ( stmt , " SELECT HashCode FROM Tiles WHERE MapID= " + mapkey + " AND x= " + x + " AND y= " + y + " AND zoom= " + zoom + " ; " ) ;
rslt = rs . next ( ) ;
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Tile exists error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return rslt ;
}
@Override
public boolean matchesHashCode ( long hash ) {
if ( mapkey = = null ) return false ;
boolean rslt = false ;
Connection c = null ;
boolean err = false ;
try {
c = getConnection ( ) ;
Statement stmt = c . createStatement ( ) ;
//ResultSet rs = stmt.executeQuery("SELECT HashCode FROM Tiles WHERE MapID=" + mapkey + " AND x=" + x + " AND y=" + y + " AND zoom=" + zoom + ";");
ResultSet rs = doExecuteQuery ( stmt , " SELECT HashCode FROM Tiles WHERE MapID= " + mapkey + " AND x= " + x + " AND y= " + y + " AND zoom= " + zoom + " ; " ) ;
if ( rs . next ( ) ) {
long v = rs . getLong ( " HashCode " ) ;
rslt = ( v = = hash ) ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Tile matches hash error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return rslt ;
}
@Override
public TileRead read ( ) {
if ( mapkey = = null ) return null ;
TileRead rslt = null ;
Connection c = null ;
boolean err = false ;
try {
c = getConnection ( ) ;
Statement stmt = c . createStatement ( ) ;
//ResultSet rs = stmt.executeQuery("SELECT HashCode,LastUpdate,Format,Image FROM Tiles WHERE MapID=" + mapkey + " AND x=" + x + " AND y=" + y + " AND zoom=" + zoom + ";");
2019-02-04 05:19:39 +01:00
ResultSet rs = doExecuteQuery ( stmt , " SELECT HashCode,LastUpdate,Format,Image,ImageLen FROM Tiles WHERE MapID= " + mapkey + " AND x= " + x + " AND y= " + y + " AND zoom= " + zoom + " ; " ) ;
2018-08-12 07:27:15 +02:00
if ( rs . next ( ) ) {
rslt = new TileRead ( ) ;
rslt . hashCode = rs . getLong ( " HashCode " ) ;
rslt . lastModified = rs . getLong ( " LastUpdate " ) ;
rslt . format = MapType . ImageEncoding . fromOrd ( rs . getInt ( " Format " ) ) ;
byte [ ] img = rs . getBytes ( " Image " ) ;
2019-02-04 05:19:39 +01:00
int len = rs . getInt ( " ImageLen " ) ;
if ( len < = 0 ) {
len = img . length ;
// Trim trailing zeros from padding by BLOB field
while ( ( len > 0 ) & & ( img [ len - 1 ] = = '\0' ) ) len - - ;
}
2022-07-31 01:04:28 +02:00
if ( img = = null ) {
rslt = null ;
} else {
rslt . image = new BufferInputStream ( img , len ) ;
}
2018-08-12 07:27:15 +02:00
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Tile read error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return rslt ;
}
@Override
2021-07-20 23:18:04 +02:00
public boolean write ( long hash , BufferOutputStream encImage , long timestamp ) {
2018-08-12 07:27:15 +02:00
if ( mapkey = = null ) return false ;
Connection c = null ;
boolean err = false ;
boolean exists = exists ( ) ;
// If delete, and doesn't exist, quit
if ( ( encImage = = null ) & & ( ! exists ) ) return false ;
try {
c = getConnection ( ) ;
PreparedStatement stmt ;
if ( encImage = = null ) { // If delete
stmt = c . prepareStatement ( " DELETE FROM Tiles WHERE MapID=? AND x=? and y=? AND zoom=?; " ) ;
stmt . setInt ( 1 , mapkey ) ;
stmt . setInt ( 2 , x ) ;
stmt . setInt ( 3 , y ) ;
stmt . setInt ( 4 , zoom ) ;
}
else if ( exists ) {
2019-02-04 05:19:39 +01:00
stmt = c . prepareStatement ( " UPDATE Tiles SET HashCode=?, LastUpdate=?, Format=?, Image=?, ImageLen=? WHERE MapID=? AND x=? and y=? AND zoom=?; " ) ;
2018-08-12 07:27:15 +02:00
stmt . setLong ( 1 , hash ) ;
2021-07-20 23:18:04 +02:00
stmt . setLong ( 2 , timestamp ) ;
2018-08-12 07:27:15 +02:00
stmt . setInt ( 3 , map . getImageFormat ( ) . getEncoding ( ) . ordinal ( ) ) ;
stmt . setBytes ( 4 , encImage . buf ) ;
2019-02-04 07:13:02 +01:00
stmt . setInt ( 5 , encImage . len ) ;
2019-02-04 05:19:39 +01:00
stmt . setInt ( 6 , mapkey ) ;
stmt . setInt ( 7 , x ) ;
stmt . setInt ( 8 , y ) ;
stmt . setInt ( 9 , zoom ) ;
2018-08-12 07:27:15 +02:00
}
else {
2019-02-04 05:19:39 +01:00
stmt = c . prepareStatement ( " INSERT INTO Tiles (MapID,x,y,zoom,HashCode,LastUpdate,Format,Image,ImageLen) VALUES (?,?,?,?,?,?,?,?,?); " ) ;
2018-08-12 07:27:15 +02:00
stmt . setInt ( 1 , mapkey ) ;
stmt . setInt ( 2 , x ) ;
stmt . setInt ( 3 , y ) ;
stmt . setInt ( 4 , zoom ) ;
stmt . setLong ( 5 , hash ) ;
2021-07-20 23:18:04 +02:00
stmt . setLong ( 6 , timestamp ) ;
2018-08-12 07:27:15 +02:00
stmt . setInt ( 7 , map . getImageFormat ( ) . getEncoding ( ) . ordinal ( ) ) ;
stmt . setBytes ( 8 , encImage . buf ) ;
2019-02-04 07:13:02 +01:00
stmt . setInt ( 9 , encImage . len ) ;
2018-08-12 07:27:15 +02:00
}
//stmt.executeUpdate();
doExecuteUpdate ( stmt ) ;
stmt . close ( ) ;
// Signal update for zoom out
if ( zoom = = 0 ) {
world . enqueueZoomOutUpdate ( this ) ;
}
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Tile write error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return ! err ;
}
@Override
public boolean getWriteLock ( ) {
return SQLiteMapStorage . this . getWriteLock ( uri ) ;
}
@Override
public void releaseWriteLock ( ) {
SQLiteMapStorage . this . releaseWriteLock ( uri ) ;
}
@Override
public boolean getReadLock ( long timeout ) {
return SQLiteMapStorage . this . getReadLock ( uri , timeout ) ;
}
@Override
public void releaseReadLock ( ) {
SQLiteMapStorage . this . releaseReadLock ( uri ) ;
}
@Override
public void cleanup ( ) {
}
@Override
public String getURI ( ) {
return uri ;
}
@Override
public void enqueueZoomOutUpdate ( ) {
world . enqueueZoomOutUpdate ( this ) ;
}
@Override
public MapStorageTile getZoomOutTile ( ) {
int xx , yy ;
int step = 1 < < zoom ;
if ( x > = 0 )
xx = x - ( x % ( 2 * step ) ) ;
else
xx = x + ( x % ( 2 * step ) ) ;
yy = - y ;
if ( yy > = 0 )
yy = yy - ( yy % ( 2 * step ) ) ;
else
yy = yy + ( yy % ( 2 * step ) ) ;
yy = - yy ;
return new StorageTile ( world , map , xx , yy , zoom + 1 , var ) ;
}
@Override
public boolean equals ( Object o ) {
if ( o instanceof StorageTile ) {
StorageTile st = ( StorageTile ) o ;
return uri . equals ( st . uri ) ;
}
return false ;
}
@Override
public int hashCode ( ) {
return uri . hashCode ( ) ;
}
}
public SQLiteMapStorage ( ) {
}
@Override
public boolean init ( DynmapCore core ) {
if ( ! super . init ( core ) ) {
2022-07-24 20:09:52 +02:00
isShutdown = true ;
2018-08-12 07:27:15 +02:00
return false ;
}
File dbfile = core . getFile ( core . configuration . getString ( " storage/dbfile " , " dynmap.db " ) ) ;
databaseFile = dbfile . getAbsolutePath ( ) ;
connectionString = " jdbc:sqlite: " + databaseFile ;
Log . info ( " Opening SQLite file " + databaseFile + " as map store " ) ;
try {
Class . forName ( " org.sqlite.JDBC " ) ;
// Initialize/update tables, if needed
return initializeTables ( ) ;
} catch ( ClassNotFoundException cnfx ) {
Log . severe ( " SQLite-JDBC classes not found - sqlite data source not usable " ) ;
2022-07-24 20:09:52 +02:00
isShutdown = true ;
2018-08-12 07:27:15 +02:00
return false ;
}
}
private int getSchemaVersion ( ) {
int ver = 0 ;
boolean err = false ;
Connection c = null ;
try {
c = getConnection ( ) ; // Get connection (create DB if needed)
Statement stmt = c . createStatement ( ) ;
//ResultSet rs = stmt.executeQuery( "SELECT level FROM SchemaVersion;");
ResultSet rs = doExecuteQuery ( stmt , " SELECT level FROM SchemaVersion; " ) ;
if ( rs . next ( ) ) {
ver = rs . getInt ( " level " ) ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
if ( c ! = null ) { releaseConnection ( c , err ) ; }
}
return ver ;
}
private void doUpdate ( Connection c , String sql ) throws SQLException {
Statement stmt = c . createStatement ( ) ;
//stmt.executeUpdate(sql);
doExecuteUpdate ( stmt , sql ) ;
stmt . close ( ) ;
}
private HashMap < String , Integer > mapKey = new HashMap < String , Integer > ( ) ;
private void doLoadMaps ( ) {
Connection c = null ;
boolean err = false ;
mapKey . clear ( ) ;
// Read the maps table - cache results
try {
c = getConnection ( ) ;
Statement stmt = c . createStatement ( ) ;
//ResultSet rs = stmt.executeQuery("SELECT * from Maps;");
ResultSet rs = doExecuteQuery ( stmt , " SELECT * from Maps; " ) ;
while ( rs . next ( ) ) {
int key = rs . getInt ( " ID " ) ;
String worldID = rs . getString ( " WorldID " ) ;
String mapID = rs . getString ( " MapID " ) ;
String variant = rs . getString ( " Variant " ) ;
mapKey . put ( worldID + " : " + mapID + " : " + variant , key ) ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Error loading map table " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
c = null ;
}
}
private Integer getMapKey ( DynmapWorld w , MapType mt , ImageVariant var ) {
String id = w . getName ( ) + " : " + mt . getPrefix ( ) + " : " + var . toString ( ) ;
synchronized ( mapKey ) {
Integer k = mapKey . get ( id ) ;
if ( k = = null ) { // No hit: new value so we need to add it to table
Connection c = null ;
boolean err = false ;
try {
c = getConnection ( ) ;
// Insert row
PreparedStatement stmt = c . prepareStatement ( " INSERT INTO Maps (WorldID,MapID,Variant) VALUES (?, ?, ?); " ) ;
stmt . setString ( 1 , w . getName ( ) ) ;
stmt . setString ( 2 , mt . getPrefix ( ) ) ;
stmt . setString ( 3 , var . toString ( ) ) ;
//stmt.executeUpdate();
doExecuteUpdate ( stmt ) ;
stmt . close ( ) ;
// Query key assigned
stmt = c . prepareStatement ( " SELECT ID FROM Maps WHERE WorldID = ? AND MapID = ? AND Variant = ?; " ) ;
stmt . setString ( 1 , w . getName ( ) ) ;
stmt . setString ( 2 , mt . getPrefix ( ) ) ;
stmt . setString ( 3 , var . toString ( ) ) ;
//ResultSet rs = stmt.executeQuery();
ResultSet rs = doExecuteQuery ( stmt ) ;
if ( rs . next ( ) ) {
k = rs . getInt ( " ID " ) ;
mapKey . put ( id , k ) ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Error updating Maps table " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
}
return k ;
}
}
private boolean initializeTables ( ) {
Connection c = null ;
boolean err = false ;
int version = getSchemaVersion ( ) ; // Get the existing schema version for the DB (if any)
// If new, add our tables
if ( version = = 0 ) {
try {
2022-01-17 03:22:16 +01:00
Log . info ( " Initializing database schema " ) ;
2018-08-12 07:27:15 +02:00
c = getConnection ( ) ;
doUpdate ( c , " CREATE TABLE Maps (ID INTEGER PRIMARY KEY AUTOINCREMENT, WorldID STRING NOT NULL, MapID STRING NOT NULL, Variant STRING NOT NULL) " ) ;
2019-02-04 05:19:39 +01:00
doUpdate ( c , " CREATE TABLE Tiles (MapID INT NOT NULL, x INT NOT NULL, y INT NOT NULL, zoom INT NOT NULL, HashCode INT NOT NULL, LastUpdate INT NOT NULL, Format INT NOT NULL, Image BLOB, ImageLen INT, PRIMARY KEY(MapID, x, y, zoom)) " ) ;
doUpdate ( c , " CREATE TABLE Faces (PlayerName STRING NOT NULL, TypeID INT NOT NULL, Image BLOB, ImageLen INT, PRIMARY KEY(PlayerName, TypeID)) " ) ;
doUpdate ( c , " CREATE TABLE MarkerIcons (IconName STRING PRIMARY KEY NOT NULL, Image BLOB, ImageLen INT) " ) ;
2018-08-12 07:27:15 +02:00
doUpdate ( c , " CREATE TABLE MarkerFiles (FileName STRING PRIMARY KEY NOT NULL, Content CLOB) " ) ;
2022-03-27 20:59:39 +02:00
// Add index, since SQLite execution planner is stupid and scans Tiles table instead of using short Maps table...
doUpdate ( c , " CREATE INDEX MapIndex ON Maps(WorldID, MapID, Variant) " ) ;
2018-08-12 07:27:15 +02:00
doUpdate ( c , " CREATE TABLE SchemaVersion (level INT PRIMARY KEY NOT NULL) " ) ;
2022-03-27 20:59:39 +02:00
doUpdate ( c , " INSERT INTO SchemaVersion (level) VALUES (3) " ) ;
version = 3 ; // Initializes to current schema
2019-02-04 05:19:39 +01:00
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Error creating tables " , x ) ;
2019-02-04 05:19:39 +01:00
err = true ;
return false ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
return false ;
2019-02-04 05:19:39 +01:00
} finally {
releaseConnection ( c , err ) ;
c = null ;
}
}
2022-01-16 20:38:06 +01:00
if ( version = = 1 ) { // Add ImageLen columns
2019-02-04 05:19:39 +01:00
try {
2022-01-17 03:22:16 +01:00
Log . info ( " Updating database schema from version = " + version ) ;
2019-02-04 05:19:39 +01:00
c = getConnection ( ) ;
doUpdate ( c , " ALTER TABLE Tiles ADD ImageLen INT " ) ;
doUpdate ( c , " ALTER TABLE Faces ADD ImageLen INT " ) ;
doUpdate ( c , " ALTER TABLE MarkerIcons ADD ImageLen INT " ) ;
doUpdate ( c , " UPDATE SchemaVersion SET level=2 " ) ;
2022-01-16 20:38:06 +01:00
version = 2 ;
2018-08-12 07:27:15 +02:00
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Error updating tables to version=2 " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
return false ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
return false ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
c = null ;
}
}
2022-03-27 20:59:39 +02:00
if ( version = = 2 ) {
try {
Log . info ( " Updating database schema from version = " + version ) ;
c = getConnection ( ) ;
// Add index, since SQLite execution planner is stupid and scans Tiles table instead of using short Maps table...
doUpdate ( c , " CREATE INDEX MapIndex ON Maps(WorldID, MapID, Variant) " ) ;
doUpdate ( c , " UPDATE SchemaVersion SET level=3 " ) ;
version = 2 ;
} catch ( SQLException x ) {
logSQLException ( " Error updating tables to version=3 " , x ) ;
err = true ;
return false ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
return false ;
2022-03-27 20:59:39 +02:00
} finally {
releaseConnection ( c , err ) ;
c = null ;
}
}
2022-01-17 03:22:16 +01:00
Log . info ( " Schema version = " + version ) ;
2018-08-12 07:27:15 +02:00
// Load maps table - cache results
doLoadMaps ( ) ;
return true ;
}
2022-07-24 20:09:52 +02:00
private Connection getConnection ( ) throws SQLException , StorageShutdownException {
2018-08-12 07:27:15 +02:00
Connection c = null ;
2022-07-24 20:09:52 +02:00
if ( isShutdown ) {
throw new StorageShutdownException ( ) ;
}
2018-08-12 07:27:15 +02:00
synchronized ( cpool ) {
2022-08-08 06:48:47 +02:00
long now = System . currentTimeMillis ( ) ;
2018-08-12 07:27:15 +02:00
while ( c = = null ) {
for ( int i = 0 ; i < cpool . length ; i + + ) { // See if available connection
if ( cpool [ i ] ! = null ) { // Found one
2022-08-08 06:48:47 +02:00
// If in pool too long, close it and move on
if ( ( now - cpoolLastUseTS [ i ] ) > IDLE_TIMEOUT ) {
try { cpool [ i ] . close ( ) ; } catch ( SQLException x ) { }
cpool [ i ] = null ;
cpoolCount - - ;
}
else { // Else, use the connection
c = cpool [ i ] ;
cpool [ i ] = null ;
cpoolLastUseTS [ i ] = now ;
break ;
}
2018-08-12 07:27:15 +02:00
}
}
if ( c = = null ) {
if ( cpoolCount < POOLSIZE ) { // Still more we can have
c = DriverManager . getConnection ( connectionString ) ;
configureConnection ( c ) ;
cpoolCount + + ;
}
else {
try {
cpool . wait ( ) ;
} catch ( InterruptedException e ) {
throw new SQLException ( " Interruped " ) ;
}
}
}
}
}
return c ;
}
private static Connection configureConnection ( Connection conn ) throws SQLException {
final Statement statement = conn . createStatement ( ) ;
2023-10-03 12:31:51 +02:00
statement . execute ( " PRAGMA auto_vacuum = FULL; " ) ;
2018-08-12 07:27:15 +02:00
statement . execute ( " PRAGMA journal_mode = WAL; " ) ;
statement . close ( ) ;
return conn ;
}
private void releaseConnection ( Connection c , boolean err ) {
if ( c = = null ) return ;
synchronized ( cpool ) {
if ( ! err ) { // Find slot to keep it in pool
for ( int i = 0 ; i < POOLSIZE ; i + + ) {
if ( cpool [ i ] = = null ) {
cpool [ i ] = c ;
2022-08-08 06:48:47 +02:00
cpoolLastUseTS [ i ] = System . currentTimeMillis ( ) ; // Record last use time
2018-08-12 07:27:15 +02:00
c = null ; // Mark it recovered (no close needed
cpool . notifyAll ( ) ;
break ;
}
}
}
if ( c ! = null ) { // If broken, just toss it
try { c . close ( ) ; } catch ( SQLException x ) { }
cpoolCount - - ; // And reduce count
cpool . notifyAll ( ) ;
}
}
}
@Override
public MapStorageTile getTile ( DynmapWorld world , MapType map , int x , int y ,
int zoom , ImageVariant var ) {
return new StorageTile ( world , map , x , y , zoom , var ) ;
}
@Override
public MapStorageTile getTile ( DynmapWorld world , String uri ) {
String [ ] suri = uri . split ( " / " ) ;
if ( suri . length < 2 ) return null ;
String mname = suri [ 0 ] ; // Map URI - might include variant
MapType mt = null ;
ImageVariant imgvar = null ;
// Find matching map type and image variant
for ( int mti = 0 ; ( mt = = null ) & & ( mti < world . maps . size ( ) ) ; mti + + ) {
MapType type = world . maps . get ( mti ) ;
ImageVariant [ ] var = type . getVariants ( ) ;
for ( int ivi = 0 ; ( imgvar = = null ) & & ( ivi < var . length ) ; ivi + + ) {
if ( mname . equals ( type . getPrefix ( ) + var [ ivi ] . variantSuffix ) ) {
mt = type ;
imgvar = var [ ivi ] ;
}
}
}
if ( mt = = null ) { // Not found?
return null ;
}
// Now, take the last section and parse out coordinates and zoom
String fname = suri [ suri . length - 1 ] ;
String [ ] coord = fname . split ( " [_ \\ .] " ) ;
if ( coord . length < 3 ) { // 3 or 4
return null ;
}
int zoom = 0 ;
int x , y ;
try {
if ( coord [ 0 ] . charAt ( 0 ) = = 'z' ) {
zoom = coord [ 0 ] . length ( ) ;
x = Integer . parseInt ( coord [ 1 ] ) ;
y = Integer . parseInt ( coord [ 2 ] ) ;
}
else {
x = Integer . parseInt ( coord [ 0 ] ) ;
y = Integer . parseInt ( coord [ 1 ] ) ;
}
return getTile ( world , mt , x , y , zoom , imgvar ) ;
} catch ( NumberFormatException nfx ) {
return null ;
}
}
@Override
public void enumMapTiles ( DynmapWorld world , MapType map ,
2019-10-21 21:32:49 +02:00
MapStorageTileEnumCB cb ) {
2018-08-12 07:27:15 +02:00
List < MapType > mtlist ;
if ( map ! = null ) {
mtlist = Collections . singletonList ( map ) ;
}
else { // Else, add all directories under world directory (for maps)
mtlist = new ArrayList < MapType > ( world . maps ) ;
}
for ( MapType mt : mtlist ) {
ImageVariant [ ] vars = mt . getVariants ( ) ;
for ( ImageVariant var : vars ) {
2019-10-21 21:32:49 +02:00
processEnumMapTiles ( world , mt , var , cb , null , null ) ;
2018-08-12 07:27:15 +02:00
}
}
}
2019-10-21 21:32:49 +02:00
@Override
public void enumMapBaseTiles ( DynmapWorld world , MapType map , MapStorageBaseTileEnumCB cbBase , MapStorageTileSearchEndCB cbEnd ) {
List < MapType > mtlist ;
if ( map ! = null ) {
mtlist = Collections . singletonList ( map ) ;
}
else { // Else, add all directories under world directory (for maps)
mtlist = new ArrayList < MapType > ( world . maps ) ;
}
for ( MapType mt : mtlist ) {
ImageVariant [ ] vars = mt . getVariants ( ) ;
for ( ImageVariant var : vars ) {
processEnumMapTiles ( world , mt , var , null , cbBase , cbEnd ) ;
}
}
}
private void processEnumMapTiles ( DynmapWorld world , MapType map , ImageVariant var , MapStorageTileEnumCB cb , MapStorageBaseTileEnumCB cbBase , MapStorageTileSearchEndCB cbEnd ) {
2018-08-12 07:27:15 +02:00
Connection c = null ;
boolean err = false ;
Integer mapkey = getMapKey ( world , map , var ) ;
2019-10-21 21:32:49 +02:00
if ( mapkey = = null ) {
if ( cbEnd ! = null )
cbEnd . searchEnded ( ) ;
return ;
}
2018-08-12 07:27:15 +02:00
try {
2022-07-24 20:52:49 +02:00
boolean done = false ;
2022-07-24 21:56:25 +02:00
int offset = 0 ;
int limit = 100 ;
2022-07-24 20:52:49 +02:00
while ( ! done ) {
2022-07-24 21:56:25 +02:00
c = getConnection ( ) ; // Do inside loop - single threaded sqlite will have issues otherwise....
2022-07-24 20:52:49 +02:00
// Query tiles for given mapkey
Statement stmt = c . createStatement ( ) ;
2022-07-24 22:11:05 +02:00
ResultSet rs = doExecuteQuery ( stmt , String . format ( " SELECT x,y,zoom,Format FROM Tiles WHERE MapID=%d LIMIT %d OFFSET %d; " , mapkey , limit , offset ) ) ;
2022-07-24 20:52:49 +02:00
int cnt = 0 ;
while ( rs . next ( ) ) {
StorageTile st = new StorageTile ( world , map , rs . getInt ( " x " ) , rs . getInt ( " y " ) , rs . getInt ( " zoom " ) , var ) ;
final MapType . ImageEncoding encoding = MapType . ImageEncoding . fromOrd ( rs . getInt ( " Format " ) ) ;
if ( cb ! = null )
cb . tileFound ( st , encoding ) ;
if ( cbBase ! = null & & st . zoom = = 0 )
cbBase . tileFound ( st , encoding ) ;
st . cleanup ( ) ;
cnt + + ;
}
rs . close ( ) ;
stmt . close ( ) ;
2022-07-24 21:56:25 +02:00
if ( cnt < limit ) done = true ;
offset + = cnt ;
releaseConnection ( c , err ) ;
c = null ;
2018-08-12 07:27:15 +02:00
}
2019-10-21 21:32:49 +02:00
if ( cbEnd ! = null )
cbEnd . searchEnded ( ) ;
2018-08-12 07:27:15 +02:00
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Tile enum error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
}
@Override
public void purgeMapTiles ( DynmapWorld world , MapType map ) {
List < MapType > mtlist ;
if ( map ! = null ) {
mtlist = Collections . singletonList ( map ) ;
}
else { // Else, add all directories under world directory (for maps)
mtlist = new ArrayList < MapType > ( world . maps ) ;
}
for ( MapType mt : mtlist ) {
ImageVariant [ ] vars = mt . getVariants ( ) ;
for ( ImageVariant var : vars ) {
processPurgeMapTiles ( world , mt , var ) ;
}
}
}
private void processPurgeMapTiles ( DynmapWorld world , MapType map , ImageVariant var ) {
Connection c = null ;
boolean err = false ;
Integer mapkey = getMapKey ( world , map , var ) ;
if ( mapkey = = null ) return ;
try {
c = getConnection ( ) ;
// Query tiles for given mapkey
Statement stmt = c . createStatement ( ) ;
//stmt.executeUpdate("DELETE FROM Tiles WHERE MapID=" + mapkey + ";");
doExecuteUpdate ( stmt , " DELETE FROM Tiles WHERE MapID= " + mapkey + " ; " ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Tile purge error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
}
@Override
public boolean setPlayerFaceImage ( String playername , FaceType facetype ,
BufferOutputStream encImage ) {
Connection c = null ;
boolean err = false ;
boolean exists = hasPlayerFaceImage ( playername , facetype ) ;
// If delete, and doesn't exist, quit
if ( ( encImage = = null ) & & ( ! exists ) ) return false ;
try {
c = getConnection ( ) ;
PreparedStatement stmt ;
if ( encImage = = null ) { // If delete
stmt = c . prepareStatement ( " DELETE FROM Faces WHERE PlayerName=? AND TypeIDx=?; " ) ;
stmt . setString ( 1 , playername ) ;
stmt . setInt ( 2 , facetype . typeID ) ;
}
else if ( exists ) {
2019-02-04 05:19:39 +01:00
stmt = c . prepareStatement ( " UPDATE Faces SET Image=?,ImageLen=? WHERE PlayerName=? AND TypeID=?; " ) ;
2018-08-12 07:27:15 +02:00
stmt . setBytes ( 1 , encImage . buf ) ;
2019-02-04 07:13:02 +01:00
stmt . setInt ( 2 , encImage . len ) ;
2019-02-04 05:19:39 +01:00
stmt . setString ( 3 , playername ) ;
stmt . setInt ( 4 , facetype . typeID ) ;
2018-08-12 07:27:15 +02:00
}
else {
2019-02-04 05:19:39 +01:00
stmt = c . prepareStatement ( " INSERT INTO Faces (PlayerName,TypeID,Image,ImageLen) VALUES (?,?,?,?); " ) ;
2018-08-12 07:27:15 +02:00
stmt . setString ( 1 , playername ) ;
stmt . setInt ( 2 , facetype . typeID ) ;
stmt . setBytes ( 3 , encImage . buf ) ;
2019-02-04 07:13:02 +01:00
stmt . setInt ( 4 , encImage . len ) ;
2018-08-12 07:27:15 +02:00
}
//stmt.executeUpdate();
doExecuteUpdate ( stmt ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Face write error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return ! err ;
}
@Override
public BufferInputStream getPlayerFaceImage ( String playername ,
FaceType facetype ) {
Connection c = null ;
boolean err = false ;
BufferInputStream image = null ;
try {
c = getConnection ( ) ;
2019-02-04 05:19:39 +01:00
PreparedStatement stmt = c . prepareStatement ( " SELECT Image,ImageLen FROM Faces WHERE PlayerName=? AND TypeID=?; " ) ;
2018-08-12 07:27:15 +02:00
stmt . setString ( 1 , playername ) ;
stmt . setInt ( 2 , facetype . typeID ) ;
//ResultSet rs = stmt.executeQuery();
ResultSet rs = doExecuteQuery ( stmt ) ;
if ( rs . next ( ) ) {
byte [ ] img = rs . getBytes ( " Image " ) ;
2019-02-04 05:19:39 +01:00
int len = rs . getInt ( " imageLen " ) ;
if ( len < = 0 ) {
len = img . length ;
// Trim trailing zeros from padding by BLOB field
while ( ( len > 0 ) & & ( img [ len - 1 ] = = '\0' ) ) len - - ;
}
image = new BufferInputStream ( img , len ) ;
2018-08-12 07:27:15 +02:00
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Face read error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return image ;
}
@Override
public boolean hasPlayerFaceImage ( String playername , FaceType facetype ) {
Connection c = null ;
boolean err = false ;
boolean exists = false ;
try {
c = getConnection ( ) ;
PreparedStatement stmt = c . prepareStatement ( " SELECT TypeID FROM Faces WHERE PlayerName=? AND TypeID=?; " ) ;
stmt . setString ( 1 , playername ) ;
stmt . setInt ( 2 , facetype . typeID ) ;
//ResultSet rs = stmt.executeQuery();
ResultSet rs = doExecuteQuery ( stmt ) ;
if ( rs . next ( ) ) {
exists = true ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Face exists error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return exists ;
}
@Override
public boolean setMarkerImage ( String markerid , BufferOutputStream encImage ) {
Connection c = null ;
boolean err = false ;
2018-09-02 02:59:06 +02:00
PreparedStatement stmt = null ;
ResultSet rs = null ;
2018-08-12 07:27:15 +02:00
try {
c = getConnection ( ) ;
boolean exists = false ;
stmt = c . prepareStatement ( " SELECT IconName FROM MarkerIcons WHERE IconName=?; " ) ;
stmt . setString ( 1 , markerid ) ;
2018-09-02 02:59:06 +02:00
rs = doExecuteQuery ( stmt ) ;
2018-08-12 07:27:15 +02:00
if ( rs . next ( ) ) {
exists = true ;
}
rs . close ( ) ;
2018-09-02 02:59:06 +02:00
rs = null ;
2018-08-12 07:27:15 +02:00
stmt . close ( ) ;
2018-09-02 02:59:06 +02:00
stmt = null ;
2018-08-12 07:27:15 +02:00
if ( encImage = = null ) { // If delete
// If delete, and doesn't exist, quit
if ( ! exists ) return false ;
stmt = c . prepareStatement ( " DELETE FROM MarkerIcons WHERE IconName=?; " ) ;
stmt . setString ( 1 , markerid ) ;
//stmt.executeUpdate();
doExecuteUpdate ( stmt ) ;
}
else if ( exists ) {
2019-02-04 05:19:39 +01:00
stmt = c . prepareStatement ( " UPDATE MarkerIcons SET Image=?,ImageLen=? WHERE IconName=?; " ) ;
2018-08-12 07:27:15 +02:00
stmt . setBytes ( 1 , encImage . buf ) ;
2019-02-04 07:13:02 +01:00
stmt . setInt ( 2 , encImage . len ) ;
2019-02-04 05:19:39 +01:00
stmt . setString ( 3 , markerid ) ;
2018-08-12 07:27:15 +02:00
}
else {
2019-02-04 05:19:39 +01:00
stmt = c . prepareStatement ( " INSERT INTO MarkerIcons (IconName,Image,ImageLen) VALUES (?,?,?); " ) ;
2018-08-12 07:27:15 +02:00
stmt . setString ( 1 , markerid ) ;
stmt . setBytes ( 2 , encImage . buf ) ;
2019-02-04 07:13:02 +01:00
stmt . setInt ( 3 , encImage . len ) ;
2018-08-12 07:27:15 +02:00
}
doExecuteUpdate ( stmt ) ;
stmt . close ( ) ;
2018-09-02 02:59:06 +02:00
stmt = null ;
2018-08-12 07:27:15 +02:00
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Marker write error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
2018-09-02 02:59:06 +02:00
if ( rs ! = null ) { try { rs . close ( ) ; } catch ( SQLException sx ) { } }
if ( stmt ! = null ) { try { stmt . close ( ) ; } catch ( SQLException sx ) { } }
2018-08-12 07:27:15 +02:00
releaseConnection ( c , err ) ;
}
return ! err ;
}
@Override
public BufferInputStream getMarkerImage ( String markerid ) {
Connection c = null ;
boolean err = false ;
BufferInputStream image = null ;
try {
c = getConnection ( ) ;
2019-02-04 05:19:39 +01:00
PreparedStatement stmt = c . prepareStatement ( " SELECT Image,ImageLen FROM MarkerIcons WHERE IconName=?; " ) ;
2018-08-12 07:27:15 +02:00
stmt . setString ( 1 , markerid ) ;
//ResultSet rs = stmt.executeQuery();
ResultSet rs = doExecuteQuery ( stmt ) ;
if ( rs . next ( ) ) {
byte [ ] img = rs . getBytes ( " Image " ) ;
2019-02-04 05:19:39 +01:00
int len = rs . getInt ( " ImageLen " ) ;
if ( len < = 0 ) {
len = img . length ;
// Trim trailing zeros from padding by BLOB field
while ( ( len > 0 ) & & ( img [ len - 1 ] = = '\0' ) ) len - - ;
}
2018-08-12 07:27:15 +02:00
image = new BufferInputStream ( img ) ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Marker read error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return image ;
}
@Override
public boolean setMarkerFile ( String world , String content ) {
Connection c = null ;
boolean err = false ;
2018-09-02 02:59:06 +02:00
PreparedStatement stmt = null ;
ResultSet rs = null ;
2018-08-12 07:27:15 +02:00
try {
c = getConnection ( ) ;
boolean exists = false ;
stmt = c . prepareStatement ( " SELECT FileName FROM MarkerFiles WHERE FileName=?; " ) ;
stmt . setString ( 1 , world ) ;
2018-09-02 02:59:06 +02:00
rs = doExecuteQuery ( stmt ) ;
2018-08-12 07:27:15 +02:00
if ( rs . next ( ) ) {
exists = true ;
}
rs . close ( ) ;
2018-09-02 02:59:06 +02:00
rs = null ;
2018-08-12 07:27:15 +02:00
stmt . close ( ) ;
2018-09-02 02:59:06 +02:00
stmt = null ;
2018-08-12 07:27:15 +02:00
if ( content = = null ) { // If delete
// If delete, and doesn't exist, quit
if ( ! exists ) return false ;
stmt = c . prepareStatement ( " DELETE FROM MarkerFiles WHERE FileName=?; " ) ;
stmt . setString ( 1 , world ) ;
doExecuteUpdate ( stmt ) ;
}
else if ( exists ) {
stmt = c . prepareStatement ( " UPDATE MarkerFiles SET Content=? WHERE FileName=?; " ) ;
stmt . setBytes ( 1 , content . getBytes ( UTF8 ) ) ;
stmt . setString ( 2 , world ) ;
}
else {
stmt = c . prepareStatement ( " INSERT INTO MarkerFiles (FileName,Content) VALUES (?,?); " ) ;
stmt . setString ( 1 , world ) ;
stmt . setBytes ( 2 , content . getBytes ( UTF8 ) ) ;
}
//stmt.executeUpdate();
doExecuteUpdate ( stmt ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Marker file write error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
2018-09-02 02:59:06 +02:00
if ( rs ! = null ) { try { rs . close ( ) ; } catch ( SQLException sx ) { } }
if ( stmt ! = null ) { try { stmt . close ( ) ; } catch ( SQLException sx ) { } }
2018-08-12 07:27:15 +02:00
releaseConnection ( c , err ) ;
}
return ! err ;
}
@Override
public String getMarkerFile ( String world ) {
Connection c = null ;
boolean err = false ;
String content = null ;
try {
c = getConnection ( ) ;
PreparedStatement stmt = c . prepareStatement ( " SELECT Content FROM MarkerFiles WHERE FileName=?; " ) ;
stmt . setString ( 1 , world ) ;
//ResultSet rs = stmt.executeQuery();
ResultSet rs = doExecuteQuery ( stmt ) ;
if ( rs . next ( ) ) {
byte [ ] img = rs . getBytes ( " Content " ) ;
content = new String ( img , UTF8 ) ;
}
rs . close ( ) ;
stmt . close ( ) ;
} catch ( SQLException x ) {
2022-01-19 04:57:48 +01:00
logSQLException ( " Marker file read error " , x ) ;
2018-08-12 07:27:15 +02:00
err = true ;
2022-07-24 20:09:52 +02:00
} catch ( StorageShutdownException x ) {
err = true ;
2018-08-12 07:27:15 +02:00
} finally {
releaseConnection ( c , err ) ;
}
return content ;
}
@Override
2020-12-31 22:56:23 +01:00
// External web server only
2018-08-12 07:27:15 +02:00
public String getMarkersURI ( boolean login_enabled ) {
return " standalone/SQLite_markers.php?marker= " ;
}
@Override
2020-12-31 22:56:23 +01:00
// External web server only
2018-08-12 07:27:15 +02:00
public String getTilesURI ( boolean login_enabled ) {
return " standalone/SQLite_tiles.php?tile= " ;
}
@Override
public void addPaths ( StringBuilder sb , DynmapCore core ) {
sb . append ( " $dbfile = \ ' " ) ;
sb . append ( WebAuthManager . esc ( databaseFile ) ) ;
sb . append ( " \ '; \ n " ) ;
// Need to call base to add webpath
super . addPaths ( sb , core ) ;
}
private ResultSet doExecuteQuery ( PreparedStatement statement ) throws SQLException {
while ( true ) {
try {
return statement . executeQuery ( ) ;
} catch ( SQLException x ) {
if ( ! x . getMessage ( ) . contains ( " [SQLITE_BUSY] " ) ) {
throw x ;
}
}
}
}
private ResultSet doExecuteQuery ( Statement statement , String sql ) throws SQLException {
while ( true ) {
try {
return statement . executeQuery ( sql ) ;
} catch ( SQLException x ) {
if ( ! x . getMessage ( ) . contains ( " [SQLITE_BUSY] " ) ) {
throw x ;
}
}
}
}
private int doExecuteUpdate ( PreparedStatement statement ) throws SQLException {
while ( true ) {
try {
return statement . executeUpdate ( ) ;
} catch ( SQLException x ) {
if ( ! x . getMessage ( ) . contains ( " [SQLITE_BUSY] " ) ) {
throw x ;
}
}
}
}
private int doExecuteUpdate ( Statement statement , String sql ) throws SQLException {
while ( true ) {
try {
return statement . executeUpdate ( sql ) ;
} catch ( SQLException x ) {
if ( ! x . getMessage ( ) . contains ( " [SQLITE_BUSY] " ) ) {
throw x ;
}
}
}
}
2022-07-24 21:56:25 +02:00
public void logSQLException ( String opmsg , SQLException x ) {
// Ignore interrupted
if ( x . getMessage ( ) . equals ( " Interrupted " ) ) return ;
super . logSQLException ( opmsg , x ) ;
}
2018-08-12 07:27:15 +02:00
}