Extending Cache using PHP

88 views
Skip to first unread message

Rob Tweed

unread,
Nov 23, 2007, 3:57:13 AM11/23/07
to intersystems...@info2.kinich.com
Here's a cool trick I recently came up with that I thought I'd share
with you all.

Whilst the very latest versions of Cache now include some very useful
new methods and functions, eg the encryption-related ones, not
everyone is in the position of being able to use them because they're
still using older versions of Cache.

Frustratingly, PHP includes a ton of libraries that allow you to do
pretty much anything under the sun.

Wouldn't it be nice if we could extend Cache's capabilities by making
use of all those PHP libraries? How would you do that? Actually a
lot more simply than you might think. Here's how.

You'll need to have PHP installed on a machine, ideally (but not
necessarily) on the same machine as the one you're running Cache on.
Indeed if PHP is on a different machine on your network, all your
Cache servers can make use of this trick.

Let's take an example. If you need to create an HMAC-MD5 hash from a
message and a secret key, you can do this directly in Cache 2007.1,
but not natively in earlier versions of Cache. I was able to find a
PHP version within a few minutes using Google. I created the
following simple PHP page:

<?php
function hmacmd5($key,$data) {
$b = 64;
if (strlen($key) > $b) {
$key = pack("H*",md5($key)) ;
}
$key = str_pad($key,$b,chr(0x00)) ;
$ipad = str_pad('',$b,chr(0x36)) ;
$opad = str_pad('',$b,chr(0x5c)) ;
$k_ipad = $key ^ $ipad ;
$k_opad = $key ^ $opad ;
return md5($k_opad . pack("H*",md5($k_ipad . $data)));
}

$key = $_REQUEST['key'] ;
$data = $_REQUEST['data'] ;
$hash = hmacmd5($key,$data) ;

header('Content-type: text/plain') ;
print('hash=' . $hash . chr('13') . chr('10')) ;
?>

I saved this as hmacmd5.php in one of my PHP directories and tested it
using a browser. If I put in the URL:

http://127.0.0.1/php/misc/hmacmd5.php?key=abcde&data=hello

then back comes:

hash=750c397713f9a8ee37b7aa593e8da225

OK so now to hook it up to Cache. Just create a method wrapper around
a %Net.HTTPRequest call to this URL, eg:

ClassMethod hmacmd5(key As %String, message As %String) As %String
{
s server="127.0.0.1"
s path="/php/misc/hmacmd5.php"
s data=$zcvt(message,"O","URL")
s http=##class(%Net.HttpRequest).%New()
s http.Server=server
s http.Port=80
s url=path_"?key="_$g(key)_"&data="_$g(data)
d http.Get(url)
s resp=http.HttpResponse.Data
s response=resp.Read(37)
QUIT $p(response,"=",2,2000)
}

And there you go! You can now get an HMAC-MD5 digest on any version
of Cache that supports %Net.HttpRequest:

USER> w ##class(php.commands).hmacmd5("abcde","hello")
750c397713f9a8ee37b7aa593e8da225

Of course you can adapt and apply this simple technique to access any
PHP functionality you want from Cache.


---
Rob Tweed
M/Gateway Developments Ltd

http://www.mgateway.com
---

George James

unread,
Nov 23, 2007, 6:49:28 AM11/23/07
to intersystems...@info2.kinich.com
Rob
This is a nice illustration of a lightweight service oriented solution
and can be applied to any kind of service that Some Other Technology
(tm) can do more easily than Cache. Or, as with your previous web-link
example, use Cache to provide a sevice that other technologies find hard
to implement.

Just one thing people should be aware of with your example. The
requests will be stored in the web-server's log files in clear text. In
this instance it would be better to use POST rather than GET so the
arguments don't get logged. You might even consider using https rather
than http if the traffic goes outside of a secure zone.

If I'm already using Cache 2007.1 what are the benefits of using this
service oriented approach rather than a built-in function?

Regards
George


George James Software
www.georgejames.com

Rob Tweed

unread,
Nov 23, 2007, 7:29:13 AM11/23/07
to intersystems...@info2.kinich.com
George

See inline...

On Fri, 23 Nov 2007 11:49:28 -0000, George James
<Geo...@georgejames.com> wrote:

>Rob
>This is a nice illustration of a lightweight service oriented solution
>and can be applied to any kind of service that Some Other Technology
>(tm) can do more easily than Cache. Or, as with your previous web-link
>example, use Cache to provide a sevice that other technologies find hard
>to implement.

Agreed. Cache's ability to act as an HTTP client via the
%Net.HttpRequest class, and its ability to act as a back-end to an
HTTP server (via WebLink or CSP, or via PHP, JSP etc using our MGWSI
gateway) are hugely powerful and, I'd hazard a guess, largely untapped
within the Cache community outside the pure web/HTML arena

It also serves to show that HTTP-based services don't need to be based
around the heavyweight SOAP/WSDL approach, or even use XML as a
response format. A very simple text/plain response can be ideal
particularly if you just need a simple, lightweight service for
internal use and/or requiring a minimum of parsing of the request or
response.

>
>Just one thing people should be aware of with your example. The
>requests will be stored in the web-server's log files in clear text. In
>this instance it would be better to use POST rather than GET so the
>arguments don't get logged. You might even consider using https rather
>than http if the traffic goes outside of a secure zone.

Yes, definitely. You always need to be aware of the security risks
when using HTTP as a transport. POST is a good way of avoiding things
getting logged by the web server.

By the way I also noticed something that needed changing in the
Cache-side method wrapper: the key would need URL encoding also, in
case it contained dodgy characters. So, adding your suggestion about
using POST, it should probably look like this:

ClassMethod hmacmd5(key As %String, message As %String) As %String
{
s server="127.0.0.1"
s path="/php/misc/hmacmd5.php"
s data=$zcvt(message,"O","URL")

s key=$zcvt(key,"O","URL")


s http=##class(%Net.HttpRequest).%New()
s http.Server=server
s http.Port=80

s http.ContentType="application/x-www-form-urlencoded"
d http.InsertFormData("key",key)
d http.InsertFormData("data",data)
d http.Post(path)


s resp=http.HttpResponse.Data
s response=resp.Read(37)
QUIT $p(response,"=",2,2000)
}

It will outwardly behave identically to the GET version:

USER>w $$hmacmd5^protx("abcde","hello")
750c397713f9a8ee37b7aa593e8da225


>


>If I'm already using Cache 2007.1 what are the benefits of using this
>service oriented approach rather than a built-in function?

Probably none - if you're using Cache 2007.1, I would hope that the
built-in function for HMAC-MD5 would be more efficient than a
round-trip to PHP via HTTP! (Note, however, that the response over
HTTP/PHP will be probably be more than adequate unless you're
hammering requests in at a very high rate).

However, I used this as an example because I specifically needed a way
of getting an HMAC-MD5 digest for an ealier version of Cache, and
really didn't like the idea of having to shell out in some way to some
third party utility (which I couldn't find anyway). Of course the
HTTP approach makes the integration totally OS/platform-independent,
whereas integrating with a third-party utility would usually require a
different technique if I was using Windows, Linux, Mac OSX or VMS.

Even with 2007.1, I'm sure there are plenty of other opportunities for
using this kind of technique for all manner of things that may not be
provided natively by Cache. When you discover the huge treasure-trove
of pre-written functionality in PHP, it may not make sense to spend
the time and money re-inventing a wheel within Cache if you can simply
hook in to someone else's work via a simple HTTP-based call and
wrapper.

Of course this technique isn't limited to PHP. If you find a cool
Java utility, you could interface it via a JSP page, or you could do
the same with a .Net utility via an ASP.Net page. The Cache-side
method wrapper would just be adjusted to call the different URL and
process the different name/value pairs.

Rob

Rob Tweed

unread,
Nov 23, 2007, 7:58:29 AM11/23/07
to intersystems...@info2.kinich.com
On Fri, 23 Nov 2007 11:49:28 -0000, George James
<Geo...@georgejames.com> wrote:

>Or, as with your previous web-link
>example, use Cache to provide a sevice that other technologies find hard
>to implement.

Just a head's up on this issue. Watch for the next version of EWD
(www.mgateway.com/ewd.htm) which is going to include a REST server
framework to allow you to very simply and quickly expose your Cache
methods/data to the outside world as simple, lightweight REST-based
services.

The cool thing about the EWD REST server framework is that you'll be
able to choose any supported gateway to Cache to support your
services: eg WebLink, CSP, PHP etc. EWD will compile the service to
use the gateway/web technology of your choice and look after the
formatting and structure of the wrappering PHP or CSP etc page, or
WebLink routine.

All you'll need to focus on is your back-end method. It won't need to
know or care which web transport/gateway service is being used, or how
it gets invoked. You'll just focus on what your service needs to do
when it's triggered. Also, how you structure/format your output (ie
the HTTP response) will be entirely up to you - as simple or as
complex as you like.

Ideal, for example, for building the kind of event handler described
by Pasi Leno in his earlier thread. But also for all manner of
things, allowing the outside world to take advantage of Cache where it
provides a better solution than other technologies.

Wolf Koelling

unread,
Nov 23, 2007, 10:00:45 AM11/23/07
to intersystems.public.cache
Hey Rob

I'm still rubbing my eyes after looking at your code example.
Packages? Class methods? What happened to free-style climbing? Getting
vertigo in your old age?

Worried,

Wolf

Rob Tweed

unread,
Nov 23, 2007, 10:30:48 AM11/23/07
to intersystems...@info2.kinich.com
Haha Wolf!

Now if I'd referred to extrinsic functions, I'd have been inundated by
calls to get modern, so I really can't win can I?

Anyway most people seem to have forgotten what that old stuff was, so
I need to keep up to date with readers of these threads! ;-)

I'll document how to do it the way *I'd* do it if you really want!!

George James

unread,
Nov 23, 2007, 12:05:53 PM11/23/07
to intersystems...@info2.kinich.com
Rob
What you are saying is that you can use http as the interface for every
service, right? So your application is just made up of service
providers and service consumers.

Reminds me of one of the catch lines from my talk at Slipstream. "All
you'll be left with is servers, services and service". Well, it sounded
good at the time :)

Seriously, making everything into a service means that it is both
platform and implementation independent. And you can easily switch from
being a consumer of one service to that of another just by changing the
url. I quite like the Xobjex services which are small and atomic and
each one try's to do one simple thing well. For example:
http://xobjex.com/service/date.xsl gives you a date in every whichway
you might want it.

I'm not sure what you'd get if you tried to build a complex application
using this kind of architecture. I'm imagining that it would look a bit
like an Ensemble production. Or would it be more like Google's APIs
which have stimulated a proliferation of mash-ups and the Balkanisation
of real applications.

Lastly, as far as treasure troves of pre-written functionality goes -
perl's CPAN is a superb resource. If only we had libraries like that
for Cache.

Regards
George

Rob Tweed

unread,
Nov 23, 2007, 1:38:23 PM11/23/07
to intersystems...@info2.kinich.com
One other change. The use of $zcvt to URL-encode the name/value pair
values only appears to be needed if you're using a GET request. If
you're using a POST and using http.InsertFormData, I don't believe you
need it (the method appears to do it for you), so the method should
be:

ClassMethod hmacmd5(key As %String, message As %String) As %String {
s server="127.0.0.1"
s path="/php/misc/hmacmd5.php"

s http=##class(%Net.HttpRequest).%New()
s http.Server=server
s http.Port=80
s http.ContentType="application/x-www-form-urlencoded"
d http.InsertFormData("key",key)
d http.InsertFormData("data",data)
d http.Post(path)
s resp=http.HttpResponse.Data
s response=resp.Read(37)
QUIT $p(response,"=",2,2000)
}

Rob Tweed

unread,
Nov 23, 2007, 4:10:23 PM11/23/07
to intersystems...@info2.kinich.com
On Fri, 23 Nov 2007 17:05:53 -0000, George James
<Geo...@georgejames.com> wrote:

>
>Lastly, as far as treasure troves of pre-written functionality goes -
>perl's CPAN is a superb resource. If only we had libraries like that
>for Cache.
>

I wonder why we don't? We all know there's little you can do in other
languages that also can't be done, often more efficiently and
effectively, in Cache. IMHO there's really no excuse for the lack of
an equivalent resource in the Cache community, but it's never too late
to start. Indeed we created the Utility Library
(www.mgateway.com/utility.htm) some years ago to try to encourage
people to create such a resource. While there's a certain amount of
stuff in there, I'm sure there are plenty more people out there that
have stuff they could contribute to the benefit of others.

I think the evidence is pretty clear that what makes a language really
take off is the ready availability of third-party utilities and a
thriving community of users who create them and make them available to
others.

Micky Hulse

unread,
Nov 24, 2007, 1:50:07 PM11/24/07
to intersystems.public.cache
Wow, very cool. Thanks for sharing! :)

David

unread,
Nov 25, 2007, 9:34:33 PM11/25/07
to intersystems.public.cache
Rob, Your posting is an excellent approach for extending Cache. I
figured I would throw this out there with regards to your MD5 Hash. I
know that your post is about the "approach" not the functionality but,
I figured that this may help someone who wants to do MD5 internally on
a older Cache version.

-David.

/// Returns MD5 One Way Hashing
///
///
/// Cache Object Script implementation of RFC 1321 MD5
///
/// RFC 1312 the MD5 message-digest algorithm.
/// The algorithm takes as input a message of arbitrary length and
produces
/// as output a 128-bit "fingerprint" or "message digest" of the
input.
/// It is conjectured that it is computationally infeasible to produce
/// two messages having the same message digest, or to produce any
/// message having a given prespecified target message digest. The MD5
/// algorithm is intended for digital signature applications, where a
/// large file must be "compressed" in a secure manner before being
/// encrypted with a private (secret) key under a public-key
cryptosystem
/// such as RSA.
ClassMethod MD5(Message As %String) [ Final ]
{
; Returns MD5 One Way Hashing of Message
; Cache Object Script implementation of RFC 1321 MD5
; Based on Cache 4 implementation Original written by Gertjan Klein &
Herman Slagman

#define AND 1
#define OR 7
#define XOR 6
#define NOTAND 4
#define ANDNOT 2
#define ORNOT 11

#define LIMIT(%value) ((%value)#4294967296)

#define F(%x,%y,%z) ($ZBoolean($ZBoolean((%x),(%y),$$$AND),
$ZBoolean((%x),(%z),$$$NOTAND),$$$OR))
#define G(%x,%y,%z) ($ZBoolean($ZBoolean((%x),(%z),$$$AND),
$ZBoolean((%y),(%z),$$$ANDNOT),$$$OR))
#define H(%x,%y,%z) ($ZBoolean($ZBoolean((%x),(%y),$$$XOR),(%z),$$
$XOR))
#define I(%x,%y,%z) ($ZBoolean((%y),$ZBoolean((%x),(%z),$$$ORNOT),$$
$XOR))

#define RL(%a,%b) ($ZBoolean($$$LIMIT(%a)*Powers(%b)#4294967296,$$
$LIMIT(%a)\Powers(32-%b),$$$OR))

#define R1(%a,%b,%c,%d,%k,%s,%i) Set %a=$$$LIMIT(%b+($$$RL(%a+$$$F(%b,
%c,%d)+X(%k)+%i,%s)))
#define R2(%a,%b,%c,%d,%k,%s,%i) Set %a=$$$LIMIT(%b+($$$RL(%a+$$$G(%b,
%c,%d)+X(%k)+%i,%s)))
#define R3(%a,%b,%c,%d,%k,%s,%i) Set %a=$$$LIMIT(%b+($$$RL(%a+$$$H(%b,
%c,%d)+X(%k)+%i,%s)))
#define R4(%a,%b,%c,%d,%k,%s,%i) Set %a=$$$LIMIT(%b+($$$RL(%a+$$$I(%b,
%c,%d)+X(%k)+%i,%s)))

; Begin Encrypt
New
i,Result,Digest,Length,Pad,Byte,X,A,B,C,D,NrOfBlocks,j,StartPos,AA,BB,CC,DD,p,Powers

Do Powers

; Step 1.
; Pad the string until the length is a multiple of 64 bytes minus 8
bytes
; The first byte is a 10000000 byte (128) the rest are 00000000
bytes

Set Length=$Length(Message)*8 ;(In bits)
Set Digest=Message_$C(128)
For Quit:$Length(Digest)#64=56 Set Digest=Digest_$C(0)

; Step 2.
; Append the original length as a 64 bits value

Set Pad=""
For i=1:1:8 Set Byte=Length#256,Length=Length\256,Pad=Pad_$Char(Byte)
Set Digest=Digest_Pad

; Step 3.
; Initialise the MD buffer

Set A=1732584193 ;$ZHex("67452301")
Set B=4023233417 ;$ZHex("efcdab89")
Set C=2562383102 ;$Zhex("98badcfe")
Set D=271733878 ;$Zhex("10325476")

; MD5 uses 64 values calculated as followes:
; F i=1:1:64 Set T(i)=4294967296*$zabs($zsin(i))\1

; Step 4.
; Process the Message in 16 word blocks (32 bytes)

Set NrOfBlocks=$Length(Digest)-1\64+1
For i=0:1:NrOfBlocks-1 Do
. ; Copy the Block in 16 X(0-15) words
. Set StartPos=i*64+1
. For j=0:1:15 Do
.. Set p=StartPos+(j*4)
.. Set X(j)=$Ascii(Digest,p+3)*16777216+($Ascii(Digest,p
+2)*65536)+($Ascii(Digest,p+1)*256)+$Ascii(Digest,p)
. Set AA=A
. Set BB=B
. Set CC=C
. Set DD=D
. ; Round 1
. ; [ABCD 0 7 1] [DABC 1 12 2] [CDAB 2 17 3] [BCDA 3
22 4]
. ; [ABCD 4 7 5] [DABC 5 12 6] [CDAB 6 17 7] [BCDA 7
22 8]
. ; [ABCD 8 7 9] [DABC 9 12 10] [CDAB 10 17 11] [BCDA 11
22 12]
. ; [ABCD 12 7 13] [DABC 13 12 14] [CDAB 14 17 15] [BCDA 15
22 16]
.
. $$$R1(A,B,C,D,0,7,3614090360)
. $$$R1(D,A,B,C,1,12,3905402710)
. $$$R1(C,D,A,B,2,17,606105819)
. $$$R1(B,C,D,A,3,22,3250441966)
. $$$R1(A,B,C,D,4,7,4118548399)
. $$$R1(D,A,B,C,5,12,1200080426)
. $$$R1(C,D,A,B,6,17,2821735955)
. $$$R1(B,C,D,A,7,22,4249261313)
. $$$R1(A,B,C,D,8,7,1770035416)
. $$$R1(D,A,B,C,9,12,2336552879)
. $$$R1(C,D,A,B,10,17,4294925233)
. $$$R1(B,C,D,A,11,22,2304563134)
. $$$R1(A,B,C,D,12,7,1804603682)
. $$$R1(D,A,B,C,13,12,4254626195)
. $$$R1(C,D,A,B,14,17,2792965006)
. $$$R1(B,C,D,A,15,22,1236535329)
.
.
. $$$R2(A,B,C,D,1,5,4129170786)
. $$$R2(D,A,B,C,6,9,3225465664)
. $$$R2(C,D,A,B,11,14,643717713)
. $$$R2(B,C,D,A,0,20,3921069994)
. $$$R2(A,B,C,D,5,5,3593408605)
. $$$R2(D,A,B,C,10,9,38016083)
. $$$R2(C,D,A,B,15,14,3634488961)
. $$$R2(B,C,D,A,4,20,3889429448)
. $$$R2(A,B,C,D,9,5,568446438)
. $$$R2(D,A,B,C,14,9,3275163606)
. $$$R2(C,D,A,B,3,14,4107603335)
. $$$R2(B,C,D,A,8,20,1163531501)
. $$$R2(A,B,C,D,13,5,2850285829)
. $$$R2(D,A,B,C,2,9,4243563512)
. $$$R2(C,D,A,B,7,14,1735328473)
. $$$R2(B,C,D,A,12,20,2368359562)
.
.
. $$$R3(A,B,C,D,5,4,4294588738)
. $$$R3(D,A,B,C,8,11,2272392833)
. $$$R3(C,D,A,B,11,16,1839030562)
. $$$R3(B,C,D,A,14,23,4259657740)
. $$$R3(A,B,C,D,1,4,2763975236)
. $$$R3(D,A,B,C,4,11,1272893353)
. $$$R3(C,D,A,B,7,16,4139469664)
. $$$R3(B,C,D,A,10,23,3200236656)
. $$$R3(A,B,C,D,13,4,681279174)
. $$$R3(D,A,B,C,0,11,3936430074)
. $$$R3(C,D,A,B,3,16,3572445317)
. $$$R3(B,C,D,A,6,23,76029189)
. $$$R3(A,B,C,D,9,4,3654602809)
. $$$R3(D,A,B,C,12,11,3873151461)
. $$$R3(C,D,A,B,15,16,530742520)
. $$$R3(B,C,D,A,2,23,3299628645)
.
.
. $$$R4(A,B,C,D,0,6,4096336452)
. $$$R4(D,A,B,C,7,10,1126891415)
. $$$R4(C,D,A,B,14,15,2878612391)
. $$$R4(B,C,D,A,5,21,4237533241)
. $$$R4(A,B,C,D,12,6,1700485571)
. $$$R4(D,A,B,C,3,10,2399980690)
. $$$R4(C,D,A,B,10,15,4293915773)
. $$$R4(B,C,D,A,1,21,2240044497)
. $$$R4(A,B,C,D,8,6,1873313359)
. $$$R4(D,A,B,C,15,10,4264355552)
. $$$R4(C,D,A,B,6,15,2734768916)
. $$$R4(B,C,D,A,13,21,1309151649)
. $$$R4(A,B,C,D,4,6,4149444226)
. $$$R4(D,A,B,C,11,10,3174756917)
. $$$R4(C,D,A,B,2,15,718787259)
. $$$R4(B,C,D,A,9,21,3951481745)
.
. Set A=$$$LIMIT(A+AA)
. Set B=$$$LIMIT(B+BB)
. Set C=$$$LIMIT(C+CC)
. Set D=$$$LIMIT(D+DD)

;// Step 5
Set Result=$$Reverse(A)_$$Reverse(B)_$$Reverse(C)_$$Reverse(D)
S Result=$ZCVT(Result,"l")
Quit Result

Reverse(S) ;
Set S=$ZHex(S)
Set S=$Extract("00000000",0,8-$Length(S))_S
Quit $Extract(S,7,8)_$Extract(S,5,6)_$Extract(S,3,4)_$Extract(S,1,2)

Powers ; To avoid the extremely slow 2**x function
Set Powers(0)=1
Set Powers(1)=2
Set Powers(2)=4
Set Powers(3)=8
Set Powers(4)=16
Set Powers(5)=32
Set Powers(6)=64
Set Powers(7)=128
Set Powers(8)=256
Set Powers(9)=512
Set Powers(10)=1024
Set Powers(11)=2048
Set Powers(12)=4096
Set Powers(13)=8192
Set Powers(14)=16384
Set Powers(15)=32768
Set Powers(16)=65536
Set Powers(17)=131072
Set Powers(18)=262144
Set Powers(19)=524288
Set Powers(20)=1048576
Set Powers(21)=2097152
Set Powers(22)=4194304
Set Powers(23)=8388608
Set Powers(24)=16777216
Set Powers(25)=33554432
Set Powers(26)=67108864
Set Powers(27)=134217728
Set Powers(28)=268435456
Set Powers(29)=536870912
Set Powers(30)=1073741824
Set Powers(31)=2147483648
Set Powers(32)=4294967296
Quit

Rob Tweed

unread,
Nov 26, 2007, 2:18:11 AM11/26/07
to intersystems...@info2.kinich.com
David

Good point. I was actually aware of this COS version of MD5, but note
that HMAC-MD5 is different from simple MD5 hashing in that it creates
a digest from the message and a secret key. I'm not aware of a native
COS version of this algorithm, though I've no doubt one could be
written.

Similarly, I'm not aware of any native COS version of the algorithm
needed to create an SHA-1 digest. I'm finding more and more
situations where I need encryption stuff like this for users of
pre-2007.1 versions of Cache.

On Sun, 25 Nov 2007 18:34:33 -0800 (PST), David <rappe...@gmail.com>
wrote:

George James

unread,
Nov 26, 2007, 4:36:33 AM11/26/07
to intersystems...@info2.kinich.com
Rob
Your M/Gateway Utility Library might be more useful if it were browsable
without the need to create an account or log in - and Google would also
index it :)

Also, it would be useful if there was some way of easily seeing what
license was applicable for each contribution (Public Domain, GPL, etc).

The library has reached the size where some kind of categorisation or
grouping would make it easier to find things.

Regards
George

George James Software
www.georgejames.com


-----Original Message-----
From: Rob Tweed [mailto:rtw...@blueyonder.co.uk]
Posted At: 23 November 2007 21:10
Posted To: Caché Newsgroup
Conversation: Extending Cache using PHP
Subject: Re: Extending Cache using PHP

Reply all
Reply to author
Forward
0 new messages