Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FeaturePanel: upload to WikimediaCommons 🎉 #492

Open
wants to merge 42 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e55f2a6
upload - add upload button
zbycz Nov 23, 2023
6b78b34
upload - parse uploads correctly, import wikiapi
zbycz Nov 30, 2023
afacb76
upload - description
zbycz Dec 2, 2023
d7c80d3
upload file + fetch user
zbycz Apr 18, 2024
f087402
parseHttpRequest
zbycz Apr 18, 2024
76fc919
exif
zbycz Apr 18, 2024
8ebd885
wikiapiUploadRequest()
zbycz Apr 18, 2024
3a4f867
upload works!!
zbycz Apr 18, 2024
ac3ea25
move
zbycz Apr 18, 2024
a284c23
custom mediawiki api
zbycz Apr 18, 2024
b627d52
get revision
zbycz Apr 19, 2024
68a8b5f
ok, this is not going to work. Lets try some libs again :)
zbycz Apr 19, 2024
4977bda
cr
zbycz May 15, 2024
6f3f82e
using formdata-node but getting HTML instead of JSON response... whaaat?
zbycz May 15, 2024
38d1ac2
ok - really use the lib now. Set-cookie used in csrf token request ju…
zbycz May 21, 2024
4733500
slight refactor + leave open missing csrftoken
zbycz Aug 20, 2024
781e89a
csrf token works !
zbycz Aug 21, 2024
4173a6c
upload to postbin works
zbycz Aug 21, 2024
0fb676c
upload works - but VERCEL has 4 MB limit...
zbycz Aug 21, 2024
7a7d98b
wip
zbycz Aug 21, 2024
f49b3c0
Revert "wip"
zbycz Aug 26, 2024
a1299ea
upload with S3 step works
zbycz Aug 28, 2024
4e6b04c
remove unneccesary deps
zbycz Aug 30, 2024
c68d4d5
claims workss!!!!!
zbycz Aug 30, 2024
208f7bd
fix test
zbycz Aug 30, 2024
0748477
fop
zbycz Aug 30, 2024
cec7fb9
advanced
zbycz Aug 30, 2024
4199744
add logging
zbycz Aug 31, 2024
1434089
Merge remote-tracking branch 'origin/master' into upload
zbycz Sep 22, 2024
e3b5aaf
add copyright claims + save to osm
zbycz Sep 23, 2024
7e91c59
quickFetchFeature, fix claims numeric, getLabel in osmApiAuth
zbycz Sep 23, 2024
2ebcb84
router refresh + lint
zbycz Sep 23, 2024
a78c217
fix upload response shape
zbycz Sep 23, 2024
f6e2685
intl console
zbycz Sep 23, 2024
b6506f7
isTitleAvail type
zbycz Sep 24, 2024
3424c0d
console
zbycz Sep 25, 2024
2edcdf5
FOP
zbycz Sep 25, 2024
bb900f3
fix heic + fopde
zbycz Sep 25, 2024
071f3fd
openclimbing url
zbycz Sep 25, 2024
fb6d9d8
Merge branch 'master' into upload
jvaclavik Sep 25, 2024
45c0e83
fix test
zbycz Sep 25, 2024
5b603e5
add OSMLink
zbycz Sep 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OSMAPPBOT_PASSWORD=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ yarn-error.log*
/public/workbox-*.js.map
/.vercel
.env.sentry-build-plugin
/.env.local
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"date-fns": "^3.6.0",
"image-size": "^1.1.1",
"isomorphic-unfetch": "^4.0.2",
"exifr": "^7.1.3",
"isomorphic-xml2js": "^0.1.3",
"js-cookie": "^3.0.5",
"js-md5": "^0.8.3",
Expand Down
2 changes: 1 addition & 1 deletion pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ MyDocument.getInitialProps = async (
// server intl is not available in App, only in this file (because we don't want to sent messages over and over again)
const serverIntl = await getServerIntl(ctx);
setIntl(serverIntl); // for ssr
setProjectForSSR(ctx);
setProjectForSSR(ctx.req);

const initialProps = await documentGetInitialProps(ctx);

Expand Down
45 changes: 45 additions & 0 deletions pages/api/_fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export const exampleUploadResponse = {
upload: {
filename: 'File_1.jpg',
result: 'Success',
imageinfo: {
url: 'https://upload.wikimedia.org/wikipedia/test/3/39/File_1.jpg',
html: '<p>A file with this name exists already, please check <strong><a class="mw-selflink selflink">File:File 1.jpg</a></strong> if you are not sure if you want to change it.\n</p>\n<div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/w/index.php?title=Special:Upload&amp;wpDestFile=File_1.jpg" class="new" title="File:File 1.jpg">File:File 1.jpg</a> <div class="thumbcaption"></div></div></div>\n',
width: 474,
size: 26703,
bitdepth: 8,
mime: 'image/jpeg',
userid: 42588,
mediatype: 'BITMAP',
descriptionurl: 'https://test.wikipedia.org/wiki/File:File_1.jpg',
extmetadata: {
ObjectName: {
value: 'File 1',
hidden: '',
source: 'mediawiki-metadata',
},
DateTime: {
value: '2019-03-06 08:43:37',
hidden: '',
source: 'mediawiki-metadata',
},
// ...
},
comment: '',
commonmetadata: [],
descriptionshorturl: 'https://test.wikipedia.org/w/index.php?curid=0',
sha1: '2ffadd0da73fab31a50407671622fd6e5282d0cf',
parsedcomment: '',
metadata: [
{
name: 'MEDIAWIKI_EXIF_VERSION',
value: 2,
},
],
canonicaltitle: 'File:File 1.jpg',
user: 'Mansi29ag',
timestamp: '2019-03-06T08:43:37Z',
height: 296,
},
},
};
52 changes: 52 additions & 0 deletions pages/api/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { serverFetchOsmUser } from '../../src/server/osmApiAuthServer';
import {
fetchFeatureWithCenter,
fetchParentFeatures,
} from '../../src/services/osmApi';
import { intl, setIntl } from '../../src/services/intl';
import { getExifData } from '../../src/server/upload/getExifData';
import { uploadToWikimediaCommons } from '../../src/server/upload/uploadToWikimediaCommons';
import { getApiId } from '../../src/services/helpers';
import { File } from '../../src/server/upload/types';
import { setProjectForSSR } from '../../src/services/project';
import { fetchToFile } from '../../src/server/upload/fetchToFile';
import { OsmId } from '../../src/services/types';
import { isClimbingRoute } from '../../src/utils';
import { Feature } from '../../src/services/types';

// inspiration: https://commons.wikimedia.org/wiki/File:Drive_approaching_the_Grecian_Lodges_-_geograph.org.uk_-_5765640.jpg
// https://github.com/multichill/toollabs/blob/master/bot/commons/geograph_uploader.py
// TODO https://commons.wikimedia.org/wiki/Template:Geograph_from_structured_data

const getFeature = async (apiId: OsmId): Promise<Feature> => {
const feature = await fetchFeatureWithCenter(apiId);
if (isClimbingRoute(feature)) {
const parentFeatures = await fetchParentFeatures(feature.osmMeta);
return { ...feature, parentFeatures };
}
return feature;
};

export default async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { shortId, lang, url, filename } = JSON.parse(req.body);
setIntl({ lang, messages: [] });
setProjectForSSR(req);

const apiId = getApiId(shortId);
const feature = await getFeature(apiId);
console.log('intl', intl); // eslint-disable-line no-console
const user = await serverFetchOsmUser(req.cookies.osmAccessToken);

const filepath = await fetchToFile(url);
const { location, date } = await getExifData(filepath);
const file: File = { filepath, filename, location, date };
const out = await uploadToWikimediaCommons(user, feature, file, lang);

res.status(200).json(out);
} catch (err) {
console.error(err); // eslint-disable-line no-console
res.status(err.code ?? 400).send(String(err));
}
};
5 changes: 2 additions & 3 deletions pages/api/token-login.ts → pages/api/user.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { serverFetchOsmUser } from '../../src/services/osmApiAuthServer';
import { serverFetchOsmUser } from '../../src/server/osmApiAuthServer';

// TODO upgrade Nextjs and use export async function POST(request: NextRequest) {
export default async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { osmAccessToken } = req.cookies;
const user = await serverFetchOsmUser({ osmAccessToken });
const user = await serverFetchOsmUser(req.cookies.osmAccessToken);

res.status(200).json({ user });
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/components/FeaturePanel/Climbing/utils/photo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const getNextWikimediaCommonsIndex = (tags: FeatureTags) => {
}
}
return max;
}, 0);
}, -1 /* so it will be 0 for the first tag*/);

return maxKey + 1;
};
Expand Down
7 changes: 7 additions & 0 deletions src/components/FeaturePanel/FeaturePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { FeaturePanelFooter } from './FeaturePanelFooter';
import { ClimbingRouteGrade } from './ClimbingRouteGrade';
import { Box } from '@mui/material';
import { ClimbingGuideInfo } from './Climbing/ClimbingGuideInfo';
import { UploadDialog } from './UploadDialog/UploadDialog';

const Flex = styled.div`
flex: 1;
Expand Down Expand Up @@ -73,6 +74,12 @@ export const FeaturePanel = ({ headingRef }: FeaturePanelProps) => {
</PanelSidePadding>

<Box mb={2}>
{advanced && (
<PanelSidePadding>
<UploadDialog />
</PanelSidePadding>
)}

<FeatureImages />
</Box>

Expand Down
168 changes: 168 additions & 0 deletions src/components/FeaturePanel/UploadDialog/UploadDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { ChangeEvent, useState } from 'react';
import { Button, CircularProgress } from '@mui/material';
import { fetchJson, fetchText } from '../../../services/fetch';
import { useFeatureContext } from '../../utils/FeatureContext';
import { getShortId } from '../../../services/helpers';
import {
editOsmFeature,
loginAndfetchOsmUser,
} from '../../../services/osmApiAuth';
import { intl } from '../../../services/intl';
import { Feature } from '../../../services/types';
import { clearFeatureCache, quickFetchFeature } from '../../../services/osmApi';
import {
getNextWikimediaCommonsIndex,
getWikimediaCommonsKey,
} from '../Climbing/utils/photo';
import { useSnackbar } from '../../utils/SnackbarContext';
import { useRouter } from 'next/navigation';

const WIKIPEDIA_LIMIT = 100 * 1024 * 1024;
const BUCKET_URL = 'https://osmapp-upload-tmp.s3.amazonaws.com/';

// Vercel has limit 4.5 MB on payload size, so we have to upload to S3 first
const uploadToS3 = async (file: File) => {
const key = `${Math.random()}/${file.name}`;

const body = new FormData();
body.append('key', key);
body.append('file', file);

await fetchText(BUCKET_URL, { method: 'POST', body });

return `${BUCKET_URL}${key}`;
};

const submitToWikimediaCommons = async (
url: string,
filename: string,
feature: Feature,
) => {
const shortId = getShortId(feature.osmMeta);

return await fetchJson('/api/upload', {
method: 'POST',
body: JSON.stringify({ url, filename, shortId, lang: intl.lang }),
});
};

const performUploadWithLogin = async (
url: string,
filename: string,
feature: Feature,
) => {
try {
return await submitToWikimediaCommons(url, filename, feature);
} catch (e) {
if (e.code === '401') {
await loginAndfetchOsmUser();
return await submitToWikimediaCommons(url, filename, feature);
}
throw e;
}
};

const submitToOsm = async (feature: Feature, fileTitle: string) => {
clearFeatureCache(feature.osmMeta);
const freshFeature = await quickFetchFeature(feature.osmMeta);
const newPhotoIndex = getNextWikimediaCommonsIndex(freshFeature.tags);
await editOsmFeature(
freshFeature,
`Upload image ${fileTitle}`,
{
...freshFeature.tags,
[getWikimediaCommonsKey(newPhotoIndex)]: fileTitle,
},
false,
);

clearFeatureCache(feature.osmMeta);
};

const useGetHandleFileUpload = (
feature: Feature,
setUploading: React.Dispatch<React.SetStateAction<boolean>>,
setResetKey: React.Dispatch<React.SetStateAction<number>>,
) => {
const { showToast } = useSnackbar();
const router = useRouter();

return async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const filename = file.name;

if (!file) {
return;
}

if (file.size > WIKIPEDIA_LIMIT) {
alert('Maximum file size for Wikipedia is 100 MB.'); // eslint-disable-line no-alert
return;
}

try {
setUploading(true);
const url = await uploadToS3(file);
const wikiResponse = await performUploadWithLogin(url, filename, feature);
const osmResponse = await submitToOsm(feature, wikiResponse.title);

showToast('Image uploaded successfully', 'success');
router.refresh();
} finally {
setUploading(false);
setResetKey((key) => key + 1);
}
};
};

const UploadButton = () => {
const { feature } = useFeatureContext();
const [uploading, setUploading] = useState<boolean>(false);
const [resetKey, setResetKey] = useState<number>(0);

const handleFileUpload = useGetHandleFileUpload(
feature,
setUploading,
setResetKey,
);

return (
<>
<Button
component="label"
variant="contained"
color="primary"
// startIcon={<UploadFileIcon />}
style={{ marginBottom: '1rem' }}
disabled={uploading}
key={resetKey}
endIcon={uploading ? <CircularProgress /> : undefined}
>
Upload image
<input
type="file"
// accept="image/*"
hidden
onChange={handleFileUpload}
/>
</Button>
</>
);
};

export const UploadDialog = () => {
const { feature } = useFeatureContext();
const { osmMeta, skeleton } = feature;
const editEnabled = !skeleton;

return (
<>
{editEnabled && (
<>
<UploadButton />
<br />
</>
)}
</>
);
};
23 changes: 2 additions & 21 deletions src/components/Map/behaviour/maptilerFix.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Feature, OsmId } from '../../../services/types';
import { FetchError, getShortId } from '../../../services/helpers';
import { osmToFeature } from '../../../services/osmToFeature';
import { fetchJson } from '../../../services/fetch';
import { getShortId } from '../../../services/helpers';
import { quickFetchFeature } from '../../../services/osmApi';

const isFarAway = (feature, skeleton) =>
feature.center &&
Expand All @@ -25,24 +24,6 @@ const isMaptilerCorruptedId = (feature: Feature, skeleton: Feature) => {
return false;
};

const getQuickOsmPromise = async (apiId: OsmId) => {
const getOsmUrl = ({ type, id }) =>
`https://api.openstreetmap.org/api/0.6/${type}/${id}.json`;
const { elements } = await fetchJson(getOsmUrl(apiId)); // TODO 504 gateway busy
return elements?.[0];
};

const quickFetchFeature = async (apiId: OsmId) => {
try {
const element = await getQuickOsmPromise(apiId);
return osmToFeature(element);
} catch (e) {
return {
error: e instanceof FetchError ? e.code : 'unknown',
} as unknown as Feature;
}
};

// Maptiler is not encoding IDs correctly, sometimes type encoding is missing, sometimes the type is just wrong
// This function tries to fix the ID by fetching possible variants and comparing them by name and distance
// more in: https://github.com/openmaptiles/openmaptiles/issues/1587
Expand Down
Loading
Loading