Skip to content

Commit b371d48

Browse files
committed
Add endpoint for S3 ListObjectsV2 API
See r-universe-org/help#574
1 parent c202a88 commit b371d48

File tree

5 files changed

+138
-4
lines changed

5 files changed

+138
-4
lines changed

routes/cache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export default function(req, res, next){
3434
res.set('Cache-Control', `public, max-age=60, stale-while-revalidate=${cdn_cache}`);
3535

3636
if(doc){
37-
const revision = 15; // bump to invalidate all caches
37+
const revision = 16; // bump to invalidate all caches
3838
const etag = `W/"${doc._id}${revision}"`;
3939
const date = new Date(doc._published.getTime() + revision * 1000).toUTCString();
4040
res.set('ETag', etag);

routes/universe.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express from 'express';
22
import url from 'node:url';
3-
import {get_universe_packages, get_universe_vignettes, get_package_info,
3+
import createError from 'http-errors';
4+
import {get_universe_packages, get_universe_files, get_universe_vignettes, get_package_info,
45
get_universe_contributors, get_universe_contributions, get_all_universes} from '../src/db.js';
56
const router = express.Router();
67

@@ -122,12 +123,47 @@ function get_contrib_data(user, max = 20){
122123
});
123124
}
124125

125-
/* Langing page (TODO) */
126+
//See https://github.com/r-universe-org/help/issues/574
127+
function send_s3_list(req, res){
128+
var universe = res.locals.universe;
129+
var delimiter = req.query['delimiter'];
130+
var start_after = req.query['start-after'] || req.query['continuation-token'];
131+
var max_keys = parseInt(req.query['max-keys'] || 1000);
132+
var prefix = req.query['prefix'] || "";
133+
return get_universe_files(universe, prefix, start_after).then(function(files){
134+
if(delimiter){
135+
var subpaths = files.map(x => x.Key.substring(prefix.length));
136+
var dirnames = subpaths.filter(x => x.includes('/')).map(x => prefix + x.split('/')[0]);
137+
var commonprefixes = [...new Set(dirnames)];
138+
files = files.filter(x => x.Key.substring(prefix.length).includes('/') == false);
139+
} else {
140+
var commonprefixes = [];
141+
}
142+
var IsTruncated = files.length > max_keys;
143+
files = files.slice(0, max_keys);
144+
return res.type('application/xml').render('S3List', {
145+
Prefix: prefix,
146+
MaxKeys: max_keys,
147+
IsTruncated: IsTruncated,
148+
NextContinuationToken: IsTruncated ? files[files.length -1].Key : undefined,
149+
commonprefixes: commonprefixes,
150+
files: files
151+
});
152+
});
153+
}
154+
126155
router.get('/', function(req, res, next) {
127156
//res.render('index');
157+
if(req.query['x-id'] == 'ListBuckets'){
158+
throw createError(400, "Please use virtual-hosted-style bucket on r-universe.dev TLD");
159+
}
160+
if(req.query['list-type']){
161+
return send_s3_list(req, res);
162+
}
128163
res.set('Cache-control', 'private, max-age=604800'); // Vary does not work in cloudflare currently
129164
const accept = req.headers['accept'];
130165
if(accept && accept.includes('html')){
166+
/* Langing page (TODO) */
131167
res.redirect(`/builds`);
132168
} else {
133169
res.send(`Welcome to the ${res.locals.universe} universe!`);

src/db.js

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {MongoClient, GridFSBucket} from 'mongodb';
22
import {Readable} from "node:stream";
3-
import {pkgfields} from './tools.js';
3+
import path from 'node:path';
4+
import {pkgfields, doc_to_paths} from './tools.js';
45
import createError from 'http-errors';
56

67
const HOST = process.env.CRANLIKE_MONGODB_SERVER || '127.0.0.1';
@@ -227,6 +228,46 @@ function mongo_universe_binaries(user, type){
227228
return cursor.toArray();
228229
}
229230

231+
function mongo_universe_files(user, prefix, start_after){
232+
var query = {_user: user, _registered: true, _type: {'$ne': 'failure'}};
233+
var proj = {Package:1, Version:1, Built:1, _distro:1, _type:1, _id:1, _published:1, _filesize:1};
234+
return mongo_find(query).sort({_id: 1}).project(proj).toArray().then(function(docs){
235+
if(!docs.length) //should not happen because we checked earlier
236+
throw createError(404, `No packages found in ${universe}`);
237+
var files = [];
238+
var indexes = {};
239+
docs.forEach(function(doc){
240+
doc_to_paths(doc).forEach(function(fpath){
241+
if(!prefix || fpath.startsWith(prefix)) {
242+
files.push({
243+
Key: fpath,
244+
ETag: doc._id,
245+
LastModified: doc._published.toISOString(),
246+
Size: doc._filesize
247+
});
248+
var repodir = path.dirname(fpath);
249+
if(!(indexes[repodir] > doc._published)){
250+
indexes[repodir] = doc._published;
251+
}
252+
}
253+
});
254+
});
255+
256+
for (const [path, date] of Object.entries(indexes)) {
257+
files.push({ Key: path + '/PACKAGES', LastModified: date.toISOString()});
258+
files.push({ Key: path + '/PACKAGES.gz', LastModified: date.toISOString()});
259+
}
260+
261+
if(start_after){
262+
var index = files.findIndex(x => x.Key == start_after);
263+
if(index > -1){
264+
files = files.slice(index + 1);
265+
}
266+
}
267+
return files;
268+
});
269+
}
270+
230271
/* NB Contributions are grouped by upstream url instead of package namme to avoid duplicate counting
231272
* of contributions in repos with many packages, e.g. https://github.com/r-forge/ctm/tree/master/pkg */
232273
function mongo_universe_contributors(user, limit = 20){
@@ -536,6 +577,14 @@ export function get_universe_contributions(universe, limit){
536577
}
537578
}
538579

580+
export function get_universe_files(universe, prefix, start_after){
581+
if(production){
582+
return mongo_universe_files(universe, prefix, start_after);
583+
} else {
584+
throw "Not implemented for devel";
585+
}
586+
}
587+
539588
export function get_repositories(){
540589
if(production){
541590
return mongo_all_universes()

src/tools.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,31 @@ export function match_macos_arch(platform){
174174
}
175175
throw createError(404, `Unsupported MacOS version: ${platform}`);
176176
}
177+
178+
export function doc_to_paths(doc){
179+
var type = doc._type;
180+
if(type == 'src'){
181+
return [`src/contrib/${doc.Package}_${doc.Version}.tar.gz`];
182+
}
183+
var built = doc.Built && doc.Built.R && doc.Built.R.substring(0,3);
184+
if(type == 'win'){
185+
return [`bin/windows/contrib/${built}/${doc.Package}_${doc.Version}.zip`];
186+
}
187+
if(type == 'mac'){
188+
var intel = `bin/macosx/big-sur-x86_64/contrib/${built}/${doc.Package}_${doc.Version}.tgz`;
189+
var arm = `bin/macosx/big-sur-arm64/contrib/${built}/${doc.Package}_${doc.Version}.tgz`;
190+
if(doc.Built.Platform){
191+
return [doc.Built.Platform.match("x86_64") ? intel : arm];
192+
} else {
193+
return [intel, arm];
194+
}
195+
}
196+
if(type == 'linux'){
197+
var distro = doc._distro || doc.Distro || 'linux';
198+
return [`bin/linux/${distro}/${built}/src/contrib/${doc.Package}_${doc.Version}.tar.gz`];
199+
}
200+
if(type == 'wasm'){
201+
return [`bin/emscripten/contrib/${built}/${doc.Package}_${doc.Version}.tgz`];
202+
}
203+
throw `Unsupported type: ${type}`;
204+
}

views/S3List.pug

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
doctype xml
2+
ListBucketResult(xmlns="http://s3.amazonaws.com/doc/2006-03-01/")
3+
Name #{universe}.r-universe.dev
4+
Prefix #{Prefix}
5+
NextContinuationToken #{NextContinuationToken}
6+
KeyCount #{files.length + commonprefixes.length}
7+
MaxKeys #{MaxKeys}
8+
IsTruncated #{IsTruncated}
9+
if StartAfter
10+
StartAfter #{StartAfter}
11+
each x in commonprefixes
12+
CommonPrefixes
13+
Prefix #{x}
14+
each x in files
15+
Contents
16+
Key #{x.Key}
17+
LastModified #{x.LastModified}
18+
if x.ETag
19+
ETag "#{x.ETag}"
20+
if x.Size
21+
Size #{x.Size}

0 commit comments

Comments
 (0)