Commit 0348baa9 authored by nanahira's avatar nanahira

add create share, but incomplete

parent 8abdc0fc
Pipeline #21404 failed with stages
in 1 minute and 4 seconds
This diff is collapsed.
......@@ -23,4 +23,57 @@ program
}
});
program
.command('create')
.description('Create a share from a file')
.argument('<file>', 'File to share')
.option('-u, --username <username>', 'Username of 123yun')
.option('-p, --password <password>', 'Password of 123yun')
.option('-n, --name <name>', 'Name of the share')
.option('-x, --expire <expire>', 'Expire time of the share')
.option('-d, --directory <directory>', 'Directory of the share')
.option('-P, --share-password <password>', 'Share password')
.option('--concorrency <concorrency>', 'Concorrency of the update process')
.action(async (file: string, options) => {
const credentials = {
username: options.username || process.env.OTT_USERNAME,
password: options.password || process.env.OTT_PASSWORD,
};
if (!credentials.username || !credentials.password) {
console.error('Username or password is not configured.');
process.exit(1);
}
const directory = parseInt(options.directory || process.env.OTT_DIRECTORY);
if (!directory) {
console.error('Directory is not configured.');
process.exit(1);
}
const ott = new OneTwoThreeShare({
credentials,
uploadConcurrent: options.concorrency || 3,
});
try {
const result = await ott.create(
[
{
media: file,
},
],
{
expiration: options.expire,
password: options.sharePassword,
directory,
name: options.name,
},
);
console.log(result);
} catch (e) {
console.error(e.message);
process.exit(1);
}
});
program.parse();
import { CacheKey } from 'aragami';
import moment from 'moment';
export class UserToken {
@CacheKey()
username: string;
token: string;
expiresAt: number;
isExpired() {
const now = moment();
const expiresAt = moment.unix(this.expiresAt);
return now.isAfter(expiresAt);
}
fromLoginResult(username: string, loginResult: LoginResult) {
this.username = username;
this.token = loginResult.token;
this.expiresAt = loginResult.refresh_token_expire_time;
return this;
}
}
......@@ -2,13 +2,29 @@ import { Aragami, RedisDriverOptions } from 'aragami';
import { CreateAxiosDefaults } from 'axios';
import { GetShare } from './types/get-share';
import { Quester } from './utility/quester';
import { pickAttributes } from './utility/utility';
import { pickAttributes, streamToBuffer } from './utility/utility';
import { UserToken } from './entities/token';
import moment, { Moment } from 'moment';
import { Readable } from 'stream';
import * as fs from 'fs';
import fileType from 'file-type';
import hasha from 'hasha';
import path from 'path';
import { Buffer } from 'buffer';
import { Uploads } from './types/upload';
import { Share } from './types/share';
import PQueue from 'p-queue';
import { Upload } from '@aws-sdk/lib-storage';
import { S3Client } from '@aws-sdk/client-s3';
export class OneTwoThreeShare {
private aragami = new Aragami({
redis: this.config.redis,
});
http = new Quester(this.config.httpOptions || {});
get axios() {
return this.http.http;
}
constructor(private config: OneTwoThreeShare.Config = {}) {}
private async handleShare(
......@@ -37,7 +53,11 @@ export class OneTwoThreeShare {
);
}
async parse(url: string, options: OneTwoThreeShare.ParseShareOptions = {}) {
private async getShareInfo(
url: string,
options: OneTwoThreeShare.ParseShareOptions = {},
login = false,
) {
const shareKey = url.split('/').pop();
const shareInfo = await this.http.get<GetShare.Response>(
'https://www.123pan.com/a/api/share/get',
......@@ -52,17 +72,253 @@ export class OneTwoThreeShare {
ParentFileId: 0,
Page: 1,
},
headers: login ? { Authorization: `Bearer ${await this.login()}` } : {},
},
);
if (shareInfo.InfoList.length === 0) {
throw new Error('Share not found.');
}
const shares = shareInfo.InfoList;
return shareInfo.InfoList;
}
async parse(url: string, options: OneTwoThreeShare.ParseShareOptions = {}) {
const shareKey = url.split('/').pop();
const shares = await this.getShareInfo(url, options);
await Promise.all(
shares.map((share) => this.handleShare(share, { ...options, shareKey })),
);
return shares;
}
private async loginProcess() {
const cached = await this.aragami.get(
UserToken,
this.config.credentials.username,
);
if (cached && !cached.isExpired()) {
return cached.token;
}
const loginResult = await this.http.post<LoginResult>(
'https://www.123pan.com/api/user/sign_in',
{
passport: this.config.credentials.username,
password: this.config.credentials.password,
},
{ responseType: 'json' },
);
const token = new UserToken().fromLoginResult(
this.config.credentials.username,
loginResult,
);
await this.aragami.set(token, {
ttl: (token.expiresAt - moment().unix()) * 1000,
});
return token.token;
}
private async login() {
if (!this.config.credentials) {
throw new Error('Credentials not provided.');
}
return this.aragami.lock(
[`ott-login:${this.config.credentials.username}`],
() => this.loginProcess(),
);
}
private async mediaToStreamAndMeta(
obj: OneTwoThreeShare.MediaObject,
): Promise<OneTwoThreeShare.MediaObject<Buffer>> {
const { media } = obj;
if (typeof media === 'string') {
if (media.startsWith('http')) {
// http
const downloading = await this.axios.get<Buffer>(media, {
responseType: 'arraybuffer',
});
let filename = media.split('/').pop().split('?')[0];
if (downloading.headers['content-disposition']?.includes('filename=')) {
const filenameFromDisposition =
downloading.headers['content-disposition'].match(
/filename="(.+)"/,
)?.[1];
if (filenameFromDisposition) {
filename = filenameFromDisposition;
}
}
return {
media: downloading.data,
meta: {
filename,
mime:
downloading.headers['content-type'] ||
(await fileType
.fromBuffer(downloading.data)
.then((t) => t.mime)) ||
'application/octet-stream',
size: downloading.data.length,
hash:
downloading.headers['etag']?.length === 32
? downloading.headers['etag']
: await hasha.async(downloading.data, { algorithm: 'md5' }),
...(obj.meta || {}),
},
};
} else {
// file
const buffer = await fs.promises.readFile(media);
return {
media: buffer,
meta: {
filename: path.basename(media),
mime:
(await fileType.fromFile(media).then((type) => type?.mime)) ||
'application/octet-stream',
size: buffer.length,
hash: await hasha.fromFile(media, { algorithm: 'md5' }),
...(obj.meta || {}),
},
};
}
}
let buffer: Buffer;
if (Buffer.isBuffer(media)) {
buffer = media;
} else {
buffer = await streamToBuffer(media);
}
const [ftype, hash] = await Promise.all([
fileType.fromBuffer(buffer),
hasha.async(buffer, { algorithm: 'md5' }),
]);
return {
media: buffer,
meta: {
filename: obj.meta?.filename || `${hash}.${ftype?.ext || 'bin'}`,
mime: ftype?.mime,
size: buffer.length,
hash,
...(obj.meta || {}),
},
};
}
async create(
medias: OneTwoThreeShare.MediaObject[],
options: OneTwoThreeShare.CreateOptions,
) {
const queue = new PQueue({
concurrency: this.config.uploadConcurrent || 10,
});
const files = await Promise.all(
medias.map((media) => queue.add(() => this.upload(media, options))),
);
return this.share(files, options);
}
async share(
files: Uploads.FileInfo[],
options: OneTwoThreeShare.CreateOptions,
) {
const shareReq = await this.http.post<Share.Response>(
'https://www.123pan.com/a/api/share/create',
{
driveId: 0,
expiration: (options.expiration
? moment(options.expiration)
: moment().add(100, 'years')
).format(),
fileIdList: files.map((file) => file.FileId).join(','),
shareName: options.name || files.map((file) => file.FileName).join(','),
sharePwd: options.password || '',
} as Share.Request,
{
responseType: 'json',
headers: {
Authorization: `Bearer ${await this.login()}`,
},
},
);
return `https://www.123pan.com/s/${shareReq.ShareKey}`;
}
async upload(
media: OneTwoThreeShare.MediaObject,
options: OneTwoThreeShare.CreateOptions,
) {
const { media: buffer, meta } = await this.mediaToStreamAndMeta(media);
let uploadFinished = false;
try {
const uploadReq = await this.http.post<Uploads.Req>(
'https://www.123pan.com/api/file/upload_request',
{
/// https://github.com/alist-org/alist/blob/main/drivers/123/driver.go#L222
driveId: 0,
duplicate: 2,
etag: meta.hash.length > 32 ? meta.hash.slice(0, 32) : meta.hash,
parentFileId: options.directory,
fileName: meta.filename,
size: meta.size,
type: 0,
},
{
responseType: 'json',
headers: {
Authorization: `Bearer ${await this.login()}`,
},
},
);
if (!uploadReq.AccessKeyId) {
return uploadReq.Info;
}
const s3 = new S3Client({
credentials: {
accessKeyId: uploadReq.AccessKeyId,
secretAccessKey: uploadReq.SecretAccessKey,
sessionToken: uploadReq.SessionToken,
},
region: '123pan',
endpoint: 'https://file.123pan.com',
forcePathStyle: true,
});
console.error(`Begin upload ${meta.filename}`);
const upload = new Upload({
client: s3,
params: {
Bucket: uploadReq.Bucket,
Key: uploadReq.Key,
Body: buffer, // TODO: stream upload
ContentType: meta.mime,
ContentLength: meta.size,
},
partSize: 10 * 1024 ** 3,
});
upload['uploadId'] = uploadReq.UploadId;
await upload.done();
console.error(`Upload ${meta.filename} finished`);
uploadFinished = true;
const completeReq = await this.http.post<Uploads.CompleteReq>(
'https://www.123pan.com/api/file/upload_complete',
{ fileId: uploadReq.FileId },
{
responseType: 'json',
headers: {
Authorization: `Bearer ${await this.login()}`,
},
},
);
return completeReq.Info;
} catch (e) {
throw e;
} finally {
if (!uploadFinished) {
// stream.destroy();
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-namespace
......@@ -76,10 +332,31 @@ export namespace OneTwoThreeShare {
credentials?: Credentials;
redis?: RedisDriverOptions;
httpOptions?: CreateAxiosDefaults;
uploadConcurrent?: number;
}
export interface ParseShareOptions {
password?: string;
clientIp?: string;
}
export type Media = string | Readable | Buffer;
export interface MediaObject<T extends Media = Media> {
media: T;
meta?: Partial<MediaMeta>;
}
export interface MediaMeta {
filename: string;
mime: string;
size: number;
hash: string;
}
export interface CreateOptions {
directory: number;
name?: string;
expiration?: Moment | Date | string;
password?: string;
}
}
interface LoginResult {
expire: string;
login_type: number;
refresh_token_expire_time: number;
token: string;
}
export namespace Share {
export interface Response {
ShareId: number;
ShareKey: string;
AllowFileIdList: string;
CheckFileIdList: string;
DenyFileIdList: string;
}
export interface Request {
driveId: number;
expiration: string;
fileIdList: string;
shareName: string;
sharePwd: string;
}
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Uploads {
export interface FileInfo {
FileId: number;
FileName: string;
Type: number;
Size: number;
ContentType: string;
S3KeyFlag: string;
CreateAt: string;
UpdateAt: string;
Hidden: boolean;
Etag: string;
Status: number;
ParentFileId: number;
Category: number;
PunishFlag: number;
ParentName: string;
DownloadUrl: string;
}
export interface CompleteReq {
Info?: FileInfo;
}
export interface Req extends CompleteReq {
AccessKeyId: string;
SecretAccessKey: string;
SessionToken: string;
Expiration: string;
Key: string;
Bucket: string;
FileId: number;
Reuse: boolean;
UploadId: string;
DownloadUrl: string;
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment